From 1e68715288a4162f709e977a075b4416e7e6cbab Mon Sep 17 00:00:00 2001 From: Mina-Sayed Date: Thu, 3 Sep 2026 03:40:37 +0300 Subject: [PATCH 1/3] fix(server): emit task lifecycle for opencode task tool OpenCode reports a subagent as plain tool parts, so the Agents panel fold (task.* only) never saw opencode subagents and stayed on 'No agents yet' while real subagents ran. Re-emit pending/running/completed|error task-tool parts as task.started/progress/completed, with description/role carried from the launch input (which arrives after the pending part). The existing item.* tool row is unchanged. --- .../provider/Layers/OpenCodeAdapter.test.ts | 152 +++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 160 ++++++++++++++++++ 2 files changed, 312 insertions(+) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 7f327cae8fb36..a9b20abbbea39 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -36,10 +36,12 @@ import { } from "../opencodeRuntime.ts"; import { appendOpenCodeAssistantTextDelta, + describeOpenCodeTaskToolInput, isOpenCodeNotFound, isSameOpenCodeDirectory, makeOpenCodeAdapter, mergeOpenCodeAssistantText, + summarizeOpenCodeTaskToolOutput, } from "./OpenCodeAdapter.ts"; // Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. @@ -5031,6 +5033,156 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("reads opencode task tool launch input and unwraps task results", () => + Effect.sync(() => { + NodeAssert.deepEqual(describeOpenCodeTaskToolInput({}), { + description: undefined, + role: undefined, + }); + NodeAssert.deepEqual( + describeOpenCodeTaskToolInput({ + description: " Implement snake game files ", + prompt: "Create snake.html", + subagent_type: "general", + }), + { description: "Implement snake game files", role: "general" }, + ); + NodeAssert.equal( + summarizeOpenCodeTaskToolOutput( + '\n\nDone.\n\n', + ), + "Done.", + ); + NodeAssert.equal(summarizeOpenCodeTaskToolOutput("plain output"), "plain output"); + }), + ); + + it.effect("emits task lifecycle events for the opencode task tool", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-task-tool-agents"); + const basePart = { + id: "part-task-agents", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-task-agents", + type: "tool", + callID: "call_task_agents", + tool: "task", + }; + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { ...basePart, state: { status: "pending", input: {}, raw: "" } }, + time: 1, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...basePart, + state: { + status: "running", + input: { + description: "Implement snake game files", + subagent_type: "general", + }, + title: "Implement snake game files", + time: { start: 1 }, + }, + }, + time: 2, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...basePart, + state: { + status: "completed", + input: { + description: "Implement snake game files", + subagent_type: "general", + }, + output: + '\n\nDone.\n\n', + title: "Implement snake game files", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + time: 3, + }, + }, + // Non-agent tool parts must not produce task lifecycle events. + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-read-control", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-task-agents", + type: "tool", + callID: "call_read_control", + tool: "read", + state: { + status: "completed", + input: {}, + output: "notes.txt", + title: "read", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + time: 4, + }, + }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("5 seconds"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["task.started", "task.progress", "task.completed"], + ); + const started = events[0]; + if (started?.type === "task.started") { + NodeAssert.equal(started.payload.taskId, "call_task_agents"); + NodeAssert.equal(started.payload.toolUseId, "call_task_agents"); + } + const progress = events[1]; + if (progress?.type === "task.progress") { + NodeAssert.equal(progress.payload.description, "Implement snake game files"); + NodeAssert.equal(progress.payload.role, "general"); + NodeAssert.equal(progress.payload.status, "running"); + } + const completed = events[2]; + if (completed?.type === "task.completed") { + NodeAssert.equal(completed.payload.status, "completed"); + NodeAssert.equal(completed.payload.summary, "Done."); + } + yield* adapter.stopSession(threadId); + }), + ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index d0b4f0de78ce1..7f76e8c730a89 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -7,6 +7,7 @@ import { type ProviderSession, RuntimeItemId, RuntimeRequestId, + RuntimeTaskId, ThreadId, type ToolLifecycleItemType, TurnId, @@ -320,6 +321,12 @@ function isOpenCodeDefaultTitle(title: string): boolean { return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } +interface OpenCodeTaskLinkage { + description: string | undefined; + role: string | undefined; + startedEmitted: boolean; +} + interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; @@ -327,6 +334,15 @@ interface OpenCodeSessionContext { readonly directory: string; readonly openCodeSessionId: string; readonly relatedSessionIds: Set; + /** + * Agent identity per `task`-tool call, keyed by the tool's stable call id. + * OpenCode reports a subagent as plain tool parts (pending → running → + * completed); the Agents surface only folds `task.*` activities, so the + * adapter re-emits that lifecycle from these parts. The record remembers + * the launch input (description/role arrive after the pending part) and + * whether `task.started` was already emitted for idempotency. + */ + readonly taskAgents: Map; readonly resolvedRequestIds: Set; readonly emittedTerminalRequestIds: Set; readonly requestRelationRetries: Map; @@ -590,6 +606,35 @@ export function appendOpenCodeAssistantTextDelta( }; } +/** + * Reads the `task`-tool launch input into agent linkage. The pending part + * commonly carries an empty input object; the description and subagent type + * arrive on later updates, so callers merge this into the per-call record + * instead of replacing it. Exported for unit testing. + */ +export function describeOpenCodeTaskToolInput(input: { readonly [key: string]: unknown }): { + readonly description: string | undefined; + readonly role: string | undefined; +} { + return { + description: trimText(typeof input.description === "string" ? input.description : undefined), + role: trimText(typeof input.subagent_type === "string" ? input.subagent_type : undefined), + }; +} + +/** + * Unwraps the `task`-tool result envelope (`…`) into + * the subagent's own summary for the Agents surface. Unknown shapes pass + * through untouched. Exported for unit testing. + */ +export function summarizeOpenCodeTaskToolOutput(output: string): string { + const inner = /([\s\S]*?)<\/task_result>/i.exec(output)?.[1]; + const unwrapped = (inner ?? output) + .replace(/^]*>\s*/i, "") + .replace(/\s*<\/task>\s*$/i, ""); + return unwrapped.trim(); +} + const isoFromEpochMs = (value: number) => DateTime.make(value).pipe( Option.match({ @@ -1015,6 +1060,117 @@ export function makeOpenCodeAdapter( }, ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + /** + * Re-emits the `task`-tool part lifecycle as `task.*` runtime events so + * subagents show up in the Agents surface. OpenCode reports a subagent + * as plain tool parts (pending → running → completed/error) with the + * launch input (`description`, `subagent_type`) filling in after the + * pending part; the per-call record in `context.taskAgents` carries that + * identity forward. The existing `item.*` tool row is left untouched. + */ + const emitOpenCodeTaskLifecycle = Effect.fn("emitOpenCodeTaskLifecycle")(function* ( + context: OpenCodeSessionContext, + part: Extract, + turnId: TurnId | undefined, + raw: unknown, + ) { + const callId = part.callID; + const seen = context.taskAgents.get(callId) ?? { + description: undefined, + role: undefined, + startedEmitted: false, + }; + const inputLinkage = describeOpenCodeTaskToolInput(part.state.input); + if (inputLinkage.description !== undefined) { + seen.description = inputLinkage.description; + } + if (inputLinkage.role !== undefined) { + seen.role = inputLinkage.role; + } + context.taskAgents.set(callId, seen); + const taskId = RuntimeTaskId.make(callId); + const linkage = { + ...(seen.description !== undefined + ? { description: seen.description, title: seen.description } + : {}), + ...(seen.role !== undefined ? { role: seen.role } : {}), + toolUseId: callId, + }; + const threadId = context.session.threadId; + switch (part.state.status) { + case "pending": { + if (seen.startedEmitted) { + return; + } + seen.startedEmitted = true; + yield* emit({ + ...(yield* buildEventBase({ + threadId, + turnId, + createdAt: toolStateCreatedAt(part), + raw, + })), + type: "task.started", + payload: { taskId, ...linkage }, + }); + return; + } + case "running": { + // `task.progress` requires a description; a running part whose + // launch input has not arrived yet contributes nothing displayable. + if (seen.description === undefined) { + return; + } + const summary = trimText(part.state.title); + yield* emit({ + ...(yield* buildEventBase({ + threadId, + turnId, + createdAt: toolStateCreatedAt(part), + raw, + })), + type: "task.progress", + payload: { + taskId, + description: seen.description, + ...(summary !== undefined ? { summary } : {}), + status: "running" as const, + ...linkage, + }, + }); + return; + } + case "completed": + case "error": { + const terminal = + part.state.status === "completed" + ? { + status: "completed" as const, + summary: trimText(summarizeOpenCodeTaskToolOutput(part.state.output)), + } + : { status: "failed" as const, summary: trimText(part.state.error) }; + yield* emit({ + ...(yield* buildEventBase({ + threadId, + turnId, + createdAt: toolStateCreatedAt(part), + raw, + })), + type: "task.completed", + payload: { + taskId, + status: terminal.status, + ...(terminal.summary !== undefined ? { summary: terminal.summary } : {}), + ...linkage, + }, + }); + return; + } + default: + return; + } + }); + const cancelIdleReconciliation = Effect.fn("cancelIdleReconciliation")(function* ( context: OpenCodeSessionContext, ) { @@ -2142,6 +2298,9 @@ export function makeOpenCodeAdapter( }; appendTurnItem(context, turnId, part); yield* emit(runtimeEvent); + if (part.tool === "task") { + yield* emitOpenCodeTaskLifecycle(context, part, turnId, event); + } } break; } @@ -2553,6 +2712,7 @@ export function makeOpenCodeAdapter( directory, openCodeSessionId: started.openCodeSession.id, relatedSessionIds: new Set([started.openCodeSession.id]), + taskAgents: new Map(), resolvedRequestIds: new Set(), emittedTerminalRequestIds: new Set(), requestRelationRetries: new Map(), From 9a3ec7a21e11df967fd0a7f91a091d645c8548b5 Mon Sep 17 00:00:00 2001 From: Mina-Sayed Date: Thu, 3 Sep 2026 04:12:16 +0300 Subject: [PATCH 2/3] fix(server): settle in-flight opencode tasks on turn interrupt Stop leaves the opencode session live, so the client fold cannot derive interruption from session death and unsettled subagent rows stay 'Working' with advancing elapsed time. Sweep taskAgents in interruptOpenCodeTurn and emit task.completed/stopped per unsettled call, mirroring Claude's teardown sweep. --- .../provider/Layers/OpenCodeAdapter.test.ts | 105 ++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 38 +++++++ 2 files changed, 143 insertions(+) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index a9b20abbbea39..3f0131dfb034e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -5183,6 +5183,111 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("settles in-flight opencode tasks when the turn is interrupted", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-task-interrupt"); + const userMessageEvent = promiseWithResolvers(); + const pendingPartEvent = promiseWithResolvers(); + const runningPartEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [ + userMessageEvent.promise, + pendingPartEvent.promise, + runningPartEvent.promise, + ]; + runtimeMock.state.promptAsyncImplementation = async () => { + const prompt = runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined; + if (prompt?.messageID) { + runtimeMock.state.messages.push({ + info: { id: prompt.messageID, role: "user" }, + parts: [], + }); + } + }; + const basePart = { + id: "part-task-interrupt", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-task-interrupt", + type: "tool", + callID: "call_task_interrupt", + tool: "task", + }; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Spawn a subagent", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const turnMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + userMessageEvent.resolve({ + id: "evt-user-interrupt-turn", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: turnMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + pendingPartEvent.resolve({ + id: "evt-part-pending-interrupt", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { ...basePart, state: { status: "pending", input: {}, raw: "" } }, + time: 1, + }, + }); + runningPartEvent.resolve({ + id: "evt-part-running-interrupt", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...basePart, + state: { + status: "running", + input: { description: "Summarize notes", subagent_type: "general" }, + title: "Summarize notes", + time: { start: 1 }, + }, + }, + time: 2, + }, + }); + yield* Effect.yieldNow; + yield* adapter.interruptTurn(threadId, turn.turnId); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("5 seconds"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["task.started", "task.progress", "task.completed"], + ); + const completed = events[2]; + if (completed?.type === "task.completed") { + NodeAssert.equal(completed.payload.taskId, "call_task_interrupt"); + NodeAssert.equal(completed.payload.status, "stopped"); + } + yield* adapter.stopSession(threadId); + }), + ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 7f76e8c730a89..fc7e4c3439ded 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -325,6 +325,12 @@ interface OpenCodeTaskLinkage { description: string | undefined; role: string | undefined; startedEmitted: boolean; + /** + * True once a terminal `task.completed` went out for this call — either + * from its own completed/error part or from turn interruption settling. + * Guards both duplicate completions and repeat interruption sweeps. + */ + settled: boolean; } interface OpenCodeSessionContext { @@ -1079,6 +1085,7 @@ export function makeOpenCodeAdapter( description: undefined, role: undefined, startedEmitted: false, + settled: false, }; const inputLinkage = describeOpenCodeTaskToolInput(part.state.input); if (inputLinkage.description !== undefined) { @@ -1142,6 +1149,10 @@ export function makeOpenCodeAdapter( } case "completed": case "error": { + if (seen.settled) { + return; + } + seen.settled = true; const terminal = part.state.status === "completed" ? { @@ -1584,6 +1595,33 @@ export function makeOpenCodeAdapter( reason: "Interrupted by user.", }, }); + // Settle in-flight subagents: Stop leaves the session live, so the + // client fold cannot derive interruption from session death (it only + // does that when the session disconnects). Mirror Claude's teardown + // sweep: one terminal row per unsettled call. + for (const [callId, task] of context.taskAgents) { + if (task.settled) { + continue; + } + task.settled = true; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw, + })), + type: "task.completed", + payload: { + taskId: RuntimeTaskId.make(callId), + status: "stopped" as const, + ...(task.description !== undefined + ? { description: task.description, title: task.description } + : {}), + ...(task.role !== undefined ? { role: task.role } : {}), + toolUseId: callId, + }, + }); + } if (cancellation) { yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); } From ecff490991b1f8d2eb4dd69559e0f238493bf803 Mon Sep 17 00:00:00 2001 From: Mina-Sayed Date: Thu, 3 Sep 2026 04:38:54 +0300 Subject: [PATCH 3/3] fix(server): ignore late running parts for settled opencode tasks A delayed duplicate running part after a terminal part would emit task.progress, and the fold reads terminal-to-running as a fresh activation, flipping a settled row back to Working. Guard the running branch on the settled flag (post-interrupt parts are already dropped by suppressInterruptedParentOutput; this covers out-of-order delivery on live sessions). --- .../provider/Layers/OpenCodeAdapter.test.ts | 154 +++++++++++++++--- .../src/provider/Layers/OpenCodeAdapter.ts | 5 +- 2 files changed, 138 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3f0131dfb034e..5c7171d38cb1e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -23,6 +24,7 @@ import { ProviderDriverKind, ProviderInstanceId, ThreadId, + type ProviderRuntimeEvent, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; @@ -5213,11 +5215,17 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { callID: "call_task_interrupt", tool: "task", }; - const eventsFiber = yield* adapter.streamEvents.pipe( + const runningState = { + status: "running", + input: { description: "Summarize notes", subagent_type: "general" }, + title: "Summarize notes", + time: { start: 1 }, + }; + const tapped = yield* Ref.make(new Array()); + yield* adapter.streamEvents.pipe( Stream.filter((event) => event.threadId === threadId), - Stream.filter((event) => event.type.startsWith("task.")), - Stream.take(3), - Stream.runCollect, + Stream.tap((event) => Ref.update(tapped, (all) => [...all, event])), + Stream.runDrain, Effect.forkChild, ); @@ -5259,35 +5267,141 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { type: "message.part.updated", properties: { sessionID: "http://127.0.0.1:9999/session", - part: { - ...basePart, - state: { - status: "running", - input: { description: "Summarize notes", subagent_type: "general" }, - title: "Summarize notes", - time: { start: 1 }, - }, - }, + part: { ...basePart, state: runningState }, time: 2, }, }); yield* Effect.yieldNow; yield* adapter.interruptTurn(threadId, turn.turnId); - - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("5 seconds"))); - NodeAssert.deepEqual( - events.map((event) => event.type), - ["task.started", "task.progress", "task.completed"], - ); - const completed = events[2]; + // Wait until the stopped row lands, then assert the exact lifecycle. + let taskTypes: Array = []; + for (let flush = 0; flush < 100; flush += 1) { + const seen = yield* Ref.get(tapped); + taskTypes = seen + .filter((event) => event.type.startsWith("task.")) + .map((event) => event.type); + if (taskTypes.includes("task.completed")) { + break; + } + yield* Effect.yieldNow; + } + NodeAssert.deepEqual(taskTypes, ["task.started", "task.progress", "task.completed"]); + const all = yield* Ref.get(tapped); + const completed = all.find((event) => event.type === "task.completed"); if (completed?.type === "task.completed") { NodeAssert.equal(completed.payload.taskId, "call_task_interrupt"); NodeAssert.equal(completed.payload.status, "stopped"); + } else { + NodeAssert.fail("expected a task.completed event"); } yield* adapter.stopSession(threadId); }), ); + it.effect("ignores late running parts for settled opencode tasks", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-task-late-part"); + const basePart = { + id: "part-task-late", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-task-late", + type: "tool", + callID: "call_task_late", + tool: "task", + }; + const input = { description: "Summarize notes", subagent_type: "general" }; + // completed, then a delayed duplicate running part: without the + // settled guard the fold reads terminal→running as a reactivation and + // the row flips back to Working. + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { ...basePart, state: { status: "pending", input: {}, raw: "" } }, + time: 1, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...basePart, + state: { status: "running", input, title: "Summarize notes", time: { start: 1 } }, + }, + time: 2, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...basePart, + state: { + status: "completed", + input, + output: "Done.", + title: "Summarize notes", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + time: 3, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...basePart, + state: { status: "running", input, title: "Summarize notes", time: { start: 1 } }, + }, + time: 4, + }, + }, + ]; + const tapped = yield* Ref.make(new Array()); + yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.tap((event) => Ref.update(tapped, (all) => [...all, event])), + Stream.runDrain, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + // Wait until the late part's tool row lands, proving it was processed. + let taskTypes: Array = []; + for (let flush = 0; flush < 100; flush += 1) { + const seen = yield* Ref.get(tapped); + const updates = seen.filter( + (event) => event.type === "item.updated" && event.itemId === "call_task_late", + ); + taskTypes = seen + .filter((event) => event.type.startsWith("task.")) + .map((event) => event.type); + if (updates.length >= 2 && taskTypes.includes("task.completed")) { + break; + } + yield* Effect.yieldNow; + } + NodeAssert.deepEqual(taskTypes, ["task.started", "task.progress", "task.completed"]); + const seen = yield* Ref.get(tapped); + const updates = seen.filter( + (event) => event.type === "item.updated" && event.itemId === "call_task_late", + ); + NodeAssert.equal(updates.length >= 2, true); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index fc7e4c3439ded..a123d3622dfd3 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1125,7 +1125,10 @@ export function makeOpenCodeAdapter( case "running": { // `task.progress` requires a description; a running part whose // launch input has not arrived yet contributes nothing displayable. - if (seen.description === undefined) { + // A settled call never runs again: after interrupt (or a terminal + // part) a late running part must not resurrect the row — the fold + // reads terminal→running as a fresh activation. + if (seen.settled || seen.description === undefined) { return; } const summary = trimText(part.state.title);