diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index f1963c6bbb..c6cb4249f6 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -18,19 +18,25 @@ import { useSlot } from "@opencode-ai/quark/solid" type PermissionStage = "permission" | "always" | "reject" -/** Resolved tool input for a permission request, once input streaming settles. */ -export function usePermissionInput(request: PermissionV2Request): () => Record { +/** + * Resolved tool input for a permission request, once input streaming settles. + * Takes an accessor: the prompt stays mounted across queued requests, so the + * slot subscription must follow the current request. + */ +export function usePermissionInput(request: () => PermissionV2Request): () => Record { const source = usePermissionSource(request) return createMemo(() => source().input ?? {}) } -function usePermissionSource(request: PermissionV2Request) { +function usePermissionSource(request: () => PermissionV2Request) { const data = useData() - const tool = request.source - if (!tool) return () => ({ input: undefined, structured: undefined }) - const part = useSlot(data.session.message.parts(request.sessionID, tool.messageID), tool.callID) + const part = createMemo(() => { + const tool = request().source + if (!tool) return + return useSlot(data.session.message.parts(request().sessionID, tool.messageID), tool.callID) + }) return createMemo(() => { - const item = part() + const item = part()?.() if (item?.type === "tool" && item.state.status !== "streaming") { return { input: item.state.input, structured: item.state.structured } } @@ -137,7 +143,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const pathFormatter = usePathFormatter() const session = createMemo(() => data.session.get(props.request.sessionID)) - const source = usePermissionSource(props.request) + const source = usePermissionSource(() => props.request) const { themeV2 } = useTheme() diff --git a/packages/tui/test/cli/tui/permission.test.tsx b/packages/tui/test/cli/tui/permission.test.tsx index fe89af4cb0..7e70b1a044 100644 --- a/packages/tui/test/cli/tui/permission.test.tsx +++ b/packages/tui/test/cli/tui/permission.test.tsx @@ -2,7 +2,7 @@ import { expect, test } from "bun:test" import { testRender } from "@opentui/solid" import type { OpenCodeEvent, PermissionV2Request } from "@opencode-ai/client" -import { createEffect, type ParentProps } from "solid-js" +import { createEffect, createSignal, type ParentProps } from "solid-js" import { ClientProvider, useClient } from "../../../src/context/client" import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" import { LocationProvider, useLocation } from "../../../src/context/location" @@ -50,15 +50,18 @@ test("permission input tracks the tool part slot as input settles", async () => let client!: ReturnType let input!: () => unknown - const request = { - id: "perm_1", - sessionID, - permission: "shell", - resources: [], - metadata: {}, - source: { messageID: "message-assistant", callID: "call-permission" }, - time: { created: 1 }, - } as unknown as PermissionV2Request + const permissionRequest = (id: string, callID: string) => + ({ + id, + sessionID, + permission: "shell", + resources: [], + metadata: {}, + source: { messageID: "message-assistant", callID }, + time: { created: 1 }, + }) as unknown as PermissionV2Request + + const [request, setRequest] = createSignal(permissionRequest("perm_1", "call-permission")) function Probe() { data = useData() @@ -109,6 +112,36 @@ test("permission input tracks the tool part slot as input settles", async () => const value = input() return typeof value === "object" && value !== null && (value as { command?: string }).command === "rm -rf ./dist" }) + + // The prompt stays mounted while requests queue: when the next request + // becomes current, the input must re-resolve against its tool call. + emitEvent(events, { + id: "evt_perm_tool_started_2", + created: 3, + type: "session.tool.input.started", + durable: { aggregateID: `session_${sessionID}`, seq: 2, version: 1 }, + data: { sessionID, assistantMessageID: "message-assistant", callID: "call-permission-2", name: "shell" }, + } as unknown as OpenCodeEvent) + emitEvent(events, { + id: "evt_perm_tool_called_2", + created: 4, + type: "session.tool.called", + durable: { aggregateID: `session_${sessionID}`, seq: 3, version: 1 }, + data: { + sessionID, + assistantMessageID: "message-assistant", + callID: "call-permission-2", + input: { command: "git push" }, + timestamp: 4, + }, + } as unknown as OpenCodeEvent) + await wait(() => data.session.message.parts(sessionID, "message-assistant").has("call-permission-2")) + + setRequest(permissionRequest("perm_2", "call-permission-2")) + await wait(() => { + const value = input() + return typeof value === "object" && value !== null && (value as { command?: string }).command === "git push" + }) } finally { app.renderer.destroy() }