diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9823a6870..9437c791a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -5021,4 +5021,254 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.deepEqual(closeCallsDuringRun, []); }), ); + + it.effect("maps OpenCode todo updates onto a turn plan", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-todo-plan"); + const todoEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [todoEvent.promise]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.plan.updated"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Plan the work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + todoEvent.resolve({ + id: "evt-todo-plan", + type: "todo.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + todos: [ + { id: "t1", content: "Read the adapter", status: "completed", priority: "high" }, + { id: "t2", content: "Emit the plan", status: "in_progress", priority: "medium" }, + { id: "t3", content: "Write tests", status: "pending", priority: "low" }, + ], + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const planEvent = events[0]; + NodeAssert.equal(planEvent?.type, "turn.plan.updated"); + NodeAssert.equal(planEvent?.turnId, turn.turnId); + if (planEvent?.type === "turn.plan.updated") { + NodeAssert.deepEqual(planEvent.payload.plan, [ + { step: "Read the adapter", status: "completed" }, + { step: "Emit the plan", status: "inProgress" }, + { step: "Write tests", status: "pending" }, + ]); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("drops cancelled todos and names an empty step", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-todo-cancelled"); + const todoEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [todoEvent.promise]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.plan.updated"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Plan with a cancelled item", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + todoEvent.resolve({ + id: "evt-todo-cancelled", + type: "todo.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + todos: [ + { id: "t1", content: " ", status: "pending", priority: "high" }, + { id: "t2", content: "Abandoned branch", status: "cancelled", priority: "low" }, + { id: "t3", content: "Still going", status: "unrecognized-status", priority: "low" }, + ], + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const planEvent = events[0]; + if (planEvent?.type === "turn.plan.updated") { + // Cancelled work is dropped rather than reported completed, an empty + // description gets a placeholder, and an unknown status stays pending. + NodeAssert.deepEqual(planEvent.payload.plan, [ + { step: "Task", status: "pending" }, + { step: "Still going", status: "pending" }, + ]); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits one plan update when OpenCode repeats a todo payload", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-todo-dedupe"); + const firstTodo = promiseWithResolvers(); + const repeatTodo = promiseWithResolvers(); + const changedTodo = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [ + firstTodo.promise, + repeatTodo.promise, + changedTodo.promise, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.plan.updated"), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Repeat the plan", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const todos = [{ id: "t1", content: "Only step", status: "pending", priority: "high" }]; + firstTodo.resolve({ + id: "evt-todo-first", + type: "todo.updated", + properties: { sessionID: "http://127.0.0.1:9999/session", todos }, + }); + yield* Effect.yieldNow; + repeatTodo.resolve({ + id: "evt-todo-repeat", + type: "todo.updated", + properties: { sessionID: "http://127.0.0.1:9999/session", todos }, + }); + yield* Effect.yieldNow; + changedTodo.resolve({ + id: "evt-todo-changed", + type: "todo.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + todos: [{ id: "t1", content: "Only step", status: "completed", priority: "high" }], + }, + }); + + // Take(2) only completes if the identical repeat was suppressed and the + // genuine status change was not. + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal(events.length, 2); + const second = events[1]; + if (second?.type === "turn.plan.updated") { + NodeAssert.deepEqual(second.payload.plan, [{ step: "Only step", status: "completed" }]); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("classifies todowrite as a tool call rather than a file change", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-todo-classification"); + const assistantMessage = promiseWithResolvers(); + const toolPart = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [assistantMessage.promise, toolPart.promise]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "item.completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Track the work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + assistantMessage.resolve({ + id: "evt-assistant-todo-tool", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: "msg-todo-tool", role: "assistant" }, + }, + }); + yield* Effect.yieldNow; + toolPart.resolve({ + id: "evt-todo-tool-part", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-todo-tool", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-todo-tool", + callID: "call-todo-tool", + type: "tool", + tool: "todowrite", + state: { status: "completed", title: "todowrite", time: { start: 1, end: 2 } }, + }, + time: 1, + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const item = events[0]; + if (item?.type === "item.completed") { + // "todowrite" contains "write" but edits nothing; filing it as + // file_change inflates the work log's edit count. + NodeAssert.equal(item.payload.itemType, "dynamic_tool_call"); + } + + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 0d67224d9..6b9e29490 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -338,6 +338,12 @@ interface OpenCodeSessionContext { activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; + /** + * Last emitted plan fingerprint. OpenCode re-emits `todo.updated` on every + * mutation, so without this each step transition writes a duplicate plan + * activity. + */ + lastPlanFingerprint: string | undefined; cancellation: OpenCodeCancellation | undefined; interruptedTurnId: TurnId | undefined; reconcileIdleStatus: boolean; @@ -411,11 +417,44 @@ type EventBaseInput = { readonly raw?: unknown; }; +type OpenCodePlanStep = { + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; +}; + +/** + * Maps an OpenCode `todo.updated` payload onto Pylon plan steps. `Todo.status` + * is typed as a bare string by the SDK, so anything unrecognized settles to + * `pending` rather than inventing progress. Cancelled entries are dropped + * outright: reporting them as `completed` would claim work that never happened, + * and Pylon's plan contract has no cancelled state to carry them into. + */ +function extractOpenCodePlanSteps(todos: ReadonlyArray): OpenCodePlanStep[] { + return todos + .filter((todo): todo is Record => todo !== null && typeof todo === "object") + .filter((todo) => todo.status !== "cancelled") + .map((todo) => ({ + step: trimText(typeof todo.content === "string" ? todo.content : undefined) ?? "Task", + status: + todo.status === "completed" + ? ("completed" as const) + : todo.status === "in_progress" + ? ("inProgress" as const) + : ("pending" as const), + })); +} + function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { const normalized = toolName.toLowerCase(); if (normalized.includes("bash") || normalized.includes("command")) { return "command_execution"; } + // Ahead of the write/edit branch: `todowrite` contains "write" but changes no + // file, and classifying it as `file_change` files it into the edit tool group + // and inflates the work log's edit count. + if (normalized.includes("todo")) { + return "dynamic_tool_call"; + } if ( normalized.includes("edit") || normalized.includes("write") || @@ -2090,6 +2129,37 @@ export function makeOpenCodeAdapter( break; } + case "todo.updated": { + // Only the parent session owns the thread's plan; a delegated child + // session's todos would otherwise overwrite it. + if (!isParentEvent) { + break; + } + const plan = extractOpenCodePlanSteps(event.properties.todos); + if (plan.length === 0) { + break; + } + // Control-character delimiters keep this deterministic without JSON; + // todo text realistically never contains them. + const fingerprint = `${turnId ?? "no-turn"}:${plan + .map((entry) => `${entry.status}\u0000${entry.step}`) + .join("\u0001")}`; + if (context.lastPlanFingerprint === fingerprint) { + break; + } + context.lastPlanFingerprint = fingerprint; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + ...(turnId ? { turnId } : {}), + raw: event, + })), + type: "turn.plan.updated", + payload: { plan }, + }); + break; + } + case "session.status": { if (event.properties.status.type === "busy") { if (turnId === undefined) { @@ -2477,6 +2547,7 @@ export function makeOpenCodeAdapter( cancellation: undefined, interruptedTurnId: undefined, reconcileIdleStatus: false, + lastPlanFingerprint: undefined, awaitingBusyAfterInterruption: false, pendingIdleReconciliation: undefined, pendingRequestRecovery: undefined, diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index f3d9b5dc9..d487da0be 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -17,6 +17,12 @@ With a server URL, Pylon connects to that external server and uses only the pass provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server. OpenCode uses this password for HTTP Basic authentication. +## Task progress + +When OpenCode keeps a todo list for a piece of work, its steps appear in the composer's Tasks tab, +in the plan summary on the turn, and on the sidebar's working line, and they update as the agent +moves through them. Steps OpenCode cancels drop off the list rather than showing as finished. + ## Refresh the model list Pylon loads the model list when an enabled OpenCode provider starts and keeps the list in its