* fix(sessions): keep a shared chat directory until its last session is deleted Deleting a root chat session removed its managed scratch directory even when forks, side threads, or subagents still lived in it; OpenCode then failed every prompt in those sessions with FileSystem.realPath NotFound. The directory is now removed only once no other known session resolves to it. The deleted subtree does not count, because the server cascade- deletes it, and an unloaded global cache keeps the directory instead of guessing. Closes #3312. * fix(sessions): relocate a session whose worktree directory disappeared A worktree removed outside OpenChamber, by the agent or by hand, left its sessions pointed at a path that no longer exists: every terminal create and restart failed with "Invalid working directory" and the tab stayed stuck, while Git, Files, and prompts kept targeting the dead path. The terminal server now names that one rejection (TERMINAL_CWD_MISSING) instead of substituting a directory of its own. The shared UI reuses the archived-restore fallback for live sessions: a server-confirmed missing directory moves the session and its stranded subtree to the project's primary directory through the control-plane move, clears the worktree hint, re-selects the session, and tells the user where it went. It runs from a terminal failure and on activation of any session whose directory is neither a project root nor a managed chat directory; available, unknown, and failed probes leave everything untouched. Closes #3338. * fix(scripts): make oc-dev load again after the changelog cleanup The changelog cleanup referenced fs.existsSync in a module that imports existsSync by name and never binds fs, so every oc-dev invocation failed with "fs is not defined" before reaching its action. * fix(sessions): probe directory availability on disk, not through OpenCode path resolution OpenCode's /path never checks that a directory exists: it echoes the requested path and resolves its project through Git discovery that swallows errors, so a deleted worktree came back as a valid location and every missing-directory fallback (draft recovery, archived restore, session relocation) stayed inert on a real server. The probe now asks OpenChamber's own /api/fs/list, which stats the path and reports not-found and not-directory explicitly; anything else stays unknown. * fix(sidebar): keep a worktree whose directory is gone visible as missing git keeps a worktree registered after its directory is deleted outside git and marks it prunable; the list parser ignored that line, so a deleted worktree looked alive, and nothing in the app asked for a new listing anyway. The server now reports prunable, the UI keeps such a worktree in the topology with worktreeStatus missing and a warning icon on its sidebar group, and relocating a session out of a confirmed- missing directory raises an in-app topology signal the sidebar rediscovers on. Dropping the worktree instead would hide every session that lived there, and a hidden session can never be opened or relocated. No idle polling is added. * fix(sessions): never relocate a session to the filesystem root OpenCode files a directory outside any Git repository under its global project, whose worktree is the filesystem root. A managed chat whose directory vanished would otherwise be moved to /. The relocation now refuses a root destination, and the activation probe recognizes chat directories through the home-based check as well, so it does not depend on the chats root having been resolved yet. * test(sessions): mirror the relocation action in the issue-2039 session-actions mock session-ui-store now imports relocateSessionFromMissingDirectory, and the mocked module in this test listed every other action but not that one, so the file failed on import.
3167 lines
139 KiB
TypeScript
3167 lines
139 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 permissionReplyError: unknown | null = null
|
|
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
|
let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
|
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
|
|
const sessionMessageRecords = new Map<string, Array<{ info: Message; parts: Part[] }>>()
|
|
const failingRevertSessionIds = new Set<string>()
|
|
const failingUnrevertSessionIds = new Set<string>()
|
|
let afterUnrevertCall: ((sessionId: string) => void) | null = null
|
|
let sessionDeleteError: unknown | null = null
|
|
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
|
|
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
|
|
let beforeControlPlaneMoveResolve: ((sessionId: string) => void) | null = null
|
|
let beforeDirectoryAvailabilityResolve: (() => void) | null = null
|
|
const controlPlaneMoveErrorsById = new Map<string, Error>()
|
|
let globalHasLoaded = true
|
|
const deletedChatDirectories: string[] = []
|
|
const globalUpsertedSessions: unknown[] = []
|
|
const globalUpsertedSessionBatches: Session[][] = []
|
|
const globalRemovedSessionIds: string[] = []
|
|
// Sessions this client is holding. `archiveSessions` reads them to decide which
|
|
// sessions can be archived by the server in one batch.
|
|
let globalActiveSessions: Session[] = []
|
|
const archiveBatchRequests: Array<{ directory: string; ids: string[] }> = []
|
|
let archiveBatchResponse: { status: number; body: unknown } = {
|
|
status: 404,
|
|
body: { error: 'not found' },
|
|
}
|
|
const deletedCleanupIdentities: Array<{ runtimeKey: string; directory: string; sessionId: string }> = []
|
|
const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = []
|
|
const globalArchivedSessions: Session[] = []
|
|
const openCodeProjects: Project[] = []
|
|
const directoryAvailability = new Map<string, "available" | "missing" | "unknown">()
|
|
const sessionUpdateResultsById = new Map<string, Session | undefined>()
|
|
let runtimeKey = "default-runtime"
|
|
const AMBIGUOUS_TRANSPORT_FAILURE = Symbol("ambiguous-transport-failure")
|
|
|
|
const mockScopedClient = {
|
|
permission: {
|
|
reply: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "permission.reply", params })
|
|
if (permissionReplyError) {
|
|
const status = (permissionReplyError as { status?: number })?.status ?? 404
|
|
return Promise.resolve({ error: permissionReplyError, response: { status } })
|
|
}
|
|
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 = {
|
|
experimental: {
|
|
controlPlane: {
|
|
moveSession: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "controlPlane.moveSession", params })
|
|
beforeControlPlaneMoveResolve?.(String(params.sessionID))
|
|
const error = controlPlaneMoveErrorsById.get(String(params.sessionID))
|
|
if (error) return Promise.resolve({ error, response: { status: 500 } })
|
|
return Promise.resolve({})
|
|
}),
|
|
},
|
|
},
|
|
project: {
|
|
list: mock(() => {
|
|
replyCalls.push({ method: "project.list", params: {} })
|
|
return Promise.resolve({ data: openCodeProjects })
|
|
}),
|
|
},
|
|
session: {
|
|
messages: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.messages", params })
|
|
return Promise.resolve(sessionMessagesResult)
|
|
}),
|
|
revert: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.revert", params })
|
|
return Promise.resolve(sessionRevertResult)
|
|
}),
|
|
unrevert: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.unrevert", params })
|
|
afterUnrevertCall?.(String(params.sessionID))
|
|
if (failingUnrevertSessionIds.has(String(params.sessionID))) {
|
|
return Promise.resolve({ error: { message: "rejected" }, response: { status: 500 } })
|
|
}
|
|
return Promise.resolve({ data: { id: params.sessionID, time: { created: 1 } } })
|
|
}),
|
|
abort: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.abort", params })
|
|
return Promise.resolve({ data: true })
|
|
}),
|
|
updateSession: mock((sessionId: string, changes: Record<string, unknown>, directory?: string | null) => {
|
|
replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } })
|
|
return Promise.resolve(sessionUpdateResult.data as Session)
|
|
}),
|
|
update: mock((params: Record<string, unknown>) => {
|
|
replyCalls.push({ method: "session.update", params })
|
|
return Promise.resolve(sessionUpdateResult)
|
|
}),
|
|
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 })
|
|
if (permissionReplyError) {
|
|
const status = (permissionReplyError as { status?: number })?.status ?? 404
|
|
return Promise.resolve({ error: permissionReplyError, response: { status } })
|
|
}
|
|
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
|
|
// SAFETY: the actions under test touch only the SDK surface mocked above.
|
|
const actionSdk = mockSdk as unknown as OpencodeClient
|
|
|
|
mock.module("@/lib/opencode/client", () => ({
|
|
opencodeClient: {
|
|
getScopedSdkClient: (directory: string) => {
|
|
scopedClientDirectories.push(directory)
|
|
return mockScopedClient
|
|
},
|
|
getDirectory: () => "/test/project",
|
|
getDirectoryAvailability: mock(async (directory: string) => {
|
|
beforeDirectoryAvailabilityResolve?.()
|
|
return directoryAvailability.get(directory) ?? "available"
|
|
}),
|
|
getFilesystemHome: mock(async () => "/home/test"),
|
|
getSdkClient: () => mockSdk,
|
|
getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => {
|
|
replyCalls.push({ method: "session.messages", params: { sessionID: sessionId, directory } })
|
|
return Promise.resolve(sessionMessageRecords.get(sessionId) ?? [])
|
|
}),
|
|
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 || failingRevertSessionIds.has(sessionId)) {
|
|
const status = sessionRevertResult.response?.status
|
|
throw new Error(`session.revert failed${status ? ` (${status})` : ""}: rejected`)
|
|
}
|
|
return Promise.resolve(sessionRevertResult.data ?? { id: sessionId, time: { created: 1 }, revert: { messageID: messageId } })
|
|
}),
|
|
updateSession: mock((sessionId: string, changes: Record<string, unknown>, directory?: string | null) => {
|
|
replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } })
|
|
// Lets a test mutate global runtime state while the SDK call is in flight,
|
|
// so the action observes the switch only after awaiting the response.
|
|
beforeSessionUpdateResolve?.(sessionId)
|
|
return Promise.resolve(sessionUpdateResultsById.get(sessionId) ?? sessionUpdateResult.data)
|
|
}),
|
|
deleteSession: mock((sessionId: string, directory?: string | null) => {
|
|
replyCalls.push({ method: "session.delete", params: { sessionID: sessionId, directory } })
|
|
// Lets a test switch runtime while the delete is in flight, so the action
|
|
// observes the change only after awaiting (or catching) the response.
|
|
beforeSessionDeleteResolve?.(sessionId)
|
|
if (sessionDeleteError) throw sessionDeleteError
|
|
return Promise.resolve(true)
|
|
}),
|
|
},
|
|
}))
|
|
|
|
// 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
|
|
},
|
|
currentSessionId: null,
|
|
setCurrentSession: () => {},
|
|
setWorktreeMetadata: () => {},
|
|
setSessionDirectory: (sessionID: string, directory: string) => {
|
|
movedSessionDirectories.push({ sessionID, directory })
|
|
},
|
|
}),
|
|
},
|
|
}))
|
|
|
|
// 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/useInlineCommentDraftStore", () => ({
|
|
useInlineCommentDraftStore: {
|
|
getState: () => ({
|
|
getDrafts: () => [],
|
|
clearDrafts: () => {},
|
|
restoreDrafts: () => {},
|
|
addDraft: () => {},
|
|
}),
|
|
},
|
|
}))
|
|
|
|
mock.module("@/lib/messages/contextParts", () => ({
|
|
draftFromContextPayload: () => null,
|
|
readContextPart: () => null,
|
|
}))
|
|
|
|
mock.module("@/stores/useGlobalSessionsStore", () => ({
|
|
resolveGlobalSessionDirectory: (session: SessionWithDirectory) => session.directory ?? session.project?.worktree ?? null,
|
|
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: () => ({
|
|
activeSessions: globalActiveSessions,
|
|
archivedSessions: globalArchivedSessions,
|
|
hasLoaded: globalHasLoaded,
|
|
upsertSession: (session: unknown) => {
|
|
globalUpsertedSessions.push(session)
|
|
},
|
|
upsertSessions: (sessions: Session[]) => {
|
|
globalUpsertedSessionBatches.push(sessions)
|
|
globalUpsertedSessions.push(...sessions)
|
|
},
|
|
removeSessions: (ids: Iterable<string>) => {
|
|
globalRemovedSessionIds.push(...ids)
|
|
},
|
|
}),
|
|
},
|
|
}))
|
|
|
|
mock.module("@/lib/runtime-fetch", () => ({
|
|
runtimeFetch: async (path: string, init?: { body?: string }) => {
|
|
const payload = JSON.parse(String(init?.body ?? "{}"))
|
|
archiveBatchRequests.push({ directory: payload.directory, ids: payload.ids })
|
|
void path
|
|
return new Response(JSON.stringify(archiveBatchResponse.body), {
|
|
status: archiveBatchResponse.status,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
},
|
|
}))
|
|
|
|
mock.module("./global-session-status", () => ({
|
|
useGlobalSessionStatusStore: {
|
|
getState: () => ({
|
|
statusById: new Map<string, { type: string }>(),
|
|
}),
|
|
},
|
|
}))
|
|
|
|
mock.module("./session-message-loader", () => ({
|
|
getImperativeSessionMessageLoader: () => ({
|
|
invalidateSession: () => {},
|
|
ensure: async () => {},
|
|
refreshTail: async () => {},
|
|
getSnapshot: () => ({ status: "ready" as const }),
|
|
}),
|
|
}))
|
|
|
|
mock.module("../lib/runtime-switch", () => ({
|
|
getRuntimeKey: () => runtimeKey,
|
|
switchRuntimeEndpoint: ({ runtimeKey: nextRuntimeKey }: { runtimeKey: string }) => {
|
|
runtimeKey = nextRuntimeKey
|
|
},
|
|
subscribeRuntimeEndpointWillChange: () => () => {},
|
|
subscribeRuntimeEndpointChanged: () => () => {},
|
|
}))
|
|
|
|
mock.module("@/lib/relay/transport-error", () => ({
|
|
markAmbiguousTransportFailure: (error: Error) => Object.assign(error, { [AMBIGUOUS_TRANSPORT_FAILURE]: true }),
|
|
isAmbiguousTransportFailure: (error: unknown) => Boolean(
|
|
error
|
|
&& typeof error === "object"
|
|
&& (error as { [AMBIGUOUS_TRANSPORT_FAILURE]?: boolean })[AMBIGUOUS_TRANSPORT_FAILURE],
|
|
),
|
|
}))
|
|
|
|
mock.module("./send-failure-classification", () => ({
|
|
getErrorStatus: (error: unknown) => {
|
|
if (!error || typeof error !== "object") return null
|
|
const direct = (error as { status?: unknown }).status
|
|
if (typeof direct === "number") return direct
|
|
const response = (error as { response?: { status?: unknown } }).response
|
|
return typeof response?.status === "number" ? response.status : null
|
|
},
|
|
isAmbiguousSendFailure: (error: unknown) => {
|
|
if (error && typeof error === "object" && (error as { [AMBIGUOUS_TRANSPORT_FAILURE]?: boolean })[AMBIGUOUS_TRANSPORT_FAILURE]) {
|
|
return true
|
|
}
|
|
|
|
const status = error && typeof error === "object"
|
|
? ((error as { status?: unknown }).status ?? (error as { response?: { status?: unknown } }).response?.status)
|
|
: undefined
|
|
if (status === 503 || status === 504 || status === 408) return true
|
|
if (error instanceof TypeError) return true
|
|
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true
|
|
|
|
const message = error instanceof Error
|
|
? error.message.toLowerCase()
|
|
: typeof error === "string"
|
|
? error.toLowerCase()
|
|
: ""
|
|
return message.includes("timeout")
|
|
|| message.includes("timed out")
|
|
|| message.includes("failed to fetch")
|
|
|| message.includes("networkerror")
|
|
|| message.includes("network error")
|
|
|| message.includes("gateway timeout")
|
|
|| message.includes("econnreset")
|
|
|| message.includes("socket hang up")
|
|
},
|
|
}))
|
|
|
|
mock.module("@/lib/chatDirectories", () => ({
|
|
deleteChatDirectory: async (directory: string) => {
|
|
deletedChatDirectories.push(directory)
|
|
},
|
|
}))
|
|
|
|
mock.module("./session-deletion-cleanup", () => ({
|
|
cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => {
|
|
deletedCleanupIdentities.push(identity)
|
|
},
|
|
}))
|
|
|
|
mock.module("./sync-refs", () => ({
|
|
getSyncSessionDirectory: () => null,
|
|
registerSessionDirectory: (sessionID: string, directory: string) => {
|
|
registeredSessionDirectories.push({ sessionID, directory })
|
|
},
|
|
}))
|
|
|
|
import { INITIAL_STATE } from "./types"
|
|
import type { DirectoryStore } from "./child-store"
|
|
import type { Message, OpencodeClient, Part, Project, 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 }
|
|
}
|
|
|
|
type TestStoreApi<T> = {
|
|
getState: () => T
|
|
setState: (patch: Partial<T> | ((state: T) => Partial<T>)) => void
|
|
}
|
|
|
|
function createStore(
|
|
permissions: Record<string, PermissionRequest[]>,
|
|
state?: Partial<DirectoryStore>,
|
|
): TestStoreApi<DirectoryStore> {
|
|
let currentState: DirectoryStore = {
|
|
...INITIAL_STATE,
|
|
...state,
|
|
permission: permissions,
|
|
patch: (partial) => setState(partial),
|
|
replace: (next) => {
|
|
currentState = { ...currentState, ...next }
|
|
},
|
|
}
|
|
|
|
function setState(patch: Partial<DirectoryStore> | ((current: DirectoryStore) => Partial<DirectoryStore>)) {
|
|
const nextPatch = typeof patch === "function" ? patch(currentState) : patch
|
|
currentState = { ...currentState, ...nextPatch }
|
|
}
|
|
|
|
return {
|
|
getState: () => currentState,
|
|
setState,
|
|
}
|
|
}
|
|
|
|
function createChildStores(entries: Array<[string, TestStoreApi<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("moveSessionToDirectory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
registeredSessionDirectories.length = 0
|
|
movedSessionDirectories.length = 0
|
|
globalUpsertedSessions.length = 0
|
|
})
|
|
|
|
test("moves through the control plane and reconciles directory stores", async () => {
|
|
const message = {
|
|
id: "message-a",
|
|
sessionID: "session-a",
|
|
role: "user",
|
|
time: { created: 1 },
|
|
} as Message
|
|
const part = {
|
|
id: "part-a",
|
|
messageID: "message-a",
|
|
type: "text",
|
|
text: "hello",
|
|
} as Part
|
|
const source = createStore({ "session-a": [{ id: "permission-a" }] as never }, {
|
|
session: [{ id: "session-a", title: "Move me", directory: "/source" } as Session],
|
|
sessionTotal: 1,
|
|
session_status: { "session-a": { type: "idle" } },
|
|
session_diff: { "session-a": [{ file: "changed.ts", additions: 1, deletions: 0 }] },
|
|
todo: { "session-a": [{ id: "todo-a", content: "Check move", status: "pending", priority: "medium" }] as never },
|
|
question: { "session-a": [{ id: "question-a" }] as never },
|
|
message: { "session-a": [message] },
|
|
part: { "message-a": [part] },
|
|
})
|
|
const destination = createStore({})
|
|
const childStores = createChildStores([["/source", source], ["/destination", destination]])
|
|
const { moveSessionToDirectory, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/source")
|
|
|
|
await moveSessionToDirectory(source.getState().session[0], "/source", "/destination", true)
|
|
|
|
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([{
|
|
method: "controlPlane.moveSession",
|
|
params: {
|
|
sessionID: "session-a",
|
|
destination: { directory: "/destination" },
|
|
moveChanges: true,
|
|
},
|
|
}])
|
|
expect(source.getState().session).toHaveLength(0)
|
|
expect(source.getState().sessionTotal).toBe(0)
|
|
expect(source.getState().session_status["session-a"]).toBe(undefined)
|
|
expect(source.getState().session_diff["session-a"]).toBe(undefined)
|
|
expect(source.getState().todo["session-a"]).toBe(undefined)
|
|
expect(source.getState().permission["session-a"]).toBe(undefined)
|
|
expect(source.getState().question["session-a"]).toBe(undefined)
|
|
expect(source.getState().message["session-a"]).toBe(undefined)
|
|
expect(source.getState().part["message-a"]).toBe(undefined)
|
|
expect(destination.getState().session[0]?.id).toBe("session-a")
|
|
expect(destination.getState().sessionTotal).toBe(1)
|
|
expect((destination.getState().session[0] as SessionWithDirectory)?.directory).toBe("/destination")
|
|
expect(destination.getState().session_status["session-a"]?.type).toBe("idle")
|
|
expect(destination.getState().session_diff["session-a"]?.[0]?.file).toBe("changed.ts")
|
|
expect(destination.getState().todo["session-a"]?.[0]?.content).toBe("Check move")
|
|
expect(destination.getState().permission["session-a"]?.[0]?.id).toBe("permission-a")
|
|
expect(destination.getState().question["session-a"]?.[0]?.id).toBe("question-a")
|
|
expect(destination.getState().message["session-a"]?.[0]?.id).toBe("message-a")
|
|
expect(destination.getState().part["message-a"]?.[0]?.id).toBe("part-a")
|
|
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/destination" }])
|
|
expect(movedSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/destination" }])
|
|
expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe("/destination")
|
|
|
|
await moveSessionToDirectory(destination.getState().session[0], "/destination", "/source", true)
|
|
|
|
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")[1]?.params.moveChanges).toBe(true)
|
|
expect(source.getState().session[0]?.id).toBe("session-a")
|
|
expect(source.getState().message["session-a"]?.[0]?.id).toBe("message-a")
|
|
expect(source.getState().part["message-a"]?.[0]?.id).toBe("part-a")
|
|
expect(destination.getState().session).toHaveLength(0)
|
|
expect(destination.getState().message["session-a"]).toBe(undefined)
|
|
expect(destination.getState().part["message-a"]).toBe(undefined)
|
|
})
|
|
})
|
|
|
|
describe("confirmed session removal", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
globalUpsertedSessions.length = 0
|
|
globalRemovedSessionIds.length = 0
|
|
deletedCleanupIdentities.length = 0
|
|
sessionDeleteError = null
|
|
sessionUpdateResult = {}
|
|
runtimeKey = "default-runtime"
|
|
beforeSessionUpdateResolve = null
|
|
beforeSessionDeleteResolve = null
|
|
globalUpsertedSessionBatches.length = 0
|
|
globalActiveSessions = []
|
|
archiveBatchRequests.length = 0
|
|
archiveBatchResponse = { status: 404, body: { error: 'not found' } }
|
|
beforeControlPlaneMoveResolve = null
|
|
beforeDirectoryAvailabilityResolve = null
|
|
controlPlaneMoveErrorsById.clear()
|
|
globalHasLoaded = true
|
|
deletedChatDirectories.length = 0
|
|
})
|
|
|
|
test("does not remove live or persisted state when delete fails", async () => {
|
|
sessionDeleteError = new Error("delete failed")
|
|
const source = createStore({}, {
|
|
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
|
})
|
|
const { deleteSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await deleteSession("session-a")).toBe(false)
|
|
expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"])
|
|
expect(globalRemovedSessionIds).toEqual([])
|
|
expect(deletedCleanupIdentities).toEqual([])
|
|
})
|
|
|
|
test("cleans persisted state after the server confirms deletion", async () => {
|
|
const source = createStore({}, {
|
|
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
|
})
|
|
const { deleteSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await deleteSession("session-a")).toBe(true)
|
|
expect(source.getState().session).toEqual([])
|
|
expect(globalRemovedSessionIds).toEqual(["session-a"])
|
|
expect(deletedCleanupIdentities).toHaveLength(1)
|
|
expect({
|
|
directory: deletedCleanupIdentities[0]?.directory,
|
|
sessionId: deletedCleanupIdentities[0]?.sessionId,
|
|
}).toEqual({ directory: "/test/project", sessionId: "session-a" })
|
|
})
|
|
|
|
test("scopes persisted cleanup to the runtime captured when the delete started", async () => {
|
|
const source = createStore({}, {
|
|
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
|
})
|
|
const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://delete-scope.test", runtimeKey: "delete-scope" })
|
|
const { deleteSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await deleteSession("session-a")).toBe(true)
|
|
// The cleanup identity must carry the captured runtime, which is what lets
|
|
// cleanupPersistedSessionState reject a stale identity instead of comparing
|
|
// the live runtime key with itself.
|
|
expect(deletedCleanupIdentities[0]?.runtimeKey).toBe("delete-scope")
|
|
expect(deletedCleanupIdentities[0]?.runtimeKey).toBe(getRuntimeKey())
|
|
})
|
|
|
|
test("rejects a delete response that arrives after a runtime switch", async () => {
|
|
const source = createStore({}, {
|
|
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
|
})
|
|
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://delete-runtime-a.test", runtimeKey: "delete-runtime-a" })
|
|
beforeSessionDeleteResolve = () => {
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://delete-runtime-b.test", runtimeKey: "delete-runtime-b" })
|
|
}
|
|
const { deleteSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await deleteSession("session-a")).toBe(false)
|
|
// Session IDs are not unique across runtimes: committing here could evict an
|
|
// unrelated session and erase its queue, todos, drafts, folders, and pins.
|
|
expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"])
|
|
expect(globalRemovedSessionIds).toEqual([])
|
|
expect(deletedCleanupIdentities).toEqual([])
|
|
})
|
|
|
|
test("does not treat a 404 as an already-completed deletion after a runtime switch", async () => {
|
|
sessionDeleteError = Object.assign(new Error("not found"), { status: 404 })
|
|
const source = createStore({}, {
|
|
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
|
})
|
|
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://delete-404-a.test", runtimeKey: "delete-404-a" })
|
|
beforeSessionDeleteResolve = () => {
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://delete-404-b.test", runtimeKey: "delete-404-b" })
|
|
}
|
|
const { deleteSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
// A 404 only proves "already deleted" for the captured runtime. After a
|
|
// switch it describes the wrong runtime, so it must not commit cleanup.
|
|
expect(await deleteSession("session-a")).toBe(false)
|
|
expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"])
|
|
expect(globalRemovedSessionIds).toEqual([])
|
|
expect(deletedCleanupIdentities).toEqual([])
|
|
})
|
|
|
|
test("still treats a 404 as an already-completed deletion while the runtime is stable", async () => {
|
|
sessionDeleteError = Object.assign(new Error("not found"), { status: 404 })
|
|
const source = createStore({}, {
|
|
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
|
})
|
|
const { deleteSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await deleteSession("session-a")).toBe(true)
|
|
expect(source.getState().session).toEqual([])
|
|
expect(globalRemovedSessionIds).toEqual(["session-a"])
|
|
expect(deletedCleanupIdentities).toHaveLength(1)
|
|
})
|
|
|
|
test("keeps committed deletions and fails the rest when the runtime changes mid-batch", async () => {
|
|
const source = createStore({}, {
|
|
session: [
|
|
{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session,
|
|
{ id: "session-b", directory: "/test/project", time: { created: 1 } } as Session,
|
|
{ id: "session-c", directory: "/test/project", time: { created: 1 } } as Session,
|
|
],
|
|
})
|
|
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://delete-batch-a.test", runtimeKey: "delete-batch-a" })
|
|
beforeSessionDeleteResolve = (sessionId) => {
|
|
if (sessionId === "session-b") {
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://delete-batch-b.test", runtimeKey: "delete-batch-b" })
|
|
}
|
|
}
|
|
const { deleteSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const result = await deleteSessions(["session-a", "session-b", "session-c"])
|
|
|
|
// session-a was committed before the switch; session-b's response is stale
|
|
// and session-c is never attempted, so both are reported as failures.
|
|
expect(result).toEqual({ deletedIds: ["session-a"], failedIds: ["session-b", "session-c"] })
|
|
expect(source.getState().session.map((item) => item.id)).toEqual(["session-b", "session-c"])
|
|
expect(globalRemovedSessionIds).toEqual(["session-a"])
|
|
expect(replyCalls.filter((call) => call.method === "session.delete").map((call) => call.params.sessionID))
|
|
.toEqual(["session-a", "session-b"])
|
|
})
|
|
|
|
const chatDirectory = "/home/user/.config/openchamber/chats/2026-09-05/session-abc"
|
|
const chatSession = (id: string, parentID?: string): Session => ({
|
|
id,
|
|
slug: id,
|
|
projectID: "project-chats",
|
|
directory: chatDirectory,
|
|
title: id,
|
|
version: "1",
|
|
time: { created: 1, updated: 1 },
|
|
parentID,
|
|
})
|
|
|
|
test("keeps a shared chat directory while another root session still uses it", async () => {
|
|
const root = chatSession("chat-root")
|
|
const fork = chatSession("chat-fork")
|
|
globalActiveSessions = [root, fork]
|
|
const source = createStore({}, { session: [root, fork] })
|
|
const { deleteSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, createChildStores([[chatDirectory, source]]), () => chatDirectory)
|
|
|
|
expect(await deleteSession("chat-root")).toBe(true)
|
|
expect(deletedChatDirectories).toEqual([])
|
|
|
|
globalActiveSessions = [fork]
|
|
expect(await deleteSession("chat-fork")).toBe(true)
|
|
expect(deletedChatDirectories).toEqual([chatDirectory])
|
|
})
|
|
|
|
test("removes the chat directory with its last root even though the root's own subagents share it", async () => {
|
|
const root = chatSession("chat-root")
|
|
const subagent = chatSession("chat-subagent", "chat-root")
|
|
globalActiveSessions = [root, subagent]
|
|
const source = createStore({}, { session: [root, subagent] })
|
|
const { deleteSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, createChildStores([[chatDirectory, source]]), () => chatDirectory)
|
|
|
|
expect(await deleteSession("chat-root")).toBe(true)
|
|
expect(deletedChatDirectories).toEqual([chatDirectory])
|
|
})
|
|
|
|
test("keeps the chat directory when the global cache cannot prove it is unused", async () => {
|
|
const root = chatSession("chat-root")
|
|
globalActiveSessions = [root]
|
|
globalHasLoaded = false
|
|
const source = createStore({}, { session: [root] })
|
|
const { deleteSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, createChildStores([[chatDirectory, source]]), () => chatDirectory)
|
|
|
|
expect(await deleteSession("chat-root")).toBe(true)
|
|
expect(deletedChatDirectories).toEqual([])
|
|
})
|
|
|
|
test("does not archive locally until the server returns the archived session", async () => {
|
|
const source = createStore({}, {
|
|
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
|
})
|
|
const { archiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await archiveSession("session-a")).toBe(false)
|
|
expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"])
|
|
expect(globalUpsertedSessions).toEqual([])
|
|
})
|
|
|
|
test("moves the session to archived state after server confirmation", async () => {
|
|
sessionUpdateResult = {
|
|
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session,
|
|
}
|
|
const source = createStore({}, {
|
|
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
|
})
|
|
const { archiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await archiveSession("session-a")).toBe(true)
|
|
expect(source.getState().session).toEqual([])
|
|
expect((globalUpsertedSessions[0] as Session)?.time?.archived).toBe(2)
|
|
})
|
|
|
|
test("rejects an archive response that arrives after a runtime switch", async () => {
|
|
sessionUpdateResult = {
|
|
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session,
|
|
}
|
|
const source = createStore({}, {
|
|
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
|
})
|
|
const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://archive-runtime-a.test", runtimeKey: "archive-runtime-a" })
|
|
beforeSessionUpdateResolve = () => {
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://archive-runtime-b.test", runtimeKey: "archive-runtime-b" })
|
|
}
|
|
const { archiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await archiveSession("session-a")).toBe(false)
|
|
expect(getRuntimeKey()).toBe("archive-runtime-b")
|
|
// The stale response must not reconcile the runtime the user switched to.
|
|
expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"])
|
|
expect(globalUpsertedSessions).toEqual([])
|
|
})
|
|
|
|
test("keeps confirmed sessions and fails the rest when the runtime changes mid-batch", async () => {
|
|
sessionUpdateResult = {
|
|
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session,
|
|
}
|
|
const source = createStore({}, {
|
|
session: [
|
|
{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session,
|
|
{ id: "session-b", directory: "/test/project", time: { created: 1 } } as Session,
|
|
{ id: "session-c", directory: "/test/project", time: { created: 1 } } as Session,
|
|
],
|
|
})
|
|
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://archive-batch-a.test", runtimeKey: "archive-batch-a" })
|
|
beforeSessionUpdateResolve = (sessionId) => {
|
|
if (sessionId === "session-b") {
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://archive-batch-b.test", runtimeKey: "archive-batch-b" })
|
|
}
|
|
}
|
|
const { archiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const result = await archiveSessions(["session-a", "session-b", "session-c"])
|
|
|
|
// session-a was confirmed before the switch and stays archived; session-b's
|
|
// response is stale and session-c is never attempted, so both are reported
|
|
// as failures instead of being silently dropped.
|
|
expect(result).toEqual({ archivedIds: ["session-a"], failedIds: ["session-b", "session-c"] })
|
|
expect(source.getState().session.map((item) => item.id)).toEqual(["session-b", "session-c"])
|
|
expect(globalUpsertedSessions).toHaveLength(1)
|
|
// session-c must not reach the SDK after the runtime changed.
|
|
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
|
|
.toEqual(["session-a", "session-b"])
|
|
})
|
|
|
|
test("archives every session when the runtime stays stable", async () => {
|
|
sessionUpdateResult = {
|
|
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session,
|
|
}
|
|
const source = createStore({}, {
|
|
session: [
|
|
{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session,
|
|
{ id: "session-b", directory: "/test/project", time: { created: 1 } } as Session,
|
|
],
|
|
})
|
|
const { getRuntimeKey } = await import("../lib/runtime-switch")
|
|
const { archiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const result = await archiveSessions(["session-a", "session-b"], {
|
|
expectedRuntimeKey: getRuntimeKey(),
|
|
})
|
|
|
|
expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] })
|
|
expect(source.getState().session).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("archiving a batch through the server", () => {
|
|
const liveSession = (id: string, metadata?: Record<string, unknown>): Session => ({
|
|
id,
|
|
directory: "/test/project",
|
|
time: { created: 1 },
|
|
...(metadata ? { metadata } : {}),
|
|
} as unknown as Session)
|
|
|
|
const archivedSession = (id: string): Session => ({
|
|
id,
|
|
directory: "/test/project",
|
|
time: { created: 1, archived: 2 },
|
|
} as unknown as Session)
|
|
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
globalUpsertedSessions.length = 0
|
|
globalUpsertedSessionBatches.length = 0
|
|
globalActiveSessions = []
|
|
archiveBatchRequests.length = 0
|
|
archiveBatchResponse = { status: 404, body: { error: "not found" } }
|
|
sessionUpdateResult = {}
|
|
beforeSessionUpdateResolve = null
|
|
})
|
|
|
|
test("archives held sessions in one request and reconciles the stores once", async () => {
|
|
globalActiveSessions = [liveSession("session-a"), liveSession("session-b")]
|
|
archiveBatchResponse = {
|
|
status: 200,
|
|
body: { archived: [archivedSession("session-a"), archivedSession("session-b")], failedIds: [] },
|
|
}
|
|
const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] })
|
|
const { archiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const result = await archiveSessions(["session-a", "session-b"])
|
|
|
|
expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] })
|
|
expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-a", "session-b"] }])
|
|
// The point of the batch: no per-session SDK call, and one store write for
|
|
// the whole set instead of one per session.
|
|
expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([])
|
|
expect(globalUpsertedSessionBatches).toHaveLength(1)
|
|
expect(source.getState().session).toEqual([])
|
|
expect(source.getState().sessionRevision).toBe(1)
|
|
})
|
|
|
|
test("batches sessions held only by the live directory store", async () => {
|
|
archiveBatchResponse = {
|
|
status: 200,
|
|
body: { archived: [archivedSession("session-a")], failedIds: [] },
|
|
}
|
|
const source = createStore({}, { session: [liveSession("session-a")] })
|
|
const { archiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const result = await archiveSessions(["session-a"])
|
|
|
|
expect(result).toEqual({ archivedIds: ["session-a"], failedIds: [] })
|
|
expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-a"] }])
|
|
expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([])
|
|
})
|
|
|
|
test("reports the sessions the server could not archive without losing the rest", async () => {
|
|
globalActiveSessions = [liveSession("session-a"), liveSession("session-b")]
|
|
archiveBatchResponse = {
|
|
status: 200,
|
|
body: { archived: [archivedSession("session-a")], failedIds: ["session-b"] },
|
|
}
|
|
const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] })
|
|
const { archiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const result = await archiveSessions(["session-a", "session-b"])
|
|
|
|
expect(result).toEqual({ archivedIds: ["session-a"], failedIds: ["session-b"] })
|
|
expect(source.getState().session.map((item) => item.id)).toEqual(["session-b"])
|
|
})
|
|
|
|
test("falls back to archiving one by one when the runtime does not serve the route", async () => {
|
|
globalActiveSessions = [liveSession("session-a"), liveSession("session-b")]
|
|
archiveBatchResponse = { status: 501, body: { error: "not supported in VS Code" } }
|
|
sessionUpdateResult = { data: archivedSession("session-a") }
|
|
const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] })
|
|
const { archiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const result = await archiveSessions(["session-a", "session-b"])
|
|
|
|
expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] })
|
|
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
|
|
.toEqual(["session-a", "session-b"])
|
|
expect(source.getState().session).toEqual([])
|
|
})
|
|
|
|
test("treats a malformed batch answer as unavailable instead of as an empty success", async () => {
|
|
globalActiveSessions = [liveSession("session-a")]
|
|
archiveBatchResponse = { status: 200, body: { archived: [{ title: "no id" }], failedIds: [] } }
|
|
sessionUpdateResult = { data: archivedSession("session-a") }
|
|
const source = createStore({}, { session: [liveSession("session-a")] })
|
|
const { archiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const result = await archiveSessions(["session-a"])
|
|
|
|
expect(result).toEqual({ archivedIds: ["session-a"], failedIds: [] })
|
|
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
|
|
.toEqual(["session-a"])
|
|
})
|
|
|
|
test("keeps review and btw sessions on the per-session path", async () => {
|
|
const review = liveSession("session-review", { openchamber: { kind: "review", originalSessionID: "session-parent" } })
|
|
const parentWithFork = liveSession("session-parent", { openchamber: { btwSessionID: "session-fork" } })
|
|
globalActiveSessions = [liveSession("session-plain"), review, parentWithFork]
|
|
archiveBatchResponse = {
|
|
status: 200,
|
|
body: { archived: [archivedSession("session-plain")], failedIds: [] },
|
|
}
|
|
sessionUpdateResult = { data: archivedSession("session-review") }
|
|
const source = createStore({}, { session: [liveSession("session-plain"), review, parentWithFork] })
|
|
const { archiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
await archiveSessions(["session-plain", "session-review", "session-parent"])
|
|
|
|
// Unlinking a partner rewrites another session's metadata, so those two
|
|
// never travel in the batch.
|
|
expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-plain"] }])
|
|
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
|
|
.toEqual(["session-review", "session-parent"])
|
|
})
|
|
|
|
test("does not reconcile a batch answered after a runtime switch", async () => {
|
|
globalActiveSessions = [liveSession("session-a")]
|
|
archiveBatchResponse = {
|
|
status: 200,
|
|
body: { archived: [archivedSession("session-a")], failedIds: [] },
|
|
}
|
|
const source = createStore({}, { session: [liveSession("session-a")] })
|
|
const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://archive-bulk-a.test", runtimeKey: "archive-bulk-a" })
|
|
const capturedRuntimeKey = getRuntimeKey()
|
|
const { archiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const pending = archiveSessions(["session-a"], { expectedRuntimeKey: capturedRuntimeKey })
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://archive-bulk-b.test", runtimeKey: "archive-bulk-b" })
|
|
const result = await pending
|
|
|
|
expect(result).toEqual({ archivedIds: [], failedIds: ["session-a"] })
|
|
expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"])
|
|
expect(globalUpsertedSessionBatches).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("session restore (unarchive)", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
registeredSessionDirectories.length = 0
|
|
movedSessionDirectories.length = 0
|
|
globalUpsertedSessions.length = 0
|
|
globalActiveSessions.length = 0
|
|
globalArchivedSessions.length = 0
|
|
openCodeProjects.length = 0
|
|
directoryAvailability.clear()
|
|
sessionUpdateResultsById.clear()
|
|
runtimeKey = "default-runtime"
|
|
sessionUpdateResult = {}
|
|
beforeSessionUpdateResolve = null
|
|
beforeControlPlaneMoveResolve = null
|
|
beforeDirectoryAvailabilityResolve = null
|
|
controlPlaneMoveErrorsById.clear()
|
|
globalHasLoaded = true
|
|
deletedChatDirectories.length = 0
|
|
})
|
|
|
|
test("does not restore locally until the server returns the restored session", async () => {
|
|
const source = createStore({}, {
|
|
session: [],
|
|
})
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await unarchiveSession("session-a")).toBe(false)
|
|
expect(globalUpsertedSessions).toEqual([])
|
|
expect(registeredSessionDirectories).toEqual([])
|
|
})
|
|
|
|
test("sends the archive-clearing sentinel and upserts the restored session after confirmation", async () => {
|
|
sessionUpdateResult = {
|
|
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
|
|
}
|
|
const source = createStore({}, {
|
|
session: [],
|
|
})
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await unarchiveSession("session-a")).toBe(true)
|
|
// The server cannot clear time.archived over HTTP, so the action must
|
|
// write the falsy sentinel rather than omitting the field.
|
|
expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([{
|
|
method: "session.update",
|
|
params: { sessionID: "session-a", time: { archived: 0 }, directory: "/test/project" },
|
|
}])
|
|
expect((globalUpsertedSessions[0] as Session)?.time?.archived).toBe(0)
|
|
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/test/project" }])
|
|
})
|
|
|
|
test("fails when the server keeps the session archived", async () => {
|
|
sessionUpdateResult = {
|
|
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session,
|
|
}
|
|
const source = createStore({}, {
|
|
session: [],
|
|
})
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
// A silent server-side no-op must surface as a failure, not a success toast.
|
|
expect(await unarchiveSession("session-a")).toBe(false)
|
|
expect(globalUpsertedSessions).toEqual([])
|
|
expect(registeredSessionDirectories).toEqual([])
|
|
})
|
|
|
|
test("keeps an existing worktree restore in place without a control-plane move", async () => {
|
|
const worktreeDirectory = "/projects/main/.worktrees/feature-a"
|
|
globalArchivedSessions.push({
|
|
id: "session-worktree",
|
|
projectID: "project-main",
|
|
directory: worktreeDirectory,
|
|
project: { worktree: worktreeDirectory },
|
|
time: { created: 1, archived: 2 },
|
|
} as SessionWithDirectory)
|
|
directoryAvailability.set(worktreeDirectory, "available")
|
|
sessionUpdateResultsById.set("session-worktree", {
|
|
id: "session-worktree",
|
|
projectID: "project-main",
|
|
directory: worktreeDirectory,
|
|
project: { worktree: worktreeDirectory },
|
|
time: { created: 1, archived: 0 },
|
|
} as SessionWithDirectory)
|
|
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[worktreeDirectory, createStore({})]]), () => worktreeDirectory)
|
|
|
|
expect(await unarchiveSession("session-worktree")).toBe(true)
|
|
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([])
|
|
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-worktree", directory: worktreeDirectory }])
|
|
expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe(worktreeDirectory)
|
|
})
|
|
|
|
test("moves a restored missing-worktree subtree to its matching project directory without changing descendants or cached transcript state", async () => {
|
|
const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch"
|
|
const destinationDirectory = "/projects/main"
|
|
const rootMessage = {
|
|
id: "message-root",
|
|
sessionID: "session-root",
|
|
role: "user",
|
|
time: { created: 10 },
|
|
} as Message
|
|
const rootPart = { id: "part-root", messageID: rootMessage.id, type: "text", text: "root" } as Part
|
|
const childMessage = {
|
|
id: "message-child",
|
|
sessionID: "session-child",
|
|
role: "assistant",
|
|
time: { created: 11 },
|
|
} as Message
|
|
const childPart = { id: "part-child", messageID: childMessage.id, type: "text", text: "child" } as Part
|
|
const rootSession = {
|
|
id: "session-root",
|
|
projectID: "project-main",
|
|
directory: missingWorktreeDirectory,
|
|
project: { worktree: destinationDirectory },
|
|
time: { created: 1, archived: 2 },
|
|
} as SessionWithDirectory
|
|
const childSession = {
|
|
id: "session-child",
|
|
parentID: "session-root",
|
|
projectID: "project-main",
|
|
directory: missingWorktreeDirectory,
|
|
project: { worktree: destinationDirectory },
|
|
time: { created: 2, archived: 3 },
|
|
} as SessionWithDirectory
|
|
globalArchivedSessions.push(rootSession, childSession)
|
|
openCodeProjects.push({ id: "project-main", worktree: destinationDirectory } as Project)
|
|
directoryAvailability.set(missingWorktreeDirectory, "missing")
|
|
sessionUpdateResultsById.set("session-root", {
|
|
...rootSession,
|
|
time: { created: 1, updated: 1, archived: 0 },
|
|
})
|
|
sessionUpdateResultsById.set("session-child", {
|
|
...childSession,
|
|
time: { created: 2, updated: 2, archived: 0 },
|
|
})
|
|
|
|
const source = createStore({}, {
|
|
session: [rootSession, childSession],
|
|
sessionTotal: 2,
|
|
message: {
|
|
"session-root": [rootMessage],
|
|
"session-child": [childMessage],
|
|
},
|
|
part: {
|
|
[rootMessage.id]: [rootPart],
|
|
[childMessage.id]: [childPart],
|
|
},
|
|
})
|
|
const destination = createStore({})
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(
|
|
mockSdk as unknown as OpencodeClient,
|
|
createChildStores([[missingWorktreeDirectory, source], [destinationDirectory, destination]]),
|
|
() => missingWorktreeDirectory,
|
|
)
|
|
|
|
expect(await unarchiveSession("session-root")).toBe(true)
|
|
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([
|
|
{
|
|
method: "controlPlane.moveSession",
|
|
params: {
|
|
sessionID: "session-root",
|
|
destination: { directory: destinationDirectory },
|
|
moveChanges: false,
|
|
},
|
|
},
|
|
{
|
|
method: "controlPlane.moveSession",
|
|
params: {
|
|
sessionID: "session-child",
|
|
destination: { directory: destinationDirectory },
|
|
moveChanges: false,
|
|
},
|
|
},
|
|
])
|
|
expect(source.getState().session).toEqual([])
|
|
expect(destination.getState().session.map((session) => ({
|
|
id: session.id,
|
|
parentID: (session as SessionWithDirectory).parentID ?? null,
|
|
directory: (session as SessionWithDirectory).directory ?? null,
|
|
}))).toEqual([
|
|
{ id: "session-root", parentID: null, directory: destinationDirectory },
|
|
{ id: "session-child", parentID: "session-root", directory: destinationDirectory },
|
|
])
|
|
expect(destination.getState().message["session-root"]?.[0]?.id).toBe(rootMessage.id)
|
|
expect(destination.getState().message["session-child"]?.[0]?.id).toBe(childMessage.id)
|
|
expect(destination.getState().part[rootMessage.id]?.[0]?.id).toBe(rootPart.id)
|
|
expect(destination.getState().part[childMessage.id]?.[0]?.id).toBe(childPart.id)
|
|
expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true)
|
|
expect(registeredSessionDirectories).toEqual([
|
|
{ sessionID: "session-root", directory: destinationDirectory },
|
|
{ sessionID: "session-child", directory: destinationDirectory },
|
|
])
|
|
expect(movedSessionDirectories).toEqual([
|
|
{ sessionID: "session-root", directory: destinationDirectory },
|
|
{ sessionID: "session-child", directory: destinationDirectory },
|
|
])
|
|
expect(globalUpsertedSessions.map((session) => ({
|
|
id: (session as SessionWithDirectory).id,
|
|
parentID: (session as SessionWithDirectory).parentID ?? null,
|
|
directory: (session as SessionWithDirectory).directory ?? null,
|
|
}))).toEqual([
|
|
{ id: "session-root", parentID: null, directory: destinationDirectory },
|
|
{ id: "session-child", parentID: "session-root", directory: destinationDirectory },
|
|
])
|
|
})
|
|
|
|
test("restores missing-worktree descendants from the global cache when their directory store is unavailable", async () => {
|
|
const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch"
|
|
const destinationDirectory = "/projects/main"
|
|
const rootSession = {
|
|
id: "session-root",
|
|
projectID: "proj_main",
|
|
directory: missingWorktreeDirectory,
|
|
project: { worktree: destinationDirectory },
|
|
time: { created: 1, archived: 2 },
|
|
} as SessionWithDirectory
|
|
const childSession = {
|
|
id: "session-child",
|
|
parentID: rootSession.id,
|
|
projectID: "proj_main",
|
|
directory: missingWorktreeDirectory,
|
|
project: { worktree: destinationDirectory },
|
|
time: { created: 2, archived: 3 },
|
|
} as SessionWithDirectory
|
|
globalArchivedSessions.push(rootSession, childSession)
|
|
openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project)
|
|
directoryAvailability.set(missingWorktreeDirectory, "missing")
|
|
sessionUpdateResultsById.set("session-root", { ...rootSession, time: { created: 1, updated: 1, archived: 0 } })
|
|
sessionUpdateResultsById.set("session-child", { ...childSession, time: { created: 2, updated: 2, archived: 0 } })
|
|
|
|
const destination = createStore({})
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(
|
|
mockSdk as unknown as OpencodeClient,
|
|
createChildStores([[destinationDirectory, destination]]),
|
|
() => missingWorktreeDirectory,
|
|
)
|
|
|
|
expect(await unarchiveSession(rootSession.id)).toBe(true)
|
|
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession").map((call) => call.params.sessionID))
|
|
.toEqual([rootSession.id, childSession.id])
|
|
expect(destination.getState().session.map((session) => session.id)).toEqual([rootSession.id, childSession.id])
|
|
expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true)
|
|
})
|
|
|
|
test("does not publish a missing-worktree move after the runtime changes during the control-plane request", async () => {
|
|
const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch"
|
|
const destinationDirectory = "/projects/main"
|
|
const session = {
|
|
id: "session-runtime-switch",
|
|
projectID: "proj_main",
|
|
directory: missingWorktreeDirectory,
|
|
project: { worktree: destinationDirectory },
|
|
time: { created: 1, archived: 2 },
|
|
} as SessionWithDirectory
|
|
globalArchivedSessions.push(session)
|
|
openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project)
|
|
directoryAvailability.set(missingWorktreeDirectory, "missing")
|
|
sessionUpdateResultsById.set(session.id, { ...session, time: { created: 1, updated: 1, archived: 0 } })
|
|
beforeControlPlaneMoveResolve = () => {
|
|
runtimeKey = "new-runtime"
|
|
}
|
|
|
|
const destination = createStore({})
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(
|
|
mockSdk as unknown as OpencodeClient,
|
|
createChildStores([[destinationDirectory, destination]]),
|
|
() => missingWorktreeDirectory,
|
|
)
|
|
|
|
expect(await unarchiveSession(session.id)).toBe(false)
|
|
expect(destination.getState().session).toEqual([])
|
|
expect(registeredSessionDirectories).toEqual([])
|
|
expect(globalUpsertedSessions).toEqual([])
|
|
})
|
|
|
|
test("re-moves a root left stranded in a missing worktree after a partial restore", async () => {
|
|
const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch"
|
|
const destinationDirectory = "/projects/main"
|
|
// A previous restore attempt already unarchived the root (server echo made
|
|
// it active), then the control-plane move failed, leaving it stranded in the
|
|
// deleted worktree. The retry must still relocate it, not report a false
|
|
// success because the root is no longer archived.
|
|
const strandedRoot = {
|
|
id: "session-root",
|
|
projectID: "proj_main",
|
|
directory: missingWorktreeDirectory,
|
|
project: { worktree: destinationDirectory },
|
|
time: { created: 1, archived: 0 },
|
|
} as SessionWithDirectory
|
|
globalActiveSessions.push(strandedRoot)
|
|
openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project)
|
|
directoryAvailability.set(missingWorktreeDirectory, "missing")
|
|
sessionUpdateResultsById.set("session-root", { ...strandedRoot, time: { created: 1, updated: 1, archived: 0 } })
|
|
|
|
const destination = createStore({})
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(
|
|
mockSdk as unknown as OpencodeClient,
|
|
createChildStores([[destinationDirectory, destination]]),
|
|
() => missingWorktreeDirectory,
|
|
)
|
|
|
|
expect(await unarchiveSession("session-root")).toBe(true)
|
|
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([
|
|
{
|
|
method: "controlPlane.moveSession",
|
|
params: {
|
|
sessionID: "session-root",
|
|
destination: { directory: destinationDirectory },
|
|
moveChanges: false,
|
|
},
|
|
},
|
|
])
|
|
expect(destination.getState().session.map((session) => session.id)).toEqual(["session-root"])
|
|
})
|
|
|
|
test("does not move a restored project session that is not a worktree", async () => {
|
|
const projectDirectory = "/projects/main"
|
|
globalArchivedSessions.push({
|
|
id: "session-project",
|
|
projectID: "project-main",
|
|
directory: projectDirectory,
|
|
time: { created: 1, archived: 2 },
|
|
} as SessionWithDirectory)
|
|
directoryAvailability.set(projectDirectory, "missing")
|
|
sessionUpdateResultsById.set("session-project", {
|
|
id: "session-project",
|
|
projectID: "project-main",
|
|
directory: projectDirectory,
|
|
time: { created: 1, archived: 0 },
|
|
} as SessionWithDirectory)
|
|
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[projectDirectory, createStore({})]]), () => projectDirectory)
|
|
|
|
expect(await unarchiveSession("session-project")).toBe(true)
|
|
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([])
|
|
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-project", directory: projectDirectory }])
|
|
})
|
|
|
|
test("does not fall back to the parent project when worktree availability is unknown", async () => {
|
|
const worktreeDirectory = "/projects/main/.worktrees/offline-branch"
|
|
globalArchivedSessions.push({
|
|
id: "session-offline",
|
|
projectID: "project-main",
|
|
directory: worktreeDirectory,
|
|
project: { worktree: "/projects/main" },
|
|
time: { created: 1, archived: 2 },
|
|
} as SessionWithDirectory)
|
|
directoryAvailability.set(worktreeDirectory, "unknown")
|
|
sessionUpdateResultsById.set("session-offline", {
|
|
id: "session-offline",
|
|
projectID: "project-main",
|
|
directory: worktreeDirectory,
|
|
project: { worktree: "/projects/main" },
|
|
time: { created: 1, archived: 0 },
|
|
} as SessionWithDirectory)
|
|
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[worktreeDirectory, createStore({})]]), () => worktreeDirectory)
|
|
|
|
expect(await unarchiveSession("session-offline")).toBe(true)
|
|
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([])
|
|
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-offline", directory: worktreeDirectory }])
|
|
expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe(worktreeDirectory)
|
|
})
|
|
|
|
test("rejects a restore response that arrives after a runtime switch", async () => {
|
|
sessionUpdateResult = {
|
|
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
|
|
}
|
|
const source = createStore({}, {
|
|
session: [],
|
|
})
|
|
const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-runtime-a.test", runtimeKey: "restore-runtime-a" })
|
|
beforeSessionUpdateResolve = () => {
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-runtime-b.test", runtimeKey: "restore-runtime-b" })
|
|
}
|
|
const { unarchiveSession, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
expect(await unarchiveSession("session-a")).toBe(false)
|
|
expect(getRuntimeKey()).toBe("restore-runtime-b")
|
|
// The stale response must not reconcile the runtime the user switched to.
|
|
expect(globalUpsertedSessions).toEqual([])
|
|
expect(registeredSessionDirectories).toEqual([])
|
|
})
|
|
|
|
test("keeps confirmed sessions and fails the rest when the runtime changes mid-batch", async () => {
|
|
sessionUpdateResult = {
|
|
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
|
|
}
|
|
const source = createStore({}, {
|
|
session: [],
|
|
})
|
|
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-batch-a.test", runtimeKey: "restore-batch-a" })
|
|
beforeSessionUpdateResolve = (sessionId) => {
|
|
if (sessionId === "session-b") {
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://restore-batch-b.test", runtimeKey: "restore-batch-b" })
|
|
}
|
|
}
|
|
const { unarchiveSessions, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project")
|
|
|
|
const result = await unarchiveSessions(["session-a", "session-b", "session-c"])
|
|
|
|
// session-a was confirmed before the switch and stays restored; session-b's
|
|
// response is stale and session-c is never attempted, so both are reported
|
|
// as failures instead of being silently dropped.
|
|
expect(result).toEqual({ restoredIds: ["session-a"], failedIds: ["session-b", "session-c"] })
|
|
expect(globalUpsertedSessions).toHaveLength(1)
|
|
// session-c must not reach the SDK after the runtime changed.
|
|
expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID))
|
|
.toEqual(["session-a", "session-b"])
|
|
})
|
|
})
|
|
|
|
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).toEqual({ ...unsharedSession, share: undefined })
|
|
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, share: undefined }])
|
|
})
|
|
|
|
test("clears a stale share URL echoed by a successful unshare response", async () => {
|
|
const sharedSession = { id: "session-a", time: { created: 1 }, share: { url: "https://share.example/a" } } as Session
|
|
const staleResponse = { id: "session-a", time: { created: 1, updated: 2 }, share: { url: "https://share.example/a" } } as Session
|
|
const sessionStore = createStore({}, { session: [sharedSession] })
|
|
const childStores = createChildStores([["/test/project", sessionStore]])
|
|
sessionShareResult = { data: staleResponse }
|
|
|
|
const { setActionRefs, unshareSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
|
|
|
const result = await unshareSession("session-a")
|
|
|
|
expect(result?.share).toBe(undefined)
|
|
expect(sessionStore.getState().session[0].share).toBe(undefined)
|
|
expect((globalUpsertedSessions[0] as Session).share).toBe(undefined)
|
|
})
|
|
|
|
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 normalizing a null share 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
|
|
expect(liveSession.share).toBe(undefined)
|
|
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("updateSessionTitle live state", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
globalUpsertedSessions.length = 0
|
|
sessionUpdateResult = {}
|
|
})
|
|
|
|
test("updates the live directory store after renaming", async () => {
|
|
const oldSession = { id: "session-a", title: "Old Title", time: { created: 1, updated: 1 } } as Session
|
|
const updatedSession = { id: "session-a", title: "New Title", time: { created: 1, updated: 2 } } as Session
|
|
const sessionStore = createStore({}, { session: [oldSession] })
|
|
const childStores = createChildStores([["/test/project", sessionStore]])
|
|
sessionUpdateResult = { data: updatedSession }
|
|
|
|
const { setActionRefs, updateSessionTitle } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
|
|
|
await updateSessionTitle("session-a", "New Title")
|
|
|
|
const updateCall = replyCalls.find((call) => call.method === "session.update")
|
|
expect(updateCall?.params.sessionID).toBe("session-a")
|
|
expect(updateCall?.params.title).toBe("New Title")
|
|
expect(updateCall?.params.directory).toBe("/test/project")
|
|
expect(globalUpsertedSessions).toEqual([updatedSession])
|
|
expect(sessionStore.getState().session[0].title).toBe("New Title")
|
|
})
|
|
})
|
|
|
|
describe("optimisticSend target directory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
sessionMessagesResult = { data: [] }
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
test("commits the new branch locally and discards its optimistic shadow when sending after a revert", async () => {
|
|
const retainedMessage = { id: "msg_ffffffffffffRetained", role: "user", sessionID: "session-reverted", time: { created: 1 } } as Message
|
|
const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message
|
|
const targetStore = createStore({}, {
|
|
session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session],
|
|
message: { "session-reverted": [retainedMessage, revertedMessage] },
|
|
part: { [revertedMessage.id]: [{ id: "part_2", type: "text", text: "old branch" } as Part] },
|
|
})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
let optimisticMessage: Message | null = null
|
|
const optimisticShadow = new Set([revertedMessage.id])
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
(input) => {
|
|
optimisticMessage = input.message
|
|
optimisticShadow.add(input.message.id)
|
|
targetStore.setState((state) => ({
|
|
message: { ...state.message, [input.sessionID]: [...(state.message[input.sessionID] ?? []), input.message] },
|
|
part: { ...state.part, [input.message.id]: input.parts },
|
|
}))
|
|
},
|
|
() => {},
|
|
(input) => optimisticShadow.delete(input.messageID),
|
|
)
|
|
|
|
await optimisticSend({
|
|
sessionId: "session-reverted",
|
|
directory: "/target/project",
|
|
content: "new branch",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
send: async () => {},
|
|
})
|
|
|
|
expect(targetStore.getState().session[0].revert).toBe(undefined)
|
|
expect(targetStore.getState().message["session-reverted"].map((message) => message.id)).toEqual([
|
|
retainedMessage.id,
|
|
(optimisticMessage as unknown as Message).id,
|
|
])
|
|
expect(targetStore.getState().part[revertedMessage.id]).toBe(undefined)
|
|
expect(optimisticShadow.has(revertedMessage.id)).toBe(false)
|
|
expect(optimisticShadow.has((optimisticMessage as unknown as Message).id)).toBe(true)
|
|
})
|
|
|
|
test("restores the reverted branch when sending fails", async () => {
|
|
const retainedMessage = { id: "msg_ffffffffffffRetained", role: "user", sessionID: "session-reverted", time: { created: 1 } } as Message
|
|
const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message
|
|
const revertedPart = { id: "part_2", type: "text", text: "old branch" } as Part
|
|
const targetStore = createStore({}, {
|
|
session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session],
|
|
message: { "session-reverted": [retainedMessage, revertedMessage] },
|
|
part: { [revertedMessage.id]: [revertedPart] },
|
|
})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
(input) => targetStore.setState((state) => ({
|
|
message: { ...state.message, [input.sessionID]: [...(state.message[input.sessionID] ?? []), input.message] },
|
|
part: { ...state.part, [input.message.id]: input.parts },
|
|
})),
|
|
(input) => targetStore.setState((state) => ({
|
|
message: { ...state.message, [input.sessionID]: (state.message[input.sessionID] ?? []).filter((message) => message.id !== input.messageID) },
|
|
part: Object.fromEntries(Object.entries(state.part).filter(([messageID]) => messageID !== input.messageID)),
|
|
})),
|
|
)
|
|
|
|
await expect(optimisticSend({
|
|
sessionId: "session-reverted",
|
|
directory: "/target/project",
|
|
content: "new branch",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
send: async () => { throw new Error("rejected") },
|
|
})).rejects.toThrow("rejected")
|
|
|
|
expect(targetStore.getState().session[0].revert?.messageID).toBe(revertedMessage.id)
|
|
expect(targetStore.getState().message["session-reverted"]).toEqual([retainedMessage, revertedMessage])
|
|
expect(targetStore.getState().part[revertedMessage.id]).toEqual([revertedPart])
|
|
})
|
|
|
|
test("runs appendSubmissions before revert cleanup and optimistic insertion", async () => {
|
|
const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message
|
|
const targetStore = createStore({}, {
|
|
session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session],
|
|
message: { "session-reverted": [revertedMessage] },
|
|
part: { [revertedMessage.id]: [{ id: "part_2", type: "text", text: "old branch" } as Part] },
|
|
})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
const callOrder: string[] = []
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
() => {
|
|
callOrder.push("optimistic-add")
|
|
},
|
|
() => {},
|
|
() => {
|
|
callOrder.push("revert-confirm")
|
|
},
|
|
)
|
|
|
|
await optimisticSend({
|
|
sessionId: "session-reverted",
|
|
directory: "/target/project",
|
|
content: "new branch",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
appendSubmissions: () => {
|
|
callOrder.push("append")
|
|
},
|
|
send: async () => {},
|
|
})
|
|
|
|
expect(callOrder).toEqual(["append", "revert-confirm", "optimistic-add"])
|
|
})
|
|
|
|
test("runs appendSubmissions once for a definite rejection", async () => {
|
|
const targetStore = createStore({})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
let appendCalls = 0
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
() => {},
|
|
() => {},
|
|
)
|
|
|
|
await expect(optimisticSend({
|
|
sessionId: "session-rejected",
|
|
directory: "/target/project",
|
|
content: "hello",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
appendSubmissions: () => {
|
|
appendCalls += 1
|
|
},
|
|
send: async () => { throw new Error("rejected") },
|
|
})).rejects.toThrow("rejected")
|
|
|
|
expect(appendCalls).toBe(1)
|
|
})
|
|
|
|
test("runs appendSubmissions once for an ambiguous confirmation", async () => {
|
|
const targetStore = createStore({})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
let appendCalls = 0
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
)
|
|
|
|
await optimisticSend({
|
|
sessionId: "session-confirmed",
|
|
directory: "/target/project",
|
|
content: "hello",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
appendSubmissions: () => {
|
|
appendCalls += 1
|
|
},
|
|
send: async (messageID) => {
|
|
sessionMessagesResult = {
|
|
data: [{
|
|
info: { id: messageID, role: "user", sessionID: "session-confirmed", time: { created: 1 } } as Message,
|
|
parts: [{ id: "server-part", type: "text", text: "hello" } as Part],
|
|
}],
|
|
}
|
|
const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number }
|
|
error.status = 504
|
|
throw error
|
|
},
|
|
})
|
|
|
|
expect(appendCalls).toBe(1)
|
|
})
|
|
|
|
test("does not run appendSubmissions when the runtime changes before dispatch", async () => {
|
|
const targetStore = createStore({})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
let appendCalls = 0
|
|
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" })
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
() => {},
|
|
() => {},
|
|
)
|
|
|
|
await expect(optimisticSend({
|
|
sessionId: "session-race",
|
|
directory: "/target/project",
|
|
runtimeKey: "runtime-a",
|
|
content: "hello",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
appendSubmissions: () => {
|
|
appendCalls += 1
|
|
},
|
|
send: async () => {},
|
|
})).rejects.toThrow("runtime changed")
|
|
|
|
expect(appendCalls).toBe(0)
|
|
})
|
|
|
|
test("rolls back a captured send when the runtime changes after optimistic insert", async () => {
|
|
const targetStore = createStore({})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
let optimisticAdd: OptimisticAddCall | null = null
|
|
let optimisticRemove: OptimisticRemoveCall | null = null
|
|
let finalSendCalled = false
|
|
const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-a.test", runtimeKey: "runtime-a" })
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
(input) => {
|
|
optimisticAdd = input
|
|
},
|
|
(input) => {
|
|
optimisticRemove = input
|
|
},
|
|
)
|
|
|
|
let caught: unknown = null
|
|
try {
|
|
await optimisticSend({
|
|
sessionId: "session-race",
|
|
directory: "/target/project",
|
|
runtimeKey: "runtime-a",
|
|
content: "hello",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
onOptimisticInsert: () => {
|
|
expect(getRuntimeKey()).toBe("runtime-a")
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" })
|
|
},
|
|
send: async () => {
|
|
finalSendCalled = true
|
|
},
|
|
})
|
|
} catch (error) {
|
|
caught = error
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(Error)
|
|
expect((caught as Error).message).toContain("runtime changed")
|
|
|
|
expect(optimisticAdd).not.toBeNull()
|
|
expect(finalSendCalled).toBe(false)
|
|
expect(optimisticRemove).not.toBeNull()
|
|
expect((optimisticRemove as unknown as OptimisticRemoveCall).sessionID).toBe("session-race")
|
|
expect(targetStore.getState().session_status["session-race"]?.type).toBe("idle")
|
|
})
|
|
|
|
test("confirms an ambiguous send failure with a recent message refetch", async () => {
|
|
const targetStore = createStore({})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
let optimisticRemove: OptimisticRemoveCall | null = null
|
|
let optimisticConfirm: OptimisticRemoveCall | null = null
|
|
let sentMessageID = ""
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
() => {},
|
|
(input) => {
|
|
optimisticRemove = input
|
|
},
|
|
(input) => {
|
|
optimisticConfirm = input
|
|
},
|
|
)
|
|
|
|
await optimisticSend({
|
|
sessionId: "session-confirmed",
|
|
directory: "/target/project",
|
|
content: "hello",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
send: async (messageID) => {
|
|
sentMessageID = messageID
|
|
sessionMessagesResult = {
|
|
data: [{
|
|
info: { id: messageID, role: "user", sessionID: "session-confirmed", time: { created: 1 } } as Message,
|
|
parts: [{ id: "server-part", type: "text", text: "hello" } as Part],
|
|
}],
|
|
}
|
|
const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number }
|
|
error.status = 504
|
|
throw error
|
|
},
|
|
})
|
|
|
|
expect(optimisticRemove).toBe(null)
|
|
expect((optimisticConfirm as OptimisticRemoveCall | null)?.messageID).toBe(sentMessageID)
|
|
expect(replyCalls.find((call) => call.method === "session.messages")?.params.limit).toBe(30)
|
|
expect(targetStore.getState().message["session-confirmed"]?.[0]?.id).toBe(sentMessageID)
|
|
expect(targetStore.getState().part[sentMessageID]?.[0]?.id).toBe("server-part")
|
|
})
|
|
|
|
// Relay tunnel aborts carry no HTTP status and no wording the text-matching
|
|
// heuristic recognizes. Without the transport tag they were classified as
|
|
// definite failures, the accepted prompt was rolled back, and the queue
|
|
// re-sent a message the engine was already answering (#2425).
|
|
test("confirms a tunnel-tagged transport failure that no text heuristic matches", async () => {
|
|
const targetStore = createStore({})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
let optimisticRemove: OptimisticRemoveCall | null = null
|
|
let optimisticConfirm: OptimisticRemoveCall | null = null
|
|
let sentMessageID = ""
|
|
|
|
const { markAmbiguousTransportFailure } = await import("@/lib/relay/transport-error")
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
() => {},
|
|
(input) => {
|
|
optimisticRemove = input
|
|
},
|
|
(input) => {
|
|
optimisticConfirm = input
|
|
},
|
|
)
|
|
|
|
await optimisticSend({
|
|
sessionId: "session-tunnel",
|
|
directory: "/target/project",
|
|
content: "hello",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
send: async (messageID) => {
|
|
sentMessageID = messageID
|
|
sessionMessagesResult = {
|
|
data: [{
|
|
info: { id: messageID, role: "user", sessionID: "session-tunnel", time: { created: 1 } } as Message,
|
|
parts: [{ id: "server-part", type: "text", text: "hello" } as Part],
|
|
}],
|
|
}
|
|
throw markAmbiguousTransportFailure(new Error("stream aborted by host"))
|
|
},
|
|
})
|
|
|
|
expect(optimisticRemove).toBe(null)
|
|
expect((optimisticConfirm as OptimisticRemoveCall | null)?.messageID).toBe(sentMessageID)
|
|
expect(targetStore.getState().message["session-tunnel"]?.[0]?.id).toBe(sentMessageID)
|
|
})
|
|
|
|
test("rolls back an ambiguous send failure when recent messages do not contain the sent ID", async () => {
|
|
const targetStore = createStore({})
|
|
const childStores = createChildStores([["/target/project", targetStore]])
|
|
let optimisticRemove: OptimisticRemoveCall | null = null
|
|
let optimisticConfirm: OptimisticRemoveCall | null = null
|
|
|
|
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
|
|
setOptimisticRefs(
|
|
() => {},
|
|
(input) => {
|
|
optimisticRemove = input
|
|
},
|
|
(input) => {
|
|
optimisticConfirm = input
|
|
},
|
|
)
|
|
|
|
let caught: unknown = null
|
|
try {
|
|
await optimisticSend({
|
|
sessionId: "session-missing",
|
|
directory: "/target/project",
|
|
content: "hello",
|
|
providerID: "provider",
|
|
modelID: "model",
|
|
send: async () => {
|
|
const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number }
|
|
error.status = 504
|
|
throw error
|
|
},
|
|
})
|
|
} catch (error) {
|
|
caught = error
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(Error)
|
|
expect((optimisticRemove as OptimisticRemoveCall | null)?.sessionID).toBe("session-missing")
|
|
expect(optimisticConfirm).toBe(null)
|
|
expect(replyCalls.filter((call) => call.method === "session.messages").every((call) => call.params.limit === 30)).toBe(true)
|
|
expect(targetStore.getState().session_status["session-missing"]?.type).toBe("idle")
|
|
})
|
|
})
|
|
|
|
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")
|
|
})
|
|
|
|
test("uses an explicit event directory before incomplete local routing state", async () => {
|
|
const childStores = createChildStores([])
|
|
|
|
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/stale/current")
|
|
|
|
await respondToPermission("unknown-session", "perm-event", "once", "/event/project")
|
|
|
|
expect(scopedClientDirectories).toContain("/event/project")
|
|
expect(replyCalls[0].params.directory).toBe("/event/project")
|
|
})
|
|
})
|
|
|
|
describe("revertToMessage passes session directory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
sessionRevertResult = {}
|
|
sessionMessageRecords.clear()
|
|
failingRevertSessionIds.clear()
|
|
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")
|
|
})
|
|
|
|
test("reverts recursive descendants at their first user message on or after the parent cutoff", async () => {
|
|
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
|
|
const sessions = [
|
|
{ id: "root", directory: "/tree", time: { created: 1 } },
|
|
{ id: "child", parentID: "root", directory: "/tree", time: { created: 2 } },
|
|
{ id: "grandchild", parentID: "child", directory: "/tree", time: { created: 3 } },
|
|
{ id: "old-child", parentID: "root", directory: "/tree", time: { created: 4 } },
|
|
] as Session[]
|
|
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
|
|
sessionMessageRecords.set("child", [
|
|
{ info: { id: "child-before", sessionID: "child", role: "user", time: { created: 10 } } as Message, parts: [] },
|
|
{ info: { id: "child-boundary", sessionID: "child", role: "user", time: { created: 20 } } as Message, parts: [] },
|
|
{ info: { id: "child-later", sessionID: "child", role: "user", time: { created: 30 } } as Message, parts: [] },
|
|
])
|
|
sessionMessageRecords.set("grandchild", [
|
|
{ info: { id: "grandchild-assistant", sessionID: "grandchild", role: "assistant", time: { created: 20 } } as Message, parts: [] },
|
|
{ info: { id: "grandchild-user", sessionID: "grandchild", role: "user", time: { created: 21 } } as Message, parts: [] },
|
|
])
|
|
sessionMessageRecords.set("old-child", [
|
|
{ info: { id: "old-child-user", sessionID: "old-child", role: "user", time: { created: 19 } } as Message, parts: [] },
|
|
])
|
|
|
|
const { setActionRefs, revertToMessage } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
|
|
|
await revertToMessage("root", "root-cutoff")
|
|
|
|
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => [
|
|
call.params.sessionID,
|
|
call.params.messageID,
|
|
])).toEqual([
|
|
["child", "child-boundary"],
|
|
["grandchild", "grandchild-user"],
|
|
["root", "root-cutoff"],
|
|
])
|
|
})
|
|
|
|
test("continues reverting other descendants and the parent when one child fails", async () => {
|
|
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
|
|
const sessions = [
|
|
{ id: "root", directory: "/tree", time: { created: 1 } },
|
|
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 } },
|
|
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 } },
|
|
] as Session[]
|
|
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
|
|
for (const id of ["failing-child", "healthy-child"]) {
|
|
sessionMessageRecords.set(id, [{
|
|
info: { id: `${id}-target`, sessionID: id, role: "user", time: { created: 20 } } as Message,
|
|
parts: [],
|
|
}])
|
|
}
|
|
failingRevertSessionIds.add("failing-child")
|
|
|
|
const { setActionRefs, revertToMessage } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
|
|
|
await revertToMessage("root", "root-cutoff")
|
|
|
|
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => call.params.sessionID)).toEqual([
|
|
"failing-child",
|
|
"healthy-child",
|
|
"root",
|
|
])
|
|
})
|
|
|
|
test("aborts a busy descendant before reverting it", async () => {
|
|
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
|
|
const sessions = [
|
|
{ id: "root", directory: "/tree", time: { created: 1 } },
|
|
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 } },
|
|
{ id: "idle-child", parentID: "root", directory: "/tree", time: { created: 3 } },
|
|
] as Session[]
|
|
const store = createStore({}, {
|
|
session: sessions,
|
|
message: { root: [rootMessage] },
|
|
session_status: { "busy-child": { type: "busy" }, "idle-child": { type: "idle" } },
|
|
})
|
|
for (const id of ["busy-child", "idle-child"]) {
|
|
sessionMessageRecords.set(id, [{
|
|
info: { id: `${id}-target`, sessionID: id, role: "user", time: { created: 20 } } as Message,
|
|
parts: [],
|
|
}])
|
|
}
|
|
|
|
const { setActionRefs, revertToMessage } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
|
|
|
await revertToMessage("root", "root-cutoff")
|
|
|
|
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
|
.toEqual(["busy-child"])
|
|
const busyAbortIndex = replyCalls.findIndex((call) => call.method === "session.abort")
|
|
const busyRevertIndex = replyCalls.findIndex(
|
|
(call) => call.method === "session.revert" && call.params.sessionID === "busy-child",
|
|
)
|
|
expect(busyAbortIndex).toBeLessThan(busyRevertIndex)
|
|
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => call.params.sessionID)).toEqual([
|
|
"busy-child",
|
|
"idle-child",
|
|
"root",
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("unrevertSession descendant cascade", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
sessionMessagesResult = { data: [] }
|
|
failingUnrevertSessionIds.clear()
|
|
afterUnrevertCall = null
|
|
})
|
|
|
|
test("unreverts only marked descendants before the parent", async () => {
|
|
const sessions = [
|
|
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
|
{ id: "marked-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "child-target" } },
|
|
{ id: "plain-child", parentID: "root", directory: "/tree", time: { created: 3 } },
|
|
{ id: "marked-grandchild", parentID: "plain-child", directory: "/tree", time: { created: 4 }, revert: { messageID: "grandchild-target" } },
|
|
] as Session[]
|
|
const store = createStore({}, { session: sessions })
|
|
|
|
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
|
|
|
await unrevertSession("root")
|
|
|
|
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
|
|
"marked-child",
|
|
"marked-grandchild",
|
|
"root",
|
|
])
|
|
})
|
|
|
|
test("continues after a descendant unrevert fails", async () => {
|
|
const sessions = [
|
|
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
|
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "first-target" } },
|
|
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "second-target" } },
|
|
] as Session[]
|
|
const store = createStore({}, { session: sessions })
|
|
failingUnrevertSessionIds.add("failing-child")
|
|
|
|
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
|
|
|
await unrevertSession("root")
|
|
|
|
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
|
|
"failing-child",
|
|
"healthy-child",
|
|
"root",
|
|
])
|
|
})
|
|
|
|
test("aborts a busy descendant before unreverting it", async () => {
|
|
const sessions = [
|
|
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
|
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "busy-target" } },
|
|
{ id: "idle-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "idle-target" } },
|
|
] as Session[]
|
|
const store = createStore({}, {
|
|
session: sessions,
|
|
session_status: { "busy-child": { type: "busy" }, "idle-child": { type: "idle" } },
|
|
})
|
|
|
|
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
|
|
|
await unrevertSession("root")
|
|
|
|
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
|
.toEqual(["busy-child"])
|
|
const abortIndex = replyCalls.findIndex((call) => call.method === "session.abort")
|
|
const unrevertIndex = replyCalls.findIndex(
|
|
(call) => call.method === "session.unrevert" && call.params.sessionID === "busy-child",
|
|
)
|
|
expect(abortIndex).toBeLessThan(unrevertIndex)
|
|
})
|
|
|
|
test("treats a descendant as busy when any child store reports a non-idle status", async () => {
|
|
const sessions = [
|
|
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
|
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "busy-target" } },
|
|
] as Session[]
|
|
// The session list is deduped onto /tree, but the live status arrived in the
|
|
// store for another directory.
|
|
const treeStore = createStore({}, { session: sessions })
|
|
const statusStore = createStore({}, { session_status: { "busy-child": { type: "busy" } } })
|
|
|
|
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
|
setActionRefs(
|
|
mockSdk as unknown as OpencodeClient,
|
|
createChildStores([["/tree", treeStore], ["/other", statusStore]]),
|
|
() => "/tree",
|
|
)
|
|
|
|
await unrevertSession("root")
|
|
|
|
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
|
.toEqual(["busy-child"])
|
|
})
|
|
|
|
test("aborts a descendant that turns busy after the subtree snapshot", async () => {
|
|
const sessions = [
|
|
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
|
|
{ id: "first-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "first-target" } },
|
|
{ id: "second-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "second-target" } },
|
|
] as Session[]
|
|
const store = createStore({}, { session: sessions, session_status: {} })
|
|
afterUnrevertCall = (sessionId) => {
|
|
if (sessionId !== "first-child") return
|
|
store.getState().patch({ session_status: { "second-child": { type: "busy" } } })
|
|
}
|
|
|
|
const { setActionRefs, unrevertSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
|
|
|
|
await unrevertSession("root")
|
|
|
|
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
|
|
.toEqual(["second-child"])
|
|
})
|
|
})
|
|
|
|
describe("dismissPermission passes directory", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
questionReplyError = null
|
|
permissionReplyError = 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 sessionFixture(id: string): Session {
|
|
// SAFETY: the question flow only reads session id/time; the fixture is
|
|
// intentionally minimal and matches the existing fixtures in this file.
|
|
return { id, time: { created: 1 } } as Session
|
|
}
|
|
|
|
function actionsSdk(): OpencodeClient {
|
|
// SAFETY: mockSdk implements the question/permission/session surface that
|
|
// session-actions uses; this cast is the established pattern in this file.
|
|
return mockSdk as never
|
|
}
|
|
|
|
describe("question dismissal clears pending state without the SSE echo (issues #2911, #2448)", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
questionReplyError = null
|
|
questionRejectError = null
|
|
})
|
|
|
|
test("rejectQuestion clears the question from the child store on success", async () => {
|
|
const question = buildQuestion("q-1", "session-a")
|
|
const store = createStore({}, {
|
|
session: [sessionFixture("session-a")],
|
|
question: { "session-a": [question] },
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, rejectQuestion } = await import("./session-actions")
|
|
setActionRefs(actionsSdk(), childStores, () => "/test/project")
|
|
|
|
await rejectQuestion("session-a", "q-1")
|
|
|
|
// The backend confirmed the rejection. The local pending state must be gone
|
|
// even if the SSE `question.rejected` event is lost (SSE gap), otherwise the
|
|
// session stays in "waiting for answer" and the next task never renders
|
|
// thinking/final response (issues #2911, #2448).
|
|
expect(store.getState().question["session-a"]).toBe(undefined)
|
|
})
|
|
|
|
test("respondToQuestion clears the question from the child store on success", async () => {
|
|
const question = buildQuestion("q-1", "session-a")
|
|
const store = createStore({}, {
|
|
session: [sessionFixture("session-a")],
|
|
question: { "session-a": [question] },
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
|
setActionRefs(actionsSdk(), childStores, () => "/test/project")
|
|
|
|
await respondToQuestion("session-a", "q-1", [["Yes"]])
|
|
|
|
expect(store.getState().question["session-a"]).toBe(undefined)
|
|
})
|
|
|
|
test("dismissOpenQuestionsForSession leaves the store cleared when the reject succeeds", async () => {
|
|
const question = buildQuestion("q-root", "session-a")
|
|
const store = createStore({}, {
|
|
session: [sessionFixture("session-a")],
|
|
question: { "session-a": [question] },
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, dismissOpenQuestionsForSession } = await import("./session-actions")
|
|
setActionRefs(actionsSdk(), childStores, () => "/test/project")
|
|
|
|
const dismissed = await dismissOpenQuestionsForSession("session-a")
|
|
|
|
expect(dismissed).toBe(true)
|
|
// The optimistic clear already removed it before the round-trip; the
|
|
// successful reject must not resurrect it.
|
|
expect(store.getState().question["session-a"]).toBe(undefined)
|
|
})
|
|
|
|
test("reply/reject actions on an already-cleared store stay no-ops (SSE echo equivalent)", async () => {
|
|
// A later (or duplicated) SSE echo for an already-cleared request must not
|
|
// error or resurrect state — the reducer only removes when present.
|
|
const store = createStore({}, {
|
|
session: [sessionFixture("session-a")],
|
|
question: {},
|
|
})
|
|
|
|
const { setActionRefs, rejectQuestion, respondToQuestion } = await import("./session-actions")
|
|
setActionRefs(actionsSdk(), createChildStores([["/test/project", store]]), () => "/test/project")
|
|
|
|
await respondToQuestion("session-a", "q-gone", [["Yes"]])
|
|
await rejectQuestion("session-a", "q-gone")
|
|
|
|
expect(store.getState().question["session-a"]).toBe(undefined)
|
|
})
|
|
})
|
|
|
|
describe("blocking request reply routing and stale recovery (issue OPE-236)", () => {
|
|
const materializationCalls: Array<{ directory: string; sessionID: string; messageID: string }> = []
|
|
const enqueueMaterialization = (directory: string, sessionID: string, messageID: string) => {
|
|
materializationCalls.push({ directory, sessionID, messageID })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
questionReplyError = null
|
|
questionRejectError = null
|
|
materializationCalls.length = 0
|
|
})
|
|
|
|
test("routes the question reply by the request's own session directory, not the containing store key", async () => {
|
|
// The question was asked by a worktree session whose record lives in the
|
|
// parent store (containment). The reply must be addressed to the session's
|
|
// own server-confirmed directory — otherwise the server resolves the
|
|
// parent instance, does not find the pending question, and answers
|
|
// QuestionNotFoundError, leaving the session stuck on "asking question".
|
|
const question = buildQuestion("q-wt", "session-wt")
|
|
const store = createStore({}, {
|
|
session: [{ id: "session-wt", directory: "/test/project/wt" } as Session],
|
|
question: { "session-wt": [question] },
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
|
|
|
await respondToQuestion("session-wt", "q-wt", [["Yes"]])
|
|
|
|
expect(scopedClientDirectories).toEqual(["/test/project/wt"])
|
|
expect(replyCalls[0]?.params.directory).toBe("/test/project/wt")
|
|
expect(replyCalls[0]?.params.requestID).toBe("q-wt")
|
|
})
|
|
|
|
test("routes permission replies by the request's own session directory", async () => {
|
|
const permission = buildPermission("perm-wt", "session-wt")
|
|
const store = createStore(
|
|
{ "session-wt": [permission] },
|
|
{
|
|
session: [{ id: "session-wt", directory: "/test/project/wt" } as Session],
|
|
},
|
|
)
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
|
|
|
await respondToPermission("session-wt", "perm-wt", "once")
|
|
|
|
expect(scopedClientDirectories).toEqual(["/test/project/wt"])
|
|
expect(replyCalls[0]?.params.directory).toBe("/test/project/wt")
|
|
expect(replyCalls[0]?.params.requestID).toBe("perm-wt")
|
|
})
|
|
|
|
test("falls back to the containing store key when the session record carries no directory", async () => {
|
|
const question = buildQuestion("q-1", "session-a")
|
|
const store = createStore({}, {
|
|
session: [{ id: "session-a" } as Session],
|
|
question: { "session-a": [question] },
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
|
|
|
await respondToQuestion("session-a", "q-1", [["Yes"]])
|
|
|
|
expect(scopedClientDirectories).toEqual(["/test/project"])
|
|
expect(replyCalls[0]?.params.directory).toBe("/test/project")
|
|
})
|
|
|
|
test("enqueues settled-running-tool tail recovery when the question reply is not found", async () => {
|
|
const question = buildQuestion("q-stale", "session-a")
|
|
const store = createStore({}, {
|
|
session: [{ id: "session-a" } as Session],
|
|
question: { "session-a": [question] },
|
|
message: {
|
|
"session-a": [{ id: "msg-1", sessionID: "session-a", role: "assistant", time: { created: 1 } } as Message],
|
|
},
|
|
part: {
|
|
"msg-1": [{
|
|
id: "prt-1",
|
|
messageID: "msg-1",
|
|
sessionID: "session-a",
|
|
type: "tool",
|
|
tool: "question",
|
|
state: { status: "running" },
|
|
} as Part],
|
|
},
|
|
})
|
|
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", enqueueMaterialization)
|
|
|
|
let thrown: unknown
|
|
try {
|
|
await respondToQuestion("session-a", "q-stale", [["Yes"]])
|
|
} catch (error) {
|
|
thrown = error
|
|
}
|
|
|
|
expect(thrown).toBeInstanceOf(Error)
|
|
// The stale request is gone from the store and the trailing running tool
|
|
// part is reconciled instead of leaving the UI stuck on "asking question".
|
|
expect(store.getState().question["session-a"]).toBe(undefined)
|
|
expect(materializationCalls).toEqual([{ directory: "/test/project", sessionID: "session-a", messageID: "msg-1" }])
|
|
})
|
|
|
|
test("enqueues tail recovery on reject not-found but not on success", async () => {
|
|
const question = buildQuestion("q-1", "session-a")
|
|
const store = createStore({}, {
|
|
session: [{ id: "session-a" } as Session],
|
|
question: { "session-a": [question] },
|
|
message: {
|
|
"session-a": [{ id: "msg-1", sessionID: "session-a", role: "assistant", time: { created: 1 } } as Message],
|
|
},
|
|
part: {
|
|
"msg-1": [{
|
|
id: "prt-1",
|
|
messageID: "msg-1",
|
|
sessionID: "session-a",
|
|
type: "tool",
|
|
tool: "question",
|
|
state: { status: "running" },
|
|
} as Part],
|
|
},
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, rejectQuestion } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project", enqueueMaterialization)
|
|
|
|
// Success: no recovery enqueued — the normal question.rejected event flow clears state.
|
|
await rejectQuestion("session-a", "q-1")
|
|
expect(materializationCalls).toEqual([])
|
|
|
|
// Not-found: the request is stale server-side; the tail must be reconciled.
|
|
questionRejectError = Object.assign(new Error("question.reject failed (404): QuestionNotFoundError"), { status: 404 })
|
|
const stale = buildQuestion("q-stale", "session-a")
|
|
store.setState({ question: { "session-a": [stale] } })
|
|
|
|
let thrown: unknown
|
|
try {
|
|
await rejectQuestion("session-a", "q-stale")
|
|
} catch (error) {
|
|
thrown = error
|
|
}
|
|
|
|
expect(thrown).toBeInstanceOf(Error)
|
|
expect(store.getState().question["session-a"]).toBe(undefined)
|
|
expect(materializationCalls).toEqual([{ directory: "/test/project", sessionID: "session-a", messageID: "msg-1" }])
|
|
})
|
|
})
|
|
|
|
function buildQuestion(id: string, sessionId: string): QuestionRequest {
|
|
return {
|
|
id,
|
|
sessionID: sessionId,
|
|
questions: [
|
|
{
|
|
question: "Choose an option",
|
|
header: "Choice",
|
|
options: [{ label: "Yes", description: "Proceed" }],
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
function buildPermission(id: string, sessionId: string): PermissionRequest {
|
|
return {
|
|
id,
|
|
sessionID: sessionId,
|
|
permission: "edit",
|
|
patterns: [],
|
|
metadata: {},
|
|
always: [],
|
|
}
|
|
}
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe("dismissPermission not-found handling", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
permissionReplyError = null
|
|
})
|
|
|
|
test("clears the stale permission and rethrows on PermissionNotFoundError", async () => {
|
|
const permission = buildPermission("perm-stale", "session-a")
|
|
const store = createStore({ "session-a": [permission] })
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
permissionReplyError = Object.assign(new Error("permission.reply failed (404): PermissionNotFoundError"), { status: 404 })
|
|
|
|
const { setActionRefs, dismissPermission } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
await expect(dismissPermission("session-a", "perm-stale")).rejects.toThrow()
|
|
expect(replyCalls.filter((call) => call.method === "permission.reply")).toHaveLength(1)
|
|
// The stale entry is cleared from the store even though the server reported not-found.
|
|
expect(store.getState().permission["session-a"]).toBe(undefined)
|
|
})
|
|
|
|
test("does not clear the store on a non-not-found failure (rethrow only)", async () => {
|
|
const permission = buildPermission("perm-500", "session-a")
|
|
const store = createStore({ "session-a": [permission] })
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
permissionReplyError = Object.assign(new Error("permission.reply failed (500)"), { status: 500 })
|
|
|
|
const { setActionRefs, dismissPermission } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
await expect(dismissPermission("session-a", "perm-500")).rejects.toThrow()
|
|
// A non-not-found failure leaves store reconciliation to the next server event.
|
|
expect(store.getState().permission["session-a"]).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe("dismissOpenPermissionsForSession", () => {
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
scopedClientDirectories.length = 0
|
|
permissionReplyError = null
|
|
})
|
|
|
|
test("returns false and rejects nothing when no permissions are pending", async () => {
|
|
const store = createStore({}, { session: [{ id: "session-a", time: { created: 1 } } as Session] })
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, dismissOpenPermissionsForSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
const dismissed = await dismissOpenPermissionsForSession("session-a")
|
|
|
|
expect(dismissed).toBe(false)
|
|
expect(replyCalls.filter((call) => call.method === "permission.reply")).toHaveLength(0)
|
|
})
|
|
|
|
test("rejects every pending permission in the session subtree (root + subagent child)", async () => {
|
|
const rootPermission = buildPermission("perm-root", "session-a")
|
|
const childPermission = buildPermission("perm-child", "session-child")
|
|
const store = createStore({
|
|
"session-a": [rootPermission],
|
|
"session-child": [childPermission],
|
|
}, {
|
|
session: [
|
|
{ id: "session-a", time: { created: 1 } } as Session,
|
|
{ id: "session-child", parentID: "session-a", time: { created: 2 } } as Session,
|
|
],
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
|
|
const { setActionRefs, dismissOpenPermissionsForSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
const dismissed = await dismissOpenPermissionsForSession("session-a")
|
|
|
|
expect(dismissed).toBe(true)
|
|
const replyCallsForPermissions = replyCalls.filter((call) => call.method === "permission.reply")
|
|
expect(replyCallsForPermissions).toHaveLength(2)
|
|
const rejectedIds = replyCallsForPermissions.map((call) => call.params.requestID).sort()
|
|
expect(rejectedIds).toEqual(["perm-child", "perm-root"])
|
|
expect(replyCallsForPermissions.every((call) => call.params.reply === "reject")).toBe(true)
|
|
// Optimistic clear: the permissions are removed from the local store so the
|
|
// prompt disappears instantly, without waiting for the reject round-trip.
|
|
expect(store.getState().permission["session-a"]).toBe(undefined)
|
|
expect(store.getState().permission["session-child"]).toBe(undefined)
|
|
})
|
|
|
|
test("swallows PermissionNotFoundError so a stranded permission never blocks the send", async () => {
|
|
const stalePermission = buildPermission("perm-stale", "session-a")
|
|
const store = createStore({ "session-a": [stalePermission] }, {
|
|
session: [{ id: "session-a", time: { created: 1 } } as Session],
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
permissionReplyError = Object.assign(new Error("permission.reply failed (404): PermissionNotFoundError"), { status: 404 })
|
|
|
|
const { setActionRefs, dismissOpenPermissionsForSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
const dismissed = await dismissOpenPermissionsForSession("session-a")
|
|
|
|
expect(dismissed).toBe(true)
|
|
const replyCallsForPermissions = replyCalls.filter((call) => call.method === "permission.reply")
|
|
expect(replyCallsForPermissions).toHaveLength(1)
|
|
expect(replyCallsForPermissions[0].params.requestID).toBe("perm-stale")
|
|
// The stale entry is cleared from the store even though the server reported not-found.
|
|
expect(store.getState().permission["session-a"]).toBe(undefined)
|
|
})
|
|
|
|
test("swallows and logs a non-not-found reject failure so the send is never blocked", async () => {
|
|
const permission = buildPermission("perm-500", "session-a")
|
|
const store = createStore({ "session-a": [permission] }, {
|
|
session: [{ id: "session-a", time: { created: 1 } } as Session],
|
|
})
|
|
const childStores = createChildStores([["/test/project", store]])
|
|
permissionReplyError = Object.assign(new Error("permission.reply failed (500)"), { status: 500 })
|
|
|
|
const { setActionRefs, dismissOpenPermissionsForSession } = await import("./session-actions")
|
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
|
|
|
const errors: unknown[][] = []
|
|
const originalError = console.error
|
|
console.error = (...args: unknown[]) => { errors.push(args) }
|
|
try {
|
|
const dismissed = await dismissOpenPermissionsForSession("session-a")
|
|
|
|
expect(dismissed).toBe(true)
|
|
const replyCallsForPermissions = replyCalls.filter((call) => call.method === "permission.reply")
|
|
expect(replyCallsForPermissions).toHaveLength(1)
|
|
expect(replyCallsForPermissions[0].params.requestID).toBe("perm-500")
|
|
expect(errors).toHaveLength(1)
|
|
expect(String(errors[0]?.[0])).toContain("[session-actions]")
|
|
} finally {
|
|
console.error = originalError
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("relocateSessionFromMissingDirectory", () => {
|
|
const missingWorktree = "/projects/main/.worktrees/gone"
|
|
const projectDirectory = "/projects/main"
|
|
const worktreeSession = (id: string, parentID: string | null, directory = missingWorktree, archived = 0): Session & { project: { worktree: string } } => ({
|
|
id,
|
|
slug: id,
|
|
projectID: "project-main",
|
|
directory,
|
|
title: id,
|
|
version: "1",
|
|
project: { worktree: projectDirectory },
|
|
time: { created: 1, updated: 1, archived },
|
|
parentID: parentID ?? undefined,
|
|
})
|
|
const mainProject: Project = { id: "project-main", worktree: projectDirectory, time: { created: 1, updated: 1 }, sandboxes: [] }
|
|
const stores = () => createChildStores([[missingWorktree, createStore({})], [projectDirectory, createStore({})]])
|
|
const movesOf = () => replyCalls
|
|
.filter((call) => call.method === "controlPlane.moveSession")
|
|
.map((call) => ({ sessionID: call.params.sessionID, destination: call.params.destination, moveChanges: call.params.moveChanges }))
|
|
|
|
beforeEach(() => {
|
|
replyCalls.length = 0
|
|
registeredSessionDirectories.length = 0
|
|
movedSessionDirectories.length = 0
|
|
globalUpsertedSessions.length = 0
|
|
globalActiveSessions = []
|
|
globalArchivedSessions.length = 0
|
|
openCodeProjects.length = 0
|
|
directoryAvailability.clear()
|
|
controlPlaneMoveErrorsById.clear()
|
|
beforeDirectoryAvailabilityResolve = null
|
|
runtimeKey = "default-runtime"
|
|
})
|
|
|
|
test("moves the whole stranded subtree, root first, to the project directory without carrying changes", async () => {
|
|
const root = worktreeSession("root", null)
|
|
const child = worktreeSession("child", "root")
|
|
const archivedChild = worktreeSession("archived-child", "root", missingWorktree, 42)
|
|
const elsewhere = worktreeSession("elsewhere", "root", projectDirectory)
|
|
globalActiveSessions = [root, child, elsewhere]
|
|
globalArchivedSessions.push(archivedChild)
|
|
openCodeProjects.push(mainProject)
|
|
directoryAvailability.set(missingWorktree, "missing")
|
|
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
|
|
|
const result = await relocateSessionFromMissingDirectory("root")
|
|
|
|
expect(result).toEqual({
|
|
status: "moved",
|
|
sourceDirectory: missingWorktree,
|
|
destinationDirectory: projectDirectory,
|
|
movedSessionIds: ["root", "child", "archived-child"],
|
|
})
|
|
expect(movesOf()).toEqual([
|
|
{ sessionID: "root", destination: { directory: projectDirectory }, moveChanges: false },
|
|
{ sessionID: "child", destination: { directory: projectDirectory }, moveChanges: false },
|
|
{ sessionID: "archived-child", destination: { directory: projectDirectory }, moveChanges: false },
|
|
])
|
|
expect(movedSessionDirectories).toEqual([
|
|
{ sessionID: "root", directory: projectDirectory },
|
|
{ sessionID: "child", directory: projectDirectory },
|
|
{ sessionID: "archived-child", directory: projectDirectory },
|
|
])
|
|
})
|
|
|
|
for (const availability of ["available", "unknown"] as const) {
|
|
test(`leaves the session alone when its directory is ${availability}`, async () => {
|
|
globalActiveSessions = [worktreeSession("root", null)]
|
|
openCodeProjects.push(mainProject)
|
|
directoryAvailability.set(missingWorktree, availability)
|
|
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
|
|
|
expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" })
|
|
expect(movesOf()).toEqual([])
|
|
})
|
|
}
|
|
|
|
test("leaves a session that already lives in its project directory alone", async () => {
|
|
globalActiveSessions = [worktreeSession("root", null, projectDirectory)]
|
|
openCodeProjects.push(mainProject)
|
|
directoryAvailability.set(projectDirectory, "missing")
|
|
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, stores(), () => projectDirectory)
|
|
|
|
expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" })
|
|
expect(movesOf()).toEqual([])
|
|
})
|
|
|
|
test("never relocates to the filesystem root OpenCode reports for its global project", async () => {
|
|
const chatDirectory = "/Users/tester/.config/openchamber/chats/2026-09-05/session-gone"
|
|
const chat = { ...worktreeSession("chat", null, chatDirectory), projectID: "global", project: { worktree: "/" } }
|
|
globalActiveSessions = [chat]
|
|
openCodeProjects.push({ id: "global", worktree: "/", time: { created: 1, updated: 1 }, sandboxes: [] })
|
|
directoryAvailability.set(chatDirectory, "missing")
|
|
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, stores(), () => chatDirectory)
|
|
|
|
expect(await relocateSessionFromMissingDirectory("chat")).toEqual({ status: "unchanged" })
|
|
expect(movesOf()).toEqual([])
|
|
})
|
|
|
|
test("leaves the session alone when OpenCode knows no project for it", async () => {
|
|
globalActiveSessions = [worktreeSession("root", null)]
|
|
directoryAvailability.set(missingWorktree, "missing")
|
|
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
|
|
|
expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" })
|
|
expect(movesOf()).toEqual([])
|
|
})
|
|
|
|
test("reports the sessions already moved when a descendant move fails", async () => {
|
|
globalActiveSessions = [worktreeSession("root", null), worktreeSession("child", "root")]
|
|
openCodeProjects.push(mainProject)
|
|
directoryAvailability.set(missingWorktree, "missing")
|
|
controlPlaneMoveErrorsById.set("child", new Error("destination busy"))
|
|
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
|
|
|
const result = await relocateSessionFromMissingDirectory("root")
|
|
|
|
expect(result.status).toBe("failed")
|
|
expect(result.status === "failed" ? result.movedSessionIds : null).toEqual(["root"])
|
|
expect(movedSessionDirectories).toEqual([{ sessionID: "root", directory: projectDirectory }])
|
|
})
|
|
|
|
test("publishes nothing when the runtime changes while the directory is being probed", async () => {
|
|
globalActiveSessions = [worktreeSession("root", null)]
|
|
openCodeProjects.push(mainProject)
|
|
directoryAvailability.set(missingWorktree, "missing")
|
|
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
|
beforeDirectoryAvailabilityResolve = () => {
|
|
switchRuntimeEndpoint({ apiBaseUrl: "http://other.test", runtimeKey: "other-runtime" })
|
|
}
|
|
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
|
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
|
|
|
expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "stale" })
|
|
expect(movesOf()).toEqual([])
|
|
expect(movedSessionDirectories).toEqual([])
|
|
})
|
|
})
|