feat(session-notification): add ready notification content builder

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-03-12 15:29:21 +09:00
parent 8e1a4dffa9
commit 943f31f460
2 changed files with 212 additions and 0 deletions

View File

@@ -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 {}

View File

@@ -0,0 +1,145 @@
import { normalizeSDKResponse } from "../shared"
type ReadyNotificationContext = {
client: {
session: {
get?: (input: { path: { id: string } }) => Promise<unknown>
messages?: (input: { path: { id: string }; query: { directory: string } }) => Promise<unknown>
}
}
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<string> {
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<SessionMessage[]> {
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,
}
}