diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 7f327cae8fb36..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"; @@ -36,10 +38,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 +5035,373 @@ 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("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 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.tap((event) => Ref.update(tapped, (all) => [...all, event])), + Stream.runDrain, + 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: runningState }, + time: 2, + }, + }); + yield* Effect.yieldNow; + yield* adapter.interruptTurn(threadId, turn.turnId); + // 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 d0b4f0de78ce1..a123d3622dfd3 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,18 @@ function isOpenCodeDefaultTitle(title: string): boolean { return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } +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 { session: ProviderSession; readonly client: OpencodeClient; @@ -327,6 +340,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 +612,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 +1066,125 @@ 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, + settled: 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. + // 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); + 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": { + if (seen.settled) { + return; + } + seen.settled = true; + 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, ) { @@ -1428,6 +1598,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); } @@ -2142,6 +2339,9 @@ export function makeOpenCodeAdapter( }; appendTurnItem(context, turnId, part); yield* emit(runtimeEvent); + if (part.tool === "task") { + yield* emitOpenCodeTaskLifecycle(context, part, turnId, event); + } } break; } @@ -2553,6 +2753,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(),