Merge worktree/schedule-conversational-after into worktree/schedule-explicit-at

This commit is contained in:
Tianyi Cui
2026-08-11 19:20:38 +08:00
3663 changed files with 86658 additions and 37212 deletions

View File

@@ -3,7 +3,7 @@
* connection handle, stream-loop sink wiring into the object layer, and the
* fiber-scoped loop teardown.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
@@ -126,6 +126,7 @@ describe('runtime client apply', () => {
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
const definition: ConversationNodeDefinition<null> = {
kind: 'registry-probe',
target: 'chat',
match: () => null,
start: () => null,
update: context => context.state,

View File

@@ -30,10 +30,16 @@ interface TestSnapshot {
}
class TestEventDefinitions {
readonly definitions: readonly ConversationNodeDefinition[]
readonly fallback: ConversationNodeDefinition | undefined
constructor(
readonly definitions: readonly ConversationNodeDefinition[],
readonly fallback?: ConversationNodeDefinition,
) {}
definitions: readonly ConversationNodeDefinition[],
fallback?: ConversationNodeDefinition,
) {
this.definitions = definitions
this.fallback = fallback
}
entries(): readonly ConversationNodeDefinition[] {
return this.definitions
@@ -93,7 +99,10 @@ function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | unde
return assembler.snapshot('chat') as TestSnapshot | undefined
}
function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0], data: unknown): ConversationViewNode {
function node(
context: Parameters<NonNullable<ConversationNodeDefinition['buildViewNode']>>[0],
data: unknown,
): ConversationViewNode {
return {
key: context.key,
kind: context.kind,
@@ -103,6 +112,17 @@ function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0
}
}
function fallbackDefinition(start: () => string): ConversationNodeDefinition<string> {
return {
kind: 'fallback',
target: 'chat',
match: event => ({ id: String(event.seq), role: 'start' }),
start,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
}
describe('ConversationNodeAssembler', () => {
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
const starts = vi.fn((
@@ -122,6 +142,7 @@ describe('ConversationNodeAssembler', () => {
},
start: starts,
update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -173,6 +194,7 @@ describe('ConversationNodeAssembler', () => {
matchCollections.add(context.matches)
return updates(context)
},
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -208,6 +230,7 @@ describe('ConversationNodeAssembler', () => {
},
start: starts,
update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -247,6 +270,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => ({ settled: false }),
update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
}
const assembler = new ConversationNodeAssembler(
@@ -280,6 +304,7 @@ describe('ConversationNodeAssembler', () => {
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const assembler = new ConversationNodeAssembler(
@@ -301,6 +326,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const consumerStart = vi.fn((
@@ -315,6 +341,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: consumerStart,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -344,6 +371,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: (_context, match) => match.event.seq,
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const consumer: ConversationNodeDefinition<number> = {
@@ -353,6 +381,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -397,6 +426,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: consumerStart,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -425,6 +455,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null,
}
const consumerStart = vi.fn((
@@ -439,6 +470,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: consumerStart,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -468,6 +500,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null,
}
const sourceX: ConversationNodeDefinition<number> = {
@@ -479,6 +512,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => 10,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null,
}
const middle: ConversationNodeDefinition<number> = {
@@ -491,6 +525,7 @@ describe('ConversationNodeAssembler', () => {
+ (reader.previous<number>('diamond-x')?.state ?? 0)
),
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const consumer: ConversationNodeDefinition<number> = {
@@ -503,6 +538,7 @@ describe('ConversationNodeAssembler', () => {
+ (reader.previous<number>('diamond-b')?.state ?? 0)
),
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -538,6 +574,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: starts,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -609,6 +646,7 @@ describe('ConversationNodeAssembler', () => {
value: { valueSeenFromStep: stepValue ?? -1 },
}
},
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
if (location?.kind !== 'step') return null
@@ -646,6 +684,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.start?.location.kind === 'turn'
? context.start.location.turn.steps.length
: -1),
@@ -701,6 +740,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
const data = location?.kind === 'step'
@@ -734,6 +774,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.start?.location.kind),
}
const assembler = new ConversationNodeAssembler(
@@ -760,6 +801,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
@@ -792,6 +834,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: () => null,
update: context => context.state,
target: 'chat',
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
@@ -826,6 +869,7 @@ describe('ConversationNodeAssembler', () => {
: null,
start: seen,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
@@ -841,24 +885,64 @@ describe('ConversationNodeAssembler', () => {
expect(seen).toHaveBeenCalledTimes(2)
})
it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => {
it('invokes the fallback when only a State-only Definition claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed-state',
match: event => (event.type as string) === 'command/run'
? { id: 'claimed', role: 'start' }
: null,
start: () => null,
update: context => context.state,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('invokes the fallback when only another target claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed-trajectory',
target: 'trajectory',
match: event => (event.type as string) === 'command/run'
? { id: 'claimed', role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('suppresses the fallback when the same target claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed',
target: 'chat',
match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const fallback: ConversationNodeDefinition<string> = {
kind: 'fallback',
match: event => ({ id: String(event.seq), role: 'start' }),
start: fallbackStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallback),
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
@@ -878,6 +962,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => true,
update: () => false,
target: 'chat',
buildViewNode: context => context.state === true ? node(context, true) : null,
}
const assembler = new ConversationNodeAssembler(
@@ -900,6 +985,7 @@ describe('ConversationNodeAssembler', () => {
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: () => undefined,
update: context => context.state,
target: 'chat',
buildViewNode: () => null,
}
const startAssembler = new ConversationNodeAssembler(
@@ -919,6 +1005,7 @@ describe('ConversationNodeAssembler', () => {
},
start: () => true,
update: () => undefined as never,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const updateAssembler = new ConversationNodeAssembler(
@@ -939,6 +1026,7 @@ describe('ConversationNodeAssembler', () => {
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: (_context, match) => match.event.seq,
update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(

View File

@@ -1,4 +1,4 @@
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
@@ -13,6 +13,7 @@ import { FakeApiClient, ok } from './fake-api.ts'
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
return {
kind,
target: 'chat',
match: () => null,
start: () => null,
update: context => context.state,
@@ -71,6 +72,40 @@ describe('Conversation registries', () => {
expect(events.fallbackEntry()).toBeUndefined()
})
it('rejects rendering Definitions that omit either target or builder', async () => {
const { events } = await bootRegistries()
const targetOnly: ConversationNodeDefinition<null> = {
kind: 'target-only',
target: 'chat',
match: () => null,
start: () => null,
update: context => context.state,
}
const builderOnly: ConversationNodeDefinition<null> = {
kind: 'builder-only',
match: () => null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/)
expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/)
})
it('rejects a State-only Definition as the unmatched-event fallback', async () => {
const { events } = await bootRegistries()
const fallback: ConversationNodeDefinition<null> = {
kind: 'state-only-fallback',
match: () => null,
start: () => null,
update: context => context.state,
}
expect(() => events.registerFallback(fallback))
.toThrow('conversation fallback Definition must declare a target')
})
it('rejects duplicate view targets and disposes a view registration once', async () => {
const { views } = await bootRegistries()
const definition = viewDefinition('chat')

View File

@@ -1,22 +1,30 @@
/** Assistant block classifier (moved here with sessions/conversation.ts). */
import { describe, expect, it } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
describe('toAssistantBlock', () => {
it('classifies the four block shapes', () => {
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 1,
height: 1,
}
const blocks: ContentBlock[] = [
{ type: 'text', text: '正文' },
{ type: 'reasoning', text: '思考' },
{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock,
{ type: 'image', data: 'x' } as unknown as ContentBlock,
{ type: 'image', attachment },
]
expect(toAssistantBlocks(blocks)).toEqual([
{ kind: 'text', text: '正文' },
{ kind: 'reasoning', text: '思考' },
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' },
{ kind: 'other', block: blocks[3] },
{ kind: 'image', attachment },
])
expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
})

View File

@@ -85,6 +85,8 @@ export class FakeApiClient implements IApiClient {
Promise<RpcResponse<{ selected: ModelSelection }>> =
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 }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (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 }))
@@ -129,6 +131,7 @@ export class FakeApiClient implements IApiClient {
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
@@ -208,6 +211,22 @@ export class FakeApiClient implements IApiClient {
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
select: (payload: { agentPreset: string }) =>
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
read: (payload: { agentPreset: string }) =>
this.record('agentPreset.read', payload, Promise.resolve(ok({
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
}))),
copy: (payload: { agentPreset: string }) =>
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
openDocument: (payload: { agentPreset: string }) =>
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
remove: (payload: { agentPreset: string }) =>
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
}
readonly skills: IApiClient['skills'] = {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}

View File

@@ -1,232 +0,0 @@
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
import { ev } from './event-script.ts'
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
describe('projectConversationHistory', () => {
it('names an injected context node from its durable source, like the live adapter', () => {
// The fold declares its own node mapping (jscpd:ignore in the source), so
// the source projection is pinned on both sides independently.
const injected = at(0, {
type: 'user/message',
surfaceOp: 'append',
data: createUserMessage({
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
// A plugin source, because the client program does not see the host
// packages that merge richer source kinds; those arms are pinned in
// context-provenance.spec.ts.
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
}),
})
const { contexts } = projectConversationHistory([{ event: injected }])
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
kind: 'context',
seq: 0,
provenance: { role: 'inject', label: 'dsh-tool-skill' },
form: 'catalog',
}])
})
it('projects next-step human input as durable steering', () => {
const steering = createUserMessage({
content: [{ type: 'text', text: 'change course' }],
source: { kind: 'user' },
})
const events = [
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
]
const projection = projectConversationHistory(events.map(event => ({ event })))
expect(projection.eventNodes).toMatchObject([{
kind: 'steering', messageId: steering.id, seq: 2,
}])
})
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
const baseSeq = 400_000
const events = [
ev.user(baseSeq, 'loaded tail'),
at(baseSeq + 1, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
sourceEventSeqs: [baseSeq],
data: {
turn: 80,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'tail summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
const projection = projectConversationHistory(events.map(event => ({ event })))
expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
expect(projection.contexts.map(context => ({
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ originSeq: undefined, nodes: [baseSeq] },
{ originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
])
})
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('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('projects nested dispatches onto settled and interrupted history calls', () => {
const projection = projectConversationHistory([
ev.turnStart(0, 1),
ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
ev.toolResult(6, 1, 'settled', 'done'),
ev.turnEnd(7, 1),
ev.turnStart(8, 2),
ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
ev.turnEnd(11, 2, 'aborted'),
].map(event => ({ event })))
const settled = {
callId: 'settled',
subCalls: [{
callId: 'settled:code:1',
subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
}],
}
expect(projection.eventNodes).toMatchObject([settled])
expect(projection.contexts[0]?.nodes).toMatchObject([settled])
expect(projection.interruptedNodes).toMatchObject([{
callId: 'interrupted',
subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
}])
})
it('drops completed token payloads without changing inspection projections', () => {
const events = [
ev.user(0, 'before'),
ev.stepStart(1, 1, 0),
ev.chunkStart(2, 1),
ev.chunkText(3, 1, ''),
ev.chunkText(4, 1, 'first'),
ev.chunkText(5, 1, ' discarded'),
at(6, { type: 'assistant/chunk', data: {
turn: 1,
step: 0,
chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
} }),
ev.assistant(7, 1, 'first discarded'),
ev.compactSummary(8, 'summary', 0, 7),
ev.compactCheckpoint(9, 8, 0, 7),
ev.stepStart(10, 2, 0),
ev.chunkStart(11, 2),
ev.chunkText(12, 2, 'interrupted'),
ev.turnEnd(13, 2, 'aborted'),
]
const raw = events.map(event => ({ event }))
const compacted = compactHistoryInspectionEntries(raw)
expect(compacted.map(entry => entry.event.seq)).toEqual([
0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
])
expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
})
})

View File

@@ -3,7 +3,7 @@
* a fired key must already carry a bumped version (emission follows the
* applied mutation), bogus payloads fail loud, foreign events pass.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as RuntimeInvariant from '../src/invariant.ts'

View File

@@ -1111,3 +1111,60 @@ describe('completed reminder', () => {
expect(entry(manager, S2)?.completed).toBe(true)
})
})
describe('background-task mirror', () => {
const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
})
const tasksFrame = (sessionId: SessionId, tasks: unknown[]) =>
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
const first = manager.getListSnapshot().tasksBySession
expect(first[S1]).toEqual([view()])
expect(first[S2]?.[0]?.label).toBe('other')
// Last-wins: the newer whole set replaces, it does not merge.
manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })]))
expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })])
})
it('stores an emptied set as an absent key so absence and [] read alike', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
manager.handleMuxEnvelope(tasksFrame(S1, []))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope({
rpcId: 's' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 },
})
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('drops the rows when the session is removed, whichever stream lands first', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('notifies list subscribers so an open header re-renders without a poll', async () => {
const manager = new SessionManager(new FakeApiClient())
const seen = vi.fn()
manager.subscribe(seen)
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
// The notifier batches on a microtask; the frame itself is already applied.
await Promise.resolve()
expect(seen).toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,4 @@
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'

View File

@@ -1,319 +0,0 @@
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('does not promote a truncated resume or change header to the initial prompt', () => {
for (const reason of ['resume', 'change'] as const) {
const snapshot = inspectRequests(entriesOf([
at(10, 'step/start', { turn: 3, step: 1 }),
at(11, 'request/header', {
reason,
header: {
config: { provider: 'fake', model: 'model' },
system: 'tail-window prompt',
},
}),
]))
expect(snapshot.requests[0]).toMatchObject({
purpose: 'assistant',
prompt: { system: 'tail-window prompt' },
})
expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
}
})
it('classifies a prompt change once the preceding header is loaded', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
system: 'before',
},
}),
at(2, 'step/start', { turn: 1, step: 2 }),
at(3, 'request/header', {
reason: 'change',
header: {
config: { provider: 'fake', model: 'model' },
system: 'after',
},
}),
]))
expect(snapshot.requests[1]).toMatchObject({
promptChange: {
seq: 3,
kind: 'system',
previous: { system: 'before' },
},
})
})
it('preserves a standalone compaction owner without widening assistant turns', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
at(1, 'compact/summary', {
summary: [{ type: 'text', text: 'standalone summary' }],
provider: 'fake',
model: 'compact-model',
}),
at(2, 'compact/end', { turn: null }),
at(3, 'step/start', { turn: 2, step: 1 }),
]))
const [compaction, assistant] = snapshot.requests
expect(compaction).toMatchObject({
purpose: 'compaction',
turn: null,
step: 0,
status: 'complete',
})
expect(assistant).toMatchObject({
purpose: 'assistant',
turn: 2,
step: 1,
status: 'running',
})
if (assistant?.purpose === 'assistant') {
const turn: number = assistant.turn
expect(turn).toBe(2)
}
})
it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
at(1, 'session/end-seed', {}),
at(2, 'compact/start', { turn: null }),
at(3, 'compact/summary', {
summary: [{ type: 'text', text: 'replacement summary' }],
provider: 'fake',
model: 'compact-model',
}),
at(4, 'compact/end', { turn: null }),
]))
expect(snapshot.requests).toMatchObject([
{
purpose: 'compaction',
startSeq: 0,
status: 'error',
completedAt: 1_700_000_000_001,
error: 'Compaction was interrupted before completion.',
},
{
purpose: 'compaction',
startSeq: 2,
status: 'complete',
completedAt: 1_700_000_000_004,
summary: [{ type: 'text', text: 'replacement summary' }],
},
])
})
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('keeps provider credential fragments out of projected request errors', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'turn/end', {
turn: 1, reason: { kind: 'error', error: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
}),
at(2, 'step/start', { turn: 2, step: 1 }),
at(3, 'turn/end', {
turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
}),
]))
expect(snapshot.requests).toMatchObject([
{ status: 'error', error: 'API key is invalid' },
{ status: 'error', error: 'plugin exploded' },
])
})
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())
const [request] = snapshot.requests
expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
})
})

View File

@@ -6,14 +6,14 @@
* and a subject-less root dispatch stays unfiltered. Scope-owned listeners
* dispose with the fiber.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { createScope, scopeOf } from '../src/client/agents/scope.ts'
const sid = (k: string): SessionId => k as SessionId
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Test-only routed probe event.

View File

@@ -1,180 +0,0 @@
import { afterEach, describe, expect, it, vi } 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
afterEach(() => {
vi.unstubAllGlobals()
})
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('SessionHistorySource', () => {
it('loads the tail first and prepends older pages on demand', 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.loadTail()
expect(api.callsOf('session.history')).toHaveLength(1)
expect(source.getSnapshot().hasMore).toBe(true)
expect(source.getSnapshot().baseSeq).toBe(12)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([13, 15])
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(false)
expect(api.callsOf('session.history')).toHaveLength(3)
expect(source.getSnapshot().hasMore).toBe(false)
expect(source.getSnapshot().baseSeq).toBe(0)
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.loadTail()
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('publishes multiple assistant chunks once per browser frame', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadTail()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const unsubscribe = source.subscribe(() => { notifications++ })
const before = source.getSnapshot().inspection
const finalizedNodes = before.eventNodes
const requests = before.requests
const contexts = before.contexts
for (const event of [
ev.chunkStart(6, 1),
ev.chunkText(7, 1, 'stream '),
ev.chunkText(8, 1, 'content'),
]) {
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event,
})
}
expect(frames).toHaveLength(1)
expect(notifications).toBe(0)
frames[0]?.(0)
await Promise.resolve()
expect(notifications).toBe(1)
const streamed = source.getSnapshot().inspection
expect(streamed.eventNodes).toBe(finalizedNodes)
expect(streamed.requests).toBe(requests)
expect(streamed.contexts).toBe(contexts)
expect(streamed.partial?.blocks).toEqual([
{ kind: 'text', text: 'stream content' },
])
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.chunkText(9, 1, ' then final'),
})
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.assistant(10, 1, 'stream content then final'),
})
await Promise.resolve()
expect(notifications).toBe(2)
const finalized = source.getSnapshot().inspection
expect(finalized.eventNodes).not.toBe(finalizedNodes)
expect(finalized.partial).toBeNull()
frames[1]?.(0)
await Promise.resolve()
expect(notifications).toBe(2)
unsubscribe()
})
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.loadTail()
expect(await source.loadOlder()).toBe(false)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
it('finishes an already started older page after consumer cancellation', 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()
await source.loadTail(controller.signal)
const complete = source.loadOlder(controller.signal)
await olderStarted.promise
controller.abort()
middle.resolve(ok({
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
hasMore: true,
}))
expect(await complete).toBe(true)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
})

View File

@@ -8,6 +8,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import type {
@@ -123,15 +124,20 @@ function testViewDefinition(): ConversationViewDefinition<ChatConversationViewNo
const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
kind: 'runtime-test-event',
target: 'chat',
match: event => ({ id: String(event.seq), role: 'start' }),
start: (_context, match) => ({ event: match.event, view: match.view }),
update: context => context.state,
publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate',
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined || context.start === undefined) return null
buildViewNode: (context) => {
if (context.state === undefined || context.start === undefined) return null
return {
key: context.key,
kind: 'runtime-test-event',
kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal'
? 'command-input'
: context.start.event.type === 'command/run' || context.start.event.type === 'command/done'
? 'command'
: 'runtime-test-event',
id: context.id,
target: 'chat',
anchorSeq: context.start.event.seq,
@@ -271,6 +277,24 @@ describe('live event path', () => {
expect(snapshot.composerPhase).toBe('blank')
})
it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => {
const { session } = await opened([])
session.handleBlank(true)
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.commandRun(0, 'cmd-goal', 'goal', ' '))
feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.'))
expect(session.getSnapshot()).toMatchObject({
blank: true,
composerPhase: 'active',
})
expect(session.getSnapshot().chat.order.map(
key => session.getSnapshot().chat.nodes.get(key)?.kind,
)).toContain('command-input')
})
it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
@@ -537,6 +561,21 @@ describe('prompt and cancel errors', () => {
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
})
it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
const { api, session } = makeSession()
const result = await session.readAttachment('attachment-1' as never)
expect(result).toEqual({
ok: true,
value: {
attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
data: Uint8Array.of(0),
},
})
expect(api.callsOf('session.attachment')).toEqual([{
sessionId: SID, attachmentId: 'attachment-1',
}])
})
})
describe('rename', () => {

View File

@@ -6,7 +6,7 @@
* deferral — the stage follows list.current), binding identity, breadcrumb
* projection, create.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
@@ -35,6 +35,7 @@ type FeedRow = {
origin?: 'subagent'
running?: boolean
blank?: boolean
agentPreset?: string
}
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
@@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
...(r.origin !== undefined ? { origin: r.origin } : {}),
...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
})),
}) as never)
await b.svc.refresh()
@@ -70,6 +72,38 @@ describe('list store projection', () => {
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
it('reprojects a blank session whose composition switched and nothing else moved', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
// A confirmed switch moves the preset alone: the row keeps its updatedAt,
// title, running, and blank bits, so an identity guard blind to the preset
// would serve the old row forever — and every reader (the hero chip's own
// no-op check, the header label) would keep the composition it replaced.
b.svc.noteAgentPreset(sid('s1'), 'minimal')
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
})
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
// Every connected client gets this frame; only the switching tab gets the
// RPC echo. A client that ignored the payload would keep labelling the
// session with the composition it replaced.
b.svc.handleHostEnvelope({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
})
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])

View File

@@ -0,0 +1,352 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import {
bindSettingsScope, SettingsScopeController, type SettingsScope,
} from '../src/client/settings-scope.ts'
interface UiTestSettings {
preference: 'light' | 'dark' | 'system'
}
const ENVELOPE = z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}).toJSON()
let rpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
}
function rejected<T>(): RpcResponse<T> {
return {
rpcId: `scope-${rpc++}` as never,
result: {
ok: false,
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
},
}
}
function view(value: unknown, revision = 0): SettingsNamespaceView {
return {
ns: 'ui-test',
schema: ENVELOPE,
value,
applies: 'live',
secrets: [],
revision,
}
}
function described(value: unknown, revision = 0) {
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
/** Record each distinct published section, starting from the current one. */
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
scope.subscribe(() => {
const value = scope.getSnapshot().value
if (value !== seen[seen.length - 1]) seen.push(value)
})
return seen
}
describe('SettingsScopeController', () => {
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
expect(scope.getSnapshot()).toEqual({
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
})
await scope.load()
expect(scope.getSnapshot()).toEqual({
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
})
})
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
.mockResolvedValueOnce(described(null, 5))
.mockResolvedValueOnce(described('scalar', 6))
.mockResolvedValueOnce(described(['queue'], 7))
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const good = trackValues(scope)
for (let i = 0; i < 7; i++) await scope.load()
expect(scope.getSnapshot()).toMatchObject({
status: 'ready', value: { preference: 'dark' }, revision: 7,
})
expect(good).toEqual([undefined, { preference: 'dark' }])
})
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
})
it('suppresses a superseded read of an unexposed namespace', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const statuses: string[] = []
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
const stale = scope.load()
const fresh = scope.load()
await Promise.all([stale, fresh])
expect(statuses).not.toContain('unavailable')
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
})
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot().status).toBe('ready')
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
})
it('applies a custom decode override in place of the wire schema', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{
namespace: 'ui-test',
decode: section => (section as UiTestSettings).preference === 'dark'
? section as UiTestSettings
: undefined,
},
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
})
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
const mutate = vi.fn()
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.load()
const dark = scope.set('preference', 'dark')
const light = scope.set('preference', 'light')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
first.resolve(ok(view({ preference: 'dark' }, 5)))
await Promise.all([dark, light])
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
expect(mutate).toHaveBeenNthCalledWith(1, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
expectedRevision: 4,
})
expect(mutate).toHaveBeenNthCalledWith(2, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
expectedRevision: 5,
})
})
it('recovers the latest rejected or thrown write from Host state', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.set('preference', 'dark')
await scope.set('preference', 'system')
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
})
it('does not recover superseded rejected or thrown writes', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await Promise.all([
scope.set('preference', 'dark'),
scope.set('preference', 'system'),
scope.set('preference', 'light'),
])
expect(describeCall).not.toHaveBeenCalled()
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
})
it('keeps the write queue usable when a subscriber throws', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
let thrown = false
scope.subscribe(() => {
if (thrown) return
thrown = true
throw new Error('subscriber failed')
})
await expect(scope.load()).rejects.toThrow('subscriber failed')
await expect(scope.load()).resolves.toBeUndefined()
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
})
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const mutate = vi.fn().mockReturnValue(first.promise)
const describeCall = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
const dark = scope.set('preference', 'dark')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
const light = scope.set('preference', 'light')
let stopped = false
const stop = scope.dispose().then(() => { stopped = true })
await Promise.resolve()
expect(stopped).toBe(false)
first.resolve(ok(view({ preference: 'dark' }, 1)))
await Promise.all([dark, light, stop])
await scope.set('preference', 'system')
await scope.load()
expect(mutate).toHaveBeenCalledOnce()
expect(describeCall).not.toHaveBeenCalled()
expect(published).toEqual([undefined])
})
it('keeps a remote browser in memory mode without Host calls', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
'memory',
)
expect(scope.getSnapshot()).toEqual({
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
})
await scope.load()
await scope.set('preference', 'dark')
await scope.dispose()
expect(describeCall).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
})
describe('bindSettingsScope', () => {
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
const initial = deferred<ReturnType<typeof described>>()
const describeCall = vi.fn()
.mockReturnValueOnce(initial.promise)
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: true,
} as never)
let scope!: SettingsScope<UiTestSettings>
const fiber = ctx.plugin({
inject: ['connection'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
},
})
await fiber.await()
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
ctx.emit('settings/changed', 'unrelated')
ctx.emit('settings/changed', 'ui-test')
ctx.emit('connection/reset')
initial.resolve(described({ preference: 'dark' }, 1))
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
await vi.waitFor(() => {
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
})
await fiber.dispose()
ctx.emit('settings/changed', 'ui-test')
await Promise.resolve()
expect(describeCall).toHaveBeenCalledTimes(3)
})
it('binds a remote browser in memory mode without starting a settings read', async () => {
const describeCall = vi.fn()
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: false,
} as never)
let scope!: SettingsScope<UiTestSettings>
const fiber = ctx.plugin({
inject: ['connection'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
},
})
await fiber.await()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
await fiber.dispose()
expect(describeCall).not.toHaveBeenCalled()
})
})

View File

@@ -5,7 +5,7 @@
* contract (double install / not installed / non-root key), store instance
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'

View File

@@ -1,9 +1,10 @@
/**
* Wire-to-typed-event bridge: host/commands-changed
* → ctx 'commands/changed'; each established connection generation
* → ctx 'commands/changed'; host/session-preset-changed
* ctx 'session/preset-changed'; each established connection generation →
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
@@ -67,6 +68,17 @@ describe('wire event bridge', () => {
])
})
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
const bench = await mount()
const seen: Array<[string, string]> = []
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
})
expect(seen).toEqual([['s1', 'minimal']])
})
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
const bench = await mount()
let resets = 0

View File

@@ -1,4 +1,4 @@
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
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'
@@ -59,7 +59,7 @@ describe('WorkspaceManager', () => {
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
})
it('creates by name/path, prepends a new row, and folds failures', async () => {
it('creates by path, prepends a new row, and folds failures', async () => {
const api = new FakeApiClient()
const manager = new WorkspaceManager(api)
api.onWorkspaceCreate = payload => Promise.resolve(ok({
@@ -67,8 +67,8 @@ describe('WorkspaceManager', () => {
created: true,
payload,
} as never))
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }])
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))