fix(sync): preserve pending questions across session switch and directory eviction (closes #918) (#1103)
* fix(sync): preserve pending questions across session switch and directory eviction Closes #918, completes the gap left by #909. The 'agent question disappears after switching session / coming back later' bug had two root causes that #909 only partially addressed: 1. Directory-eviction TTL (20 min) silently dropped child stores that held pending questions/permissions. The discard wasn't gated on in-flight blocking-request state, so any 'question.asked' event that arrived during the eviction-then-rehydrate window was routed to a non-existent store and silently lost. 2. PR #909 re-fetches listPendingQuestions/Permissions only on SSE reconnect. Switching sessions within the same socket — including navigating back to a directory whose child store was rebuilt after eviction — left the UI relying on store state that may have missed events that fired while a different session was active. Three edits, in src/sync: - eviction.ts / types.ts / child-store.ts: add hasPendingBlockingRequests to EvictPlan + DisposeCheck and never evict a directory whose store carries a non-empty state.question or state.permission record. - sync-context.tsx: extract resyncBlockingRequestsForDirectory from the reconnect path and call it on currentSessionId changes (debounced 250ms), reusing PR #909's signature-based merge so concurrent SSE updates aren't clobbered. - __tests__/eviction.test.ts, __tests__/session-switch-resync.test.ts: new unit coverage for the eviction guard and resync semantics (deduped fetch per switch, in-flight SSE preservation, stale entry cleanup, unknown-session filtering). * fix(sync): refresh store before blocking request resync --------- Co-authored-by: Alexander Busse <alex@ableph.net> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Alexander Busse
Bohdan Triapitsyn
parent
a49e2ce1ae
commit
2b0e1ef42e
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
canDisposeDirectory,
|
||||
hasPendingBlockingRequests,
|
||||
pickDirectoriesToEvict,
|
||||
} from "../eviction"
|
||||
import { INITIAL_STATE, type DirState, type State } from "../types"
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function buildState(overrides: Partial<State> = {}): State {
|
||||
return {
|
||||
...INITIAL_STATE,
|
||||
question: {},
|
||||
permission: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuestion(overrides: Partial<QuestionRequest> = {}): QuestionRequest {
|
||||
return {
|
||||
id: "que_1",
|
||||
sessionID: "ses_1",
|
||||
questions: [{ question: "Continue?", header: "Q", options: [{ label: "Yes", description: "" }] }],
|
||||
...overrides,
|
||||
} as QuestionRequest
|
||||
}
|
||||
|
||||
function buildPermission(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
|
||||
return {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
permission: "bash",
|
||||
patterns: [],
|
||||
metadata: {},
|
||||
always: [],
|
||||
...overrides,
|
||||
} as PermissionRequest
|
||||
}
|
||||
|
||||
describe("hasPendingBlockingRequests", () => {
|
||||
test("returns false on undefined or empty state", () => {
|
||||
expect(hasPendingBlockingRequests(undefined)).toBe(false)
|
||||
expect(hasPendingBlockingRequests(buildState())).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when at least one session has a pending question", () => {
|
||||
const state = buildState({ question: { ses_a: [buildQuestion()] } })
|
||||
expect(hasPendingBlockingRequests(state)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true when at least one session has a pending permission", () => {
|
||||
const state = buildState({ permission: { ses_a: [buildPermission()] } })
|
||||
expect(hasPendingBlockingRequests(state)).toBe(true)
|
||||
})
|
||||
|
||||
test("treats empty arrays under a session key as no pending work", () => {
|
||||
const state = buildState({ question: { ses_a: [] }, permission: { ses_b: [] } })
|
||||
expect(hasPendingBlockingRequests(state)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("does not evict an idle directory that has a pending question", () => {
|
||||
const stores = ["/idle-with-question", "/idle-empty"]
|
||||
const state = new Map<string, DirState>([
|
||||
["/idle-with-question", { lastAccessAt: 0 }],
|
||||
["/idle-empty", { lastAccessAt: 0 }],
|
||||
])
|
||||
const list = pickDirectoriesToEvict({
|
||||
stores,
|
||||
state,
|
||||
pins: new Set(),
|
||||
max: 30,
|
||||
ttl: 1000,
|
||||
now: DAY_MS,
|
||||
hasPendingBlockingRequests: (dir) => dir === "/idle-with-question",
|
||||
})
|
||||
expect(list).toEqual(["/idle-empty"])
|
||||
})
|
||||
|
||||
test("never includes a directory with pending blocking requests even under overflow pressure", () => {
|
||||
const stores = ["/active", "/overflow-with-permission", "/old-empty"]
|
||||
const state = new Map<string, DirState>([
|
||||
["/active", { lastAccessAt: DAY_MS }],
|
||||
["/overflow-with-permission", { lastAccessAt: DAY_MS - 100_000 }],
|
||||
["/old-empty", { lastAccessAt: 0 }],
|
||||
])
|
||||
const list = pickDirectoriesToEvict({
|
||||
stores,
|
||||
state,
|
||||
pins: new Set(),
|
||||
max: 1,
|
||||
ttl: 60_000,
|
||||
now: DAY_MS,
|
||||
hasPendingBlockingRequests: (dir) => dir === "/overflow-with-permission",
|
||||
})
|
||||
expect(list).not.toContain("/overflow-with-permission")
|
||||
expect(list).toContain("/old-empty")
|
||||
})
|
||||
|
||||
test("falls back to legacy behavior when no predicate is provided", () => {
|
||||
const stores = ["/idle"]
|
||||
const state = new Map<string, DirState>([["/idle", { lastAccessAt: 0 }]])
|
||||
const list = pickDirectoriesToEvict({
|
||||
stores,
|
||||
state,
|
||||
pins: new Set(),
|
||||
max: 30,
|
||||
ttl: 1000,
|
||||
now: DAY_MS,
|
||||
})
|
||||
expect(list).toEqual(["/idle"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("canDisposeDirectory", () => {
|
||||
const baseInput = {
|
||||
directory: "/repo",
|
||||
hasStore: true,
|
||||
pinned: false,
|
||||
booting: false,
|
||||
loadingSessions: false,
|
||||
hasPendingBlockingRequests: false,
|
||||
}
|
||||
|
||||
test("refuses to dispose a directory holding pending blocking requests", () => {
|
||||
expect(canDisposeDirectory({ ...baseInput, hasPendingBlockingRequests: true })).toBe(false)
|
||||
})
|
||||
|
||||
test("permits disposal when no blocking requests are pending", () => {
|
||||
expect(canDisposeDirectory(baseInput)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, test, beforeEach, mock } from "bun:test"
|
||||
import { create, type StoreApi } from "zustand"
|
||||
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
const listPendingQuestionsCalls: Array<{ directories?: Array<string | null | undefined> }> = []
|
||||
const listPendingPermissionsCalls: Array<{ directories?: Array<string | null | undefined> }> = []
|
||||
let pendingQuestionsResponse: QuestionRequest[] = []
|
||||
let pendingPermissionsResponse: PermissionRequest[] = []
|
||||
|
||||
mock.module("@/lib/opencode/client", () => ({
|
||||
opencodeClient: {
|
||||
listPendingQuestions: mock(async (opts?: { directories?: Array<string | null | undefined> }) => {
|
||||
listPendingQuestionsCalls.push(opts ?? {})
|
||||
return pendingQuestionsResponse
|
||||
}),
|
||||
listPendingPermissions: mock(async (opts?: { directories?: Array<string | null | undefined> }) => {
|
||||
listPendingPermissionsCalls.push(opts ?? {})
|
||||
return pendingPermissionsResponse
|
||||
}),
|
||||
getDirectory: () => "/repo",
|
||||
getScopedSdkClient: () => ({}),
|
||||
setDirectory: () => undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/stores/permissionStore", () => ({
|
||||
usePermissionStore: {
|
||||
getState: () => ({ isSessionAutoAccepting: () => false }),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/stores/useConfigStore", () => ({
|
||||
useConfigStore: {
|
||||
getState: () => ({ isConnected: true, hasEverConnected: true }),
|
||||
setState: () => undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/stores/useTodosPersistStore", () => ({
|
||||
useTodosPersistStore: { getState: () => ({}) },
|
||||
}))
|
||||
|
||||
mock.module("@/components/ui", () => ({
|
||||
toast: { info: () => undefined, error: () => undefined, success: () => undefined },
|
||||
}))
|
||||
|
||||
import { INITIAL_STATE, type State } from "../types"
|
||||
import type { DirectoryStore } from "../child-store"
|
||||
import { resyncBlockingRequestsForDirectory } from "../sync-context"
|
||||
|
||||
function buildQuestion(overrides: Partial<QuestionRequest> = {}): QuestionRequest {
|
||||
return {
|
||||
id: "que_1",
|
||||
sessionID: "ses_a",
|
||||
questions: [{ question: "Continue?", header: "Q", options: [{ label: "Yes", description: "" }] }],
|
||||
...overrides,
|
||||
} as QuestionRequest
|
||||
}
|
||||
|
||||
function buildPermission(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
|
||||
return {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_a",
|
||||
permission: "bash",
|
||||
patterns: [],
|
||||
metadata: {},
|
||||
always: [],
|
||||
...overrides,
|
||||
} as PermissionRequest
|
||||
}
|
||||
|
||||
function createDirectoryStore(initial: Partial<State>): StoreApi<DirectoryStore> {
|
||||
return create<DirectoryStore>()((set) => ({
|
||||
...INITIAL_STATE,
|
||||
...initial,
|
||||
session: initial.session ?? [{ id: "ses_a", title: "ses_a", time: { created: 1, updated: 1 }, version: "1" } as State["session"][number]],
|
||||
patch: (partial) => set(partial),
|
||||
replace: (next) => set(next),
|
||||
}))
|
||||
}
|
||||
|
||||
describe("resyncBlockingRequestsForDirectory", () => {
|
||||
beforeEach(() => {
|
||||
listPendingQuestionsCalls.length = 0
|
||||
listPendingPermissionsCalls.length = 0
|
||||
pendingQuestionsResponse = []
|
||||
pendingPermissionsResponse = []
|
||||
})
|
||||
|
||||
test("calls listPendingQuestions and listPendingPermissions exactly once for the directory", async () => {
|
||||
const store = createDirectoryStore({})
|
||||
pendingQuestionsResponse = [buildQuestion()]
|
||||
pendingPermissionsResponse = [buildPermission()]
|
||||
|
||||
await resyncBlockingRequestsForDirectory("/repo", store)
|
||||
|
||||
expect(listPendingQuestionsCalls).toHaveLength(1)
|
||||
expect(listPendingQuestionsCalls[0]).toEqual({ directories: ["/repo"] })
|
||||
expect(listPendingPermissionsCalls).toHaveLength(1)
|
||||
expect(listPendingPermissionsCalls[0]).toEqual({ directories: ["/repo"] })
|
||||
})
|
||||
|
||||
test("merges newly fetched questions/permissions into the directory store", async () => {
|
||||
const store = createDirectoryStore({})
|
||||
pendingQuestionsResponse = [buildQuestion()]
|
||||
pendingPermissionsResponse = [buildPermission()]
|
||||
|
||||
await resyncBlockingRequestsForDirectory("/repo", store)
|
||||
|
||||
expect(store.getState().question["ses_a"]).toHaveLength(1)
|
||||
expect(store.getState().question["ses_a"]?.[0]?.id).toBe("que_1")
|
||||
expect(store.getState().permission["ses_a"]).toHaveLength(1)
|
||||
expect(store.getState().permission["ses_a"]?.[0]?.id).toBe("perm_1")
|
||||
})
|
||||
|
||||
test("preserves an in-flight SSE-delivered question whose signature changed during the fetch", async () => {
|
||||
const store = createDirectoryStore({
|
||||
question: { ses_a: [{ ...buildQuestion(), id: "que_initial" }] },
|
||||
})
|
||||
pendingQuestionsResponse = []
|
||||
|
||||
const promise = resyncBlockingRequestsForDirectory("/repo", store)
|
||||
store.setState({
|
||||
question: { ses_a: [{ ...buildQuestion(), id: "que_sse_arrived" }] },
|
||||
})
|
||||
await promise
|
||||
|
||||
expect(store.getState().question["ses_a"]).toHaveLength(1)
|
||||
expect(store.getState().question["ses_a"]?.[0]?.id).toBe("que_sse_arrived")
|
||||
})
|
||||
|
||||
test("clears stale entries when API returns no pending requests and signature unchanged", async () => {
|
||||
const store = createDirectoryStore({
|
||||
question: { ses_a: [{ ...buildQuestion(), id: "que_stale" }] },
|
||||
})
|
||||
pendingQuestionsResponse = []
|
||||
pendingPermissionsResponse = []
|
||||
|
||||
await resyncBlockingRequestsForDirectory("/repo", store)
|
||||
|
||||
expect(store.getState().question["ses_a"]).toEqual(undefined)
|
||||
})
|
||||
|
||||
test("ignores questions for sessions the directory does not know about", async () => {
|
||||
const store = createDirectoryStore({})
|
||||
pendingQuestionsResponse = [{ ...buildQuestion(), sessionID: "ses_unknown" }]
|
||||
|
||||
await resyncBlockingRequestsForDirectory("/repo", store)
|
||||
|
||||
expect(store.getState().question["ses_unknown"]).toEqual(undefined)
|
||||
})
|
||||
|
||||
test("returns early without fetching when no candidate sessions are known", async () => {
|
||||
const store = createDirectoryStore({ session: [] })
|
||||
await resyncBlockingRequestsForDirectory("/repo", store)
|
||||
expect(listPendingQuestionsCalls).toHaveLength(0)
|
||||
expect(listPendingPermissionsCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create, type StoreApi } from "zustand"
|
||||
import type { DirState, State } from "./types"
|
||||
import { INITIAL_STATE, MAX_DIR_STORES, DIR_IDLE_TTL_MS } from "./types"
|
||||
import { pickDirectoriesToEvict, canDisposeDirectory } from "./eviction"
|
||||
import { pickDirectoriesToEvict, canDisposeDirectory, hasPendingBlockingRequests } from "./eviction"
|
||||
import { readDirCache, persistVcs, persistProjectMeta, persistIcon } from "./persist-cache"
|
||||
|
||||
export type DirectoryStore = State & {
|
||||
@@ -123,6 +123,7 @@ export class ChildStoreManager {
|
||||
pinned: this.pinned(directory),
|
||||
booting: this.isBooting?.(directory) ?? false,
|
||||
loadingSessions: this.isLoadingSessions?.(directory) ?? false,
|
||||
hasPendingBlockingRequests: this.hasPendingBlockingRequestsForDirectory(directory),
|
||||
})
|
||||
) {
|
||||
return false
|
||||
@@ -150,12 +151,17 @@ export class ChildStoreManager {
|
||||
max: MAX_DIR_STORES,
|
||||
ttl: DIR_IDLE_TTL_MS,
|
||||
now: Date.now(),
|
||||
hasPendingBlockingRequests: (dir) => this.hasPendingBlockingRequestsForDirectory(dir),
|
||||
}).filter((d) => d !== skip)
|
||||
for (const directory of list) {
|
||||
this.disposeDirectory(directory)
|
||||
}
|
||||
}
|
||||
|
||||
hasPendingBlockingRequestsForDirectory(directory: string): boolean {
|
||||
return hasPendingBlockingRequests(this.children.get(directory)?.getState())
|
||||
}
|
||||
|
||||
/** Apply a state mutation to a directory's store */
|
||||
update(directory: string, fn: (state: State) => Partial<State>) {
|
||||
const store = this.children.get(directory)
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
import type { DisposeCheck, EvictPlan } from "./types"
|
||||
import type { DisposeCheck, EvictPlan, State } from "./types"
|
||||
|
||||
/**
|
||||
* Returns true when the directory's child store holds at least one pending
|
||||
* blocking request — a question awaiting an answer or a permission awaiting
|
||||
* approval. Such directories must never be evicted, otherwise the SSE-routed
|
||||
* request data is lost and the user can never satisfy the agent.
|
||||
*
|
||||
* Tracks the same `state.question` / `state.permission` shape that
|
||||
* {@link bootstrapDirectory} re-hydrates on a fresh `ensureChild` call —
|
||||
* an empty record key (e.g. after a `question.replied` event clears the
|
||||
* array) is treated as "no pending requests" so a fully resolved directory
|
||||
* remains a normal eviction candidate.
|
||||
*/
|
||||
export function hasPendingBlockingRequests(state: State | undefined): boolean {
|
||||
if (!state) return false
|
||||
for (const list of Object.values(state.question ?? {})) {
|
||||
if (list && list.length > 0) return true
|
||||
}
|
||||
for (const list of Object.values(state.permission ?? {})) {
|
||||
if (list && list.length > 0) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function pickDirectoriesToEvict(input: EvictPlan) {
|
||||
const overflow = Math.max(0, input.stores.length - input.max)
|
||||
let pendingOverflow = overflow
|
||||
const sorted = input.stores
|
||||
.filter((dir) => !input.pins.has(dir))
|
||||
.filter((dir) => !input.hasPendingBlockingRequests?.(dir))
|
||||
.slice()
|
||||
.sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
|
||||
const output: string[] = []
|
||||
@@ -24,5 +48,6 @@ export function canDisposeDirectory(input: DisposeCheck) {
|
||||
if (input.pinned) return false
|
||||
if (input.booting) return false
|
||||
if (input.loadingSessions) return false
|
||||
if (input.hasPendingBlockingRequests) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -690,6 +690,169 @@ const updateRoutingIndexFromEvent = (
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch pending questions and permissions for a directory and merge them
|
||||
* into the directory's child store, preserving any in-flight SSE updates that
|
||||
* arrived while the request was pending. Shared between reconnect resync and
|
||||
* session-switch resync (the latter is a belt-and-suspenders backstop for any
|
||||
* code path that drops a `question.asked` / `permission.requested` event —
|
||||
* directory-eviction rehydration, cross-directory session switches, transport
|
||||
* fallback gaps, etc.). When `candidateSessionIds` is omitted, every session
|
||||
* known to the directory store is treated as a candidate.
|
||||
*/
|
||||
export async function resyncBlockingRequestsForDirectory(
|
||||
directory: string,
|
||||
store: StoreApi<DirectoryStore>,
|
||||
candidateSessionIds?: string[],
|
||||
) {
|
||||
const before = store.getState()
|
||||
const knownSessionIds = new Set<string>([
|
||||
...before.session.map((session) => session.id),
|
||||
...Object.keys(before.message ?? {}),
|
||||
...Object.keys(before.session_status ?? {}),
|
||||
...Object.keys(before.question ?? {}),
|
||||
...Object.keys(before.permission ?? {}),
|
||||
])
|
||||
const candidates = candidateSessionIds ?? Array.from(knownSessionIds)
|
||||
if (candidates.length === 0) return
|
||||
|
||||
// Re-fetch pending questions — they may have been asked during an SSE gap,
|
||||
// a directory-eviction window, or a session-switch that bypassed bootstrap.
|
||||
try {
|
||||
const beforeSignatures = new Map(
|
||||
candidates.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]),
|
||||
)
|
||||
const pendingQuestions = await opencodeClient.listPendingQuestions({ directories: [directory] })
|
||||
const grouped: Record<string, QuestionRequest[]> = {}
|
||||
for (const q of pendingQuestions) {
|
||||
if (!q?.id || !q.sessionID) continue
|
||||
if (!knownSessionIds.has(q.sessionID)) continue
|
||||
const list = grouped[q.sessionID]
|
||||
if (list) list.push(q)
|
||||
else grouped[q.sessionID] = [q]
|
||||
}
|
||||
for (const sessionId of Object.keys(grouped)) {
|
||||
grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}
|
||||
|
||||
for (const [sessionId, questions] of Object.entries(grouped)) {
|
||||
const knownIds = new Set((before.question[sessionId] ?? []).map((item) => item.id))
|
||||
const isViewed = isViewedInCurrentSession(directory, sessionId)
|
||||
if (isViewed) continue
|
||||
for (const question of questions) {
|
||||
if (knownIds.has(question.id)) continue
|
||||
const toastKey = getQuestionToastKey(sessionId, question.id)
|
||||
if (!toastKey || pendingQuestionToastIds.has(toastKey)) continue
|
||||
pendingQuestionToastIds.add(toastKey)
|
||||
const firstQuestion = question.questions?.[0]
|
||||
const title = firstQuestion?.header?.trim() || "Input needed"
|
||||
const description = firstQuestion?.question?.trim() || "Agent is waiting for your response"
|
||||
toast.info(title, {
|
||||
id: `question-${toastKey}`,
|
||||
description,
|
||||
action: {
|
||||
label: "Open session",
|
||||
onClick: () => openSessionFromToast(sessionId, directory),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
store.setState((state: DirectoryStore) => {
|
||||
const merged = { ...state.question }
|
||||
for (const [sessionId, questions] of Object.entries(grouped)) {
|
||||
merged[sessionId] = questions
|
||||
}
|
||||
for (const sessionId of candidates) {
|
||||
if (grouped[sessionId]) continue
|
||||
const beforeSignature = beforeSignatures.get(sessionId) ?? ""
|
||||
const currentSignature = requestSignature(state.question[sessionId])
|
||||
if (currentSignature !== beforeSignature) continue
|
||||
delete merged[sessionId]
|
||||
}
|
||||
return { question: merged }
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal: question resync best-effort
|
||||
}
|
||||
|
||||
// Re-fetch pending permissions — same rationale as questions.
|
||||
try {
|
||||
const beforeSignatures = new Map(
|
||||
candidates.map((sessionId) => [sessionId, requestSignature(before.permission[sessionId])]),
|
||||
)
|
||||
const pendingPermissions = await opencodeClient.listPendingPermissions({ directories: [directory] })
|
||||
const grouped: Record<string, PermissionRequest[]> = {}
|
||||
for (const permission of pendingPermissions) {
|
||||
if (!permission?.id || !permission.sessionID) continue
|
||||
if (!knownSessionIds.has(permission.sessionID)) continue
|
||||
const list = grouped[permission.sessionID]
|
||||
if (list) list.push(permission)
|
||||
else grouped[permission.sessionID] = [permission]
|
||||
}
|
||||
for (const sessionId of Object.keys(grouped)) {
|
||||
grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}
|
||||
|
||||
const permissionStore = usePermissionStore.getState()
|
||||
const autoAcceptingSessionIds = Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId))
|
||||
|
||||
if (autoAcceptingSessionIds.length > 0) {
|
||||
await Promise.all(
|
||||
autoAcceptingSessionIds.flatMap((sessionId) =>
|
||||
(grouped[sessionId] ?? []).map((permission) =>
|
||||
sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
for (const sessionId of autoAcceptingSessionIds) {
|
||||
delete grouped[sessionId]
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sessionId, permissions] of Object.entries(grouped)) {
|
||||
const knownIds = new Set((before.permission[sessionId] ?? []).map((item) => item.id))
|
||||
const isViewed = isViewedInCurrentSession(directory, sessionId)
|
||||
if (isViewed) continue
|
||||
for (const permission of permissions) {
|
||||
if (knownIds.has(permission.id)) continue
|
||||
const toastKey = getPermissionToastKey(sessionId, permission.id)
|
||||
if (!toastKey || pendingPermissionToastIds.has(toastKey)) continue
|
||||
pendingPermissionToastIds.add(toastKey)
|
||||
const description = typeof permission.permission === "string" && permission.permission.trim().length > 0
|
||||
? permission.permission
|
||||
: "Agent needs your approval"
|
||||
toast.info("Permission needed", {
|
||||
id: `permission-${toastKey}`,
|
||||
description,
|
||||
action: {
|
||||
label: "Open session",
|
||||
onClick: () => openSessionFromToast(sessionId, directory),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
store.setState((state: DirectoryStore) => {
|
||||
const merged = { ...state.permission }
|
||||
for (const [sessionId, permissions] of Object.entries(grouped)) {
|
||||
merged[sessionId] = permissions
|
||||
}
|
||||
for (const sessionId of candidates) {
|
||||
if (grouped[sessionId]) continue
|
||||
const beforeSignature = beforeSignatures.get(sessionId) ?? ""
|
||||
const currentSignature = requestSignature(state.permission[sessionId])
|
||||
if (currentSignature !== beforeSignature) continue
|
||||
delete merged[sessionId]
|
||||
}
|
||||
return { permission: merged }
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal: permission resync best-effort
|
||||
}
|
||||
}
|
||||
|
||||
async function resyncDirectoryAfterReconnect(
|
||||
directory: string,
|
||||
store: StoreApi<DirectoryStore>,
|
||||
@@ -805,161 +968,7 @@ async function resyncDirectoryAfterReconnect(
|
||||
setIndexedSessionMessages(routingIndex, sessionId, directory, nextMessages)
|
||||
}))
|
||||
|
||||
// Re-fetch pending questions on reconnect — they may have been asked
|
||||
// during the SSE disconnection window and will not arrive via SSE events.
|
||||
// Overwrite sessions covered by API response, and clear reconnect candidates
|
||||
// that remain unchanged during the request but are absent from the response.
|
||||
// If SSE changed a session while the request was in-flight, keep that data.
|
||||
try {
|
||||
const before = store.getState()
|
||||
const knownSessionIds = new Set<string>([
|
||||
...before.session.map((session) => session.id),
|
||||
...Object.keys(before.message ?? {}),
|
||||
...Object.keys(before.session_status ?? {}),
|
||||
...Object.keys(before.question ?? {}),
|
||||
...Object.keys(before.permission ?? {}),
|
||||
])
|
||||
const beforeSignatures = new Map(
|
||||
candidateSessionIds.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]),
|
||||
)
|
||||
const pendingQuestions = await opencodeClient.listPendingQuestions({ directories: [directory] })
|
||||
const grouped: Record<string, QuestionRequest[]> = {}
|
||||
for (const q of pendingQuestions) {
|
||||
if (!q?.id || !q.sessionID) continue
|
||||
if (!knownSessionIds.has(q.sessionID)) continue
|
||||
const list = grouped[q.sessionID]
|
||||
if (list) list.push(q)
|
||||
else grouped[q.sessionID] = [q]
|
||||
}
|
||||
// Sort each group by id for binary-search compatibility
|
||||
for (const sessionId of Object.keys(grouped)) {
|
||||
grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}
|
||||
|
||||
for (const [sessionId, questions] of Object.entries(grouped)) {
|
||||
const knownIds = new Set((before.question[sessionId] ?? []).map((item) => item.id))
|
||||
const isViewed = isViewedInCurrentSession(directory, sessionId)
|
||||
if (isViewed) continue
|
||||
for (const question of questions) {
|
||||
if (knownIds.has(question.id)) continue
|
||||
const toastKey = getQuestionToastKey(sessionId, question.id)
|
||||
if (!toastKey || pendingQuestionToastIds.has(toastKey)) continue
|
||||
pendingQuestionToastIds.add(toastKey)
|
||||
const firstQuestion = question.questions?.[0]
|
||||
const title = firstQuestion?.header?.trim() || "Input needed"
|
||||
const description = firstQuestion?.question?.trim() || "Agent is waiting for your response"
|
||||
toast.info(title, {
|
||||
id: `question-${toastKey}`,
|
||||
description,
|
||||
action: {
|
||||
label: "Open session",
|
||||
onClick: () => openSessionFromToast(sessionId, directory),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
store.setState((state: DirectoryStore) => {
|
||||
const merged = { ...state.question }
|
||||
for (const [sessionId, questions] of Object.entries(grouped)) {
|
||||
merged[sessionId] = questions
|
||||
}
|
||||
for (const sessionId of candidateSessionIds) {
|
||||
if (grouped[sessionId]) continue
|
||||
const beforeSignature = beforeSignatures.get(sessionId) ?? ""
|
||||
const currentSignature = requestSignature(state.question[sessionId])
|
||||
if (currentSignature !== beforeSignature) continue
|
||||
delete merged[sessionId]
|
||||
}
|
||||
return { question: merged }
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal: question resync best-effort
|
||||
}
|
||||
|
||||
// Re-fetch pending permissions on reconnect — same rationale as questions.
|
||||
try {
|
||||
const before = store.getState()
|
||||
const knownSessionIds = new Set<string>([
|
||||
...before.session.map((session) => session.id),
|
||||
...Object.keys(before.message ?? {}),
|
||||
...Object.keys(before.session_status ?? {}),
|
||||
...Object.keys(before.question ?? {}),
|
||||
...Object.keys(before.permission ?? {}),
|
||||
])
|
||||
const beforeSignatures = new Map(
|
||||
candidateSessionIds.map((sessionId) => [sessionId, requestSignature(before.permission[sessionId])]),
|
||||
)
|
||||
const pendingPermissions = await opencodeClient.listPendingPermissions({ directories: [directory] })
|
||||
const grouped: Record<string, PermissionRequest[]> = {}
|
||||
for (const permission of pendingPermissions) {
|
||||
if (!permission?.id || !permission.sessionID) continue
|
||||
if (!knownSessionIds.has(permission.sessionID)) continue
|
||||
const list = grouped[permission.sessionID]
|
||||
if (list) list.push(permission)
|
||||
else grouped[permission.sessionID] = [permission]
|
||||
}
|
||||
for (const sessionId of Object.keys(grouped)) {
|
||||
grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}
|
||||
|
||||
const permissionStore = usePermissionStore.getState()
|
||||
const autoAcceptingSessionIds = Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId))
|
||||
|
||||
if (autoAcceptingSessionIds.length > 0) {
|
||||
await Promise.all(
|
||||
autoAcceptingSessionIds.flatMap((sessionId) =>
|
||||
(grouped[sessionId] ?? []).map((permission) =>
|
||||
sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
for (const sessionId of autoAcceptingSessionIds) {
|
||||
delete grouped[sessionId]
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sessionId, permissions] of Object.entries(grouped)) {
|
||||
const knownIds = new Set((before.permission[sessionId] ?? []).map((item) => item.id))
|
||||
const isViewed = isViewedInCurrentSession(directory, sessionId)
|
||||
if (isViewed) continue
|
||||
for (const permission of permissions) {
|
||||
if (knownIds.has(permission.id)) continue
|
||||
const toastKey = getPermissionToastKey(sessionId, permission.id)
|
||||
if (!toastKey || pendingPermissionToastIds.has(toastKey)) continue
|
||||
pendingPermissionToastIds.add(toastKey)
|
||||
const description = typeof permission.permission === "string" && permission.permission.trim().length > 0
|
||||
? permission.permission
|
||||
: "Agent needs your approval"
|
||||
toast.info("Permission needed", {
|
||||
id: `permission-${toastKey}`,
|
||||
description,
|
||||
action: {
|
||||
label: "Open session",
|
||||
onClick: () => openSessionFromToast(sessionId, directory),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
store.setState((state: DirectoryStore) => {
|
||||
const merged = { ...state.permission }
|
||||
for (const [sessionId, permissions] of Object.entries(grouped)) {
|
||||
merged[sessionId] = permissions
|
||||
}
|
||||
for (const sessionId of candidateSessionIds) {
|
||||
if (grouped[sessionId]) continue
|
||||
const beforeSignature = beforeSignatures.get(sessionId) ?? ""
|
||||
const currentSignature = requestSignature(state.permission[sessionId])
|
||||
if (currentSignature !== beforeSignature) continue
|
||||
delete merged[sessionId]
|
||||
}
|
||||
return { permission: merged }
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal: permission resync best-effort
|
||||
}
|
||||
await resyncBlockingRequestsForDirectory(directory, store, candidateSessionIds)
|
||||
|
||||
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||
}
|
||||
@@ -1469,6 +1478,46 @@ export function SyncProvider(props: {
|
||||
return unsubscribe
|
||||
}, [props.directory, childStores])
|
||||
|
||||
// Re-fetch pending questions/permissions on session-switch.
|
||||
// PR #909 only re-fetches on SSE reconnect, leaving an event-drop gap when
|
||||
// switching sessions within the same socket — the question.asked event may
|
||||
// have arrived while a different session was active and the directory store
|
||||
// was evicted, or the user may navigate back to a directory whose store was
|
||||
// rebuilt after eviction. A 250ms debounce coalesces rapid switches.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastSessionId: string | null = null
|
||||
let unsub: (() => void) | undefined
|
||||
void import("./session-ui-store")
|
||||
.then(({ useSessionUIStore }) => {
|
||||
if (cancelled) return
|
||||
lastSessionId = useSessionUIStore.getState().currentSessionId
|
||||
unsub = useSessionUIStore.subscribe((state) => {
|
||||
const nextSessionId = state.currentSessionId
|
||||
if (nextSessionId === lastSessionId) return
|
||||
lastSessionId = nextSessionId
|
||||
if (!nextSessionId) return
|
||||
const sessionDirectory = state.getDirectoryForSession(nextSessionId)
|
||||
?? opencodeClient.getDirectory()
|
||||
?? props.directory
|
||||
if (!sessionDirectory) return
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
const currentStore = childStores.getChild(sessionDirectory)
|
||||
if (!currentStore) return
|
||||
void resyncBlockingRequestsForDirectory(sessionDirectory, currentStore).catch(() => undefined)
|
||||
}, 250)
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
unsub?.()
|
||||
}
|
||||
}, [props.directory, childStores])
|
||||
|
||||
return <SyncContext.Provider value={system}>{props.children}</SyncContext.Provider>
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export type EvictPlan = {
|
||||
max: number
|
||||
ttl: number
|
||||
now: number
|
||||
hasPendingBlockingRequests?: (directory: string) => boolean
|
||||
}
|
||||
|
||||
export type DisposeCheck = {
|
||||
@@ -101,6 +102,7 @@ export type DisposeCheck = {
|
||||
pinned: boolean
|
||||
booting: boolean
|
||||
loadingSessions: boolean
|
||||
hasPendingBlockingRequests: boolean
|
||||
}
|
||||
|
||||
export type ChildOptions = {
|
||||
|
||||
Reference in New Issue
Block a user