Skip to content
Closed
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
58 changes: 55 additions & 3 deletions packages/tui/src/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,22 @@ export const {

const fullSyncedSessions = new Set<string>()
const syncingSessions = new Map<string, Promise<void>>()
const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
const hydratingSessions = new Map<
string,
{ messages: Set<string>; parts: Set<string>; permissions: Set<string>; questions: Set<string> }
>()
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" }
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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])
Expand Down Expand Up @@ -589,21 +602,60 @@ export const {
if (fullSyncedSessions.has(sessionID)) return
const syncing = syncingSessions.get(sessionID)
if (syncing) return syncing
const tracker = { messages: new Set<string>(), parts: new Set<string>() }
const tracker = {
messages: new Set<string>(),
parts: new Set<string>(),
permissions: new Set<string>(),
questions: new Set<string>(),
}
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) => {
const match = search(draft.session, sessionID, (s) => s.id)
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]
Expand Down
147 changes: 147 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,150 @@ 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<Response>((resolve) => {
resolvePermissions = resolve
})
let resolveQuestions!: (response: Response) => void
const questions = new Promise<Response>((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: [{ header: "Pending", 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: [{ header: "Live", 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: [] },
{ 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: [{ 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: [] },
]),
)
await hydrate

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()
}
})

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: [{ header: "Live", 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][0].id).toBe("permission_live")
expect(sync.data.question[sessionID][0].id).toBe("question_live")
} finally {
app.renderer.destroy()
}
})
Loading