diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..a87e807f2c57 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -167,6 +167,71 @@ export const { .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } + // Insert a pending request into its per-session list, kept sorted by id so the + // binary `search` stays valid. Idempotent: skip a request a live `*.asked` event + // already added, so hydration and live events can interleave safely. + function upsertPermission(request: PermissionRequest) { + const requests = store.permission[request.sessionID] + if (!requests) { + setStore("permission", request.sessionID, [request]) + return + } + const match = search(requests, request.id, (r) => r.id) + if (match.found) return + setStore( + "permission", + request.sessionID, + produce((draft) => { + draft.splice(match.index, 0, request) + }), + ) + } + + function upsertQuestion(request: QuestionRequest) { + const requests = store.question[request.sessionID] + if (!requests) { + setStore("question", request.sessionID, [request]) + return + } + const match = search(requests, request.id, (r) => r.id) + if (match.found) return + setStore( + "question", + request.sessionID, + produce((draft) => { + draft.splice(match.index, 0, request) + }), + ) + } + + // Fetch pending prompts on (re)connect. The `*.asked` events fire once, at ask + // time, and aren't replayed, so a client that connects afterwards (e.g. after + // detaching the TUI) never sees them via events alone and would leave the agent + // blocked with no prompt to answer. + function hydratePending(workspace: string | undefined) { + const permissions = sdk.client.permission + .list({ workspace }) + .then((x) => { + for (const request of x.data ?? []) { + if (permission.mode === "auto") { + void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace }) + continue + } + upsertPermission(request) + } + }) + .catch(() => {}) + const questions = sdk.client.question + .list({ workspace }) + .then((x) => { + for (const request of x.data ?? []) { + upsertQuestion(request) + } + }) + .catch(() => {}) + return Promise.all([permissions, questions]) + } + event.subscribe((event, { directory, workspace }) => { switch (event.type) { case "server.instance.disposed": @@ -526,6 +591,7 @@ export const { }), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), + hydratePending(workspace), project.workspace.sync(), ]).then(() => { setStore("status", "complete") diff --git a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx index 398c5c9ab524..f27e349709ce 100644 --- a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx @@ -260,3 +260,38 @@ test("a message removed during hydration does not regain stale parts", async () app.renderer.destroy() } }) + +test("pending permissions and questions are hydrated on connect without a live event", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const permission = { + id: "per_hydrated", + sessionID, + permission: "bash", + patterns: ["rm -rf /"], + metadata: {}, + always: ["rm -rf /"], + } + const question = { + id: "que_hydrated", + sessionID, + questions: [{ question: "Proceed?", header: "Confirm", options: [{ label: "Yes", description: "do it" }] }], + } + + // The TUI has just (re)attached: the *.asked events fired while it was + // detached, so the only way it can learn about these pending prompts is by + // fetching them on connect. No live event is emitted in this test. + const { app, sync } = await mount((url) => { + if (url.pathname === "/permission") return json([permission]) + if (url.pathname === "/question") return json([question]) + return undefined + }, tmp.path) + + try { + expect(sync.data.permission[sessionID]).toEqual([permission]) + expect(sync.data.question[sessionID]).toEqual([question]) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index d1cf3c7dfc2a..27bdc40467a8 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -102,6 +102,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType