From cbd40e7d2a8e37793a7744d80be14d1797722765 Mon Sep 17 00:00:00 2001 From: sabraman Date: Sat, 22 Aug 2026 00:48:14 +0300 Subject: [PATCH] fix(opencode): surface child-session activity and approvals in parent thread A Task-tool child session's permission.asked events were filtered out because the adapter dropped every event whose session id differed from the parent's, so external_directory prompts hung invisibly and the thread looked idle forever. Track child sessions via parentID chain resolution, route their events to the parent thread with real child session refs, map unmapped permission kinds to a visible generic approval carrying raw permission detail, reply against the child session directory, keep the turn open while children run, settle pending permissions/questions on abort, error, exit, and teardown, and re-assert the T3 runtime-mode ruleset on each child at registration layered under OpenCode's derived rules so inherited allows and plan/subagent denies stay authoritative while full-access vs supervised behavior no longer depends on upstream inheritance (#30527/#30529). Subagent narration stays out of the parent transcript, mirroring the Claude adapter. --- .../features/threads/PendingApprovalCard.tsx | 8 +- apps/mobile/src/lib/threadActivity.test.ts | 29 + apps/mobile/src/lib/threadActivity.ts | 11 +- .../Layers/ProviderRuntimeIngestion.test.ts | 87 ++ .../Layers/ProviderRuntimeIngestion.ts | 35 +- .../provider/Layers/OpenCodeAdapter.test.ts | 517 +++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 904 ++++++++++++++++-- .../ComposerPendingApprovalPanel.test.tsx | 18 + .../chat/ComposerPendingApprovalPanel.tsx | 8 +- .../src/components/chat/MessagesTimeline.tsx | 1 + apps/web/src/session-logic.test.ts | 29 + apps/web/src/session-logic.ts | 10 +- packages/contracts/src/providerRuntime.ts | 4 + 13 files changed, 1587 insertions(+), 74 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index 377ae82aba8b..b900b0c6432a 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -22,7 +22,13 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { Approval needed - {props.approval.requestKind} + {props.approval.requestKind === "command" + ? "Command approval" + : props.approval.requestKind === "file-read" + ? "File-read approval" + : props.approval.requestKind === "file-change" + ? "File-change approval" + : "Permission approval"} {props.approval.detail ? ( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e1d46fd858e9..272e93b45284 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -14,6 +14,7 @@ import { import { buildPendingUserInputAnswers, buildThreadFeed, + derivePendingApprovals, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, @@ -150,6 +151,34 @@ function makeThread( }; } +describe("derivePendingApprovals", () => { + it("keeps unknown provider permissions visible", () => { + const approvals = derivePendingApprovals([ + makeActivity({ + id: EventId.make("mobile-approval-external-directory"), + createdAt: "2026-04-01T00:00:01.000Z", + kind: "approval.requested", + summary: "Approval requested", + tone: "approval", + payload: { + requestId: "mobile-req-external-directory", + requestType: "unknown", + detail: "Permission: external_directory", + }, + }), + ]); + + expect(approvals).toEqual([ + { + requestId: "mobile-req-external-directory", + requestKind: "unknown", + createdAt: "2026-04-01T00:00:01.000Z", + detail: "Permission: external_directory", + }, + ]); + }); +}); + describe("buildThreadFeed", () => { it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2a..bf720a968cac 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -14,7 +14,7 @@ import * as Order from "effect/Order"; export interface PendingApproval { readonly requestId: ApprovalRequestId; - readonly requestKind: "command" | "file-read" | "file-change"; + readonly requestKind: "command" | "file-read" | "file-change" | "unknown"; readonly createdAt: string; readonly detail?: string; } @@ -147,6 +147,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "unknown": + return "unknown"; default: return null; } @@ -632,6 +634,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { if (entry.requestKind === "command") return "command"; if (entry.requestKind === "file-read") return "eye"; if (entry.requestKind === "file-change") return "edit"; + if (entry.requestKind === "unknown") return "alert"; if (entry.itemType === "command_execution" || entry.command) return "command"; if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; if (entry.itemType === "web_search") return "globe"; @@ -967,7 +970,8 @@ function extractWorkLogRequestKind( if ( payload?.requestKind === "command" || payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" + payload?.requestKind === "file-change" || + payload?.requestKind === "unknown" ) { return payload.requestKind; } @@ -1367,7 +1371,8 @@ export function derivePendingApprovals( const requestKind = payload?.requestKind === "command" || payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" + payload?.requestKind === "file-change" || + payload?.requestKind === "unknown" ? payload.requestKind : requestKindFromRequestType(payload?.requestType); const detail = typeof payload?.detail === "string" ? payload.detail : undefined; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1e1374c966b6..425aae07d6cf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2651,6 +2651,26 @@ describe("ProviderRuntimeIngestion", () => { }, }); + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened-unknown"), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId: asThreadId("thread-1"), + requestId: ApprovalRequestId.make("req-external-directory"), + providerRefs: { + providerSessionId: "ses_child", + }, + payload: { + requestType: "unknown", + detail: "Permission: external_directory\n\nPatterns:\n/another/project/*", + args: { + permission: "external_directory", + sessionID: "ses_child", + }, + }, + }); + await waitForThread( harness.readModel, (entry) => @@ -2659,6 +2679,9 @@ describe("ProviderRuntimeIngestion", () => { ) && entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.resolved", + ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-opened-unknown", ), ); @@ -2676,6 +2699,21 @@ describe("ProviderRuntimeIngestion", () => { expect(requestedPayload?.requestKind).toBe("command"); expect(requestedPayload?.requestType).toBe("command_execution_approval"); + const unknownRequested = thread?.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-opened-unknown", + ); + const unknownPayload = + unknownRequested?.payload && typeof unknownRequested.payload === "object" + ? (unknownRequested.payload as Record) + : undefined; + expect(unknownPayload?.requestKind).toBe("unknown"); + expect(unknownPayload?.providerSessionId).toBe("ses_child"); + expect(unknownPayload?.detail).toContain("external_directory"); + expect(unknownPayload?.args).toEqual({ + permission: "external_directory", + sessionID: "ses_child", + }); + const resolved = thread?.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-resolved", ); @@ -2714,6 +2752,55 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime exploded"); }); + it("keeps parent session state when a child-session runtime.error arrives", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + // Seed a running session so there is live turn state to protect. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-child-error"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-child-error"), + updatedAt: now, + lastError: null, + }, + createdAt: now, + }); + + // A subagent failure must surface as an activity, but it must not flip + // the parent thread's session into error — the root session is still + // working and owns that lifecycle. + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-runtime-error-child"), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-child-error"), + providerRefs: { + providerSessionId: "ses_child", + providerParentSessionId: "ses_parent", + }, + payload: { + message: "child task failed", + }, + }); + + await waitForThread(harness.readModel, (entry) => + entry.activities.some((activity) => activity.id === "evt-runtime-error-child"), + ); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("running"); + expect(thread?.session?.lastError).toBeNull(); + }); + it("records runtime.error activities from the typed payload message", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 953ba1ec9b0d..72c0fbc24fa6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -298,7 +298,7 @@ function sessionStatusAllowsActiveTurn( function requestKindFromCanonicalRequestType( requestType: string | undefined, -): "command" | "file-read" | "file-change" | undefined { +): "command" | "file-read" | "file-change" | "unknown" | undefined { switch (requestType) { case "command_execution_approval": case "exec_command_approval": @@ -308,6 +308,8 @@ function requestKindFromCanonicalRequestType( case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "unknown": + return "unknown"; default: return undefined; } @@ -369,6 +371,15 @@ export function runtimeEventToActivities( ? { sequence: eventWithSequence.sessionSequence } : {}; })(); + const providerSessionFields = + event.providerRefs?.providerSessionId !== undefined + ? { + providerSessionId: event.providerRefs.providerSessionId, + ...(event.providerRefs.providerParentSessionId !== undefined + ? { providerParentSessionId: event.providerRefs.providerParentSessionId } + : {}), + } + : {}; switch (event.type) { case "request.opened": { if (event.payload.requestType === "tool_user_input") { @@ -391,9 +402,11 @@ export function runtimeEventToActivities( : "Approval requested", payload: { requestId: toApprovalRequestId(event.requestId), + ...providerSessionFields, ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.detail ? { detail: event.payload.detail } : {}), + ...(event.payload.args !== undefined ? { args: event.payload.args } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -415,6 +428,7 @@ export function runtimeEventToActivities( summary: "Approval resolved", payload: { requestId: toApprovalRequestId(event.requestId), + ...providerSessionFields, ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.decision ? { decision: event.payload.decision } : {}), @@ -435,6 +449,7 @@ export function runtimeEventToActivities( summary: "Runtime error", payload: { message: truncateDetail(event.payload.message), + ...providerSessionFields, }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -452,6 +467,7 @@ export function runtimeEventToActivities( summary: `Tool denied: ${event.payload.toolName}`, payload: { toolName: event.payload.toolName, + ...providerSessionFields, ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), ...(event.payload.reason ? { detail: truncateDetail(event.payload.reason) } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), @@ -474,6 +490,7 @@ export function runtimeEventToActivities( summary: truncateDetail(event.payload.message, 120), payload: { message: truncateDetail(event.payload.message), + ...providerSessionFields, ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), }, turnId: toTurnId(event.turnId) ?? null, @@ -512,6 +529,7 @@ export function runtimeEventToActivities( summary: "User input requested", payload: { ...(event.requestId ? { requestId: event.requestId } : {}), + ...providerSessionFields, questions: event.payload.questions, }, turnId: toTurnId(event.turnId) ?? null, @@ -530,6 +548,7 @@ export function runtimeEventToActivities( summary: "User input submitted", payload: { ...(event.requestId ? { requestId: event.requestId } : {}), + ...providerSessionFields, answers: event.payload.answers, }, turnId: toTurnId(event.turnId) ?? null, @@ -553,6 +572,7 @@ export function runtimeEventToActivities( : "Task started", payload: { taskId: event.payload.taskId, + ...providerSessionFields, ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}), ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } @@ -602,6 +622,7 @@ export function runtimeEventToActivities( : "Reasoning update", payload: { taskId: event.payload.taskId, + ...providerSessionFields, ...title, detail: truncateDetail(event.payload.summary ?? event.payload.description), ...(event.payload.summary @@ -630,6 +651,7 @@ export function runtimeEventToActivities( summary: "Task usage updated", payload: { taskId: event.payload.taskId, + ...providerSessionFields, ...title, ...identityLinkage, usageSnapshot: true, @@ -658,6 +680,7 @@ export function runtimeEventToActivities( : "Task updated", payload: { taskId: event.payload.taskId, + ...providerSessionFields, ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), @@ -692,6 +715,7 @@ export function runtimeEventToActivities( summary: event.payload.toolName ?? "Tool progress", payload: { taskId: event.payload.taskId, + ...providerSessionFields, ...(event.payload.toolName ? { toolName: event.payload.toolName } : {}), ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), ...(event.payload.elapsedSeconds !== undefined @@ -722,6 +746,7 @@ export function runtimeEventToActivities( : "Task completed", payload: { taskId: event.payload.taskId, + ...providerSessionFields, status: event.payload.status, ...(taskTitle ? { title: truncateDetail(taskTitle, 120) } : {}), // summary + detail mirror task.progress: clients label the row from @@ -754,6 +779,7 @@ export function runtimeEventToActivities( kind: "context-compaction", summary: "Context compacted", payload: { + ...providerSessionFields, state: event.payload.state, ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), }, @@ -776,7 +802,7 @@ export function runtimeEventToActivities( tone: "info", kind: "context-window.updated", summary: "Context window updated", - payload, + payload: { ...payload, ...providerSessionFields }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, }, @@ -802,6 +828,7 @@ export function runtimeEventToActivities( kind: "tool.updated", summary: event.payload.title ?? "Tool updated", payload: { + ...providerSessionFields, itemType: event.payload.itemType, ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), @@ -830,6 +857,7 @@ export function runtimeEventToActivities( kind: "tool.completed", summary: event.payload.title ?? "Tool", payload: { + ...providerSessionFields, itemType: event.payload.itemType, ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), @@ -858,6 +886,7 @@ export function runtimeEventToActivities( kind: "tool.started", summary: `${event.payload.title ?? "Tool"} started`, payload: { + ...providerSessionFields, itemType: event.payload.itemType, ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), @@ -1884,7 +1913,7 @@ const make = Effect.gen(function* () { ? true : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); - if (shouldApplyRuntimeError) { + if (shouldApplyRuntimeError && event.providerRefs?.providerParentSessionId === undefined) { yield* orchestrationEngine.dispatch({ type: "thread.session.set", commandId: yield* providerCommandId(event, "runtime-error-session-set"), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..63df48408124 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -16,6 +16,7 @@ import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; import { + ApprovalRequestId, OpenCodeSettings, ProviderDriverKind, ProviderInstanceId, @@ -68,11 +69,21 @@ const runtimeMock = { closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as unknown[], + eventGate: undefined as Promise | undefined, sessionGetIds: [] as string[], + sessionInfoById: new Map>(), missingSessionIds: new Set(), transientErrorSessionIds: new Set(), sessionDirectoryById: new Map(), sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, + permissionReplyCalls: [] as Array<{ + requestID: string; + directory?: string; + reply: string; + }>, + permissionRejectCalls: [] as Array<{ requestID: string; directory?: string }>, + questionReplyCalls: [] as Array<{ requestID: string; directory?: string; answers: unknown }>, + questionRejectCalls: [] as Array<{ requestID: string; directory?: string }>, forkCalls: [] as Array<{ sessionID: string; directory?: string }>, }, reset() { @@ -89,11 +100,17 @@ const runtimeMock = { this.state.messages = []; this.state.subscribedEvents = []; this.state.sessionGetIds.length = 0; + this.state.sessionInfoById.clear(); this.state.missingSessionIds.clear(); this.state.transientErrorSessionIds.clear(); this.state.sessionDirectoryById.clear(); this.state.sessionUpdateCalls.length = 0; + this.state.permissionReplyCalls.length = 0; + this.state.permissionRejectCalls.length = 0; + this.state.questionReplyCalls.length = 0; + this.state.questionRejectCalls.length = 0; this.state.forkCalls.length = 0; + this.state.eventGate = undefined; }, }; @@ -159,7 +176,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }); } const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); - return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; + const sessionInfo = runtimeMock.state.sessionInfoById.get(sessionID); + return { + data: { + id: sessionID, + ...(directory ? { directory } : {}), + ...sessionInfo, + }, + }; }, update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); @@ -174,7 +198,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; }, - abort: async ({ sessionID }: { sessionID: string }) => { + abort: async ({ sessionID }: { sessionID: string; directory?: string }) => { runtimeMock.state.abortCalls.push(sessionID); }, promptAsync: async (input: unknown) => { @@ -203,9 +227,30 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : runtimeMock.state.messages; }, }, + permission: { + reply: async (input: { requestID: string; directory?: string; reply: string }) => { + runtimeMock.state.permissionReplyCalls.push(input); + }, + reject: async (input: { requestID: string; directory?: string }) => { + runtimeMock.state.permissionRejectCalls.push(input); + }, + }, + question: { + reply: async (input: { requestID: string; directory?: string; answers: unknown }) => { + runtimeMock.state.questionReplyCalls.push(input); + }, + reject: async (input: { requestID: string; directory?: string }) => { + runtimeMock.state.questionRejectCalls.push(input); + }, + }, event: { subscribe: async () => ({ stream: (async function* () { + // Optional gate so tests can hold provider events until the + // adapter-side setup (e.g. an in-flight turn) has landed. + if (runtimeMock.state.eventGate) { + await runtimeMock.state.eventGate; + } for (const event of runtimeMock.state.subscribedEvents) { yield event; } @@ -1258,6 +1303,474 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("routes child permission requests into the parent with generic details", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-child-permission"); + const parentSessionId = "http://127.0.0.1:9999/session"; + const childSessionId = "ses_child-permission"; + runtimeMock.state.subscribedEvents = [ + { + type: "session.created", + properties: { + info: { + id: childSessionId, + parentID: parentSessionId, + directory: "/repo/child", + title: "Inspect workspace", + agent: "explore", + model: { providerID: "openai", id: "gpt-5" }, + }, + }, + }, + { + type: "session.status", + properties: { sessionID: childSessionId, status: { type: "busy" } }, + }, + { + type: "permission.asked", + properties: { + id: "perm-child-external-directory", + sessionID: childSessionId, + permission: "external_directory", + patterns: ["/Users/sabraman/sandbox/marginalia/marginalia-simulator/*"], + always: [], + metadata: { reason: "Task needs simulator files" }, + }, + }, + { + type: "session.status", + properties: { sessionID: parentSessionId, status: { type: "idle" } }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["session.started", "thread.started", "task.started", "task.progress", "request.opened"], + ); + const approval = events.find((event) => event.type === "request.opened"); + NodeAssert.ok(approval); + if (approval.type === "request.opened") { + NodeAssert.equal(approval.requestId, "perm-child-external-directory"); + NodeAssert.equal(approval.providerRefs?.providerSessionId, childSessionId); + NodeAssert.equal(approval.payload.requestType, "unknown"); + NodeAssert.match(approval.payload.detail ?? "", /external_directory/); + NodeAssert.match(approval.payload.detail ?? "", /marginalia-simulator\//); + NodeAssert.deepEqual(approval.payload.args, { + permission: "external_directory", + patterns: ["/Users/sabraman/sandbox/marginalia/marginalia-simulator/*"], + metadata: { reason: "Task needs simulator files" }, + always: [], + sessionID: childSessionId, + }); + } + NodeAssert.deepEqual(runtimeMock.state.sessionUpdateCalls, [ + { + sessionID: childSessionId, + permission: [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "webfetch", pattern: "*", action: "ask" }, + { permission: "websearch", pattern: "*", action: "ask" }, + { permission: "codesearch", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "doom_loop", pattern: "*", action: "ask" }, + { permission: "question", pattern: "*", action: "allow" }, + ], + }, + ]); + NodeAssert.equal( + (yield* adapter.listSessions()).find((session) => session.threadId === threadId)?.status, + "running", + ); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make("perm-child-external-directory"), + "accept", + ); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { + requestID: "perm-child-external-directory", + directory: "/repo/child", + reply: "once", + }, + ]); + }), + ); + + it.effect("inherits full-access permissions and exposes child completion errors", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-child-error"); + const parentSessionId = "http://127.0.0.1:9999/session"; + const childSessionId = "ses_child-error"; + runtimeMock.state.subscribedEvents = [ + { + type: "session.status", + properties: { sessionID: parentSessionId, status: { type: "busy" } }, + }, + { + type: "session.created", + properties: { + info: { + id: childSessionId, + parentID: parentSessionId, + title: "Run checks", + }, + }, + }, + { + type: "session.status", + properties: { sessionID: childSessionId, status: { type: "busy" } }, + }, + { + type: "message.updated", + properties: { + sessionID: childSessionId, + info: { id: "child-error-message", role: "assistant" }, + }, + }, + { + type: "session.error", + properties: { + sessionID: childSessionId, + error: { data: { message: "child task failed" } }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(7), + 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("1 second"))); + const taskEvents = events.filter( + (event) => + event.type === "task.started" || + event.type === "task.progress" || + event.type === "task.completed", + ); + NodeAssert.deepEqual( + taskEvents.map((event) => event.type), + ["task.started", "task.progress", "task.progress", "task.completed"], + ); + const completed = taskEvents.find((event) => event.type === "task.completed"); + NodeAssert.ok(completed); + if (completed.type === "task.completed") { + NodeAssert.equal(completed.payload.status, "failed"); + NodeAssert.equal(completed.payload.summary, "child task failed"); + NodeAssert.equal(completed.providerRefs?.providerSessionId, childSessionId); + NodeAssert.equal(completed.providerRefs?.providerParentSessionId, parentSessionId); + } + const runtimeError = events.find( + (event) => + event.type === "runtime.error" && + event.providerRefs?.providerSessionId === childSessionId, + ); + NodeAssert.ok(runtimeError); + if (runtimeError?.type === "runtime.error") { + NodeAssert.equal(runtimeError.providerRefs?.providerParentSessionId, parentSessionId); + } + NodeAssert.deepEqual(runtimeMock.state.sessionUpdateCalls, [ + { + sessionID: childSessionId, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }, + ]); + }), + ); + + it.effect("settles child approvals when the parent session is torn down", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-child-teardown"); + const parentSessionId = "http://127.0.0.1:9999/session"; + const childSessionId = "ses_child-teardown"; + runtimeMock.state.subscribedEvents = [ + { + type: "session.created", + properties: { + info: { id: childSessionId, parentID: parentSessionId, directory: "/repo/child" }, + }, + }, + { + type: "permission.asked", + properties: { + id: "perm-child-teardown", + sessionID: childSessionId, + permission: "task", + patterns: [], + always: [], + metadata: {}, + }, + }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* advanceTestClock(10); + yield* adapter.stopSession(threadId); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal( + events.some((event) => event.type === "request.opened"), + true, + ); + const resolved = events.find((event) => event.type === "request.resolved"); + NodeAssert.ok(resolved); + if (resolved.type === "request.resolved") { + NodeAssert.equal(resolved.requestId, "perm-child-teardown"); + NodeAssert.equal(resolved.payload.decision, "cancel"); + NodeAssert.equal(resolved.providerRefs?.providerSessionId, childSessionId); + } + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "perm-child-teardown", directory: "/repo/child", reply: "reject" }, + ]); + NodeAssert.equal(runtimeMock.state.abortCalls.includes(childSessionId), true); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("layers the runtime-mode baseline under inherited child rules", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-child-inherit"); + const parentSessionId = "http://127.0.0.1:9999/session"; + const childSessionId = "ses_child-inherit"; + // Rules OpenCode derived at child-create time: an inherited parent + // allow plus a plan-style deny. They must stay authoritative over the + // T3 baseline (last matching rule wins). + runtimeMock.state.sessionInfoById.set(childSessionId, { + parentID: parentSessionId, + directory: "/repo/child", + permission: [ + { permission: "external_directory", pattern: "/tmp/foo/*", action: "allow" }, + { permission: "edit", pattern: "*", action: "deny" }, + ], + }); + runtimeMock.state.subscribedEvents = [ + { + type: "session.created", + properties: { + info: { id: childSessionId, parentID: parentSessionId, directory: "/repo/child" }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["session.started", "thread.started", "task.started"], + ); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, childSessionId); + NodeAssert.deepEqual(runtimeMock.state.sessionUpdateCalls[0]?.permission, [ + // Runtime-mode baseline first … + { permission: "*", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "webfetch", pattern: "*", action: "ask" }, + { permission: "websearch", pattern: "*", action: "ask" }, + { permission: "codesearch", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "doom_loop", pattern: "*", action: "ask" }, + { permission: "question", pattern: "*", action: "allow" }, + // … inherited rules last, so they override where they match. + { permission: "external_directory", pattern: "/tmp/foo/*", action: "allow" }, + { permission: "edit", pattern: "*", action: "deny" }, + ]); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("completes a root turn once its final pending approval is resolved", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-root-reply-settles"); + const parentSessionId = "http://127.0.0.1:9999/session"; + // Hold provider events until the turn is in flight so the idle-while- + // pending interleaving is deterministic. + let releaseEvents!: () => void; + runtimeMock.state.eventGate = new Promise((resolve) => { + releaseEvents = resolve; + }); + runtimeMock.state.subscribedEvents = [ + { + type: "session.status", + properties: { sessionID: parentSessionId, status: { type: "busy" } }, + }, + { + type: "permission.asked", + properties: { + id: "perm-root-last", + sessionID: parentSessionId, + permission: "bash", + patterns: ["ls -la"], + always: [], + metadata: {}, + }, + }, + // The root reports idle while the approval is still open; resolving + // it afterwards must finish the turn without needing another event. + { + type: "session.status", + properties: { sessionID: parentSessionId, status: { type: "idle" } }, + }, + { + type: "permission.replied", + properties: { sessionID: parentSessionId, requestID: "perm-root-last", reply: "once" }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(6), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + const started = yield* adapter.sendTurn({ + provider: ProviderDriverKind.make("opencode"), + threadId, + interactionMode: "default", + input: "run it", + modelSelection: { instanceId: ProviderInstanceId.make("opencode"), model: "prov/model" }, + }); + releaseEvents(); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + [ + "session.started", + "thread.started", + "turn.started", + "request.opened", + "request.resolved", + "turn.completed", + ], + ); + NodeAssert.equal(started.turnId != null, true); + const completed = events.find((event) => event.type === "turn.completed"); + if (completed?.type === "turn.completed") { + NodeAssert.equal(completed.payload.state, "completed"); + } + NodeAssert.equal( + (yield* adapter.listSessions()).find((session) => session.threadId === threadId)?.status, + "ready", + ); + }), + ); + + it.effect("keeps child narration out of the parent transcript", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-child-text"); + const parentSessionId = "http://127.0.0.1:9999/session"; + const childSessionId = "ses_child-text"; + runtimeMock.state.subscribedEvents = [ + { + type: "session.created", + properties: { + info: { id: childSessionId, parentID: parentSessionId, directory: "/repo/child" }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: childSessionId, + info: { id: "child-msg-1", role: "assistant" }, + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "child-part-1", + messageID: "child-msg-1", + type: "text", + text: "Subagent narration that must not reach the chat", + time: { start: 1750000000000, end: 1750000001000 }, + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["session.started", "thread.started", "task.started", "task.progress"], + ); + NodeAssert.equal( + events.some((event) => event.type === "content.delta" || event.type === "item.completed"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("writes provider-native observability records using the session thread id", () => Effect.gen(function* () { const nativeEvents: Array<{ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..f71a7e1a09fe 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1,12 +1,15 @@ import { EventId, + type CanonicalRequestType, type OpenCodeSettings, + type ProviderRefs, ProviderDriverKind, ProviderInstanceId, type ProviderRuntimeEvent, type ProviderSession, RuntimeItemId, RuntimeRequestId, + RuntimeTaskId, ThreadId, type ToolLifecycleItemType, TurnId, @@ -23,7 +26,13 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; +import type { + OpencodeClient, + Part, + PermissionRequest, + PermissionRuleset, + QuestionRequest, +} from "@opencode-ai/sdk/v2"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -229,6 +238,8 @@ interface OpenCodeSessionContext { readonly openCodeSessionId: string; readonly pendingPermissions: Map; readonly pendingQuestions: Map; + readonly childSessions: Map; + readonly ignoredSessionIds: Set; readonly messageRoleById: Map; readonly partById: Map; readonly emittedTextByPartId: Map; @@ -237,6 +248,7 @@ interface OpenCodeSessionContext { activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; + rootStatus: "busy" | "idle" | "unknown"; /** * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. * The session lifecycle is owned by `sessionScope`; this Ref exists only @@ -254,6 +266,21 @@ interface OpenCodeSessionContext { readonly sessionScope: Scope.Closeable; } +interface OpenCodeChildSessionContext { + readonly sessionId: string; + readonly taskId: RuntimeTaskId; + parentSessionId: string; + directory: string; + title: string | undefined; + agent: string | undefined; + model: string | undefined; + active: boolean; + terminal: boolean; + failure: string | undefined; + permissionsSynchronized: boolean; + taskStarted: boolean; +} + export interface OpenCodeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; @@ -297,6 +324,7 @@ type EventBaseInput = { readonly itemId?: string | undefined; readonly requestId?: string | undefined; readonly createdAt?: string | undefined; + readonly providerRefs?: ProviderRefs | undefined; readonly raw?: unknown; }; @@ -332,9 +360,7 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { return "dynamic_tool_call"; } -function mapPermissionToRequestType( - permission: string, -): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { +function mapPermissionToRequestType(permission: string): CanonicalRequestType { switch (permission) { case "bash": return "command_execution_approval"; @@ -359,6 +385,31 @@ function mapPermissionDecision(reply: "once" | "always" | "reject"): string { } } +function openCodePermissionDetail(request: PermissionRequest): string { + const lines = [`Permission: ${request.permission}`]; + if (request.patterns.length > 0) { + lines.push(`Patterns:\n${request.patterns.join("\n")}`); + } + if (request.always.length > 0) { + lines.push(`Always allow patterns:\n${request.always.join("\n")}`); + } + if (Object.keys(request.metadata).length > 0) { + lines.push(`Metadata:\n${JSON.stringify(request.metadata)}`); + } + return lines.join("\n\n"); +} + +function openCodePermissionArgs(request: PermissionRequest): Record { + return { + permission: request.permission, + patterns: request.patterns, + metadata: request.metadata, + always: request.always, + sessionID: request.sessionID, + ...(request.tool ? { tool: request.tool } : {}), + }; +} + function resolveTurnSnapshot( context: OpenCodeSessionContext, turnId: TurnId, @@ -555,8 +606,15 @@ function updateProviderSession( }); } +interface OpenCodeContextCleanup { + readonly settlePendingRequests?: (context: OpenCodeSessionContext) => Effect.Effect; + readonly abortSessions?: (context: OpenCodeSessionContext) => Effect.Effect; + readonly completeChildTasks?: (context: OpenCodeSessionContext) => Effect.Effect; +} + const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( context: OpenCodeSessionContext, + cleanup?: OpenCodeContextCleanup, ) { // Race-safe one-shot: first caller flips the flag, everyone else no-ops. if (yield* Ref.getAndSet(context.stopped, true)) { @@ -566,9 +624,23 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( // Best-effort remote abort. The scope close below tears down the local // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), // but we still want to tell OpenCode that this session is done. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); + if (cleanup?.abortSessions) { + yield* cleanup.abortSessions(context); + } else { + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.ignore({ log: true })); + } + + // Abort first so no new provider request can be created while the pending + // maps are being drained. Event handlers also observe `stopped` and return + // without projecting late upstream events. + if (cleanup?.settlePendingRequests) { + yield* cleanup.settlePendingRequests(context); + } + if (cleanup?.completeChildTasks) { + yield* cleanup.completeChildTasks(context); + } // Closing the session scope interrupts every fiber forked into it and // runs each finalizer we registered — the `AbortController.abort()` call, @@ -627,6 +699,7 @@ export function makeOpenCodeAdapter( ...(input.turnId ? { turnId: input.turnId } : {}), ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input.providerRefs ? { providerRefs: input.providerRefs } : {}), ...(input.raw !== undefined ? { raw: { @@ -653,7 +726,7 @@ export function makeOpenCodeAdapter( // the remaining cleanups. yield* Effect.forEach( contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + (context) => Effect.ignoreCause(stopOpenCodeContext(context, openCodeContextCleanup)), { concurrency: "unbounded", discard: true }, ); // Close the logger AFTER session teardown so any final lifecycle @@ -683,6 +756,553 @@ export function makeOpenCodeAdapter( }, ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + type OpenCodeSessionInfo = { + readonly id: string; + readonly parentID?: string; + readonly directory?: string; + readonly title?: string; + readonly agent?: string; + readonly model?: { + readonly id?: string; + readonly providerID?: string; + }; + readonly permission?: PermissionRuleset; + }; + + const childDescription = (child: OpenCodeChildSessionContext): string => + trimText(child.title) ?? trimText(child.agent) ?? "OpenCode subagent"; + + const childModelLabel = (info: OpenCodeSessionInfo): string | undefined => { + if (!info.model) { + return undefined; + } + if (info.model.providerID && info.model.id) { + return `${info.model.providerID}/${info.model.id}`; + } + return trimText(info.model.id); + }; + + const openCodeEventSessionInfo = ( + event: OpenCodeSubscribedEvent, + ): OpenCodeSessionInfo | undefined => { + if ( + event.type !== "session.created" && + event.type !== "session.updated" && + event.type !== "session.deleted" + ) { + return undefined; + } + return event.properties.info as OpenCodeSessionInfo; + }; + + const buildContextEventBase = ( + context: OpenCodeSessionContext, + providerSessionId: string, + input: Omit, + ) => + buildEventBase({ + ...input, + providerRefs: { + providerSessionId, + ...(providerSessionId !== context.openCodeSessionId + ? { + // Nested tasks report their immediate parent, not the root, so + // grandchild rows stay attached to the subagent that ran them. + providerParentSessionId: + context.childSessions.get(providerSessionId)?.parentSessionId ?? + context.openCodeSessionId, + } + : {}), + }, + }); + + const hasActiveChildSessions = (context: OpenCodeSessionContext): boolean => + [...context.childSessions.values()].some((child) => child.active && !child.terminal); + + const directoryForOpenCodeSession = ( + context: OpenCodeSessionContext, + sessionId: string, + ): string => context.childSessions.get(sessionId)?.directory ?? context.directory; + + /** + * Probe one session by id. `directory` is passed through verbatim — + * `undefined` omits the filter entirely (server default), which is the + * fallback when a scoped lookup misses a child that lives under another + * working directory. A confirmed miss is distinct from a transport error: + * only misses may blacklist a session id; errors stay retryable. + */ + type OpenCodeSessionProbe = + | { readonly kind: "found"; readonly info: OpenCodeSessionInfo } + | { readonly kind: "missing" } + | { readonly kind: "unavailable" }; + + const loadOpenCodeSession = Effect.fn("loadOpenCodeSession")(function* ( + context: OpenCodeSessionContext, + sessionId: string, + directory?: string, + ) { + return yield* runOpenCodeSdk("session.get", () => + context.client.session.get({ + sessionID: sessionId, + ...(directory !== undefined ? { directory } : {}), + }), + ).pipe( + Effect.map((response): OpenCodeSessionProbe => { + const data = response.data as OpenCodeSessionInfo | undefined; + return data ? { kind: "found", info: data } : { kind: "missing" }; + }), + // A confirmed miss may blacklist the id; any other failure stays + // retryable so a transient blip can't permanently hide a child. + Effect.catch((cause) => + Effect.succeed({ + kind: isOpenCodeNotFound(cause) ? ("missing" as const) : ("unavailable" as const), + } satisfies OpenCodeSessionProbe), + ), + ); + }); + + const synchronizeChildPermissions = Effect.fn("synchronizeChildPermissions")(function* ( + context: OpenCodeSessionContext, + child: OpenCodeChildSessionContext, + ) { + if (child.permissionsSynchronized) { + return; + } + + // OpenCode derives child rules from the parent at create time + // (inherited allows, agent deny restrictions, per-call tool denies). + // Layer them ON TOP of our runtime-mode baseline: evaluation picks the + // last matching rule, so derived rules stay authoritative where they + // name a key while the baseline covers everything they don't. Writing + // only the baseline would downgrade an inherited external_directory + // allow back to ask and stomp plan-mode or subagent-specific denies. + const probe = yield* loadOpenCodeSession(context, child.sessionId, child.directory); + const existing = + probe.kind === "found" + ? ((probe.info as { readonly permission?: PermissionRuleset }).permission ?? []) + : []; + const merged = [...buildOpenCodePermissionRules(context.session.runtimeMode), ...existing]; + + const synchronized = yield* runOpenCodeSdk("session.update", () => + context.client.session.update({ + sessionID: child.sessionId, + directory: child.directory, + permission: merged, + }), + ).pipe( + Effect.mapError(toRequestError), + Effect.as(true), + Effect.catch((error) => + Effect.logWarning( + `OpenCode child session '${child.sessionId}' permission synchronization failed: ${error.detail}`, + ).pipe(Effect.as(false)), + ), + ); + if (synchronized) { + child.permissionsSynchronized = true; + } + }); + + const emitChildTaskStarted = Effect.fn("emitChildTaskStarted")(function* ( + context: OpenCodeSessionContext, + child: OpenCodeChildSessionContext, + raw: unknown, + ) { + if (child.taskStarted) { + return; + } + child.taskStarted = true; + const description = childDescription(child); + yield* emit({ + ...(yield* buildContextEventBase(context, child.sessionId, { + threadId: context.session.threadId, + turnId: context.activeTurnId, + itemId: child.sessionId, + raw, + })), + type: "task.started", + payload: { + taskId: child.taskId, + description, + taskType: "subagent", + title: description, + ...(child.agent ? { role: child.agent } : {}), + ...(child.model ? { model: child.model } : {}), + }, + }); + }); + + const emitChildTaskProgress = Effect.fn("emitChildTaskProgress")(function* ( + context: OpenCodeSessionContext, + child: OpenCodeChildSessionContext, + raw: unknown, + input?: { + readonly summary?: string; + readonly lastToolName?: string; + }, + ) { + const description = childDescription(child); + yield* emit({ + ...(yield* buildContextEventBase(context, child.sessionId, { + threadId: context.session.threadId, + turnId: context.activeTurnId, + itemId: child.sessionId, + raw, + })), + type: "task.progress", + payload: { + taskId: child.taskId, + description, + taskType: "subagent", + title: description, + ...(input?.summary ? { summary: input.summary } : {}), + ...(input?.lastToolName ? { lastToolName: input.lastToolName } : {}), + ...(child.agent ? { role: child.agent } : {}), + ...(child.model ? { model: child.model } : {}), + status: "running", + }, + }); + }); + + const emitChildTaskCompleted = Effect.fn("emitChildTaskCompleted")(function* ( + context: OpenCodeSessionContext, + child: OpenCodeChildSessionContext, + raw: unknown, + status: "completed" | "failed" | "stopped", + summary?: string, + ) { + if (child.terminal) { + return; + } + child.active = false; + child.terminal = true; + yield* emit({ + ...(yield* buildContextEventBase(context, child.sessionId, { + threadId: context.session.threadId, + turnId: context.activeTurnId, + itemId: child.sessionId, + raw, + })), + type: "task.completed", + payload: { + taskId: child.taskId, + status, + ...(summary ? { summary } : {}), + taskType: "subagent", + title: childDescription(child), + ...(child.agent ? { role: child.agent } : {}), + ...(child.model ? { model: child.model } : {}), + }, + }); + }); + + const settlePendingOpenCodeRequests = Effect.fn("settlePendingOpenCodeRequests")(function* ( + context: OpenCodeSessionContext, + sessionId?: string, + reason = "OpenCode session ended.", + ) { + const permissions = [...context.pendingPermissions.values()].filter( + (request) => sessionId === undefined || request.sessionID === sessionId, + ); + for (const request of permissions) { + context.pendingPermissions.delete(request.id); + } + for (const request of permissions) { + yield* runOpenCodeSdk("permission.reply", () => + context.client.permission.reply({ + requestID: request.id, + directory: directoryForOpenCodeSession(context, request.sessionID), + reply: "reject", + }), + ).pipe(Effect.catch(() => Effect.void)); + yield* Effect.gen(function* () { + yield* emit({ + ...(yield* buildContextEventBase(context, request.sessionID, { + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw: { + type: "permission.cancelled", + reason, + request, + }, + })), + type: "request.resolved", + payload: { + requestType: mapPermissionToRequestType(request.permission), + decision: "cancel", + resolution: { reason }, + }, + }); + }).pipe(Effect.catch(() => Effect.void)); + } + + const questions = [...context.pendingQuestions.values()].filter( + (request) => sessionId === undefined || request.sessionID === sessionId, + ); + for (const request of questions) { + context.pendingQuestions.delete(request.id); + } + for (const request of questions) { + yield* runOpenCodeSdk("question.reject", () => + context.client.question.reject({ + requestID: request.id, + directory: directoryForOpenCodeSession(context, request.sessionID), + }), + ).pipe(Effect.catch(() => Effect.void)); + yield* Effect.gen(function* () { + yield* emit({ + ...(yield* buildContextEventBase(context, request.sessionID, { + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw: { + type: "question.cancelled", + reason, + request, + }, + })), + type: "user-input.resolved", + payload: { answers: {} }, + }); + }).pipe(Effect.catch(() => Effect.void)); + } + }); + + const abortOpenCodeSessions = Effect.fn("abortOpenCodeSessions")(function* ( + context: OpenCodeSessionContext, + ) { + const sessionIds = [ + context.openCodeSessionId, + ...[...context.childSessions.values()] + .filter((child) => child.active && !child.terminal) + .map((child) => child.sessionId), + ]; + yield* Effect.forEach( + [...new Set(sessionIds)], + (sessionId) => + runOpenCodeSdk("session.abort", () => + context.client.session.abort({ + sessionID: sessionId, + directory: directoryForOpenCodeSession(context, sessionId), + }), + ).pipe(Effect.catch(() => Effect.void)), + { concurrency: "unbounded", discard: true }, + ); + }); + + const openCodeContextCleanup: OpenCodeContextCleanup = { + settlePendingRequests: (context) => settlePendingOpenCodeRequests(context), + abortSessions: (context) => abortOpenCodeSessions(context), + completeChildTasks: (context) => + Effect.forEach( + [...context.childSessions.values()].filter((child) => child.active && !child.terminal), + (child) => + emitChildTaskCompleted( + context, + child, + { type: "session.stopped" }, + "stopped", + "OpenCode child session stopped.", + ).pipe(Effect.catch(() => Effect.void)), + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.asVoid), + }; + + const completeRootTurnIfSettled = Effect.fn("completeRootTurnIfSettled")(function* ( + context: OpenCodeSessionContext, + raw: unknown, + ) { + if ( + context.rootStatus !== "idle" || + hasActiveChildSessions(context) || + context.pendingPermissions.size > 0 || + context.pendingQuestions.size > 0 + ) { + return; + } + const turnId = context.activeTurnId; + if (!turnId) { + return; + } + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); + yield* emit({ + ...(yield* buildContextEventBase(context, context.openCodeSessionId, { + threadId: context.session.threadId, + turnId, + raw, + })), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + }); + + const handleOpenCodeSessionIdle = Effect.fn("handleOpenCodeSessionIdle")(function* ( + context: OpenCodeSessionContext, + child: OpenCodeChildSessionContext | undefined, + raw: unknown, + ) { + if (child !== undefined) { + yield* settlePendingOpenCodeRequests( + context, + child.sessionId, + "OpenCode child session became idle.", + ); + yield* emitChildTaskCompleted( + context, + child, + raw, + child.failure ? "failed" : "completed", + child.failure ?? "OpenCode child session completed.", + ); + yield* completeRootTurnIfSettled(context, raw); + return; + } + + context.rootStatus = "idle"; + yield* completeRootTurnIfSettled(context, raw); + if (hasActiveChildSessions(context)) { + yield* updateProviderSession(context, { + status: "running", + activeTurnId: context.activeTurnId, + }); + } + }); + + const registerChildSession = Effect.fn("registerChildSession")(function* ( + context: OpenCodeSessionContext, + info: OpenCodeSessionInfo, + raw: unknown, + ) { + if ( + info.id === context.openCodeSessionId || + !info.parentID || + (info.parentID !== context.openCodeSessionId && !context.childSessions.has(info.parentID)) + ) { + return false; + } + + const existing = context.childSessions.get(info.id); + const child = + existing ?? + ({ + sessionId: info.id, + taskId: RuntimeTaskId.make(info.id), + parentSessionId: info.parentID, + directory: info.directory ?? context.directory, + title: trimText(info.title), + agent: trimText(info.agent), + model: childModelLabel(info), + active: true, + terminal: false, + failure: undefined, + permissionsSynchronized: false, + taskStarted: false, + } satisfies OpenCodeChildSessionContext); + if (existing) { + child.parentSessionId = info.parentID; + child.directory = info.directory ?? child.directory; + child.title = trimText(info.title) ?? child.title; + child.agent = trimText(info.agent) ?? child.agent; + child.model = childModelLabel(info) ?? child.model; + if (!child.terminal) { + child.active = true; + } + } else { + context.childSessions.set(info.id, child); + } + + yield* synchronizeChildPermissions(context, child); + yield* emitChildTaskStarted(context, child, raw); + return true; + }); + + const resolveOpenCodeEventSession = Effect.fn("resolveOpenCodeEventSession")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeSubscribedEvent, + ) { + const sessionId = openCodeEventSessionId(event); + if (!sessionId) { + return undefined; + } + if (sessionId === context.openCodeSessionId) { + return { sessionId, child: undefined } as const; + } + const knownChild = context.childSessions.get(sessionId); + if (knownChild) { + yield* synchronizeChildPermissions(context, knownChild); + return { sessionId, child: knownChild } as const; + } + if (context.ignoredSessionIds.has(sessionId)) { + return undefined; + } + + const eventInfo = openCodeEventSessionInfo(event); + const probeInitial = (): Effect.Effect => + Effect.gen(function* () { + // Unknown children usually share the parent's cwd; if the scoped + // lookup misses, retry unscoped before giving up — a child spawned + // under another working directory must still resolve. + const scoped = yield* loadOpenCodeSession(context, sessionId, context.directory); + if (scoped.kind !== "missing") { + return scoped; + } + return yield* loadOpenCodeSession(context, sessionId); + }); + let current: OpenCodeSessionInfo | undefined = + eventInfo?.id === sessionId ? eventInfo : undefined; + if (!current) { + const probe = yield* probeInitial(); + if (probe.kind !== "found") { + if (probe.kind === "missing") { + context.ignoredSessionIds.add(sessionId); + } + return undefined; + } + current = probe.info; + } + + const chain: Array = []; + const visited = new Set(); + while (current.id !== context.openCodeSessionId) { + if (visited.has(current.id) || !current.parentID) { + context.ignoredSessionIds.add(sessionId); + return undefined; + } + visited.add(current.id); + chain.push(current); + if ( + current.parentID === context.openCodeSessionId || + context.childSessions.has(current.parentID) + ) { + break; + } + const parentDirectory = + context.childSessions.get(current.parentID)?.directory ?? context.directory; + const parentProbe = yield* loadOpenCodeSession(context, current.parentID, parentDirectory); + if (parentProbe.kind === "unavailable") { + // Leave the id out of the ignore set so a later event can retry. + return undefined; + } + if (parentProbe.kind === "missing") { + context.ignoredSessionIds.add(sessionId); + return undefined; + } + current = parentProbe.info; + } + + for (const childInfo of chain.toReversed()) { + yield* registerChildSession(context, childInfo, event); + } + const child = context.childSessions.get(sessionId); + return child ? ({ sessionId, child } as const) : undefined; + }); + const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( context: OpenCodeSessionContext, message: string, @@ -696,12 +1316,26 @@ export function makeOpenCodeAdapter( } const turnId = context.activeTurnId; sessions.delete(context.session.threadId); + context.rootStatus = "idle"; + yield* abortOpenCodeSessions(context); + for (const child of context.childSessions.values()) { + if (child.active && !child.terminal) { + yield* emitChildTaskCompleted( + context, + child, + { type: "session.exited", message }, + "failed", + message, + ); + } + } + yield* settlePendingOpenCodeRequests(context, undefined, message); // Emit lifecycle events BEFORE tearing down the scope. Both call sites // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; // closing that scope triggers the fiber-interrupt finalizer, so any // subsequent yield point would unwind and silently drop these emits. yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, context.openCodeSessionId, { threadId: context.session.threadId, turnId, })), @@ -712,7 +1346,7 @@ export function makeOpenCodeAdapter( }, }).pipe(Effect.ignore); yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, context.openCodeSessionId, { threadId: context.session.threadId, turnId, })), @@ -723,12 +1357,9 @@ export function makeOpenCodeAdapter( exitKind: "error", }, }).pipe(Effect.ignore); - // Inline the teardown that `stopOpenCodeContext` would do; we can't + // Inline the scope close that `stopOpenCodeContext` would do; we can't // delegate to it because our `getAndSet` above already flipped the // one-shot guard, so the call would no-op. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); yield* Scope.close(context.sessionScope, Exit.void); }); @@ -737,6 +1368,7 @@ export function makeOpenCodeAdapter( context: OpenCodeSessionContext, part: Part, turnId: TurnId | undefined, + providerSessionId: string, raw: unknown, ) { const text = textFromPart(part); @@ -756,7 +1388,7 @@ export function makeOpenCodeAdapter( } if (deltaToEmit.length > 0) { yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, providerSessionId, { threadId: context.session.threadId, turnId, itemId: part.id, @@ -781,7 +1413,7 @@ export function makeOpenCodeAdapter( ) { context.completedAssistantPartIds.add(part.id); yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, providerSessionId, { threadId: context.session.threadId, turnId, itemId: part.id, @@ -803,10 +1435,15 @@ export function makeOpenCodeAdapter( context: OpenCodeSessionContext, event: OpenCodeSubscribedEvent, ) { - const payloadSessionId = openCodeEventSessionId(event); - if (payloadSessionId !== context.openCodeSessionId) { + if (yield* Ref.get(context.stopped)) { return; } + const route = yield* resolveOpenCodeEventSession(context, event); + if (!route) { + return; + } + const payloadSessionId = route.sessionId; + const child = route.child; const turnId = context.activeTurnId; yield* writeNativeEventBestEffort(context.session.threadId, { @@ -814,7 +1451,7 @@ export function makeOpenCodeAdapter( event: { provider: PROVIDER, threadId: context.session.threadId, - providerThreadId: context.openCodeSessionId, + providerThreadId: payloadSessionId, type: event.type, ...(turnId ? { turnId } : {}), payload: event, @@ -822,11 +1459,14 @@ export function makeOpenCodeAdapter( }); switch (event.type) { + case "session.created": + break; + case "session.updated": { const title = openCodeEventSessionTitle(event); - if (title) { + if (title && child === undefined) { yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, raw: event, })), @@ -838,6 +1478,29 @@ export function makeOpenCodeAdapter( }, }, }); + } else if (child !== undefined && !child.terminal) { + yield* emitChildTaskProgress(context, child, event, { + summary: title ?? "Working", + }); + } + break; + } + + case "session.deleted": { + if (child !== undefined) { + yield* settlePendingOpenCodeRequests( + context, + child.sessionId, + "OpenCode child session was deleted.", + ); + yield* emitChildTaskCompleted( + context, + child, + event, + "stopped", + "OpenCode child session was deleted.", + ); + yield* completeRootTurnIfSettled(context, event); } break; } @@ -849,9 +1512,20 @@ export function makeOpenCodeAdapter( if (part.messageID !== event.properties.info.id) { continue; } - yield* emitAssistantTextDelta(context, part, turnId, event); + // Child narration stays out of the parent transcript — it would + // interleave subagent prose into the chat. Their results reach + // the UI via task.* lifecycle rows instead (mirrors Claude). + if (child === undefined) { + yield* emitAssistantTextDelta(context, part, turnId, payloadSessionId, event); + } } } + if (child !== undefined && !child.terminal) { + yield* emitChildTaskProgress(context, child, event, { + summary: + event.properties.info.role === "assistant" ? "Generating response" : "Working", + }); + } break; } @@ -869,6 +1543,11 @@ export function makeOpenCodeAdapter( if (role !== "assistant") { break; } + // Subagent narration is not streamed into the parent transcript; + // task.progress already reports child activity. + if (child !== undefined) { + break; + } const streamKind = resolveTextStreamKind(existingPart); const delta = event.properties.delta; if (delta.length === 0) { @@ -890,7 +1569,7 @@ export function makeOpenCodeAdapter( }); } yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId, itemId: event.properties.partID, @@ -902,6 +1581,11 @@ export function makeOpenCodeAdapter( delta: deltaToEmit, }, }); + if (child !== undefined && !child.terminal) { + yield* emitChildTaskProgress(context, child, event, { + summary: "Generating response", + }); + } break; } @@ -910,8 +1594,8 @@ export function makeOpenCodeAdapter( context.partById.set(part.id, part); const messageRole = messageRoleForPart(context, part); - if (messageRole === "assistant") { - yield* emitAssistantTextDelta(context, part, turnId, event); + if (messageRole === "assistant" && child === undefined) { + yield* emitAssistantTextDelta(context, part, turnId, payloadSessionId, event); } if (part.type === "tool") { @@ -934,7 +1618,7 @@ export function makeOpenCodeAdapter( }, }; const runtimeEvent: ProviderRuntimeEvent = { - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId, itemId: part.callID, @@ -951,14 +1635,41 @@ export function makeOpenCodeAdapter( }; appendTurnItem(context, turnId, part); yield* emit(runtimeEvent); + if (child !== undefined && !child.terminal) { + yield* emitChildTaskProgress(context, child, event, { + summary: + part.state.status === "running" + ? (part.state.title ?? `Running ${part.tool}`) + : part.state.status === "completed" + ? `Completed ${part.tool}` + : part.state.status === "error" + ? `Failed ${part.tool}` + : `Working ${part.tool}`, + lastToolName: part.tool, + }); + } } break; } case "permission.asked": { context.pendingPermissions.set(event.properties.id, event.properties); + // The approval surfaces below regardless of child lifecycle; do not + // resurrect a child that already emitted task.completed — reopening + // it without a fresh task.started leaves settlement state ambiguous. + if (child !== undefined) { + if (!child.terminal) { + child.active = true; + } + } else { + context.rootStatus = "busy"; + yield* updateProviderSession(context, { + status: "running", + activeTurnId: turnId, + }); + } yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId, requestId: event.properties.id, @@ -967,20 +1678,18 @@ export function makeOpenCodeAdapter( type: "request.opened", payload: { requestType: mapPermissionToRequestType(event.properties.permission), - detail: - event.properties.patterns.length > 0 - ? event.properties.patterns.join("\n") - : event.properties.permission, - args: event.properties.metadata, + detail: openCodePermissionDetail(event.properties), + args: openCodePermissionArgs(event.properties), }, }); break; } case "permission.replied": { + const request = context.pendingPermissions.get(event.properties.requestID); context.pendingPermissions.delete(event.properties.requestID); yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId, requestId: event.properties.requestID, @@ -988,17 +1697,33 @@ export function makeOpenCodeAdapter( })), type: "request.resolved", payload: { - requestType: "unknown", + requestType: mapPermissionToRequestType(request?.permission ?? "unknown"), decision: mapPermissionDecision(event.properties.reply), }, }); + if (child !== undefined && !child.terminal) { + yield* emitChildTaskProgress(context, child, event, { + summary: "Permission response received", + }); + } + // The root may have already reported idle while this request was + // pending; resolving the last blocker must let the turn finish even + // if no further provider event arrives. + yield* completeRootTurnIfSettled(context, event); break; } case "question.asked": { context.pendingQuestions.set(event.properties.id, event.properties); + if (child === undefined) { + context.rootStatus = "busy"; + yield* updateProviderSession(context, { + status: "running", + activeTurnId: turnId, + }); + } yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId, requestId: event.properties.id, @@ -1022,7 +1747,7 @@ export function makeOpenCodeAdapter( ]), ); yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId, requestId: event.properties.requestID, @@ -1031,13 +1756,16 @@ export function makeOpenCodeAdapter( type: "user-input.resolved", payload: { answers }, }); + // Mirror the permission path: the root may have idled while the + // question was pending. + yield* completeRootTurnIfSettled(context, event); break; } case "question.rejected": { context.pendingQuestions.delete(event.properties.requestID); yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId, requestId: event.properties.requestID, @@ -1046,11 +1774,23 @@ export function makeOpenCodeAdapter( type: "user-input.resolved", payload: { answers: {} }, }); + yield* completeRootTurnIfSettled(context, event); break; } + case "session.idle": + yield* handleOpenCodeSessionIdle(context, child, event); + break; + case "session.status": { if (event.properties.status.type === "busy") { + if (child !== undefined && !child.terminal) { + child.active = true; + yield* emitChildTaskProgress(context, child, event, { summary: "Working" }); + } + if (child === undefined) { + context.rootStatus = "busy"; + } yield* updateProviderSession(context, { status: "running", activeTurnId: turnId, @@ -1059,7 +1799,7 @@ export function makeOpenCodeAdapter( if (event.properties.status.type === "retry") { yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId, raw: event, @@ -1070,30 +1810,54 @@ export function makeOpenCodeAdapter( detail: event.properties.status, }, }); + if (child !== undefined && !child.terminal) { + yield* emitChildTaskProgress(context, child, event, { + summary: event.properties.status.message, + }); + } break; } - if (event.properties.status.type === "idle" && turnId) { - context.activeTurnId = undefined; - yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); + if (event.properties.status.type === "idle") { + yield* handleOpenCodeSessionIdle(context, child, event); + } + break; + } + + case "session.error": { + const message = sessionErrorMessage(event.properties.error); + if (child !== undefined) { + child.failure = message; + child.active = false; + yield* settlePendingOpenCodeRequests(context, child.sessionId, message); + yield* emitChildTaskCompleted(context, child, event, "failed", message); yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId, raw: event, })), - type: "turn.completed", + type: "runtime.error", payload: { - state: "completed", + message, + class: "provider_error", + detail: event.properties.error, }, }); + yield* completeRootTurnIfSettled(context, event); + break; } - break; - } - case "session.error": { - const message = sessionErrorMessage(event.properties.error); const activeTurnId = context.activeTurnId; + context.rootStatus = "idle"; + yield* abortOpenCodeSessions(context); + yield* settlePendingOpenCodeRequests(context, undefined, message); + for (const childSession of context.childSessions.values()) { + if (childSession.active && !childSession.terminal) { + childSession.failure = message; + yield* emitChildTaskCompleted(context, childSession, event, "failed", message); + } + } context.activeTurnId = undefined; yield* updateProviderSession( context, @@ -1105,7 +1869,7 @@ export function makeOpenCodeAdapter( ); if (activeTurnId) { yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, turnId: activeTurnId, raw: event, @@ -1118,7 +1882,7 @@ export function makeOpenCodeAdapter( }); } yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, payloadSessionId, { threadId: context.session.threadId, raw: event, })), @@ -1210,7 +1974,7 @@ export function makeOpenCodeAdapter( const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; const existing = sessions.get(input.threadId); if (existing) { - yield* stopOpenCodeContext(existing); + yield* stopOpenCodeContext(existing, openCodeContextCleanup); sessions.delete(input.threadId); } @@ -1394,6 +2158,8 @@ export function makeOpenCodeAdapter( openCodeSessionId: started.openCodeSession.id, pendingPermissions: new Map(), pendingQuestions: new Map(), + childSessions: new Map(), + ignoredSessionIds: new Set(), partById: new Map(), emittedTextByPartId: new Map(), messageRoleById: new Map(), @@ -1402,6 +2168,7 @@ export function makeOpenCodeAdapter( activeTurnId: undefined, activeAgent: undefined, activeVariant: undefined, + rootStatus: "unknown", stopped: yield* Ref.make(false), sessionScope: started.sessionScope, }; @@ -1476,6 +2243,7 @@ export function makeOpenCodeAdapter( const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); context.activeTurnId = turnId; + context.rootStatus = "busy"; context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); context.activeVariant = variant; yield* updateProviderSession( @@ -1519,6 +2287,7 @@ export function makeOpenCodeAdapter( ? Effect.void : Effect.gen(function* () { context.activeTurnId = undefined; + context.rootStatus = "idle"; context.activeAgent = undefined; context.activeVariant = undefined; yield* updateProviderSession( @@ -1558,14 +2327,26 @@ export function makeOpenCodeAdapter( const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = yield* ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { + const activeTurnId = turnId ?? context.activeTurnId; + if (openCodeContextCleanup.abortSessions) { + yield* openCodeContextCleanup.abortSessions(context); + } + if (openCodeContextCleanup.settlePendingRequests) { + yield* openCodeContextCleanup.settlePendingRequests(context); + } + if (openCodeContextCleanup.completeChildTasks) { + yield* openCodeContextCleanup.completeChildTasks(context); + } + context.rootStatus = "idle"; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); + if (activeTurnId) { yield* emit({ - ...(yield* buildEventBase({ + ...(yield* buildContextEventBase(context, context.openCodeSessionId, { threadId, - turnId: turnId ?? context.activeTurnId, + turnId: activeTurnId, })), type: "turn.aborted", payload: { @@ -1580,7 +2361,8 @@ export function makeOpenCodeAdapter( "respondToRequest", )(function* (threadId, requestId, decision) { const context = yield* ensureSessionContext(sessions, threadId); - if (!context.pendingPermissions.has(requestId)) { + const request = context.pendingPermissions.get(requestId); + if (!request) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "permission.reply", @@ -1591,6 +2373,7 @@ export function makeOpenCodeAdapter( yield* runOpenCodeSdk("permission.reply", () => context.client.permission.reply({ requestID: requestId, + directory: directoryForOpenCodeSession(context, request.sessionID), reply: toOpenCodePermissionReply(decision), }), ).pipe(Effect.mapError(toRequestError)); @@ -1612,6 +2395,7 @@ export function makeOpenCodeAdapter( yield* runOpenCodeSdk("question.reply", () => context.client.question.reply({ requestID: requestId, + directory: directoryForOpenCodeSession(context, request.sessionID), answers: toOpenCodeQuestionAnswers(request, answers), }), ).pipe(Effect.mapError(toRequestError)); @@ -1626,7 +2410,7 @@ export function makeOpenCodeAdapter( threadId, }); } - const stopped = yield* stopOpenCodeContext(context); + const stopped = yield* stopOpenCodeContext(context, openCodeContextCleanup); sessions.delete(threadId); if (!stopped) { return; @@ -1710,7 +2494,7 @@ export function makeOpenCodeAdapter( // interrupt the sibling fibers. Same pattern as the layer finalizer. yield* Effect.forEach( contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + (context) => Effect.ignoreCause(stopOpenCodeContext(context, openCodeContextCleanup)), { concurrency: "unbounded", discard: true }, ); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx index 5e82298d50f0..64e6ce00a721 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx @@ -50,4 +50,22 @@ describe("ComposerPendingApprovalPanel", () => { expect(markup).toContain("File read approval"); }); + + it("renders generic permission details", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Permission approval"); + expect(markup).toContain('aria-label="Permission details"'); + expect(markup).toContain("external_directory"); + }); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index d73f0f16b28f..0a8331853c11 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -18,13 +18,17 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova ? "Command approval" : approval.requestKind === "file-read" ? "File read approval" - : "File change approval"; + : approval.requestKind === "file-change" + ? "File change approval" + : "Permission approval"; const detailAriaLabel = approval.requestKind === "command" ? "Command" : approval.requestKind === "file-read" ? "File to read" - : "File change"; + : approval.requestKind === "file-change" + ? "File change" + : "Permission details"; return (
{ ]); }); + it("keeps unknown provider permissions visible as generic approvals", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-external-directory", + kind: "approval.requested", + summary: "Approval requested", + tone: "approval", + payload: { + requestId: "req-external-directory", + requestType: "unknown", + detail: "Permission: external_directory\n\nPatterns:\n/another/project/*", + args: { + permission: "external_directory", + sessionID: "ses_child", + }, + }, + }), + ]; + + expect(derivePendingApprovals(activities)).toEqual([ + { + requestId: "req-external-directory", + requestKind: "unknown", + createdAt: "2026-02-23T00:00:00.000Z", + detail: "Permission: external_directory\n\nPatterns:\n/another/project/*", + }, + ]); + }); + it("clears stale pending approvals when provider reports unknown pending request", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..06c12a85a241 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -109,7 +109,7 @@ interface DerivedWorkLogEntry extends WorkLogEntry { export interface PendingApproval { requestId: ApprovalRequestId; - requestKind: "command" | "file-read" | "file-change"; + requestKind: "command" | "file-read" | "file-change" | "unknown"; createdAt: string; detail?: string; } @@ -378,6 +378,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "unknown": + return "unknown"; default: return null; } @@ -418,7 +420,8 @@ export function derivePendingApprovals( payload && (payload.requestKind === "command" || payload.requestKind === "file-read" || - payload.requestKind === "file-change") + payload.requestKind === "file-change" || + payload.requestKind === "unknown") ? payload.requestKind : payload ? requestKindFromRequestType(payload.requestType) @@ -1674,7 +1677,8 @@ function extractWorkLogRequestKind( if ( payload?.requestKind === "command" || payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" + payload?.requestKind === "file-change" || + payload?.requestKind === "unknown" ) { return payload.requestKind; } diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index bd525e6542e2..cdfd00d0b3d8 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -43,6 +43,10 @@ const ProviderRequestId = TrimmedNonEmptyStringSchema; export type ProviderRequestId = typeof ProviderRequestId.Type; const ProviderRefs = Schema.Struct({ + /** Provider-native session identifier, including nested child sessions. */ + providerSessionId: Schema.optional(TrimmedNonEmptyStringSchema), + /** Provider-native parent session identifier for nested child sessions. */ + providerParentSessionId: Schema.optional(TrimmedNonEmptyStringSchema), providerTurnId: Schema.optional(TrimmedNonEmptyStringSchema), providerItemId: Schema.optional(ProviderItemId), providerRequestId: Schema.optional(ProviderRequestId),