Merge remote-tracking branch 'origin/master' into codex/basic-session-search
Conflict resolutions: - `session.list`: master's projection columns fold into the PR's cancellable, batched `listVisibleSessionSummaries`, which `session.search` shares as its visibility baseline; master's goal helpers stay beside it. - Client sessions face: master narrowed `ctx.sessions` to `ISessions`, so the search verb and its protocol-constant bound are declared there and the test-runtime double implements them (recorded, empty page unless a scenario stubs hits). - `WorkspaceBrowser`: master's per-row Rename wiring rides the PR's search results view; the tree keeps the PR's query-free derivations. - `dsh web` bin: the PR's shutdown-handlers-before-readiness order with master's boot-time LAN address snapshot. - `session-query-sqlite`: master's `SCHEMA_VERSION` 7 stands; the PR's bump carried no schema change. - Specs: master wraps assistant/steering message payloads and requires an `application/json` carrier request, so the search fixtures and tests follow. - Web aria goldens keep master's recording plus the PR's search placeholder; the navigation-panes inventory keeps master's terminal-card golden next to the PR's search-results golden.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage, createMessage, createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
// Minimal SessionEvent builders for orchestration tests (shape mirrors what the
|
||||
// host emits; only the fields the object layer reads).
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
@@ -13,7 +14,9 @@ export const ev = {
|
||||
turnStart: (seq: number, turn: number): SessionEvent =>
|
||||
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
user: (seq: number, body: string): SessionEvent =>
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: { content: text(body), source: { kind: 'user' } } }),
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: text(body), source: { kind: 'user' },
|
||||
}) }),
|
||||
stepStart: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/start', data: { turn, step } }),
|
||||
chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent =>
|
||||
@@ -21,11 +24,33 @@ export const ev = {
|
||||
chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
|
||||
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
|
||||
assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(body), provenance: { provider: 'fake', model: 'fk-1' } } }),
|
||||
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: {
|
||||
turn, step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: text(body),
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'fake', model: 'fk-1' },
|
||||
},
|
||||
}),
|
||||
} }),
|
||||
toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
|
||||
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
|
||||
at(seq, {
|
||||
type: 'tool/result',
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
turn,
|
||||
step,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId(callId),
|
||||
content: text(body),
|
||||
isError: false,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch-start',
|
||||
@@ -40,8 +65,10 @@ export const ev = {
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
|
||||
at(seq, { type: 'todo/write', data: { todos } }),
|
||||
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
||||
}
|
||||
|
||||
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -64,8 +65,9 @@ export class FakeApiClient implements IApiClient {
|
||||
() => Promise.resolve(ok({ items: [], hasMore: false }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[] }>> =
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
@@ -82,10 +84,25 @@ export class FakeApiClient implements IApiClient {
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
home: string
|
||||
crumbs: { name: string; path: string; hidden: boolean }[]
|
||||
entries: { name: string; path: string; hidden: boolean }[]
|
||||
truncated: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
|
||||
|
||||
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake/new' }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
@@ -106,6 +123,7 @@ export class FakeApiClient implements IApiClient {
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: { provider: string; model: string }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
@@ -113,6 +131,9 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
|
||||
createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
|
||||
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
@@ -140,10 +161,12 @@ export class FakeApiClient implements IApiClient {
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program requires-bearing catalogs and dual-address
|
||||
// skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> =
|
||||
() => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
@@ -154,6 +177,15 @@ export class FakeApiClient implements IApiClient {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
/**
|
||||
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
|
||||
* windows, incremental append with node-cache identity, six-variant
|
||||
@@ -7,6 +8,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
@@ -26,6 +28,7 @@ describe('FoldAdapter', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
|
||||
const first = adapter.nodes()
|
||||
expect(adapter.nodes()).toBe(first)
|
||||
adapter.append(ev.user(6, '追加'))
|
||||
const second = adapter.nodes()
|
||||
expect(second.nodes).toHaveLength(3)
|
||||
@@ -34,13 +37,67 @@ describe('FoldAdapter', () => {
|
||||
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
ev.user(1, 'b'),
|
||||
at(2, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
at(3, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 2, end: 1 },
|
||||
sourceEventSeqs: [2, 1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary 2' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
|
||||
id: context.id,
|
||||
parentId: context.parentId,
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
|
||||
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
|
||||
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
|
||||
])
|
||||
})
|
||||
|
||||
it('materializes all six node variants with field mapping', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const events = [
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
} }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
|
||||
}) }),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(5, 0, 'c1', '结果'),
|
||||
]
|
||||
@@ -76,7 +133,17 @@ describe('FoldAdapter', () => {
|
||||
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
|
||||
const window = [
|
||||
ev.user(10, '正常'),
|
||||
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
|
||||
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
|
||||
turn: 0, step: 0,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '坏 op' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'x', model: 'y' },
|
||||
},
|
||||
}),
|
||||
} }),
|
||||
]
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
@@ -95,14 +162,122 @@ describe('FoldAdapter', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('silently degrades when a replacement needs an earlier history page', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([
|
||||
at(10, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 1, end: 3 },
|
||||
sourceEventSeqs: [1, 3],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'partial summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
ev.user(11, 'newer message'),
|
||||
], 10)
|
||||
|
||||
expect(adapter.nodes()).toMatchObject({
|
||||
degraded: true,
|
||||
nodes: [{ seq: 10 }, { seq: 11 }],
|
||||
})
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('silently degrades when a live replacement needs an earlier history page', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([ev.user(10, 'window head')], 10)
|
||||
adapter.append(at(11, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
sourceEventSeqs: [1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'live summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
expect(adapter.nodes()).toMatchObject({
|
||||
degraded: true,
|
||||
nodes: [{ seq: 10 }, { seq: 11 }],
|
||||
})
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('materializes a tool-result error field when present', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
|
||||
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
|
||||
turn: 0, step: 0,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c1'),
|
||||
content: [],
|
||||
isError: true,
|
||||
}),
|
||||
error: { name: 'Boom', code: 'boom' },
|
||||
} }),
|
||||
], 0)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
|
||||
})
|
||||
|
||||
it('projects assistant timing and the active request header from history', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.stepStart(0, 1, 2),
|
||||
at(1, { type: 'request/header', data: {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'first' },
|
||||
tools: [],
|
||||
},
|
||||
} }),
|
||||
ev.chunkStart(2, 1, 2),
|
||||
ev.chunkText(3, 1, 'token', 2),
|
||||
ev.assistant(4, 1, 'done', 2),
|
||||
ev.stepStart(5, 2, 1),
|
||||
ev.chunkText(6, 2, 'next', 1),
|
||||
ev.assistant(7, 2, 'next done', 1),
|
||||
].map(event => ({ event })))
|
||||
|
||||
expect(projection.eventNodes[0]).toMatchObject({
|
||||
kind: 'assistant',
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_000,
|
||||
firstTokenTime: 1_700_000_000_003,
|
||||
completedTime: 1_700_000_000_004,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
|
||||
expect(projection.eventNodes.at(-1)).toMatchObject({
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_005,
|
||||
firstTokenTime: 1_700_000_000_006,
|
||||
completedTime: 1_700_000_000_007,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes the in-window call index for runningCalls material', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
|
||||
@@ -142,4 +317,83 @@ describe('FoldAdapter', () => {
|
||||
const node = adapter.nodes().nodes[0]
|
||||
expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
|
||||
})
|
||||
|
||||
describe('command lifecycle nodes', () => {
|
||||
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '先说话'),
|
||||
ev.commandRun(1, 'cmd-1', 'plan'),
|
||||
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
|
||||
ev.assistant(3, 0, '然后回答'),
|
||||
], 0)
|
||||
const { nodes } = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
|
||||
outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a run with no done as still executing (outcome null)', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({
|
||||
kind: 'command', name: 'goal', args: ' ship it', outcome: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({
|
||||
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
|
||||
outcome: { kind: 'error', text: '失败了' },
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a live-appended done in place, keeping the node at the run seq', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
|
||||
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
|
||||
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
|
||||
expect(running).toMatchObject({ outcome: null })
|
||||
adapter.append(ev.commandDone(7, 'cmd-4'))
|
||||
const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
|
||||
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
|
||||
// Settlement replaced the node object rather than mutating the published one.
|
||||
expect(settled).not.toBe(running)
|
||||
})
|
||||
|
||||
it('tails command nodes whose seq is past every surface node', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
|
||||
expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
|
||||
})
|
||||
|
||||
it('command nodes survive the degraded linear-scan branch', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([
|
||||
ev.commandRun(0, 'cmd-5', 'plan'),
|
||||
ev.commandDone(1, 'cmd-5'),
|
||||
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
|
||||
turn: 0,
|
||||
step: 0,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '坏 op' }],
|
||||
source: { kind: 'model', provider: 'x', model: 'y' },
|
||||
}),
|
||||
} }),
|
||||
], 0)
|
||||
const { nodes, degraded } = adapter.nodes()
|
||||
expect(degraded).toBe(true)
|
||||
expect(nodes.some(n => n.kind === 'command')).toBe(true)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,21 +133,18 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
||||
})
|
||||
|
||||
it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
|
||||
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-new' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-stale' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-equal' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
|
||||
})
|
||||
const titleFrame = (rpcId: string, title: string, seq: number) => {
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: rpcId as never,
|
||||
payload: { type: 'session/projection', sessionId: S1, key: 'title', value: title, seq } as never,
|
||||
})
|
||||
}
|
||||
titleFrame('title-new', 'Newest', 4)
|
||||
titleFrame('title-stale', 'Stale', 3)
|
||||
titleFrame('title-equal', 'Equal', 4)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
|
||||
}))
|
||||
@@ -155,7 +152,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
const titled = manager.getListSnapshot()
|
||||
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
|
||||
expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
|
||||
expect(titled.items[0]?.title).toBe('Newest')
|
||||
expect(titled.items[1]?.title).toBeUndefined()
|
||||
|
||||
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
@@ -163,34 +160,49 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// A push frame landed before the list (S2's title is newer than the block's cut).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'push-newer' as never,
|
||||
payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never,
|
||||
})
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
|
||||
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
|
||||
] as never[],
|
||||
}))
|
||||
await manager.refreshList()
|
||||
const items = manager.getListSnapshot().items
|
||||
// Cold row: title surfaces straight from the list block — no open, no history.
|
||||
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
|
||||
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
|
||||
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
|
||||
})
|
||||
|
||||
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-unflushed' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
|
||||
})
|
||||
const frame = (rpcId: string, payload: object) => {
|
||||
manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never })
|
||||
}
|
||||
frame('title-unflushed', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-recovered' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
// The durable baseline says the host only knows up to seq 2: the phantom
|
||||
// row rode lost state and must drop, or last-wins pins it forever.
|
||||
frame('subscribed-recovered', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-durable' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
frame('title-durable', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 })
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-current' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
// A baseline at or past the row's seq keeps it (nothing phantom to drop).
|
||||
frame('subscribed-current', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -407,3 +419,62 @@ describe('connected generation', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
// Mux-open replay of the same question (same approvalId) is idempotent.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
})
|
||||
|
||||
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// Removed sessions drop their bit outright.
|
||||
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
// Generation death clears (resolved-while-disconnected questions send no frame)…
|
||||
manager.handleDisconnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// …and a replayed frame arriving before onConnected (stream open precedes
|
||||
// the readiness handshake) survives the later handleConnected untouched.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleConnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
// Buffered pre-instantiation: an approval pair and a queued row.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
manager.handleDisconnected()
|
||||
// Instantiate after the death sweep: no zombie interaction replays (the
|
||||
// pendingBuffers held only dead-generation rpcIds), so the session mints
|
||||
// no pending waits.
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().pending).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
187
packages/client/runtime/tests/projection-store.spec.ts
Normal file
187
packages/client/runtime/tests/projection-store.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Projection value store (session-projection RFC, push model): the single
|
||||
* higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
|
||||
* newer push frame; a replayed frame cannot regress), capability absence as
|
||||
* undefined, generation truncation, and the Session/manager wiring (tail-page
|
||||
* seeding, session/projection frame routing pre- and post-instantiation, the
|
||||
* list rows' title projection).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
import { entries, plainTurn } from './event-script.ts'
|
||||
|
||||
// Test-domain keys merged into the projection map (the interface package's
|
||||
// pure-type outlet), the same way domain host plugins merge theirs.
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
'test/marks': { marks: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
|
||||
describe('ProjectionValueStore semantics', () => {
|
||||
it('reads undefined until a value lands (capability absence)', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.get('test/marks')).toBeUndefined()
|
||||
expect(store.faceOf('test/marks').getSnapshot()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies frames last-wins by seq: replayed and stale frames drop', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
store.apply('test/marks', { marks: ['a'] }, 5)
|
||||
store.apply('test/marks', { marks: ['a', 'b'] }, 9)
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
|
||||
store.apply('test/marks', { marks: ['stale'] }, 5)
|
||||
store.apply('test/marks', { marks: ['equal'] }, 9)
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
|
||||
})
|
||||
|
||||
it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
store.apply('test/marks', { marks: ['frame-20'] }, 20)
|
||||
// Stale cut: carried key loses to the newer frame; omitted key survives.
|
||||
store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } })
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
|
||||
store.seed({ asOfSeq: 15, values: {} })
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
|
||||
// Fresh cut: carried key reseeds…
|
||||
store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } })
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
|
||||
// …and an omitting fresh cut clears (capability absent as of the cut).
|
||||
store.seed({ asOfSeq: 40, values: {} })
|
||||
expect(store.get('test/marks')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('truncate drops rows past the durable baseline and keeps the rest', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
store.apply('test/marks', { marks: ['durable'] }, 5)
|
||||
store.apply('other', 'phantom', 50)
|
||||
store.truncate(10)
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
|
||||
expect(store.get('other')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notifies the key face on change (batched) and not on dropped applications', async () => {
|
||||
const store = new ProjectionValueStore()
|
||||
let keyTicks = 0
|
||||
let anyTicks = 0
|
||||
store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
|
||||
store.subscribeAny(() => { anyTicks += 1 })
|
||||
store.apply('test/marks', { marks: ['a'] }, 5)
|
||||
await Promise.resolve()
|
||||
expect(keyTicks).toBe(1)
|
||||
expect(anyTicks).toBe(1)
|
||||
store.apply('test/marks', { marks: ['replay'] }, 3)
|
||||
await Promise.resolve()
|
||||
expect(keyTicks).toBe(1)
|
||||
expect(anyTicks).toBe(1)
|
||||
})
|
||||
|
||||
it('faces are identity-stable per key (the React binding cache premise)', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session tail-page seeding', () => {
|
||||
it('seeds the store from a history response carrying a projections block', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
|
||||
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
|
||||
} as never))
|
||||
await session.open()
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
|
||||
})
|
||||
|
||||
it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
||||
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
|
||||
} as never))
|
||||
await session.open()
|
||||
session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9)
|
||||
await session.resync()
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
|
||||
})
|
||||
|
||||
it('treats a blockless response as no reset: pushed values survive', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
||||
await session.open()
|
||||
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
|
||||
await session.resync()
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('manager frame routing', () => {
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
|
||||
})
|
||||
const session = manager.get(sid('s1'))
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
|
||||
// Frames after instantiation land in the same store.
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p2' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never,
|
||||
})
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
|
||||
})
|
||||
|
||||
it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 't1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
|
||||
// The durable baseline says the host only knows up to seq 2: the row rode
|
||||
// lost state and must drop (the un-flushed title precedent).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'sub' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 't1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never,
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'rm' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
|
||||
})
|
||||
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@
|
||||
* pre-instantiation buffering, and snapshot reference stability.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
@@ -19,8 +20,12 @@ const rid = (id: string): RpcId => id as RpcId
|
||||
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
|
||||
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
|
||||
return {
|
||||
type: 'session/queued', sessionId: SID, content: text(body),
|
||||
source: { kind: 'user', rpcId: rid(rpcId) } as never,
|
||||
type: 'session/queued',
|
||||
sessionId: SID,
|
||||
message: createUserMessage({
|
||||
content: text(body),
|
||||
source: { kind: 'user', rpcId: rid(rpcId) } as never,
|
||||
}),
|
||||
steering,
|
||||
}
|
||||
}
|
||||
@@ -40,9 +45,12 @@ describe('queue intake', () => {
|
||||
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-2'), {
|
||||
type: 'session/queued', sessionId: SID,
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
source: { kind: 'plugin', plugin: 'loop' },
|
||||
type: 'session/queued',
|
||||
sessionId: SID,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
source: { kind: 'plugin', plugin: 'loop' },
|
||||
}),
|
||||
steering: false,
|
||||
})
|
||||
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
|
||||
@@ -93,14 +101,26 @@ describe('queue retirement (host queuedMirror rules)', () => {
|
||||
const foreignSteering = {
|
||||
seq: 0, time: 1,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } },
|
||||
data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: text('loop'),
|
||||
source: { kind: 'plugin', plugin: 'loop' },
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering })
|
||||
expect(session.getSnapshot().queue).toHaveLength(2)
|
||||
const matchedSteering = {
|
||||
seq: 1, time: 2,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } },
|
||||
data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: text('插话'),
|
||||
source: { kind: 'user', rpcId: rid('p-2') },
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
|
||||
@@ -154,7 +174,13 @@ describe('queue reconnect semantics', () => {
|
||||
const committed = {
|
||||
seq: 6, time: 2,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } },
|
||||
data: {
|
||||
turn: 1,
|
||||
message: createUserMessage({
|
||||
content: text('重连插话'),
|
||||
source: { kind: 'user', rpcId: rid('p-steer') },
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
|
||||
184
packages/client/runtime/tests/request-inspection.spec.ts
Normal file
184
packages/client/runtime/tests/request-inspection.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
|
||||
const at = (seq: number, type: string, data: unknown): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
|
||||
|
||||
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
|
||||
events.map(event => ({ event }))
|
||||
|
||||
describe('inspectRequests', () => {
|
||||
it('projects ordinary and compaction calls into one chronological request stream', () => {
|
||||
const events = [
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 5, outputTokens: 2 },
|
||||
}),
|
||||
at(4, 'step/end', { turn: 1, step: 1 }),
|
||||
at(5, 'compact/start', { turn: 1 }),
|
||||
at(6, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
rawOutput: [
|
||||
{ type: 'reasoning', text: 'thought' },
|
||||
{ type: 'text', text: 'summary' },
|
||||
],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
usage: { inputTokens: 8, outputTokens: 3 },
|
||||
}),
|
||||
at(7, 'user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
})),
|
||||
at(8, 'compact/end', { turn: 1 }),
|
||||
]
|
||||
const snapshot = inspectRequests(entriesOf(events))
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'assistant',
|
||||
startSeq: 0,
|
||||
resultSeq: 3,
|
||||
status: 'complete',
|
||||
prompt: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
},
|
||||
promptChange: { seq: 1, kind: 'initial' },
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 5,
|
||||
resultSeq: 6,
|
||||
replacementSeq: 7,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
},
|
||||
])
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('captures schemas for nested tool dispatches from the active request header', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(1, 'tool/code-dispatch-start', {
|
||||
parentCallId: 'parent',
|
||||
subCallId: 'nested',
|
||||
name: 'read',
|
||||
arguments: {},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
|
||||
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
|
||||
const retryUsage = {
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
}
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: chunkUsage },
|
||||
}),
|
||||
at(2, 'llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 100,
|
||||
failure: { message: 'rate limited' },
|
||||
}),
|
||||
at(3, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: retryUsage },
|
||||
}),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'recovered' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
status: 'complete',
|
||||
usage: {
|
||||
inputTokens: 26,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: '{{tools}}',
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas).toEqual(new Map())
|
||||
expect(snapshot.requests[0]?.prompt?.tools).toEqual([])
|
||||
})
|
||||
})
|
||||
98
packages/client/runtime/tests/session-history-source.spec.ts
Normal file
98
packages/client/runtime/tests/session-history-source.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionHistorySource } from '../src/client/session-history/source.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const SID = 'history-s1' as SessionId
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('SessionHistorySource', () => {
|
||||
it('loads every older page without changing a Chat session', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
plainTurn(12, 2, '最新问', '最新答'),
|
||||
]
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
|
||||
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
|
||||
return histResponse(pages[0]!, false)
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(source.getSnapshot().hasMore).toBe(false)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
|
||||
it('pins a lazy inspection to the entries in its source snapshot', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
const before = source.getSnapshot()
|
||||
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.user(6, 'later'),
|
||||
})
|
||||
|
||||
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 6])
|
||||
})
|
||||
|
||||
it('stops loading when an older page fails to advance', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
|
||||
: Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'page unavailable',
|
||||
details: {},
|
||||
}))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('observes consumer cancellation between older pages', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const olderStarted = deferred<undefined>()
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) {
|
||||
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
|
||||
}
|
||||
olderStarted.resolve(undefined)
|
||||
return middle.promise
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
const controller = new AbortController()
|
||||
const complete = source.loadAll(controller.signal)
|
||||
await olderStarted.promise
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
|
||||
hasMore: true,
|
||||
}))
|
||||
|
||||
await complete
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
|
||||
return { api, session: new Session(SID, api) }
|
||||
}
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) {
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } }))
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
@@ -104,6 +104,43 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().nodes).toEqual(before.nodes)
|
||||
})
|
||||
|
||||
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
|
||||
// Live path: run mints an executing node, done settles it in the flow.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(6, 'cmd-live', 'plan'))
|
||||
let command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
|
||||
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
|
||||
command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
|
||||
|
||||
// Replay path (refresh): the same pair inside the history window folds identically.
|
||||
const replayed = await opened([
|
||||
...plainTurn(0, 0, 'a', 'b'),
|
||||
ev.commandRun(6, 'cmd-live', 'plan'),
|
||||
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
|
||||
])
|
||||
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => {
|
||||
// A fresh session whose only window content is a command pair (plus the
|
||||
// knob events a /permission switch appends — not surface-eligible, so
|
||||
// they never become nodes) stays phase 'blank': selecting a preset from
|
||||
// the hero must not enter the conversation view.
|
||||
const { session } = await opened([])
|
||||
expect(session.getSnapshot().composerPhase).toBe('blank')
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
|
||||
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
|
||||
expect(snapshot.composerPhase).toBe('blank')
|
||||
})
|
||||
|
||||
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -158,42 +195,6 @@ describe('live event path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => {
|
||||
const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }]
|
||||
const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }]
|
||||
const { session } = await opened()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.todoWrite(6, listA))
|
||||
expect(session.getSnapshot().todos).toEqual(listA)
|
||||
feed(ev.todoWrite(7, listB))
|
||||
expect(session.getSnapshot().todos).toEqual(listB)
|
||||
// Window replay converges on the same last snapshot (history contains both writes).
|
||||
const replayed = makeSession()
|
||||
replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)])
|
||||
await replayed.session.open()
|
||||
expect(replayed.session.getSnapshot().todos).toEqual(listB)
|
||||
})
|
||||
|
||||
it('seeds todos from the tail page projection when the last write precedes the window', async () => {
|
||||
const list = [{ content: '窗口外的计划', status: 'in_progress' as const }]
|
||||
// Cold open: the page window carries NO todo/write; the projection rides the response.
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list)
|
||||
await session.open()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// Paging an older window in must not clear the session-level projection.
|
||||
api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false)
|
||||
await session.loadOlder()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// A later live write still overrides the seeded projection.
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }])
|
||||
})
|
||||
|
||||
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
|
||||
@@ -207,37 +208,6 @@ describe('live event path', () => {
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
|
||||
})
|
||||
|
||||
it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
// The missed range contained a todo/write that the repulled page no longer
|
||||
// covers; the response's session-level projection is the only carrier.
|
||||
const current = [{ content: '断线期间写的', status: 'in_progress' as const }]
|
||||
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current)
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') })
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.history').length).toBe(2)
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(session.getSnapshot().todos).toEqual(current)
|
||||
})
|
||||
|
||||
it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => {
|
||||
// Live write lands, then the host crashes before persisting it: the
|
||||
// authoritative log holds no todo/write, so the resync tail response
|
||||
// carries no projection — an omitted field on a tail request is the empty
|
||||
// list, not a missing carrier, and the rolled-back plan must disappear.
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b'))
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }])
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('paging', () => {
|
||||
@@ -331,6 +301,32 @@ describe('prompt and cancel errors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 }))
|
||||
const result = await session.rename(' 正名 ')
|
||||
expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } })
|
||||
expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }])
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
|
||||
// A stale lower-seq apply (the push-frame path routes into this same
|
||||
// store) must not roll the settled value back.
|
||||
session.projections.apply('title', '旧名', 3)
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
|
||||
})
|
||||
|
||||
it('returns the business error untouched and folds a transport throw to internal', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } }))
|
||||
const rejected = await session.rename(' ')
|
||||
expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
|
||||
api.onRename = () => Promise.reject(new Error('rename transport down'))
|
||||
const folded = await session.rename('x')
|
||||
expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending interactions', () => {
|
||||
it('adds approval/question on requested and removes them on resolved', async () => {
|
||||
const { session } = makeSession()
|
||||
@@ -731,6 +727,7 @@ describe('resync', () => {
|
||||
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
|
||||
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch indexing', () => {
|
||||
@@ -844,20 +841,23 @@ describe('reference stability (the memo contract)', () => {
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'c1', 'echo', '{}'))
|
||||
feed(ev.stepStart(7, 1))
|
||||
feed(ev.toolCall(8, 1, 'c1', 'echo', '{}'))
|
||||
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
|
||||
const before = session.getSnapshot()
|
||||
// A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
|
||||
feed(ev.chunkStart(8, 1))
|
||||
feed(ev.chunkText(9, 1, '与工具无关的流式'))
|
||||
// A chunk storm touches partial/nodes only: unrelated projections keep identity.
|
||||
feed(ev.chunkStart(9, 1))
|
||||
feed(ev.chunkText(10, 1, '与工具无关的流式'))
|
||||
const after = session.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
const resolved = session.getSnapshot()
|
||||
expect(resolved.runningCalls).not.toBe(after.runningCalls)
|
||||
expect(resolved.pending).toBe(after.pending)
|
||||
feed(ev.assistant(12, 1, '完成'))
|
||||
expect(session.getSnapshot()).not.toBe(resolved)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('list store projection', () => {
|
||||
const b = bench()
|
||||
b.svc.handleMuxEnvelope({
|
||||
rpcId: 'title' as never,
|
||||
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2 } as never,
|
||||
})
|
||||
await feedList(b, [
|
||||
{ id: 's1', cwd: '/home/u/proj-a/' },
|
||||
@@ -102,7 +102,8 @@ describe('scope tree', () => {
|
||||
expect(scopeOf(scoped as Context)).toBe('s1')
|
||||
expect(scopeOf(b.ctx)).toBeUndefined()
|
||||
const binding = b.svc.binding(sid('s1'))
|
||||
expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
|
||||
b.svc.open(sid('s1'))
|
||||
expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
|
||||
expect(b.svc.binding(sid('s1'))).toBe(binding)
|
||||
expect(binding?.ctx).toBe(scoped)
|
||||
})
|
||||
@@ -206,24 +207,82 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
|
||||
})
|
||||
|
||||
describe('cell (render-layer session kit)', () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const info = b.svc.provideInfo('s1')
|
||||
expect(info).toBeDefined()
|
||||
expect(info?.sessionId).toBe('s1')
|
||||
b.svc.open(sid('s1'))
|
||||
const info = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(info.sessionId).toBe('s1')
|
||||
// The bundle carries bare observables; hook binding happens in React.
|
||||
expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(b.svc.provideInfo('s1')).toBe(info)
|
||||
expect(b.svc.provideInfo('ghost')).toBeUndefined()
|
||||
expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
// Re-staging the same id republishes nothing: identity holds.
|
||||
b.svc.open(sid('s1'))
|
||||
expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
|
||||
})
|
||||
|
||||
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
const absent = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(absent.sessionId).toBeUndefined()
|
||||
expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
|
||||
const notified = vi.fn()
|
||||
b.svc.currentProvideInfo.subscribe(notified)
|
||||
b.svc.open(sid('s1'))
|
||||
const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(s1Bundle.sessionId).toBe('s1')
|
||||
expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(notified).toHaveBeenCalledTimes(1)
|
||||
b.svc.open(sid('s2'))
|
||||
const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(s2Bundle.sessionId).toBe('s2')
|
||||
expect(s2Bundle).not.toBe(s1Bundle)
|
||||
expect(notified).toHaveBeenCalledTimes(2)
|
||||
b.svc.clear()
|
||||
await Promise.resolve() // clearSelection projects through the manager notifier
|
||||
expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a provider roster change under a stable current id republishes the bundle', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
const before = b.svc.currentProvideInfo.getSnapshot()
|
||||
const notified = vi.fn()
|
||||
b.svc.currentProvideInfo.subscribe(notified)
|
||||
const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
|
||||
const dispose = b.svc.provide({
|
||||
hooks: ['extra'],
|
||||
props: ['marker'],
|
||||
resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
|
||||
})
|
||||
const added = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(added).not.toBe(before)
|
||||
expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
|
||||
expect(added.hooks['extra']).toBe(source)
|
||||
expect(notified).toHaveBeenCalledTimes(1)
|
||||
dispose()
|
||||
const removed = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(removed).not.toBe(added)
|
||||
expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
|
||||
expect(notified).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const notified = vi.fn()
|
||||
const off = b.svc.currentProvideInfo.subscribe(notified)
|
||||
off()
|
||||
b.svc.open(sid('s1'))
|
||||
expect(notified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binding() is pure resolution: no staging, no deferred sweep', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.open(sid('s1')) // staged
|
||||
b.svc.provideInfo('s2') // resolution only — must NOT move the stage
|
||||
b.svc.binding(sid('s2'))
|
||||
b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
})
|
||||
@@ -234,7 +293,6 @@ describe('cell (render-layer session kit)', () => {
|
||||
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
|
||||
// Resolution is addressing, not staging: no window pull.
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.provideInfo('s1')
|
||||
b.svc.binding(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(0)
|
||||
b.svc.open(sid('s1'))
|
||||
|
||||
@@ -97,18 +97,13 @@ function fakeWorkspaces() {
|
||||
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
|
||||
}
|
||||
|
||||
/** Minimal sessions face for the host seam (list observable + provide bundle). */
|
||||
/** Minimal sessions face for the host seam (list observable + current provide projection). */
|
||||
function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
|
||||
return {
|
||||
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||
provideInfo: (id: string) => (id === 'known'
|
||||
? {
|
||||
sessionId: id,
|
||||
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
|
||||
props: {},
|
||||
}
|
||||
: undefined),
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,13 +227,11 @@ describe('host face', () => {
|
||||
expect(host.entriesOf('t.host')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
|
||||
it('exposes the session list and the atomic current provide projection', async () => {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench)
|
||||
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
|
||||
expect(host.sessions.current.getSnapshot()).toBeUndefined()
|
||||
expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
|
||||
expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
|
||||
})
|
||||
|
||||
it('exposes the independent Workspace list source', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
@@ -234,6 +234,40 @@ describe('WorkspacesService', () => {
|
||||
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
|
||||
await expect(workspaces.pickDirectory()).resolves.toBeNull()
|
||||
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
|
||||
api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
|
||||
await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
|
||||
})
|
||||
|
||||
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
|
||||
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
|
||||
api.onListDirectory = () => Promise.resolve(ok(listing))
|
||||
await expect(workspaces.listDirectory()).resolves.toEqual(listing)
|
||||
await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
|
||||
// The optional path is omitted from the payload, not sent as undefined.
|
||||
expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
|
||||
api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
|
||||
const listFailure = workspaces.listDirectory('/x')
|
||||
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
|
||||
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
|
||||
|
||||
await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
|
||||
expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
|
||||
api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
|
||||
await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
|
||||
})
|
||||
|
||||
it('opens a filesystem path through the host without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
|
||||
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
|
||||
api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
|
||||
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
|
||||
})
|
||||
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
@@ -252,3 +286,78 @@ describe('WorkspacesService', () => {
|
||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startInitialSelection', () => {
|
||||
function bench() {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
return { api, sessions, workspaces }
|
||||
}
|
||||
|
||||
it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
|
||||
const b = bench()
|
||||
const stop = b.workspaces.startInitialSelection()
|
||||
// Nothing happens before both baselines land.
|
||||
expect(b.api.callsOf('session.create')).toHaveLength(0)
|
||||
|
||||
b.api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
|
||||
}))
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
|
||||
await b.workspaces.refresh()
|
||||
await b.sessions.refresh()
|
||||
// Store notifications and the connect round trip are microtask-batched.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
|
||||
expect(b.sessions.list.getSnapshot().current).toBe('s-new')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('stays idle when a session is already current or no recent Workspace exists', async () => {
|
||||
const withCurrent = bench()
|
||||
withCurrent.api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
|
||||
}))
|
||||
await withCurrent.sessions.refresh()
|
||||
withCurrent.sessions.open(sid('s1'))
|
||||
withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
|
||||
const stopCurrent = withCurrent.workspaces.startInitialSelection()
|
||||
await withCurrent.workspaces.refresh()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
|
||||
stopCurrent()
|
||||
|
||||
const noRecent = bench()
|
||||
const stopEmpty = noRecent.workspaces.startInitialSelection()
|
||||
await noRecent.workspaces.refresh()
|
||||
await noRecent.sessions.refresh()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
|
||||
expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
|
||||
stopEmpty()
|
||||
})
|
||||
|
||||
it('a failed connect returns to waiting and retries on the next list change', async () => {
|
||||
const b = bench()
|
||||
b.api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
|
||||
}))
|
||||
b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
|
||||
const stop = b.workspaces.startInitialSelection()
|
||||
await b.workspaces.refresh()
|
||||
await b.sessions.refresh()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(b.api.callsOf('session.create')).toHaveLength(1)
|
||||
expect(b.sessions.list.getSnapshot().current).toBeUndefined()
|
||||
|
||||
// Recovery: the next workspace-list change re-runs the reconcile.
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
|
||||
await b.workspaces.refresh()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(b.api.callsOf('session.create')).toHaveLength(2)
|
||||
expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user