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:
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user