Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/tui/src/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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")
Expand Down
35 changes: 35 additions & 0 deletions packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
1 change: 1 addition & 0 deletions packages/tui/test/fixture/tui-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
if (url.pathname === "/session") return json([])
if (url.pathname === "/permission" || url.pathname === "/question") return json([])
if (url.pathname === "/vcs") return json({ branch: "main" })
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
Expand Down
Loading