From f5134c11211fb8a7833d39b63bae306126a62591 Mon Sep 17 00:00:00 2001 From: MickaelRoger Date: Sat, 25 Jul 2026 12:26:51 +0200 Subject: [PATCH 1/3] fix(tui): hydrate pending prompts --- packages/tui/src/context/sync.tsx | 58 ++++++- .../cli/cmd/tui/sync-live-hydration.test.tsx | 143 ++++++++++++++++++ 2 files changed, 198 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..9b7d2652224e 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -143,13 +143,22 @@ export const { const fullSyncedSessions = new Set() const syncingSessions = new Map>() - const hydratingSessions = new Map; parts: Set }>() + const hydratingSessions = new Map< + string, + { messages: Set; parts: Set; permissions: Set; questions: Set } + >() const touchMessage = (sessionID: string, messageID: string) => { hydratingSessions.get(sessionID)?.messages.add(messageID) } const touchPart = (sessionID: string, partID: string) => { hydratingSessions.get(sessionID)?.parts.add(partID) } + const touchPermission = (sessionID: string, requestID: string) => { + hydratingSessions.get(sessionID)?.permissions.add(requestID) + } + const touchQuestion = (sessionID: string, requestID: string) => { + hydratingSessions.get(sessionID)?.questions.add(requestID) + } function sessionListQuery(): { scope?: "project"; path?: string } { if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } @@ -173,6 +182,7 @@ export const { void bootstrap() break case "permission.replied": { + touchPermission(event.properties.sessionID, event.properties.requestID) const requests = store.permission[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) @@ -189,6 +199,7 @@ export const { case "permission.asked": { const request = event.properties + touchPermission(request.sessionID, request.id) if (permission.mode === "auto") { void sdk.client.permission.reply({ requestID: request.id, @@ -220,6 +231,7 @@ export const { case "question.replied": case "question.rejected": { + touchQuestion(event.properties.sessionID, event.properties.requestID) const requests = store.question[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) @@ -236,6 +248,7 @@ export const { case "question.asked": { const request = event.properties + touchQuestion(request.sessionID, request.id) const requests = store.question[request.sessionID] if (!requests) { setStore("question", request.sessionID, [request]) @@ -589,14 +602,21 @@ export const { if (fullSyncedSessions.has(sessionID)) return const syncing = syncingSessions.get(sessionID) if (syncing) return syncing - const tracker = { messages: new Set(), parts: new Set() } + const tracker = { + messages: new Set(), + parts: new Set(), + permissions: new Set(), + questions: new Set(), + } hydratingSessions.set(sessionID, tracker) const task = (async () => { - const [session, messages, todo, diff] = await Promise.all([ + const [session, messages, todo, diff, permissions, questions] = await Promise.all([ sdk.client.session.get({ sessionID }, { throwOnError: true }), sdk.client.session.messages({ sessionID, limit: 100 }), sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), + sdk.client.permission.list(), + sdk.client.question.list(), ]) setStore( produce((draft) => { @@ -604,6 +624,38 @@ export const { if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] + if (permissions.data !== undefined) { + const currentPermissions = draft.permission[sessionID] ?? [] + draft.permission[sessionID] = [ + ...permissions.data.filter( + (request, index, all) => + request.sessionID === sessionID && + !tracker.permissions.has(request.id) && + all.findIndex((item) => item.sessionID === sessionID && item.id === request.id) === index, + ), + ...currentPermissions.filter( + (request, index, all) => + tracker.permissions.has(request.id) && + all.findIndex((item) => item.id === request.id) === index, + ), + ].toSorted((a, b) => a.id.localeCompare(b.id)) + } + if (questions.data !== undefined) { + const currentQuestions = draft.question[sessionID] ?? [] + draft.question[sessionID] = [ + ...questions.data.filter( + (request, index, all) => + request.sessionID === sessionID && + !tracker.questions.has(request.id) && + all.findIndex((item) => item.sessionID === sessionID && item.id === request.id) === index, + ), + ...currentQuestions.filter( + (request, index, all) => + tracker.questions.has(request.id) && + all.findIndex((item) => item.id === request.id) === index, + ), + ].toSorted((a, b) => a.id.localeCompare(b.id)) + } const currentMessages = draft.message[sessionID] ?? [] const infos = (messages.data ?? []).flatMap((message) => { if (!tracker.messages.has(message.info.id)) return [message.info] 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..6debca64f224 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,146 @@ test("a message removed during hydration does not regain stale parts", async () app.renderer.destroy() } }) + +test("hydrates pending prompts without overwriting live prompt changes", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + + let resolvePermissions!: (response: Response) => void + const permissions = new Promise((resolve) => { + resolvePermissions = resolve + }) + let resolveQuestions!: (response: Response) => void + const questions = new Promise((resolve) => { + resolveQuestions = resolve + }) + let requestedPermissions = false + let requestedQuestions = false + const { app, emit, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(session) + if (url.pathname === `/session/${sessionID}/message`) return json([]) + if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname === "/permission") { + requestedPermissions = true + return permissions + } + if (url.pathname === "/question") { + requestedQuestions = true + return questions + } + return undefined + }, tmp.path) + + try { + emit( + global({ + id: "evt_permission_pending", + type: "permission.asked", + properties: { id: "permission_pending", sessionID, permission: "bash", patterns: [], metadata: {}, always: [] }, + }), + ) + emit( + global({ + id: "evt_question_pending", + type: "question.asked", + properties: { id: "question_pending", sessionID, questions: [{ question: "pending", options: [] }] }, + }), + ) + const hydrate = sync.session.sync(sessionID) + await wait(() => requestedPermissions && requestedQuestions) + emit( + global({ + id: "evt_permission_replied", + type: "permission.replied", + properties: { sessionID, requestID: "permission_pending", reply: "once" }, + }), + ) + emit( + global({ + id: "evt_question_rejected", + type: "question.rejected", + properties: { sessionID, requestID: "question_pending" }, + }), + ) + emit( + global({ + id: "evt_permission", + type: "permission.asked", + properties: { id: "permission_live", sessionID, permission: "bash", patterns: ["live"], metadata: {}, always: [] }, + }), + ) + emit( + global({ + id: "evt_question", + type: "question.asked", + properties: { id: "question_live", sessionID, questions: [{ question: "live", options: [] }] }, + }), + ) + resolvePermissions( + json([ + { id: "permission_server", sessionID, permission: "read", patterns: ["server"], metadata: {}, always: [] }, + { id: "permission_live", sessionID, permission: "bash", patterns: ["stale"], metadata: {}, always: [] }, + { id: "permission_pending", sessionID, permission: "bash", patterns: [], metadata: {}, always: [] }, + { id: "permission_other", sessionID: "ses_other", permission: "read", patterns: [], metadata: {}, always: [] }, + ]), + ) + resolveQuestions( + json([ + { id: "question_server", sessionID, questions: [{ question: "server", options: [] }] }, + { id: "question_live", sessionID, questions: [{ question: "stale", options: [] }] }, + { id: "question_pending", sessionID, questions: [{ question: "pending", options: [] }] }, + { id: "question_other", sessionID: "ses_other", questions: [] }, + ]), + ) + await hydrate + + expect(sync.data.permission[sessionID]).toMatchObject([ + { id: "permission_live", patterns: ["live"] }, + { id: "permission_server", patterns: ["server"] }, + ]) + expect(sync.data.question[sessionID]).toMatchObject([ + { id: "question_live", questions: [{ question: "live" }] }, + { id: "question_server", questions: [{ question: "server" }] }, + ]) + expect(sync.data.permission[sessionID].map((request) => request.id)).toEqual(["permission_live", "permission_server"]) + expect(sync.data.question[sessionID].map((request) => request.id)).toEqual(["question_live", "question_server"]) + } finally { + app.renderer.destroy() + } +}) + +test("undefined prompt lists do not clear pending prompts", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const { app, emit, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(session) + if (url.pathname === `/session/${sessionID}/message`) return json([]) + if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname === "/permission" || url.pathname === "/question") return json({}, { status: 500 }) + return undefined + }, tmp.path) + + try { + emit( + global({ + id: "evt_permission", + type: "permission.asked", + properties: { id: "permission_live", sessionID, permission: "bash", patterns: ["live"], metadata: {}, always: [] }, + }), + ) + emit( + global({ + id: "evt_question", + type: "question.asked", + properties: { id: "question_live", sessionID, questions: [{ question: "live", options: [] }] }, + }), + ) + await sync.session.sync(sessionID) + + expect(sync.data.permission[sessionID]).toMatchObject([{ id: "permission_live" }]) + expect(sync.data.question[sessionID]).toMatchObject([{ id: "question_live" }]) + } finally { + app.renderer.destroy() + } +}) From 2f583130a2dd84cf5196c81bb049fc07a7f83438 Mon Sep 17 00:00:00 2001 From: MickaelRoger Date: Sat, 25 Jul 2026 12:31:42 +0200 Subject: [PATCH 2/3] test(tui): stabilize prompt hydration coverage --- .../cli/cmd/tui/sync-live-hydration.test.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) 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 6debca64f224..958f0d303cac 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 @@ -335,6 +335,13 @@ test("hydrates pending prompts without overwriting live prompt changes", async ( properties: { id: "question_live", sessionID, questions: [{ question: "live", options: [] }] }, }), ) + await wait( + () => + sync.data.permission[sessionID]?.some((request) => request.id === "permission_live") && + !sync.data.permission[sessionID]?.some((request) => request.id === "permission_pending") && + sync.data.question[sessionID]?.some((request) => request.id === "question_live") && + !sync.data.question[sessionID]?.some((request) => request.id === "question_pending"), + ) resolvePermissions( json([ { id: "permission_server", sessionID, permission: "read", patterns: ["server"], metadata: {}, always: [] }, @@ -353,16 +360,12 @@ test("hydrates pending prompts without overwriting live prompt changes", async ( ) await hydrate - expect(sync.data.permission[sessionID]).toMatchObject([ - { id: "permission_live", patterns: ["live"] }, - { id: "permission_server", patterns: ["server"] }, - ]) - expect(sync.data.question[sessionID]).toMatchObject([ - { id: "question_live", questions: [{ question: "live" }] }, - { id: "question_server", questions: [{ question: "server" }] }, - ]) expect(sync.data.permission[sessionID].map((request) => request.id)).toEqual(["permission_live", "permission_server"]) expect(sync.data.question[sessionID].map((request) => request.id)).toEqual(["question_live", "question_server"]) + expect(sync.data.permission[sessionID][0].patterns).toEqual(["live"]) + expect(sync.data.permission[sessionID][1].patterns).toEqual(["server"]) + expect(sync.data.question[sessionID][0].questions[0].question).toBe("live") + expect(sync.data.question[sessionID][1].questions[0].question).toBe("server") } finally { app.renderer.destroy() } @@ -395,10 +398,11 @@ test("undefined prompt lists do not clear pending prompts", async () => { properties: { id: "question_live", sessionID, questions: [{ question: "live", options: [] }] }, }), ) + await wait(() => sync.data.permission[sessionID]?.length === 1 && sync.data.question[sessionID]?.length === 1) await sync.session.sync(sessionID) - expect(sync.data.permission[sessionID]).toMatchObject([{ id: "permission_live" }]) - expect(sync.data.question[sessionID]).toMatchObject([{ id: "question_live" }]) + expect(sync.data.permission[sessionID][0].id).toBe("permission_live") + expect(sync.data.question[sessionID][0].id).toBe("question_live") } finally { app.renderer.destroy() } From 411b0ae9dd6de74a76020a41f6c5c22183c09b69 Mon Sep 17 00:00:00 2001 From: MickaelRoger Date: Sat, 25 Jul 2026 12:33:45 +0200 Subject: [PATCH 3/3] test(tui): add question headers --- .../test/cli/cmd/tui/sync-live-hydration.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 958f0d303cac..fd54403c923a 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 @@ -302,7 +302,7 @@ test("hydrates pending prompts without overwriting live prompt changes", async ( global({ id: "evt_question_pending", type: "question.asked", - properties: { id: "question_pending", sessionID, questions: [{ question: "pending", options: [] }] }, + properties: { id: "question_pending", sessionID, questions: [{ header: "Pending", question: "pending", options: [] }] }, }), ) const hydrate = sync.session.sync(sessionID) @@ -332,7 +332,7 @@ test("hydrates pending prompts without overwriting live prompt changes", async ( global({ id: "evt_question", type: "question.asked", - properties: { id: "question_live", sessionID, questions: [{ question: "live", options: [] }] }, + properties: { id: "question_live", sessionID, questions: [{ header: "Live", question: "live", options: [] }] }, }), ) await wait( @@ -352,9 +352,9 @@ test("hydrates pending prompts without overwriting live prompt changes", async ( ) resolveQuestions( json([ - { id: "question_server", sessionID, questions: [{ question: "server", options: [] }] }, - { id: "question_live", sessionID, questions: [{ question: "stale", options: [] }] }, - { id: "question_pending", sessionID, questions: [{ question: "pending", options: [] }] }, + { id: "question_server", sessionID, questions: [{ header: "Server", question: "server", options: [] }] }, + { id: "question_live", sessionID, questions: [{ header: "Stale", question: "stale", options: [] }] }, + { id: "question_pending", sessionID, questions: [{ header: "Pending", question: "pending", options: [] }] }, { id: "question_other", sessionID: "ses_other", questions: [] }, ]), ) @@ -395,7 +395,7 @@ test("undefined prompt lists do not clear pending prompts", async () => { global({ id: "evt_question", type: "question.asked", - properties: { id: "question_live", sessionID, questions: [{ question: "live", options: [] }] }, + properties: { id: "question_live", sessionID, questions: [{ header: "Live", question: "live", options: [] }] }, }), ) await wait(() => sync.data.permission[sessionID]?.length === 1 && sync.data.question[sessionID]?.length === 1)