Merge remote-tracking branch 'origin/main' into port-2667

This commit is contained in:
Bohdan Triapitsyn
2026-08-29 00:26:15 +03:00
1295 changed files with 113712 additions and 30102 deletions
@@ -1,5 +1,15 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { createEventPipeline } from '../event-pipeline';
import { afterEach, describe, expect, it, mock } from 'bun:test';
// A WebSocket attempt mints an `oc_url_token` before connecting, because a WS
// upgrade cannot carry an Authorization header. Stub only that mint so the
// socket assertions below exercise the transport rather than the auth round-trip.
const actualRuntimeAuth = await import('@/lib/runtime-auth');
mock.module('@/lib/runtime-auth', () => ({
...actualRuntimeAuth,
refreshRuntimeUrlAuthToken: async () => 'test-url-token',
}));
const { createEventPipeline } = await import('../event-pipeline');
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
@@ -48,9 +58,9 @@ class FakeWebSocket {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitClose() {
emitClose(code = 1006, reason = '') {
this.readyState = 3;
this.onclose?.();
this.onclose?.({ code, reason });
}
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2"
import type { Event, Part, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client"
import type { Event, Message, Part, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client"
import { applyDirectoryEvent } from "../event-reducer"
import { INITIAL_STATE, type State } from "../types"
@@ -65,6 +65,71 @@ function buildSession(title: string, time: Session["time"]): Session {
}
describe("applyDirectoryEvent", () => {
test("inserts post-rollover message events by creation time rather than ID", () => {
const legacy = {
id: "msg_ffffffffffffLegacy",
sessionID: "ses_1",
role: "user",
time: { created: 100 },
} as Message
const current = {
id: "msg_000000000000Current",
sessionID: "ses_1",
role: "assistant",
time: { created: 200 },
} as Message
const draft = state({ message: { ses_1: [legacy] } })
expect(applyDirectoryEvent(draft, {
type: "message.updated",
properties: { info: current },
} as Event)).toBe(true)
expect(draft.message.ses_1).toEqual([legacy, current])
})
test("preserves part event order across the part ID rollover", () => {
const legacyPart = {
id: "prt_ffffffffffffLegacy",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: "legacy",
} as Part
const currentPart = {
id: "prt_000000000000Current",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: "current",
} as Part
const draft = state({
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "assistant", time: { created: 1 } } as Message] },
part: { msg_1: [legacyPart] },
})
expect(applyDirectoryEvent(draft, {
type: "message.part.updated",
properties: { part: currentPart },
} as Event)).toBe(true)
expect(draft.part.msg_1).toEqual([legacyPart, currentPart])
})
test("replaces an optimistic user part in place instead of appending it", () => {
const optimisticText = { id: "prt_optimistic_text", messageID: "msg_1", type: "text", text: "hi" } as Part
const optimisticFile = { id: "prt_optimistic_file", messageID: "msg_1", type: "file", filename: "a.png" } as Part
const serverText = { id: "prt_server_text", messageID: "msg_1", sessionID: "ses_1", type: "text", text: "hi" } as Part
const draft = state({
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "user", time: { created: 1 } } as Message] },
part: { msg_1: [optimisticText, optimisticFile] },
})
expect(applyDirectoryEvent(draft, {
type: "message.part.updated",
properties: { part: serverText },
} as Event)).toBe(true)
expect(draft.part.msg_1).toEqual([serverText, optimisticFile])
})
test("returns typed materialization when delta arrives before parts", () => {
const result = applyDirectoryEvent(state(), deltaEvent())
@@ -0,0 +1,184 @@
/**
* Tests for interrupted-turn reconciliation (#2577): when a managed OpenCode
* process dies mid-turn, the persisted turn never settles — the trailing
* assistant message has no time.completed and its tool parts stay running.
* Once the session is authoritatively settled, `interruptedTurnToolParts`
* completes the assistant message as aborted and finalizes orphaned parts.
*/
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { interruptedTurnToolParts } from "../sync-context"
import type { DirectoryStore } from "../child-store"
import { INITIAL_STATE } from "../types"
function state(overrides: Partial<DirectoryStore> = {}): DirectoryStore {
return {
...INITIAL_STATE,
session_status: {},
message: {},
part: {},
question: {},
permission: {},
...overrides,
} as unknown as DirectoryStore
}
function runningTool(id: string, messageID: string, start = 1000): Part {
return {
id,
messageID,
sessionID: "ses_1",
type: "tool",
tool: "bash",
state: { status: "running", time: { start }, input: {} },
} as unknown as Part
}
function completedTool(id: string, messageID: string): Part {
return {
id,
messageID,
sessionID: "ses_1",
type: "tool",
tool: "bash",
state: { status: "completed", time: { start: 1000, end: 2000 }, input: {} },
} as unknown as Part
}
function pendingTool(id: string, messageID: string): Part {
return {
id,
messageID,
sessionID: "ses_1",
type: "tool",
tool: "bash",
state: { status: "pending", time: { start: 1000 }, input: {} },
} as unknown as Part
}
function unfinishedAssistantMessage(id: string): Message {
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10 } } as unknown as Message
}
function finishedAssistantMessage(id: string): Message {
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10, completed: 2000 } } as unknown as Message
}
describe("interruptedTurnToolParts (#2577)", () => {
test("settled session with unfinished message and running tool finalizes the part", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
const part = result!.parts![0] as { state: { status: string; error: string; time: { end: number } } }
expect(part.state.status).toBe("error")
expect(part.state.error).toBe("Interrupted")
expect(part.state.time.end).toBe(5000)
expect(result!.messages[0]).toEqual({
...unfinishedAssistantMessage("msg_1"),
time: { created: 10, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
})
})
test("busy session is never marked (live work)", () => {
const store = state({
session_status: { ses_1: { type: "busy" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("absent status is unknown, not settled — never marked", () => {
const store = state({
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("finished message is not an interruption (tail refresh reconciles it)", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [finishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("pending question means the turn is waiting for input, not interrupted", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
question: { ses_1: [{ id: "q_1", sessionID: "ses_1", questions: [{ question: "?", header: "h", options: [{ label: "a", description: "" }] }] }] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("pending permission means the turn is waiting for input, not interrupted", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [runningTool("tool_1", "msg_1")] },
permission: { ses_1: [{ id: "p_1", sessionID: "ses_1", permission: "bash", patterns: [], metadata: {}, always: [] }] },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
test("only active parts are finalized; completed parts are untouched", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: {
msg_1: [runningTool("tool_1", "msg_1"), completedTool("tool_2", "msg_1"), pendingTool("tool_3", "msg_1")],
},
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
const statuses = result!.parts!.map((part) => (part as { state: { status: string } }).state.status)
expect(statuses).toEqual(["error", "completed", "error"])
})
test("unfinished assistant with no tools is completed as aborted", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: {},
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
expect(result!.parts).toBe(undefined)
expect(result!.messages[0]).toEqual({
...unfinishedAssistantMessage("msg_1"),
time: { created: 10, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
})
})
test("completed tools are untouched while the unfinished assistant is aborted", () => {
const completed = completedTool("tool_2", "msg_1")
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [completed] },
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
expect(result!.parts).toBe(undefined)
expect(store.part.msg_1[0]).toBe(completed)
expect(result!.messages[0]).toEqual({
...unfinishedAssistantMessage("msg_1"),
time: { created: 10, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
})
})
})
@@ -4,6 +4,11 @@ import { togglePermissionAutoAccept } from "../../components/chat/permissionAuto
const storage = new Map<string, string>()
const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = []
const permissionAutoAcceptCalls: Array<[string, boolean]> = []
const savedVariantCalls: Array<string | undefined> = []
let configVariantOverride: string | null | undefined
// Sync's session→directory index. `createSession` writes it, and directory
// resolution reads it as the authoritative source, so the mock has to keep one.
const sessionDirectoryRegistry = new Map<string, string>()
let createdSessionDirectory: string | undefined
const getMockCalls = (fn: unknown): unknown[][] => ((fn as { mock?: { calls: unknown[][] } }).mock?.calls ?? [])
@@ -73,6 +78,8 @@ mock.module("@/stores/utils/safeStorage", () => ({
mock.module("@/lib/opencode/client", () => ({
opencodeClient: {
getDirectory: () => null,
getFilesystemHome: mock(async () => "/home/test"),
createDirectory: mock(async (path: string) => ({ success: true, path })),
setDirectory: mock(() => undefined),
},
}))
@@ -91,6 +98,9 @@ mock.module("@/stores/useConfigStore", () => ({
useConfigStore: {
getState: () => ({
currentAgentName: "agent-default",
currentProviderId: "provider",
currentModelId: "model",
currentVariantSelection: { override: configVariantOverride, inherited: "high" },
agents: [],
activateDirectory: mock(async () => undefined),
applyDefaultModelAgentSelection: mock(() => undefined),
@@ -165,7 +175,9 @@ mock.module("../selection-store", () => ({
saveSessionModelSelection: () => undefined,
saveSessionAgentSelection: () => undefined,
saveAgentModelForSession: () => undefined,
saveAgentModelVariantForSession: () => undefined,
saveAgentModelVariantForSession: (_sessionId: string, _agent: string, _provider: string, _model: string, variant: string | undefined) => {
savedVariantCalls.push(variant)
},
getSessionAgentSelection: () => null,
getSessionModelSelection: () => null,
getAgentModelForSession: () => null,
@@ -239,13 +251,34 @@ mock.module("../sync-refs", () => ({
getSyncMessages: () => [],
getSyncParts: () => [],
getAllSyncSessions: () => [],
getSyncSessionDirectory: () => null,
getSyncSessionDirectory: (sessionId: string) => sessionDirectoryRegistry.get(sessionId) ?? null,
registerSessionDirectory: (sessionId: string, directory: string) => {
sessionDirectoryRegistry.set(sessionId, directory)
},
}))
mock.module("../session-actions", () => ({
createSession: mock(async (title: string | undefined, directory: string | null, parentID: string | null, metadata?: unknown) => {
// Mirrors the real action's authoritative steps: the created session becomes
// current under the directory the server confirmed, and that directory enters
// the routing index. Everything these tests assert about routing depends on
// those two, so a mock without them tests nothing.
createSession: mock(async (
title: string | undefined,
directory: string | null,
parentID: string | null,
metadata?: unknown,
selectionTransition?: "submitted-draft",
) => {
createSessionCalls.push({ title, directory, parentID, metadata })
return { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory }
const session = { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory }
const sessionDirectory = session.directory ?? null
if (sessionDirectory) {
sessionDirectoryRegistry.set(session.id, sessionDirectory)
}
const { useSessionUIStore: store } = await import("../session-ui-store")
store.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition)
store.getState().markSessionAsOpenChamberCreated(session.id)
return session
}),
deleteSession: mock(async () => true),
deleteSessions: mock(async () => ({ deletedIds: [], failedIds: [] })),
@@ -320,16 +353,21 @@ describe("issue 2039 draft auto-accept", () => {
beforeEach(() => {
storage.clear()
createSessionCalls.length = 0
sessionDirectoryRegistry.clear()
permissionAutoAcceptCalls.length = 0
savedVariantCalls.length = 0
configVariantOverride = undefined
createdSessionDirectory = undefined
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
newSessionDraft: {
draftId: 0,
open: false,
directoryOverride: null,
parentID: null,
target: "chat",
},
})
})
@@ -355,6 +393,29 @@ describe("issue 2039 draft auto-accept", () => {
expect(useSessionUIStore.getState().currentSessionId).toBe("ses_issue_2039")
})
test("stores only an explicit draft variant as the session override", async () => {
useSessionUIStore.getState().openNewSessionDraft()
await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
agent: "agent-default",
variant: "high",
})
expect(savedVariantCalls).toEqual([undefined])
configVariantOverride = "high"
useSessionUIStore.getState().openNewSessionDraft()
await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
agent: "agent-default",
variant: "high",
})
expect(savedVariantCalls).toEqual([undefined, "high"])
})
test("does not apply draft auto-accept after the draft is closed", async () => {
useSessionUIStore.getState().openNewSessionDraft()
useSessionUIStore.getState().setDraftPermissionAutoAcceptEnabled(true)
@@ -373,6 +434,27 @@ describe("issue 2039 draft auto-accept", () => {
expect(permissionAutoAcceptCalls).toHaveLength(0)
})
test("transfers draft project context pins only to the session it creates", async () => {
useSessionUIStore.getState().openNewSessionDraft({
projectContextPins: { notes: ["note-a"], plans: [] },
})
useSessionUIStore.getState().setDraftProjectContextPin("plan", "plan-a", true)
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
expect(createSessionCalls[0]?.metadata).toEqual({
openchamber: {
project_context_pins: { notes: ["note-a"], plans: ["plan-a"] },
},
})
expect(useSessionUIStore.getState().newSessionDraft.projectContextPins).toBe(undefined)
useSessionUIStore.getState().openNewSessionDraft()
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
expect(createSessionCalls[1]?.metadata).toBe(undefined)
})
test("uses the server-authoritative directory after worktree session creation", async () => {
createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({
@@ -7,7 +7,6 @@ import {
findLiveSession,
findLiveSessionStatus,
} from '../live-aggregate.ts'
import { deriveRecentSessions, RECENT_SESSION_MAX_AGE_MS } from '../../components/session/sidebar/activitySections.ts'
const session = (id, directory, updated, extra = {}) => ({
id,
@@ -94,19 +93,4 @@ describe('live aggregate', () => {
)).toBe(false)
})
it('derives recent sessions from the 48h window, excluding archived/subtasks', () => {
const now = 1_000_000_000
const sessions = [
session('ses-1', '/a', now - 1_000),
session('ses-2', '/b', now - 500),
session('ses-3', '/c', now - 10, { time: { created: now - 11, updated: now - 10, archived: now - 5 } }),
session('ses-4', '/d', now - 200, { parentID: 'ses-parent' }),
session('ses-5', '/e', now - RECENT_SESSION_MAX_AGE_MS - 1),
]
const recent = deriveRecentSessions(sessions, now)
// ses-3 archived, ses-4 subtask, ses-5 older than 48h -> excluded; rest newest-first
expect(recent.map((item) => item.id)).toEqual(['ses-2', 'ses-1'])
})
})
@@ -119,6 +119,62 @@ describe("materializeSessionSnapshots", () => {
expect(result.part.msg_1[0]).toBe(livePart)
})
test("preserves a locally aborted assistant message when a stale unfinished snapshot arrives", () => {
const unfinishedMessage = message("msg_1")
if (unfinishedMessage.role !== "assistant") throw new Error("Expected assistant fixture")
const abortedMessage: Message = {
...unfinishedMessage,
time: { created: 1, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" } },
}
const staleMessage = message("msg_1")
const state = {
message: { ses_1: [abortedMessage] },
part: { msg_1: [] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: staleMessage, parts: [] }],
)
expect(result.message).toBe(state.message)
expect(result.message.ses_1[0]).toBe(abortedMessage)
expect(result.message.ses_1[0]).not.toBe(staleMessage)
})
test("replaces a locally aborted assistant message with the authoritative completed snapshot", () => {
const unfinishedMessage = message("msg_1")
if (unfinishedMessage.role !== "assistant") throw new Error("Expected assistant fixture")
const abortedMessage: Message = {
...unfinishedMessage,
time: { created: 1, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" } },
}
const completedMessage: Message = {
...unfinishedMessage,
time: { created: 1, completed: 4000 },
}
const state = {
message: { ses_1: [abortedMessage] },
part: { msg_1: [] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: completedMessage, parts: [] }],
)
const reconciled = result.message.ses_1[0]
expect(reconciled).toBe(completedMessage)
expect(reconciled?.role).toBe("assistant")
if (reconciled?.role !== "assistant") throw new Error("Expected assistant result")
expect("error" in reconciled).toBe(false)
expect(reconciled.time.completed).toBe(4000)
})
test("does not preserve omitted optimistic user text parts beside server snapshot parts", () => {
const optimisticPart = { id: "prt_optimistic", messageID: "msg_1", type: "text", text: "Hello" } as Part
const serverPart = part("prt_server", "msg_1", "text", "Hello")
@@ -167,6 +223,39 @@ describe("materializeSessionSnapshots", () => {
expect(mergedPart.state?.time?.end).toBe(2000)
})
test("does not regress a locally interrupted tool (error + end) when a stale running snapshot arrives", () => {
// The #2577 mark writes status "error" + end time; a later stale refresh
// that still reports the part as running must not undo it.
const interruptedTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "error", error: "Interrupted", time: { start: 1000, end: 5000 } },
} as unknown as Part
const staleRunningTool = {
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "tool",
state: { status: "running", time: { start: 1000 } },
} as unknown as Part
const state = {
message: { ses_1: [message("msg_1")] },
part: { msg_1: [interruptedTool] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: message("msg_1"), parts: [staleRunningTool] }],
)
expect(result.part.msg_1[0]).toBe(interruptedTool)
expect(result.part.msg_1[0]).not.toBe(staleRunningTool)
expect((result.part.msg_1[0] as { state: { status: string } }).state.status).toBe("error")
})
test("does not regress a completed tool when a stale running snapshot arrives", () => {
const completedTool = {
id: "prt_1",
@@ -1,9 +1,10 @@
import { describe, expect, test, beforeEach, mock } from "bun:test"
import { create, type StoreApi } from "zustand"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client"
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client"
const listPendingQuestionsCalls: Array<{ directories?: Array<string | null | undefined> }> = []
const listPendingPermissionsCalls: Array<{ directories?: Array<string | null | undefined> }> = []
const todoPersistWrites: Array<{ directory: string; sessionID: string; todos: unknown }> = []
let pendingQuestionsResponse: QuestionRequest[] = []
let pendingPermissionsResponse: PermissionRequest[] = []
let pendingQuestionsShouldThrow = false
@@ -41,7 +42,22 @@ mock.module("@/stores/useConfigStore", () => ({
}))
mock.module("@/stores/useTodosPersistStore", () => ({
useTodosPersistStore: { getState: () => ({}) },
useTodosPersistStore: {
getState: () => ({
setSessionTodos: (directory: string, sessionID: string, todos: unknown) => {
todoPersistWrites.push({ directory, sessionID, todos })
},
}),
},
}))
mock.module("sonner", () => ({
toast: {
dismiss: () => undefined,
error: () => undefined,
info: () => undefined,
success: () => undefined,
},
}))
mock.module("@/components/ui", () => ({
@@ -49,8 +65,15 @@ mock.module("@/components/ui", () => ({
}))
import { INITIAL_STATE, type State } from "../types"
import type { DirectoryStore } from "../child-store"
import { resyncBlockingRequestsForDirectory } from "../sync-context"
import { ChildStoreManager, type DirectoryStore } from "../child-store"
import { getRuntimeKey } from "@/lib/runtime-switch"
const {
createEventRoutingIndex,
handleEvent,
resyncBlockingRequestsForActiveDirectory,
resyncBlockingRequestsForDirectory,
setActiveSession,
} = await import("../sync-context")
function buildQuestion(overrides: Partial<QuestionRequest> = {}): QuestionRequest {
return {
@@ -91,6 +114,8 @@ describe("resyncBlockingRequestsForDirectory", () => {
pendingPermissionsResponse = []
pendingQuestionsShouldThrow = false
pendingPermissionsShouldThrow = false
todoPersistWrites.length = 0
setActiveSession("", "")
})
test("calls listPendingQuestions and listPendingPermissions exactly once for the directory", async () => {
@@ -106,6 +131,34 @@ describe("resyncBlockingRequestsForDirectory", () => {
expect(listPendingPermissionsCalls[0]).toEqual({ directories: ["/repo"] })
})
test("resume recovery refreshes blocking requests only for the active materialized directory", async () => {
const childStores = new ChildStoreManager()
childStores.ensureChild("/resume-active", { bootstrap: false }).setState({
session: [{ id: "ses_a", title: "ses_a", time: { created: 1, updated: 1 }, version: "1" } as State["session"][number]],
})
childStores.ensureChild("/resume-inactive", { bootstrap: false }).setState({
session: [{ id: "ses_b", title: "ses_b", time: { created: 1, updated: 1 }, version: "1" } as State["session"][number]],
})
pendingQuestionsResponse = [buildQuestion()]
await resyncBlockingRequestsForActiveDirectory("/resume-active", childStores)
expect(listPendingQuestionsCalls).toEqual([{ directories: ["/resume-active"] }])
expect(listPendingPermissionsCalls).toEqual([{ directories: ["/resume-active"] }])
expect(childStores.getChild("/resume-active")?.getState().question.ses_a?.[0]?.id).toBe("que_1")
expect(childStores.getChild("/resume-inactive")?.getState().question.ses_b).toBe(undefined)
})
test("resume recovery does not materialize or fetch an unopened directory", async () => {
const childStores = new ChildStoreManager()
await resyncBlockingRequestsForActiveDirectory("/unopened", childStores)
expect(childStores.getChild("/unopened")).toBe(undefined)
expect(listPendingQuestionsCalls).toHaveLength(0)
expect(listPendingPermissionsCalls).toHaveLength(0)
})
test("merges newly fetched questions/permissions into the directory store", async () => {
const store = createDirectoryStore({})
pendingQuestionsResponse = [buildQuestion()]
@@ -163,6 +216,36 @@ describe("resyncBlockingRequestsForDirectory", () => {
expect(listPendingPermissionsCalls).toHaveLength(0)
})
test("recovers an explicit session candidate before directory bootstrap materializes it", async () => {
const store = createDirectoryStore({ session: [] })
pendingQuestionsResponse = [buildQuestion()]
await resyncBlockingRequestsForDirectory("/repo", store, ["ses_a"], { includePermissions: false })
expect(listPendingQuestionsCalls).toEqual([{ directories: ["/repo"] }])
expect(listPendingPermissionsCalls).toHaveLength(0)
expect(store.getState().question.ses_a?.[0]?.id).toBe("que_1")
})
test("limits explicit question-only recovery to the requested session", async () => {
const store = createDirectoryStore({
session: [
{ id: "ses_a", title: "ses_a", time: { created: 1, updated: 1 }, version: "1" },
{ id: "ses_b", title: "ses_b", time: { created: 1, updated: 1 }, version: "1" },
] as State["session"],
})
pendingQuestionsResponse = [
buildQuestion(),
buildQuestion({ id: "que_b", sessionID: "ses_b" }),
]
await resyncBlockingRequestsForDirectory("/repo", store, ["ses_a"], { includePermissions: false })
expect(store.getState().question.ses_a?.[0]?.id).toBe("que_1")
expect(store.getState().question.ses_b).toBe(undefined)
expect(listPendingPermissionsCalls).toHaveLength(0)
})
// Regression: prior to the fix, listPendingQuestions silently returned [] on
// fetch failure, indistinguishable from a successful empty server response.
// The resync then walked the candidate set and deleted any question that
@@ -205,4 +288,56 @@ describe("resyncBlockingRequestsForDirectory", () => {
expect(store.getState().question["ses_a"]?.[0]?.id).toBe("que_1")
expect(listPendingPermissionsCalls).toHaveLength(1)
})
test("routes a directory-less todo snapshot to its active session during a multi-store routing-index gap", () => {
const childStores = new ChildStoreManager()
const store = childStores.ensureChild("/target", { bootstrap: false })
childStores.ensureChild("/other", { bootstrap: false })
const todos = [
{ content: "Finish plan", status: "completed", priority: "high" },
{ content: "Implement changes", status: "in_progress", priority: "high" },
]
const event = {
type: "todo.updated",
properties: { sessionID: "ses_a", todos },
} as Event
const routingIndex = createEventRoutingIndex()
expect(childStores.children.size).toBe(2)
expect(routingIndex.sessionDirectoryById.size).toBe(0)
for (const candidate of childStores.children.values()) {
const state = candidate.getState()
expect(state.session).toEqual([])
expect(state.message.ses_a).toBe(undefined)
expect(state.session_status.ses_a).toBe(undefined)
}
let storeWrites = 0
const unsubscribe = store.subscribe(() => {
storeWrites += 1
})
setActiveSession("/target", "ses_a")
handleEvent("global", event, childStores, routingIndex, getRuntimeKey())
expect(store.getState().todo.ses_a).toEqual(todos)
expect(todoPersistWrites).toEqual([{ directory: "/target", sessionID: "ses_a", todos }])
expect(storeWrites).toBe(1)
const stateAfterFirstSnapshot = store.getState()
const duplicateTodos = todos.map((todo) => ({ ...todo }))
const duplicateEvent = {
type: "todo.updated",
properties: { sessionID: "ses_a", todos: duplicateTodos },
} as Event
expect(duplicateTodos).not.toBe(todos)
expect(duplicateTodos).toEqual(todos)
handleEvent("global", duplicateEvent, childStores, routingIndex, getRuntimeKey())
expect(store.getState()).toBe(stateAfterFirstSnapshot)
expect(todoPersistWrites).toEqual([{ directory: "/target", sessionID: "ses_a", todos }])
expect(storeWrites).toBe(1)
unsubscribe()
childStores.disposeAll()
})
})
@@ -4,6 +4,7 @@ import type { Event, Session } from "@opencode-ai/sdk/v2/client"
let currentSessions: Session[] = []
const upsertedSessions: Session[] = []
const removedSessionIds: string[] = []
let mutationCalls = 0
let runtimeKey = "runtime-a"
let runtimeWillChange: (() => void) | null = null
@@ -15,6 +16,7 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
getState: () => ({
activeSessions: currentSessions,
archivedSessions: [] as Session[],
entityById: new Map(currentSessions.map((session) => [session.id, session])),
upsertSession: (session: Session) => {
upsertedSessions.push(session)
},
@@ -24,6 +26,15 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
removeSessions: (ids: string[]) => {
removedSessionIds.push(...ids)
},
applySessionMutations: (mutations: Array<
{ type: "upsert"; session: Session } | { type: "remove"; sessionId: string }
>) => {
mutationCalls += 1
for (const mutation of mutations) {
if (mutation.type === "upsert") upsertedSessions.push(mutation.session)
else removedSessionIds.push(mutation.sessionId)
}
},
}),
},
}))
@@ -34,7 +45,7 @@ mock.module("@/lib/runtime-switch", () => ({
return () => undefined
},
}))
import { applySessionEventToGlobalSessions } from "../session-event-router"
import { applySessionEventsToGlobalSessions, applySessionEventToGlobalSessions } from "../session-event-router"
const buildSession = (title: string, time: Session["time"]): Session => ({
id: "ses_1",
@@ -66,6 +77,7 @@ describe("applySessionEventToGlobalSessions", () => {
currentSessions = []
upsertedSessions.length = 0
removedSessionIds.length = 0
mutationCalls = 0
})
test("skips stale global session.updated echoes after a newer rename", () => {
@@ -116,4 +128,22 @@ describe("applySessionEventToGlobalSessions", () => {
expect(upsertedSessions).toEqual([])
})
test("commits an ordered event batch once", () => {
const events = Array.from({ length: 1_000 }, (_, index) => ({
type: "session.created",
properties: {
info: {
id: `ses_${index}`,
title: `Session ${index}`,
time: { created: index, updated: index },
},
},
} as Event))
applySessionEventsToGlobalSessions(events)
expect(mutationCalls).toBe(1)
expect(upsertedSessions).toHaveLength(1_000)
})
})