diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 4aea0eb2e..c2953b422 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1159,6 +1159,38 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function mapOpenCodeTodosToTimelineItems( + todos: Array<{ content?: string | null; status?: string | null }>, +): Extract { + return { + type: "todo", + items: todos.flatMap((todo) => { + const text = readNonEmptyString(todo.content); + if (!text) { + return []; + } + + return [ + { + text, + completed: todo.status === "completed", + }, + ]; + }), + }; +} + +function createCompactionTimelineItem( + status: Extract["status"], + trigger?: Extract["trigger"], +): Extract { + return { + type: "compaction", + status, + ...(trigger ? { trigger } : {}), + }; +} + export function translateOpenCodeEvent( event: OpenCodeEvent, state: OpenCodeEventTranslationState, @@ -1258,6 +1290,12 @@ export function translateOpenCodeEvent( item: parsedToolPart.data, }); } + } else if (part.type === "compaction") { + events.push({ + type: "timeline", + provider: "opencode", + item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"), + }); } else if (part.type === "step-finish") { mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part); if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) { @@ -1378,6 +1416,32 @@ export function translateOpenCodeEvent( break; } + case "todo.updated": { + if (event.properties.sessionID !== state.sessionId) { + break; + } + + events.push({ + type: "timeline", + provider: "opencode", + item: mapOpenCodeTodosToTimelineItems(event.properties.todos), + }); + break; + } + + case "session.compacted": { + if (event.properties.sessionID !== state.sessionId) { + break; + } + + events.push({ + type: "timeline", + provider: "opencode", + item: createCompactionTimelineItem("completed"), + }); + break; + } + case "session.idle": { if (event.properties.sessionID === state.sessionId) { state.streamedPartKeys.clear(); diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 72986fe28..69bd08ce6 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -310,6 +310,96 @@ describe("translateOpenCodeEvent", () => { }); }); + it("emits normalized todo timeline items from todo.updated", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "todo.updated", + properties: { + sessionID: "session-1", + todos: [ + { content: "Outline", status: "pending", priority: "high" }, + { content: "Ship", status: "completed", priority: "medium" }, + { content: " ", status: "completed", priority: "low" }, + ], + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "todo", + items: [ + { text: "Outline", completed: false }, + { text: "Ship", completed: true }, + ], + }, + }, + ]); + }); + + it("emits compaction loading timeline items from compaction parts", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "compaction-part-1", + sessionID: "session-1", + messageID: "message-compaction-1", + type: "compaction", + auto: true, + }, + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "compaction", + status: "loading", + trigger: "auto", + }, + }, + ]); + }); + + it("emits compaction completed timeline items from session.compacted", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "session.compacted", + properties: { + sessionID: "session-1", + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "compaction", + status: "completed", + }, + }, + ]); + }); + it("emits reasoning from message.part.delta events", () => { const state = createState(); @@ -510,6 +600,99 @@ describe("translateOpenCodeEvent", () => { expect(result).toEqual([]); }); + it("emits turn_completed from session.status idle", () => { + const state = createState(); + state.streamedPartKeys.add("text:part-1"); + state.partTypes.set("part-1", "text"); + + const result = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "idle" }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "turn_completed", + provider: "opencode", + usage: undefined, + }, + ]); + expect(state.streamedPartKeys.size).toBe(0); + expect(state.partTypes.size).toBe(0); + }); + + it("emits turn_failed from fatal session.status retry", () => { + const state = createState(); + state.streamedPartKeys.add("text:part-1"); + state.partTypes.set("part-1", "text"); + + const result = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { + type: "retry", + attempt: 2, + message: "Invalid API key", + next: Date.now() + 1000, + }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "turn_failed", + provider: "opencode", + error: "Invalid API key", + }, + ]); + expect(state.streamedPartKeys.size).toBe(0); + expect(state.partTypes.size).toBe(0); + }); + + it("ignores transient session.status updates", () => { + const state = createState(); + + const busy = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "busy" }, + }, + }, + state, + ); + + const retry = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { + type: "retry", + attempt: 1, + message: "rate limited", + next: Date.now() + 1000, + }, + }, + }, + state, + ); + + expect(busy).toEqual([]); + expect(retry).toEqual([]); + }); + it("emits structured assistant output when schema mode completes without text parts", () => { const state = createState();