From 1f963fbbfab2b6b2d11836017c06a18997dfb6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20Aky=C3=BCz?= <16724072+gidorah@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:09:18 +0300 Subject: [PATCH] fix(server): match OpenCode v2 subagent tool for task lifecycle The adapter only recognized the v1 task tool name, but v2 spawns subagents through the subagent tool, so no task events ever reached the Agents panel. Match both names exactly. --- .../provider/Layers/OpenCodeAdapter.test.ts | 96 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 8 +- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index a0bd6219574b..3fdff06ea887 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -5110,6 +5110,102 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.equal(tasks[2].payload.timelineBypass, true); }), ); + it.effect("emits task lifecycle for the v2 subagent tool but not for task_status", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-subagent-tool-name"); + const events = Array.from({ length: 5 }, () => promiseWithResolvers()); + runtimeMock.state.subscribedEvents = events.map((event) => event.promise); + const collected = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type.startsWith("task.") || event.type.startsWith("item.")), + ), + Stream.take(8), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Delegate", + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), + }); + const sessionID = "http://127.0.0.1:9999/session"; + const durable = (seq: number) => ({ aggregateID: sessionID, seq, version: 1 as const }); + events[0]!.resolve({ + id: "subagent-input", + created: 1, + type: "session.tool.input.started", + durable: durable(1), + data: { sessionID, assistantMessageID: "msg", id: "subagent-call", name: "subagent" }, + } satisfies OpenCodeEvent); + events[1]!.resolve({ + id: "subagent-called", + created: 2, + type: "session.tool.called", + durable: durable(2), + data: { + sessionID, + assistantMessageID: "msg", + id: "subagent-call", + input: { description: "Scout deps", subagent: "scout" }, + executed: true, + }, + } satisfies OpenCodeEvent); + events[2]!.resolve({ + id: "subagent-success", + created: 3, + type: "session.tool.success", + durable: { aggregateID: sessionID, seq: 3, version: 2 }, + data: { + sessionID, + assistantMessageID: "msg", + id: "subagent-call", + executed: true, + content: [{ type: "text", text: "Deps scouted" }], + }, + } satisfies OpenCodeEvent); + events[3]!.resolve({ + id: "status-input", + created: 4, + type: "session.tool.input.started", + durable: durable(4), + data: { sessionID, assistantMessageID: "msg", id: "status-call", name: "task_status" }, + } satisfies OpenCodeEvent); + events[4]!.resolve({ + id: "status-success", + created: 5, + type: "session.tool.success", + durable: { aggregateID: sessionID, seq: 5, version: 2 }, + data: { + sessionID, + assistantMessageID: "msg", + id: "status-call", + executed: true, + content: [{ type: "text", text: "ok" }], + }, + } satisfies OpenCodeEvent); + const received = yield* Fiber.join(collected); + const tasks = received.filter((event) => event.type.startsWith("task.")); + NodeAssert.deepEqual( + tasks.map((event) => event.type), + ["task.started", "task.progress", "task.completed"], + ); + NodeAssert.ok(tasks[0]?.type === "task.started"); + NodeAssert.equal(tasks[0].payload.taskId, "subagent-call"); + NodeAssert.ok(tasks[1]?.type === "task.progress"); + NodeAssert.equal(tasks[1].payload.description, "Scout deps"); + NodeAssert.equal(tasks[1].payload.role, "scout"); + NodeAssert.ok(tasks[2]?.type === "task.completed"); + NodeAssert.equal(tasks[2].payload.status, "completed"); + }), + ); it.effect("closes a task whose terminal arrives before its start", () => 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 5ec22fb5399c..efa05dde9865 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -574,7 +574,11 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { } function isSubagentToolName(name: string): boolean { - return name.trim().toLowerCase() === "task"; + // OpenCode v1 spawns subagents through the `task` tool; v2 renamed it to + // `subagent` (proven by live `data.tool` values, never `task`). Match both + // exactly so near-misses like `task_status` stay plain tool rows. + const normalized = name.trim().toLowerCase(); + return normalized === "task" || normalized === "subagent"; } function toolInputString( @@ -2424,6 +2428,7 @@ export function makeOpenCodeAdapter( input, "subagent_type", "subagentType", + "subagent", "agent", "taskType", ); @@ -2521,6 +2526,7 @@ export function makeOpenCodeAdapter( input, "subagent_type", "subagentType", + "subagent", "agent", "taskType", );