From 57312d4f75e7b51b22838404ff3d13ede1201341 Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Thu, 16 Apr 2026 12:29:05 +1000 Subject: [PATCH] fix(server): Map OpenCode todo and compaction events (#429) Translate OpenCode todo and compaction events into Paseo timeline items so issue #106 uses the existing todo list and compaction UI without changing the client model. Keep session.status handling limited to existing terminal states and add focused translator coverage for each mapped event. Fixes #106 Co-authored-by: OpenCode --- .../server/agent/providers/opencode-agent.ts | 64 ++++++ .../opencode/event-translator.test.ts | 183 ++++++++++++++++++ 2 files changed, 247 insertions(+) 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();