Removes missing expanded folders from persisted Files state Prevents repeated 404 noise for stale file tree paths Avoids startup SDK race when restoring sessions
712 lines
27 KiB
TypeScript
712 lines
27 KiB
TypeScript
import { describe, expect, test, beforeEach, mock } from "bun:test"
|
|
import type { PermissionRequest } from "@/types/permission"
|
|
import type { QuestionRequest } from "@/types/question"
|
|
|
|
// Mock SDK client that records permission.reply / question.reply calls
|
|
const replyCalls: Array<{ method: string; params: Record<string, unknown> }> = []
|
|
const scopedClientDirectories: string[] = []
|
|
const registeredSessionDirectories: Array<{ sessionID: string; directory: string }> = []
|
|
let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
|
let questionReplyError: unknown | null = null
|
|
let questionRejectError: unknown | null = null
|
|
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
|
const globalUpsertedSessions: unknown[] = []
|
|
|
|
const mockScopedClient = {
|
|
permission: {
|
|
reply: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "permission.reply", params })
|
|
return Promise.resolve({ data: true })
|
|
}),
|
|
},
|
|
question: {
|
|
reply: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "question.reply", params })
|
|
if (questionReplyError) {
|
|
return Promise.resolve({ error: questionReplyError, response: { status: 404 } })
|
|
}
|
|
return Promise.resolve({ data: true })
|
|
}),
|
|
reject: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "question.reject", params })
|
|
if (questionRejectError) {
|
|
return Promise.resolve({ error: questionRejectError, response: { status: 404 } })
|
|
}
|
|
return Promise.resolve({ data: true })
|
|
}),
|
|
},
|
|
}
|
|
|
|
const mockSdk = {
|
|
session: {
|
|
messages: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.messages", params })
|
|
return Promise.resolve({ data: [] })
|
|
}),
|
|
revert: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.revert", params })
|
|
return Promise.resolve(sessionRevertResult)
|
|
}),
|
|
abort: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.abort", params })
|
|
return Promise.resolve({ data: true })
|
|
}),
|
|
share: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.share", params })
|
|
return Promise.resolve(sessionShareResult)
|
|
}),
|
|
unshare: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.unshare", params })
|
|
return Promise.resolve(sessionShareResult)
|
|
}),
|
|
},
|
|
permission: {
|
|
reply: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "permission.reply", params })
|
|
return Promise.resolve({ data: true })
|
|
}),
|
|
},
|
|
question: {
|
|
reply: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "question.reply", params })
|
|
if (questionReplyError) {
|
|
return Promise.resolve({ error: questionReplyError, response: { status: 404 } })
|
|
}
|
|
return Promise.resolve({ data: true })
|
|
}),
|
|
reject: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "question.reject", params })
|
|
if (questionRejectError) {
|
|
return Promise.resolve({ error: questionRejectError, response: { status: 404 } })
|
|
}
|
|
return Promise.resolve({ data: true })
|
|
}),
|
|
},
|
|
}
|
|
|
|
// Mock opencodeClient singleton
|
|
mock.module("@/lib/opencode/client", () => ({
|
|
opencodeClient: {
|
|
getScopedSdkClient: (directory: string) => {
|
|
scopedClientDirectories.push(directory)
|
|
return mockScopedClient
|
|
},
|
|
getDirectory: () => "/test/project",
|
|
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
|
|
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
|
|
return Promise.resolve(true)
|
|
}),
|
|
replyToQuestion: mock((requestId: string, answers: string[] | string[][], directory?: string | null) => {
|
|
replyCalls.push({ method: "question.reply", params: { requestID: requestId, answers, directory } })
|
|
return Promise.resolve(true)
|
|
}),
|
|
revertSession: mock((sessionId: string, messageId: string, partId?: string, directory?: string | null) => {
|
|
replyCalls.push({
|
|
method: "session.revert",
|
|
params: { sessionID: sessionId, messageID: messageId, partID: partId, directory },
|
|
})
|
|
if (sessionRevertResult.error) {
|
|
const status = sessionRevertResult.response?.status
|
|
throw new Error(`session.revert failed${status ? ` (${status})` : ""}: rejected`)
|
|
}
|
|
return Promise.resolve(sessionRevertResult.data)
|
|
}),
|
|
},
|
|
}))
|
|
|
|
// Mock useConfigStore
|
|
mock.module("@/stores/useConfigStore", () => ({
|
|
useConfigStore: {
|
|
getState: () => ({
|
|
isConnected: true,
|
|
hasEverConnected: true,
|
|
}),
|
|
},
|
|
}))
|
|
|
|
// Mock useSessionUIStore
|
|
mock.module("./session-ui-store", () => ({
|
|
useSessionUIStore: {
|
|
getState: () => ({
|
|
getDirectoryForSession: (sessionId: string) => {
|
|
if (sessionId === "session-a") return "/test/project"
|
|
if (sessionId === "session-b") return "/other/project"
|
|
return null
|
|
},
|
|
}),
|
|
},
|
|
}))
|
|
|
|
// Mock useInputStore
|
|
const inputState = {
|
|
pendingInputText: "",
|
|
pendingInputMode: "normal" as const,
|
|
attachedFiles: [],
|
|
clearAttachedFiles: () => {
|
|
inputState.attachedFiles = []
|
|
},
|
|
addRestoredAttachment: (attachment: never) => {
|
|
inputState.attachedFiles = [...inputState.attachedFiles, attachment]
|
|
},
|
|
}
|
|
|
|
mock.module("./input-store", () => ({
|
|
useInputStore: {
|
|
getState: () => inputState,
|
|
setState: (patch: Partial<typeof inputState>) => Object.assign(inputState, patch),
|
|
},
|
|
}))
|
|
|
|
mock.module("@/stores/useGlobalSessionsStore", () => ({
|
|
mergeSessionDirectoryMetadata: (incoming: Session, existing?: SessionWithDirectory | null): SessionWithDirectory => {
|
|
if (!existing) return incoming as SessionWithDirectory
|
|
const next = { ...(incoming as SessionWithDirectory) }
|
|
if (!next.directory && existing.directory) next.directory = existing.directory
|
|
if (!next.project && existing.project) next.project = existing.project
|
|
if (next.project && !next.project.worktree && existing.project?.worktree) {
|
|
next.project = { ...next.project, worktree: existing.project.worktree }
|
|
}
|
|
return next
|
|
},
|
|
useGlobalSessionsStore: {
|
|
getState: () => ({
|
|
upsertSession: (session: unknown) => {
|
|
globalUpsertedSessions.push(session)
|
|
},
|
|
}),
|
|
},
|
|
}))
|
|
|
|
mock.module("./sync-refs", () => ({
|
|
registerSessionDirectory: (sessionID: string, directory: string) => {
|
|
registeredSessionDirectories.push({ sessionID, directory })
|
|
},
|
|
}))
|
|
|
|
import { create, type StoreApi } from "zustand"
|
|
import { INITIAL_STATE } from "./types"
|
|
import type { DirectoryStore } from "./child-store"
|
|
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
|
|
|
type OptimisticAddCall = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
|
|
type OptimisticRemoveCall = { sessionID: string; directory?: string | null; messageID: string }
|
|
type SessionWithDirectory = Session & {
|
|
directory?: string | null
|
|
project?: { worktree?: string | null }
|
|
}
|
|
|
|
function createStore(
|
|
permissions: Record<string, PermissionRequest[]>,
|
|
state?: Partial<DirectoryStore>,
|
|
): StoreApi<DirectoryStore> {
|
|
return create<DirectoryStore>()((set) => ({
|
|
...INITIAL_STATE,
|
|
...state,
|
|
permission: permissions,
|
|
patch: (partial) => set(partial),
|
|
replace: (next) => set(next),
|
|
}))
|
|
}
|
|
|
|
function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
|
|
return {
|
|
children: new Map(entries),
|
|
ensureChild: (dir: string) => {
|
|
const store = new Map(entries).get(dir)
|
|
if (!store) throw new Error(`No store for ${dir}`)
|
|
return store
|
|
},
|
|
getChild: (dir: string) => new Map(entries).get(dir),
|
|
} as unknown as import("./child-store").ChildStoreManager
|
|
}
|
|
|
|
describe("fetchMessagesForSession startup race", () => {
|
|
test("does not reject before sync action refs are initialized", async () => {
|
|
const { fetchMessagesForSession } = await import("./session-actions")
|
|
|
|
let error: unknown = null
|
|
try {
|
|
await fetchMessagesForSession("session-a", "/test/project")
|
|
} catch (err) {
|
|
error = err
|
|
}
|
|
|
|
expect(error).toBe(null)
|
|
})
|
|
})
|
|
|
|
describe("shareSession live state", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
globalUpsertedSessions.length = 0
|
|
sessionShareResult = {}
|
|
})
|
|
|
|
test("updates the directory live store after unsharing", async () => {
|
|
const sharedSession = { id: "session-a", time: { created: 1 }, share: { url: "https://share.example/a" } } as Session
|
|
const unsharedSession = { id: "session-a", time: { created: 1, updated: 2 } } as Session
|
|
const sessionStore = createStore({}, { session: [sharedSession] })
|
|
const otherStore = createStore({}, { session: [{ id: "other", time: { created: 1 } } as Session] })
|
|
const childStores = createChildStores([
|
|
["/test/project", sessionStore],
|
|
["/other/project", otherStore],
|
|
])
|
|
sessionShareResult = { data: unsharedSession }
|
|
|
|
const { setActionRefs, unshareSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
|
|
|
const result = await unshareSession("session-a")
|
|
|
|
expect(result).toBe(unsharedSession)
|
|
expect(replyCalls.find((call) => call.method === "session.unshare")?.params.directory).toBe("/test/project")
|
|
expect(sessionStore.getState().session[0].share).toBe(undefined)
|
|
expect(otherStore.getState().session[0].id).toBe("other")
|
|
expect(globalUpsertedSessions).toEqual([unsharedSession])
|
|
})
|
|
|
|
test("updates the directory live store after sharing", async () => {
|
|
const unsharedSession = { id: "session-a", time: { created: 1 } } as Session
|
|
const sharedSession = { id: "session-a", time: { created: 1, updated: 2 }, share: { url: "https://share.example/a" } } as Session
|
|
const sessionStore = createStore({}, { session: [unsharedSession] })
|
|
const childStores = createChildStores([["/test/project", sessionStore]])
|
|
sessionShareResult = { data: sharedSession }
|
|
|
|
const { setActionRefs, shareSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
|
|
|
const result = await shareSession("session-a")
|
|
|
|
expect(result).toBe(sharedSession)
|
|
expect(replyCalls.find((call) => call.method === "session.share")?.params.directory).toBe("/test/project")
|
|
expect(sessionStore.getState().session[0].share?.url).toBe("https://share.example/a")
|
|
expect(globalUpsertedSessions).toEqual([sharedSession])
|
|
})
|
|
|
|
test("preserves live directory metadata while clearing share from null response", async () => {
|
|
const sharedSession = {
|
|
id: "session-a",
|
|
time: { created: 1 },
|
|
directory: "/test/project",
|
|
project: { worktree: "/test/project" },
|
|
share: { url: "https://share.example/a" },
|
|
} as SessionWithDirectory
|
|
const unsharedSession = {
|
|
id: "session-a",
|
|
time: { created: 1, updated: 2 },
|
|
share: null,
|
|
} as unknown as Session
|
|
const sessionStore = createStore({}, { session: [sharedSession] })
|
|
const childStores = createChildStores([["/test/project", sessionStore]])
|
|
sessionShareResult = { data: unsharedSession }
|
|
|
|
const { setActionRefs, unshareSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
|
|
|
await unshareSession("session-a")
|
|
|
|
const liveSession = sessionStore.getState().session[0] as SessionWithDirectory & { share?: null }
|
|
expect(liveSession.share).toBe(null)
|
|
expect(liveSession.directory).toBe("/test/project")
|
|
expect(liveSession.project?.worktree).toBe("/test/project")
|
|
})
|
|
|
|
test("strips oversized diff snapshots before updating session stores", async () => {
|
|
const sessionWithDiff = {
|
|
id: "session-a",
|
|
time: { created: 1, updated: 2 },
|
|
share: { url: "https://share.example/a" },
|
|
summary: {
|
|
diffs: [{ file: "a.txt", before: "old", after: "new", additions: 1, deletions: 1 }],
|
|
},
|
|
} as unknown as Session
|
|
const sessionStore = createStore({}, { session: [{ id: "session-a", time: { created: 1 } } as Session] })
|
|
const childStores = createChildStores([["/test/project", sessionStore]])
|
|
sessionShareResult = { data: sessionWithDiff }
|
|
|
|
const { setActionRefs, shareSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
|
|
|
const result = await shareSession("session-a")
|
|
|
|
const storedDiff = ((sessionStore.getState().session[0] as { summary?: { diffs?: Array<Record<string, unknown>> } }).summary?.diffs ?? [])[0]
|
|
const globalDiff = (((globalUpsertedSessions[0] as { summary?: { diffs?: Array<Record<string, unknown>> } }).summary?.diffs ?? [])[0])
|
|
const resultDiff = ((result as { summary?: { diffs?: Array<Record<string, unknown>> } }).summary?.diffs ?? [])[0]
|
|
expect(storedDiff.before).toBe(undefined)
|
|
expect(storedDiff.after).toBe(undefined)
|
|
expect(globalDiff.before).toBe(undefined)
|
|
expect(resultDiff.after).toBe(undefined)
|
|
})
|
|
})
|
|
|
|
describe("optimisticSend target directory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
})
|
|
|
|
test("passes the prompt directory to optimistic state during session switch races", async () => {
|
|
const currentStore = createStore({})
|
|
const targetStore = createStore({})
|
|
const childStores = createChildStores([
|
|
["/current/project", currentStore],
|
|
["/target/project", targetStore],
|
|
])
|
|
let optimisticAdd: OptimisticAddCall | null = null
|
|
let optimisticRemove: OptimisticRemoveCall | null = null
|
|
let sentMessageID = ""
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
|
setOptimisticRefs(
|
|
(input) => {
|
|
optimisticAdd = input
|
|
},
|
|
(input) => {
|
|
optimisticRemove = input
|
|
},
|
|
)
|
|
|
|
await optimisticSend({
|
|
sessionId: "session-new",
|
|
directory: "/target/project",
|
|
content: "hello",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
send: async (messageID) => {
|
|
sentMessageID = messageID
|
|
},
|
|
})
|
|
|
|
expect(optimisticAdd).not.toBeNull()
|
|
const add = optimisticAdd as unknown as OptimisticAddCall
|
|
expect(add.directory).toBe("/target/project")
|
|
expect(add.sessionID).toBe("session-new")
|
|
expect(add.message.id).toBe(sentMessageID)
|
|
expect(optimisticRemove).toBe(null)
|
|
expect(targetStore.getState().session_status["session-new"]?.type).toBe("busy")
|
|
expect(currentStore.getState().session_status["session-new"]).toBe(undefined)
|
|
})
|
|
})
|
|
|
|
describe("respondToPermission passes directory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
sessionRevertResult = {}
|
|
})
|
|
|
|
test("passes directory from child store when permission is found", async () => {
|
|
const permission: PermissionRequest = {
|
|
id: "perm-1",
|
|
sessionID: "session-a",
|
|
permission: "bash",
|
|
patterns: [],
|
|
metadata: {},
|
|
always: [],
|
|
}
|
|
|
|
const store = createStore({ "session-a": [permission] })
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
await respondToPermission("session-a", "perm-1", "once")
|
|
|
|
expect(replyCalls.length).toBe(1)
|
|
expect(replyCalls[0].params.requestID).toBe("perm-1")
|
|
expect(replyCalls[0].params.reply).toBe("once")
|
|
expect(replyCalls[0].params.directory).toBe("/test/project")
|
|
})
|
|
|
|
test("passes directory from session mapping when permission not in store", async () => {
|
|
const childStores = createChildStores([])
|
|
|
|
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
await respondToPermission("session-b", "perm-2", "always")
|
|
|
|
expect(replyCalls.length).toBe(1)
|
|
expect(replyCalls[0].params.requestID).toBe("perm-2")
|
|
expect(replyCalls[0].params.reply).toBe("always")
|
|
expect(replyCalls[0].params.directory).toBe("/other/project")
|
|
})
|
|
|
|
test("passes directory from current directory as last resort", async () => {
|
|
const childStores = createChildStores([])
|
|
|
|
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/fallback/dir")
|
|
|
|
await respondToPermission("unknown-session", "perm-3", "reject")
|
|
|
|
expect(replyCalls.length).toBe(1)
|
|
expect(replyCalls[0].params.requestID).toBe("perm-3")
|
|
expect(replyCalls[0].params.reply).toBe("reject")
|
|
expect(replyCalls[0].params.directory).toBe("/fallback/dir")
|
|
})
|
|
})
|
|
|
|
describe("revertToMessage passes session directory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
sessionRevertResult = {}
|
|
Object.assign(inputState, {
|
|
pendingInputText: "previous draft",
|
|
pendingInputMode: "normal" as const,
|
|
attachedFiles: [],
|
|
})
|
|
})
|
|
|
|
test("routes revert through the session directory instead of the current directory", async () => {
|
|
const session = { id: "session-a", time: { created: 1 } } as Session
|
|
const targetMessage = { id: "msg_2", sessionID: "session-a", role: "user", time: { created: 2 } } as Message
|
|
const targetPart = { id: "prt_2", messageID: "msg_2", type: "text", text: "edit this" } as Part
|
|
const sessionStore = createStore({}, {
|
|
session: [session],
|
|
message: { "session-a": [targetMessage] },
|
|
part: { "msg_2": [targetPart] },
|
|
})
|
|
const currentStore = createStore({})
|
|
const childStores = createChildStores([
|
|
["/test/project", sessionStore],
|
|
["/current/project", currentStore],
|
|
])
|
|
sessionRevertResult = { data: { id: "session-a", time: { created: 1, updated: 2 }, revert: { messageID: "msg_2" } } }
|
|
|
|
const { setActionRefs, revertToMessage } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
|
|
|
await revertToMessage("session-a", "msg_2")
|
|
|
|
expect(replyCalls.find((call) => call.method === "session.revert")?.params.directory).toBe("/test/project")
|
|
expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert?.messageID).toBe("msg_2")
|
|
expect(currentStore.getState().session).toHaveLength(0)
|
|
expect(inputState.pendingInputText).toBe("edit this")
|
|
})
|
|
|
|
test("rolls back optimistic revert when the SDK returns an error", async () => {
|
|
const session = { id: "session-a", time: { created: 1 } } as Session
|
|
const targetMessage = { id: "msg_2", sessionID: "session-a", role: "user", time: { created: 2 } } as Message
|
|
const targetPart = { id: "prt_2", messageID: "msg_2", type: "text", text: "edit this" } as Part
|
|
const sessionStore = createStore({}, {
|
|
session: [session],
|
|
message: { "session-a": [targetMessage] },
|
|
part: { "msg_2": [targetPart] },
|
|
})
|
|
const childStores = createChildStores([["/test/project", sessionStore]])
|
|
sessionRevertResult = { error: { message: "rejected" }, response: { status: 500 } }
|
|
|
|
const { setActionRefs, revertToMessage } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
let thrown: unknown
|
|
try {
|
|
await revertToMessage("session-a", "msg_2")
|
|
} catch (error) {
|
|
thrown = error
|
|
}
|
|
|
|
expect(thrown).toBeInstanceOf(Error)
|
|
expect((thrown as Error).message).toContain("session.revert failed (500)")
|
|
expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert).toBe(undefined)
|
|
expect(inputState.pendingInputText).toBe("previous draft")
|
|
})
|
|
})
|
|
|
|
describe("dismissPermission passes directory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
questionReplyError = null
|
|
})
|
|
|
|
test("passes directory and reply=reject", async () => {
|
|
const permission: PermissionRequest = {
|
|
id: "perm-10",
|
|
sessionID: "session-a",
|
|
permission: "edit",
|
|
patterns: [],
|
|
metadata: {},
|
|
always: [],
|
|
}
|
|
|
|
const store = createStore({ "session-a": [permission] })
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, dismissPermission } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
await dismissPermission("session-a", "perm-10")
|
|
|
|
expect(replyCalls.length).toBe(1)
|
|
expect(replyCalls[0].params.requestID).toBe("perm-10")
|
|
expect(replyCalls[0].params.reply).toBe("reject")
|
|
expect(replyCalls[0].params.directory).toBe("/test/project")
|
|
})
|
|
})
|
|
|
|
describe("respondToQuestion passes directory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
questionReplyError = null
|
|
})
|
|
|
|
test("passes directory to question.reply", async () => {
|
|
const childStores = createChildStores([])
|
|
|
|
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
await respondToQuestion("session-a", "q-1", [["answer1"]])
|
|
|
|
expect(replyCalls.length).toBe(1)
|
|
expect(replyCalls[0].params.requestID).toBe("q-1")
|
|
expect(replyCalls[0].params.directory).toBe("/test/project")
|
|
expect(scopedClientDirectories).toEqual(["/test/project"])
|
|
})
|
|
|
|
test("removes stale question from child store when reply returns not found", async () => {
|
|
const question: QuestionRequest = {
|
|
id: "q-stale",
|
|
sessionID: "session-a",
|
|
questions: [
|
|
{
|
|
question: "Choose an option",
|
|
header: "Choice",
|
|
options: [{ label: "Yes", description: "Proceed" }],
|
|
},
|
|
],
|
|
}
|
|
const store = createStore({}, { question: { "session-a": [question] } })
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
questionReplyError = Object.assign(new Error("question.reply failed (404): QuestionNotFoundError"), { status: 404 })
|
|
|
|
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
let thrown: unknown
|
|
try {
|
|
await respondToQuestion("session-a", "q-stale", [["Yes"]])
|
|
} catch (error) {
|
|
thrown = error
|
|
}
|
|
|
|
expect(thrown).toBeInstanceOf(Error)
|
|
expect(store.getState().question["session-a"]).toBe(undefined)
|
|
})
|
|
})
|
|
|
|
describe("rejectQuestion passes directory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
questionReplyError = null
|
|
})
|
|
|
|
test("passes directory to question.reject", async () => {
|
|
const childStores = createChildStores([])
|
|
|
|
const { setActionRefs, rejectQuestion } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
await rejectQuestion("session-a", "q-2")
|
|
|
|
expect(replyCalls.length).toBe(1)
|
|
expect(replyCalls[0].params.requestID).toBe("q-2")
|
|
expect(replyCalls[0].params.directory).toBe("/test/project")
|
|
})
|
|
})
|
|
|
|
function buildQuestion(id: string, sessionId: string): QuestionRequest {
|
|
return {
|
|
id,
|
|
sessionID: sessionId,
|
|
questions: [
|
|
{
|
|
question: "Choose an option",
|
|
header: "Choice",
|
|
options: [{ label: "Yes", description: "Proceed" }],
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
describe("dismissOpenQuestionsForSession", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
questionReplyError = null
|
|
})
|
|
|
|
test("returns false and rejects nothing when no questions are pending", async () => {
|
|
const store = createStore({}, { session: [{ id: "session-a", time: { created: 1 } } as Session] })
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, dismissOpenQuestionsForSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
const dismissed = await dismissOpenQuestionsForSession("session-a")
|
|
|
|
expect(dismissed).toBe(false)
|
|
expect(replyCalls.filter((call) => call.method === "question.reject")).toHaveLength(0)
|
|
})
|
|
|
|
test("rejects every pending question in the session subtree (root + subagent child)", async () => {
|
|
const rootQuestion = buildQuestion("q-root", "session-a")
|
|
const childQuestion = buildQuestion("q-child", "session-child")
|
|
const store = createStore({}, {
|
|
session: [
|
|
{ id: "session-a", time: { created: 1 } } as Session,
|
|
{ id: "session-child", parentID: "session-a", time: { created: 2 } } as Session,
|
|
],
|
|
question: {
|
|
"session-a": [rootQuestion],
|
|
"session-child": [childQuestion],
|
|
},
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, dismissOpenQuestionsForSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
const dismissed = await dismissOpenQuestionsForSession("session-a")
|
|
|
|
expect(dismissed).toBe(true)
|
|
const rejectCalls = replyCalls.filter((call) => call.method === "question.reject")
|
|
expect(rejectCalls).toHaveLength(2)
|
|
const rejectedIds = rejectCalls.map((call) => call.params.requestID).sort()
|
|
expect(rejectedIds).toEqual(["q-child", "q-root"])
|
|
// Optimistic clear: the questions are removed from the local store so the
|
|
// prompt disappears instantly, without waiting for the reject round-trip.
|
|
expect(store.getState().question["session-a"]).toBe(undefined)
|
|
expect(store.getState().question["session-child"]).toBe(undefined)
|
|
})
|
|
|
|
test("swallows QuestionNotFoundError so a stranded question never blocks the send", async () => {
|
|
const staleQuestion = buildQuestion("q-stale", "session-a")
|
|
const store = createStore({}, {
|
|
session: [{ id: "session-a", time: { created: 1 } } as Session],
|
|
question: { "session-a": [staleQuestion] },
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
questionRejectError = Object.assign(new Error("question.reject failed (404): QuestionNotFoundError"), { status: 404 })
|
|
|
|
const { setActionRefs, dismissOpenQuestionsForSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
const dismissed = await dismissOpenQuestionsForSession("session-a")
|
|
|
|
expect(dismissed).toBe(true)
|
|
const rejectCalls = replyCalls.filter((call) => call.method === "question.reject")
|
|
expect(rejectCalls).toHaveLength(1)
|
|
expect(rejectCalls[0].params.requestID).toBe("q-stale")
|
|
// The stale entry is cleared from the store even though the server reported not-found.
|
|
expect(store.getState().question["session-a"]).toBe(undefined)
|
|
})
|
|
})
|