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:
Alexander Busse
2026-05-05 19:39:35 +03:00
committed by GitHub
co-authored by Alexander Busse Bohdan Triapitsyn
parent a49e2ce1ae
commit 2b0e1ef42e
6 changed files with 533 additions and 157 deletions
@@ -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)
})
})
+7 -1
View File
@@ -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)
+26 -1
View File
@@ -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
}
+204 -155
View File
@@ -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>
}
+2
View File
@@ -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 = {