From b1e56e9e574467255c0c8ddd2813cc6a76dc11f2 Mon Sep 17 00:00:00 2001 From: isit <1663054+triamazikamno@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:41:39 +0200 Subject: [PATCH 1/2] fix(opencode): surface dynamic approval requests --- apps/mobile/src/lib/threadActivity.test.ts | 28 + apps/mobile/src/lib/threadActivity.ts | 1 + .../ProviderRuntimeIngestion.approval.test.ts | 20 + .../Layers/ProviderRuntimeIngestion.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 501 +++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 156 +++++- apps/server/src/provider/opencodeRuntime.ts | 2 +- apps/web/src/session-logic.test.ts | 27 +- apps/web/src/session-logic.ts | 1 + 9 files changed, 721 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e1d46fd858e9..35e9d4bdbf5a 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, @@ -125,6 +126,33 @@ function makeActivity( }; } +describe("derivePendingApprovals", () => { + it("keeps legacy unknown approvals actionable", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: EventId.make("approval-legacy-unknown"), + kind: "approval.requested", + summary: "Approval requested", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { + requestId: "req-legacy-unknown", + requestType: "unknown", + detail: "*", + }, + }), + ]; + + expect(derivePendingApprovals(activities)).toEqual([ + { + requestId: "req-legacy-unknown", + requestKind: "command", + createdAt: "2026-04-01T00:00:02.000Z", + detail: "*", + }, + ]); + }); +}); + function makeThread( input: Partial & Pick, ): OrchestrationThread { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2a..bbe1b54b2147 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -141,6 +141,7 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ switch (requestType) { case "command_execution_approval": case "exec_command_approval": + case "unknown": return "command"; case "file_read_approval": return "file-read"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts index 05370781c0d0..3b57afe32a1e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts @@ -30,4 +30,24 @@ describe("runtimeEventToActivities approval details", () => { expect(activity?.kind).toBe("approval.requested"); expect((activity?.payload as Record | undefined)?.detail).toBe(detail); }); + + it("stamps dynamic tool approvals as command requests", () => { + const event = { + type: "request.opened", + eventId: EventId.make("evt-dynamic-request-opened"), + provider: ProviderDriverKind.make("opencode"), + createdAt: "2026-07-18T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + requestId: RuntimeRequestId.make("approval-dynamic-1"), + payload: { + requestType: "dynamic_tool_call", + detail: "codegraph_codegraph_explore", + args: {}, + }, + } satisfies ProviderRuntimeEvent; + + const [activity] = runtimeEventToActivities(event); + + expect((activity?.payload as Record | undefined)?.requestKind).toBe("command"); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 953ba1ec9b0d..7b21f84c0a05 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -302,6 +302,7 @@ function requestKindFromCanonicalRequestType( switch (requestType) { case "command_execution_approval": case "exec_command_approval": + case "dynamic_tool_call": return "command"; case "file_read_approval": return "file-read"; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..e6d4095c1361 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -67,6 +67,11 @@ const runtimeMock = { promptAsyncError: null as Error | null, closeError: null as Error | null, messages: [] as MessageEntry[], + messageCalls: [] as Array<{ sessionID: string; messageID: string }>, + messagesNeverSettling: false, + messagesRequestStarted: Promise.resolve() as Promise, + messagesRequestStartedResolve: undefined as (() => void) | undefined, + messagesAborted: false, subscribedEvents: [] as unknown[], sessionGetIds: [] as string[], missingSessionIds: new Set(), @@ -87,6 +92,14 @@ const runtimeMock = { this.state.promptAsyncError = null; this.state.closeError = null; this.state.messages = []; + this.state.messageCalls.length = 0; + this.state.messagesNeverSettling = false; + let resolveMessagesRequestStarted!: () => void; + this.state.messagesRequestStarted = new Promise((resolve) => { + resolveMessagesRequestStarted = resolve; + }); + this.state.messagesRequestStartedResolve = resolveMessagesRequestStarted; + this.state.messagesAborted = false; this.state.subscribedEvents = []; this.state.sessionGetIds.length = 0; this.state.missingSessionIds.clear(); @@ -183,7 +196,53 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { throw runtimeMock.state.promptAsyncError; } }, - messages: async () => ({ data: runtimeMock.state.messages }), + messages: async ( + { sessionID }: { sessionID: string }, + { signal }: { signal?: AbortSignal } = {}, + ) => { + runtimeMock.state.messagesRequestStartedResolve?.(); + runtimeMock.state.messagesRequestStartedResolve = undefined; + if (runtimeMock.state.messagesNeverSettling) { + return await new Promise((_, reject) => { + if (!signal) return; + const abort = () => { + runtimeMock.state.messagesAborted = true; + reject(signal.reason); + }; + if (signal.aborted) { + abort(); + } else { + signal.addEventListener("abort", abort, { once: true }); + } + }); + } + return { data: runtimeMock.state.messages }; + }, + message: async ( + { sessionID, messageID }: { sessionID: string; messageID: string }, + { signal }: { signal?: AbortSignal } = {}, + ) => { + runtimeMock.state.messageCalls.push({ sessionID, messageID }); + runtimeMock.state.messagesRequestStartedResolve?.(); + runtimeMock.state.messagesRequestStartedResolve = undefined; + if (runtimeMock.state.messagesNeverSettling) { + return await new Promise((_, reject) => { + if (!signal) return; + const abort = () => { + runtimeMock.state.messagesAborted = true; + reject(signal.reason); + }; + if (signal.aborted) { + abort(); + } else { + signal.addEventListener("abort", abort, { once: true }); + } + }); + } + return { + data: runtimeMock.state.messages.find((entry) => entry.info.id === messageID) ?? null, + }; + }, revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { runtimeMock.state.revertCalls.push({ sessionID, @@ -1150,6 +1209,446 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("maps wildcard OpenCode permissions to actionable dynamic approvals", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-dynamic-approval"); + runtimeMock.state.messages = [ + { + info: { id: "msg-gh-grep-unrelated", role: "assistant" }, + parts: [ + { + id: "part-gh-grep-unrelated", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-gh-grep-unrelated", + type: "tool", + callID: "call-gh-grep-unrelated", + tool: "gh_grep_searchGitHub", + state: { + status: "running", + input: { + repo: "effect-ts/effect", + query: "permission.asked", + }, + time: { start: 2 }, + }, + }, + ], + }, + { + info: { id: "msg-gh-grep-first", role: "assistant" }, + parts: [ + { + id: "part-gh-grep-first-running", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-gh-grep-first", + type: "tool", + callID: "call-gh-grep-first", + tool: "gh_grep_searchGitHub", + state: { + status: "running", + input: { + repo: "t3code/t3code", + query: "OpenCodeAdapter", + }, + time: { start: 3 }, + }, + }, + ], + }, + ]; + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-gh-grep-first", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-gh-grep-first", + type: "tool", + callID: "call-gh-grep-first", + tool: "gh_grep_searchGitHub", + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + time: 1, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-gh-grep-second", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-gh-grep-second", + type: "tool", + callID: "call-gh-grep-second", + tool: "gh_grep_searchGitHub", + state: { + status: "running", + input: { + repo: "effect-ts/effect", + query: "permission.asked", + }, + time: { start: 2 }, + }, + }, + time: 2, + }, + }, + { + type: "permission.asked", + properties: { + id: "permission-gh-grep", + sessionID: "http://127.0.0.1:9999/session", + permission: "gh_grep_searchGitHub", + patterns: ["*"], + metadata: {}, + always: [], + tool: { + messageID: "msg-gh-grep-first", + callID: "call-gh-grep-first", + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "request.opened"), + Stream.take(1), + 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 event = events[0]; + NodeAssert.ok(event?.type === "request.opened"); + if (event?.type === "request.opened") { + NodeAssert.equal(event.payload.requestType, "dynamic_tool_call"); + NodeAssert.equal( + event.payload.detail, + `gh_grep_searchGitHub\n\nArguments:\n{ + "repo": "t3code/t3code", + "query": "OpenCodeAdapter" +}`, + ); + NodeAssert.deepEqual(event.payload.args, { + repo: "t3code/t3code", + query: "OpenCodeAdapter", + }); + } + }), + ); + + it.effect("labels skill approvals with their permission name and pattern", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-skill-approval"); + const sessionID = "http://127.0.0.1:9999/session"; + runtimeMock.state.subscribedEvents = [ + { + type: "permission.asked", + properties: { + id: "permission-skill", + sessionID, + permission: "skill", + patterns: ["fix-review"], + metadata: {}, + always: [], + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "request.opened"), + Stream.take(1), + 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 event = events[0]; + NodeAssert.ok(event?.type === "request.opened"); + if (event?.type === "request.opened") { + NodeAssert.equal(event.payload.detail, "skill: fix-review"); + NodeAssert.deepEqual(event.payload.args, {}); + } + }), + ); + + it.effect("emits standard wildcard permissions from metadata without fetching the message", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-standard-approval"); + const sessionID = "http://127.0.0.1:9999/session"; + const metadata = { command: "pwd" }; + runtimeMock.state.subscribedEvents = [ + { + type: "permission.asked", + properties: { + id: "permission-bash", + sessionID, + permission: "bash", + patterns: ["*"], + metadata, + always: [], + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "request.opened"), + Stream.take(1), + 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 event = events[0]; + NodeAssert.ok(event?.type === "request.opened"); + if (event?.type === "request.opened") { + NodeAssert.equal(event.payload.requestType, "command_execution_approval"); + NodeAssert.equal(event.payload.detail, "*"); + NodeAssert.deepEqual(event.payload.args, metadata); + } + NodeAssert.deepEqual(runtimeMock.state.messageCalls, []); + }), + ); + + it.effect("uses an exact cached dynamic tool part without fetching the message", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-cached-dynamic-approval"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "msg-cached-dynamic"; + const callID = "call-cached-dynamic"; + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "part-cached-dynamic", + sessionID, + messageID, + type: "tool", + callID, + tool: "gh_grep_searchGitHub", + state: { + status: "running", + input: { repo: "t3code/t3code", query: "cached" }, + time: { start: 1 }, + }, + }, + time: 1, + }, + }, + { + type: "permission.asked", + properties: { + id: "permission-cached-dynamic", + sessionID, + permission: "gh_grep_searchGitHub", + patterns: ["*"], + metadata: {}, + always: [], + tool: { messageID, callID }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "request.opened"), + Stream.take(1), + 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 event = events[0]; + NodeAssert.ok(event?.type === "request.opened"); + if (event?.type === "request.opened") { + NodeAssert.deepEqual(event.payload.args, { + repo: "t3code/t3code", + query: "cached", + }); + } + NodeAssert.deepEqual(runtimeMock.state.messageCalls, []); + }), + ); + + it.effect("caps enriched dynamic permission details without truncating args", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-dynamic-approval-limit"); + const oversizedPattern = "x".repeat(3_000); + const sessionID = "http://127.0.0.1:9999/session"; + runtimeMock.state.subscribedEvents = [ + { + type: "permission.asked", + properties: { + id: "permission-dynamic-limit", + sessionID, + permission: "gh_grep_searchGitHub", + patterns: [oversizedPattern], + metadata: {}, + always: [], + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "request.opened"), + Stream.take(1), + 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 event = events[0]; + NodeAssert.ok(event?.type === "request.opened"); + if (event?.type === "request.opened") { + const detail = event.payload.detail; + NodeAssert.ok(detail); + NodeAssert.equal(detail.length, 2_000); + NodeAssert.equal(detail.endsWith("…"), true); + NodeAssert.deepEqual(event.payload.args, {}); + } + }), + ); + + it.effect("falls back to metadata when dynamic permission enrichment exceeds its timeout", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-permission-enrichment-timeout"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "msg-permission-timeout"; + const callID = "call-permission-timeout"; + runtimeMock.state.messagesNeverSettling = true; + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "part-permission-timeout-pending", + sessionID, + messageID, + type: "tool", + callID, + tool: "gh_grep_searchGitHub", + state: { status: "pending", input: {}, raw: "" }, + }, + time: 1, + }, + }, + { + type: "permission.asked", + properties: { + id: "permission-timeout", + sessionID, + permission: "gh_grep_searchGitHub", + patterns: ["*"], + metadata: {}, + always: [], + tool: { messageID, callID }, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "part-permission-timeout-running", + sessionID, + messageID, + type: "tool", + callID, + tool: "gh_grep_searchGitHub", + state: { + status: "running", + input: { command: "pwd" }, + time: { start: 2 }, + }, + }, + time: 2, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || + ((event.type === "item.started" || event.type === "item.updated") && + String(event.itemId) === callID)), + ), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + yield* Effect.promise(() => runtimeMock.state.messagesRequestStarted); + + yield* advanceTestClock(250); + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.equal(runtimeMock.state.messagesAborted, true); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["item.started", "request.opened", "item.updated"], + ); + + const requestEvents = events.filter((event) => event.type === "request.opened"); + NodeAssert.equal(requestEvents.length, 1); + const request = requestEvents[0]; + if (request?.type === "request.opened") { + NodeAssert.deepEqual(request.payload.args, {}); + } + }), + ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..cc173783061c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -61,6 +61,8 @@ const PROVIDER = ProviderDriverKind.make("opencode"); * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION). */ const OPENCODE_RESUME_VERSION = 1 as const; +const OPENCODE_PERMISSION_ENRICHMENT_TIMEOUT_MS = 250; +const OPENCODE_PERMISSION_DETAIL_MAX_LENGTH = 2_000; /** * Decode a persisted resume cursor into the upstream `ses_…` id. Anything @@ -175,11 +177,58 @@ type OpenCodeSubscribedEvent = ? TEvent : never; +type OpenCodeSessionMessage = NonNullable< + Awaited>["data"] +>; + function trimText(value: string | undefined | null): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; } +function hasOpenCodeToolInput( + part: Extract | undefined, +): part is Extract { + return part !== undefined && Object.keys(part.state.input).length > 0; +} + +function findOpenCodeToolPart( + message: OpenCodeSessionMessage | undefined, + sessionID: string, + messageID: string, + callID: string, +): Extract | undefined { + if (message?.info.id !== messageID) { + return undefined; + } + return message.parts.find( + (part): part is Extract => + part.type === "tool" && + part.sessionID === sessionID && + part.messageID === messageID && + part.callID === callID, + ); +} + +function findCachedOpenCodeToolPart( + parts: Iterable, + sessionID: string, + messageID: string, + callID: string, +): Extract | undefined { + for (const part of parts) { + if ( + part.type === "tool" && + part.sessionID === sessionID && + part.messageID === messageID && + part.callID === callID + ) { + return part; + } + } + return undefined; +} + function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined { const properties = "properties" in event ? event.properties : undefined; if (!properties || typeof properties !== "object") { @@ -334,7 +383,11 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { function mapPermissionToRequestType( permission: string, -): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { +): + | "command_execution_approval" + | "file_read_approval" + | "file_change_approval" + | "dynamic_tool_call" { switch (permission) { case "bash": return "command_execution_approval"; @@ -343,10 +396,51 @@ function mapPermissionToRequestType( case "edit": return "file_change_approval"; default: - return "unknown"; + return "dynamic_tool_call"; } } +function permissionDetail(permission: string, patterns: ReadonlyArray): string { + return patterns.length > 0 ? patterns.join("\n") : permission; +} + +function dynamicPermissionBaseDetail(permission: string, patterns: ReadonlyArray): string { + const nonBlankPatterns = patterns.filter((pattern) => pattern.trim().length > 0); + const hasMeaningfulPattern = nonBlankPatterns.some((pattern) => pattern.trim() !== "*"); + return hasMeaningfulPattern ? `${permission}: ${nonBlankPatterns.join("\n")}` : permission; +} + +function truncatePermissionDetail(detail: string): string { + return detail.length <= OPENCODE_PERMISSION_DETAIL_MAX_LENGTH + ? detail + : `${detail.slice(0, OPENCODE_PERMISSION_DETAIL_MAX_LENGTH - 1)}…`; +} + +function dynamicPermissionDetail(baseDetail: string, args: unknown): string { + if (args === undefined || args === null) { + return truncatePermissionDetail(baseDetail); + } + + let serialized: string | undefined; + try { + serialized = JSON.stringify(args, null, 2); + } catch { + return truncatePermissionDetail(baseDetail); + } + + if ( + serialized === undefined || + serialized === "{}" || + serialized === "[]" || + serialized === '""' + ) { + return truncatePermissionDetail(baseDetail); + } + + const detail = `${baseDetail}\n\nArguments:\n${serialized}`; + return truncatePermissionDetail(detail); +} + function mapPermissionDecision(reply: "once" | "always" | "reject"): string { switch (reply) { case "once": @@ -957,6 +1051,54 @@ export function makeOpenCodeAdapter( case "permission.asked": { context.pendingPermissions.set(event.properties.id, event.properties); + const requestType = mapPermissionToRequestType(event.properties.permission); + const toolCallId = event.properties.tool?.callID; + const toolMessageId = event.properties.tool?.messageID; + let toolPart = + requestType === "dynamic_tool_call" && + toolCallId !== undefined && + toolMessageId !== undefined + ? findCachedOpenCodeToolPart( + context.partById.values(), + context.openCodeSessionId, + toolMessageId, + toolCallId, + ) + : undefined; + if ( + requestType === "dynamic_tool_call" && + toolCallId !== undefined && + toolMessageId !== undefined && + Object.keys(event.properties.metadata).length === 0 && + !hasOpenCodeToolInput(toolPart) + ) { + const snapshotToolPart = yield* runOpenCodeSdk("session.message", (signal) => + context.client.session.message( + { + sessionID: context.openCodeSessionId, + messageID: toolMessageId, + }, + { signal }, + ), + ).pipe( + Effect.timeout(`${OPENCODE_PERMISSION_ENRICHMENT_TIMEOUT_MS} millis`), + Effect.map(({ data }) => + findOpenCodeToolPart(data, context.openCodeSessionId, toolMessageId, toolCallId), + ), + Effect.orElseSucceed(() => undefined), + ); + if (hasOpenCodeToolInput(snapshotToolPart)) { + toolPart = snapshotToolPart; + context.partById.set(snapshotToolPart.id, snapshotToolPart); + } + } + const baseDetail = + requestType === "dynamic_tool_call" + ? dynamicPermissionBaseDetail(event.properties.permission, event.properties.patterns) + : permissionDetail(event.properties.permission, event.properties.patterns); + const args = toolPart + ? { ...toolPart.state.input, ...event.properties.metadata } + : event.properties.metadata; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -966,12 +1108,12 @@ export function makeOpenCodeAdapter( })), type: "request.opened", payload: { - requestType: mapPermissionToRequestType(event.properties.permission), + requestType, detail: - event.properties.patterns.length > 0 - ? event.properties.patterns.join("\n") - : event.properties.permission, - args: event.properties.metadata, + requestType === "dynamic_tool_call" + ? dynamicPermissionDetail(baseDetail, args) + : baseDetail, + args, }, }); break; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..a620ace9d46f 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -95,7 +95,7 @@ export function openCodeRuntimeErrorDetail(cause: unknown): string { export const runOpenCodeSdk = ( operation: string, - fn: () => Promise, + fn: (signal: AbortSignal) => Promise, ): Effect.Effect => Effect.tryPromise({ try: fn, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e94712d3e4da..963cb19b490e 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -130,28 +130,41 @@ describe("derivePendingApprovals", () => { ]); }); - it("derives dynamic tool requests as actionable generic approvals", () => { + it.each([ + { + name: "dynamic tool", + requestId: "req-dynamic-tool", + requestType: "dynamic_tool_call", + detail: "Search the web", + }, + { + name: "legacy unknown", + requestId: "req-legacy-unknown", + requestType: "unknown", + detail: "*", + }, + ])("derives $name requests as actionable command approvals", (approval) => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ - id: "approval-open-dynamic-tool", + id: `approval-open-${approval.name.replace(" ", "-")}`, createdAt: "2026-02-23T00:00:01.000Z", kind: "approval.requested", summary: "Approval requested", tone: "approval", payload: { - requestId: "req-dynamic-tool", - requestType: "dynamic_tool_call", - detail: "Search the web", + requestId: approval.requestId, + requestType: approval.requestType, + detail: approval.detail, }, }), ]; expect(derivePendingApprovals(activities)).toEqual([ { - requestId: "req-dynamic-tool", + requestId: approval.requestId, requestKind: "command", createdAt: "2026-02-23T00:00:01.000Z", - detail: "Search the web", + detail: approval.detail, }, ]); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..dc3ffa0053c5 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -372,6 +372,7 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ case "command_execution_approval": case "exec_command_approval": case "dynamic_tool_call": + case "unknown": return "command"; case "file_read_approval": return "file-read"; From 05ee826fa36cef0435d7f4df80a68fe6b9e51b16 Mon Sep 17 00:00:00 2001 From: isit <1663054+triamazikamno@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:16:51 +0200 Subject: [PATCH 2/2] fix(providers): prefer populated OpenCode tool cache entries --- .../provider/Layers/OpenCodeAdapter.test.ts | 22 ++++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 9 ++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index e6d4095c1361..974b1ea8e2f9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1438,7 +1438,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("uses an exact cached dynamic tool part without fetching the message", () => + it.effect("prefers populated cached tool input", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-opencode-cached-dynamic-approval"); @@ -1446,6 +1446,26 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const messageID = "msg-cached-dynamic"; const callID = "call-cached-dynamic"; runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "part-cached-dynamic-pending", + sessionID, + messageID, + type: "tool", + callID, + tool: "gh_grep_searchGitHub", + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + time: 0, + }, + }, { type: "message.part.updated", properties: { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index cc173783061c..999df7c96fb0 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -216,6 +216,8 @@ function findCachedOpenCodeToolPart( messageID: string, callID: string, ): Extract | undefined { + let latestExact: Extract | undefined; + let latestPopulated: Extract | undefined; for (const part of parts) { if ( part.type === "tool" && @@ -223,10 +225,13 @@ function findCachedOpenCodeToolPart( part.messageID === messageID && part.callID === callID ) { - return part; + latestExact = part; + if (hasOpenCodeToolInput(part)) { + latestPopulated = part; + } } } - return undefined; + return latestPopulated ?? latestExact; } function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined {