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(),