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