Merge remote-tracking branch 'origin/main' into port-2667
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user