From 943f31f4601fcdda786f977ef397bc4cc8f1b9d6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 12 Mar 2026 15:29:21 +0900 Subject: [PATCH] feat(session-notification): add ready notification content builder Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../session-notification-content.test.ts | 67 ++++++++ src/hooks/session-notification-content.ts | 145 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 src/hooks/session-notification-content.test.ts create mode 100644 src/hooks/session-notification-content.ts diff --git a/src/hooks/session-notification-content.test.ts b/src/hooks/session-notification-content.test.ts new file mode 100644 index 000000000..39ae0660f --- /dev/null +++ b/src/hooks/session-notification-content.test.ts @@ -0,0 +1,67 @@ +const { describe, expect, test } = require("bun:test") +import { buildReadyNotificationContent } from "./session-notification-content" + +describe("buildReadyNotificationContent", () => { + describe("#given session metadata and messages exist", () => { + test("#when ready notification content is built, #then it includes session title, last user query, and last assistant line", async () => { + const ctx = { + directory: "/tmp/test", + client: { + session: { + get: async () => ({ data: { title: "Bugfix session" } }), + messages: async () => ({ + data: [ + { + info: { role: "user" }, + parts: [{ type: "text", text: "Investigate\nthis flaky test" }], + }, + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "First line\nFinal answer line" }], + }, + ], + }), + }, + }, + } + + const result = await buildReadyNotificationContent(ctx, { + sessionID: "ses_123", + baseTitle: "OpenCode", + baseMessage: "Agent is ready for input", + }) + + expect(result).toEqual({ + title: "OpenCode · Bugfix session", + message: "Agent is ready for input\nUser: Investigate this flaky test\nAssistant: Final answer line", + }) + }) + }) + + describe("#given session APIs do not provide rich data", () => { + test("#when ready notification content is built, #then it falls back to session id and the base message", async () => { + const ctx = { + directory: "/tmp/test", + client: { + session: { + get: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + }, + } + + const result = await buildReadyNotificationContent(ctx, { + sessionID: "ses_fallback", + baseTitle: "OpenCode", + baseMessage: "Agent is ready for input", + }) + + expect(result).toEqual({ + title: "OpenCode · ses_fallback", + message: "Agent is ready for input", + }) + }) + }) +}) + +export {} diff --git a/src/hooks/session-notification-content.ts b/src/hooks/session-notification-content.ts new file mode 100644 index 000000000..eaf33180d --- /dev/null +++ b/src/hooks/session-notification-content.ts @@ -0,0 +1,145 @@ +import { normalizeSDKResponse } from "../shared" + +type ReadyNotificationContext = { + client: { + session: { + get?: (input: { path: { id: string } }) => Promise + messages?: (input: { path: { id: string }; query: { directory: string } }) => Promise + } + } + directory: string +} + +type SessionInfo = { + title?: string +} + +type SessionMessagePart = { + type?: string + text?: string +} + +type SessionMessage = { + info?: { + role?: string + error?: unknown + } + parts?: SessionMessagePart[] +} + +type ReadyNotificationInput = { + sessionID: string + baseTitle: string + baseMessage: string +} + +function extractMessageText(message: SessionMessage | undefined): string { + return (message?.parts ?? []) + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text?.trim() ?? "") + .filter(Boolean) + .join("\n") +} + +function collapseWhitespace(text: string): string { + return text + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join(" ") +} + +function getLastNonEmptyLine(text: string): string { + const lines = text + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + + return lines.at(-1) ?? "" +} + +function findLastMessage(messages: SessionMessage[], role: "user" | "assistant"): SessionMessage | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + if (message.info?.role !== role) continue + if (role === "assistant" && message.info?.error) continue + if (!extractMessageText(message)) continue + return message + } + + return undefined +} + +async function readSessionTitle( + ctx: ReadyNotificationContext, + sessionID: string, +): Promise { + if (typeof ctx.client.session.get !== "function") { + return sessionID + } + + try { + const response = await ctx.client.session.get({ path: { id: sessionID } }) + const sessionInfo = normalizeSDKResponse(response, null as SessionInfo | null, { + preferResponseOnMissingData: true, + }) + + if (sessionInfo?.title && sessionInfo.title.trim().length > 0) { + return sessionInfo.title.trim() + } + } catch { + } + + return sessionID +} + +async function readSessionMessages( + ctx: ReadyNotificationContext, + sessionID: string, +): Promise { + if (typeof ctx.client.session.messages !== "function") { + return [] + } + + try { + const response = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }) + + const messages = normalizeSDKResponse(response, [] as SessionMessage[], { + preferResponseOnMissingData: true, + }) + + return Array.isArray(messages) ? messages : [] + } catch { + return [] + } +} + +export async function buildReadyNotificationContent( + ctx: ReadyNotificationContext, + input: ReadyNotificationInput, +): Promise<{ title: string; message: string }> { + const [sessionTitle, messages] = await Promise.all([ + readSessionTitle(ctx, input.sessionID), + readSessionMessages(ctx, input.sessionID), + ]) + + const lastUserText = collapseWhitespace(extractMessageText(findLastMessage(messages, "user"))) + const lastAssistantLine = getLastNonEmptyLine( + extractMessageText(findLastMessage(messages, "assistant")), + ) + + const detailLines = [ + lastUserText ? `User: ${lastUserText}` : "", + lastAssistantLine ? `Assistant: ${lastAssistantLine}` : "", + ].filter(Boolean) + + return { + title: `${input.baseTitle} · ${sessionTitle}`, + message: detailLines.length > 0 + ? [input.baseMessage, ...detailLines].join("\n") + : input.baseMessage, + } +}