Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/subsystems/attachment.i18n.yaml
#	docs/subsystems/attachment.md
#	docs/subsystems/attachment.zh.md
#	docs/subsystems/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/README.md
#	packages/README.zh.md
#	packages/client/runtime/package.json
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/self-modification/tool-cordis/src/api-catalog.ts
#	pnpm-lock.yaml
#	scripts/type-equiv.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-09 23:33:35 +08:00
3271 changed files with 69578 additions and 24953 deletions

View File

@@ -4,12 +4,14 @@
* fiber-scoped loop teardown.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
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'
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as RuntimeClient from '../src/client/index.ts'
import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts'
import { Session } from '../src/client/sessions/session.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
@@ -52,7 +54,7 @@ describe('runtime client apply', () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
// The built-in 'root' declaration ships with this package's SlotsService
// (the SlotMap 'root' merge lives here since the slot-parity rework).
// (the SlotMap 'root' merge lives here).
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
const sessions = bench.ctx.get('sessions')
const workspaces = bench.ctx.get('workspaces')
@@ -112,6 +114,31 @@ describe('runtime client apply', () => {
expect(bench.api.callsOf('session.create')).toHaveLength(1)
})
it('wires registry changes into resident Sessions during the runtime apply pass', async () => {
const bench = await mount()
const sessions = bench.ctx.get('sessions') as SessionsService
bench.sinks?.onHostEnvelope?.({
rpcId: 'r-registry' as never,
payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never,
})
await flushMicrotasks()
expect(sessions.binding('s-registry' as never)).toBeDefined()
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
const definition: ConversationNodeDefinition<null> = {
kind: 'registry-probe',
match: () => null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
bench.ctx.conversationEvents.register(definition)
await flushMicrotasks()
expect(rebuild).toHaveBeenCalledOnce()
rebuild.mockRestore()
})
it('stops the stream loop when the plugin fiber unloads', async () => {
const bench = await mount()
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))

View File

@@ -1,52 +0,0 @@
/**
* Behavioral half of the compaction-checkpoint drift trap.
*
* `TranscriptAdapter` pins its plugin literal to the seam's own declaration at
* compile time through a type-only import of `dsh-compact/checkpoint`, so
* renaming the seam's plugin already fails `tsc`. This spec covers the same
* drift from the other side — end to end through the adapter, driving it with a
* checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` value and
* checking the seam's own predicate agrees. Both values come from the
* cordis-free checkpoint leaf, so the client test program never loads the host
* package root or its `Context` merges.
*/
import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
/** A replacement user message stamped with the seam's own canonical source. */
function canonicalCheckpoint(seq: number): SessionEvent {
return {
type: 'user/message',
seq,
time: 1_700_000_000_000 + seq,
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
source: COMPACT_CHECKPOINT_SOURCE,
}),
} as unknown as SessionEvent
}
describe('compaction checkpoint recognition', () => {
it('recognizes a checkpoint carrying the seam-canonical source', () => {
const adapter = new TranscriptAdapter()
adapter.reset([canonicalCheckpoint(1)])
expect(adapter.nodes()).toEqual([{
kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}])
})
it("agrees with the seam's own predicate on the source it recognizes", () => {
// Both sides answer the same question about the same value: if the seam
// renames its plugin, this equality is what breaks.
const checkpoint = canonicalCheckpoint(1)
expect(checkpoint.type === 'user/message' && isCompactCheckpointSource(checkpoint.data.source)).toBe(true)
expect(COMPACT_CHECKPOINT_SOURCE).toEqual({ kind: 'plugin', plugin: 'compact' })
})
})

View File

@@ -0,0 +1,959 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts'
import type {
ConversationEventInput, ConversationMatch, ConversationNodeContext,
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
} from '../src/client/contract/conversation.ts'
interface ScopeProbeStepData {
readonly value: number
}
interface ScopeProbeTurnData {
readonly valueSeenFromStep: number
}
declare module '../src/client/contract/conversation.ts' {
interface ConversationStepDataMap {
'scope-probe': ScopeProbeStepData
}
interface ConversationTurnDataMap {
'scope-probe': ScopeProbeTurnData
}
}
interface TestSnapshot {
readonly order: readonly string[]
readonly nodes: ReadonlyMap<string, ConversationViewNode>
}
class TestEventDefinitions {
constructor(
readonly definitions: readonly ConversationNodeDefinition[],
readonly fallback?: ConversationNodeDefinition,
) {}
entries(): readonly ConversationNodeDefinition[] {
return this.definitions
}
fallbackEntry(): ConversationNodeDefinition | undefined {
return this.fallback
}
}
class TestViewDefinitions {
constructor(readonly definitions: readonly ConversationViewDefinition[]) {}
entries(): readonly ConversationViewDefinition[] {
return this.definitions
}
}
function testView(
apply = vi.fn(),
): ConversationViewDefinition<ConversationViewNode, TestSnapshot> {
return {
target: 'chat',
create: () => {
let current: TestSnapshot = { order: [], nodes: new Map() }
return {
empty: current,
replace: ({ nodes }) => {
current = { order: nodes.map(node => node.key), nodes: new Map(nodes.map(node => [node.key, node])) }
return current
},
apply: ({ upserts }) => {
apply(upserts)
const nodes = new Map(current.nodes)
const order = [...current.order]
for (const node of upserts) {
if (!nodes.has(node.key)) order.push(node.key)
nodes.set(node.key, node)
}
current = { order, nodes }
return current
},
}
},
}
}
function at(seq: number, type: string, data: unknown): SessionEvent {
return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent
}
function input(event: SessionEvent): ConversationEventInput {
return { event, view: undefined }
}
function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined {
return assembler.snapshot('chat') as TestSnapshot | undefined
}
function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0], data: unknown): ConversationViewNode {
return {
key: context.key,
kind: context.kind,
id: context.id,
target: 'chat',
data,
}
}
describe('ConversationNodeAssembler', () => {
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
const starts = vi.fn((
_context: ConversationNodeContext<{ callSeq: number; results: number }>,
match: ConversationMatch,
) => ({ callSeq: match.event.seq, results: 0 }))
const updates = vi.fn((context: { state: { callSeq: number; results: number } }) => ({
...context.state,
results: context.state.results + 1,
}))
const definition: ConversationNodeDefinition<{ callSeq: number; results: number }> = {
kind: 'tool',
match: (event) => {
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
return null
},
start: starts,
update: updates,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })),
input(at(2, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })),
], false)
assembler.flush()
starts.mockClear()
assembler.append(input(at(3, 'tool/result', {
turn: 1,
step: 1,
message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
})))
assembler.flush()
expect(starts).not.toHaveBeenCalled()
expect(updates).toHaveBeenCalledOnce()
const snapshot = chatSnapshot(assembler)
expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([
{ callSeq: 1, results: 1 },
{ callSeq: 2, results: 0 },
])
})
it('keeps one Match collection while a long Context appends without replay', () => {
const starts = vi.fn(() => 0)
const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
context.state + 1
))
const matchCollections = new Set<readonly ConversationMatch[]>()
const definition: ConversationNodeDefinition<number> = {
kind: 'append-linear',
match: (event) => {
const type: string = event.type
if (type === 'linear/start') return { id: 'one', role: 'start' }
if (type === 'linear/update') return { id: 'one', role: 'update' }
return null
},
start: (context) => {
matchCollections.add(context.matches)
return starts()
},
update: (context) => {
matchCollections.add(context.matches)
return updates(context)
},
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'linear/start', {}))], false)
starts.mockClear()
for (let seq = 2; seq <= 1_001; seq++) {
assembler.append(input(at(seq, 'linear/update', {})))
}
assembler.flush()
expect(starts).not.toHaveBeenCalled()
expect(updates).toHaveBeenCalledTimes(1_000)
expect(matchCollections.size).toBe(1)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000)
})
it('merges an older page and replays its affected Context once', () => {
const starts = vi.fn(() => 0)
const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
context.state + 1
))
const definition: ConversationNodeDefinition<number> = {
kind: 'prepend-linear',
match: (event) => {
const type: string = event.type
if (type === 'linear/start') return { id: 'one', role: 'start' }
if (type === 'linear/update') return { id: 'one', role: 'update' }
return null
},
start: starts,
update: updates,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
const current = Array.from({ length: 100 }, (_, index) => (
input(at(index + 102, 'linear/update', {}))
))
assembler.replaceWindow(current, true)
assembler.flush()
expect(starts).not.toHaveBeenCalled()
expect(updates).not.toHaveBeenCalled()
const older = [
input(at(1, 'linear/start', {})),
...Array.from({ length: 100 }, (_, index) => (
input(at(index + 2, 'linear/update', {}))
)),
]
assembler.prepend(older, false)
assembler.flush()
expect(starts).toHaveBeenCalledOnce()
expect(updates).toHaveBeenCalledTimes(200)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200)
})
it('collects an update before its start and replays it once prepend supplies the start', () => {
const updates = vi.fn((context: { state: { settled: boolean } }) => ({ ...context.state, settled: true }))
const definition: ConversationNodeDefinition<{ settled: boolean }> = {
kind: 'tool',
match: (event) => {
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
return null
},
start: () => ({ settled: false }),
update: updates,
buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(10, 'tool/result', {
turn: 1,
step: 1,
message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
}))], true)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toEqual({ pendingStart: true })
assembler.prepend([input(at(5, 'tool/call', {
turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}',
}))], false)
assembler.flush()
expect(updates).toHaveBeenCalledOnce()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toEqual({ settled: true })
})
it('rejects a Definition whose declared start follows an update in log order', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'invalid-lifecycle',
match: event => event.type === 'turn/end'
? { id: 'one', role: 'start' }
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
expect(() => assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } })),
], false)).toThrow('received an update before its start Match')
})
it('replays a window-gap reader when prepend supplies a nearer predecessor', () => {
const source: ConversationNodeDefinition<number> = {
kind: 'source',
match: event => event.type === 'user/message'
? { id: String(event.data.id), role: 'start' }
: null,
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
update: context => context.state,
buildViewNode: () => null,
}
const consumerStart = vi.fn((
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
) => reader.previous<number>('source')?.state ?? -1)
const consumer: ConversationNodeDefinition<number> = {
kind: 'consumer',
match: event => event.type === 'assistant/message'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: consumerStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([source, consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(10, 'assistant/message', {
turn: 2, step: 1, message: { role: 'assistant', content: [] },
}))], true)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
assembler.prepend([input(at(5, 'user/message', {
id: 'm1', value: 7, content: [], source: { kind: 'user' },
}))], false)
assembler.flush()
expect(consumerStart).toHaveBeenCalledTimes(2)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7)
})
it('keeps the predecessor index ordered across prepend and append', () => {
const source: ConversationNodeDefinition<number> = {
kind: 'source',
match: event => event.type === 'user/message'
? { id: String(event.data.id), role: 'start' }
: null,
start: (_context, match) => match.event.seq,
update: context => context.state,
buildViewNode: () => null,
}
const consumer: ConversationNodeDefinition<number> = {
kind: 'consumer',
match: event => event.type === 'assistant/message'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([source, consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(40, 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })),
input(at(50, 'assistant/message', {
turn: 1, step: 1, message: { role: 'assistant', content: [] },
})),
], true)
assembler.flush()
assembler.prepend([
input(at(10, 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })),
input(at(30, 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })),
], false)
assembler.flush()
assembler.append(input(at(60, 'user/message', {
id: 'm60', content: [], source: { kind: 'user' },
})))
assembler.append(input(at(70, 'assistant/message', {
turn: 2, step: 1, message: { role: 'assistant', content: [] },
})))
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
.toEqual([40, 60])
})
it('replays a window-gap reader when an empty prepend closes the unknown prefix', () => {
const consumerStart = vi.fn((
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
) => reader.previous<number>('source')?.state ?? -1)
const consumer: ConversationNodeDefinition<number> = {
kind: 'consumer',
match: event => event.type === 'assistant/message'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: consumerStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(10, 'assistant/message', {
turn: 2, step: 1, message: { role: 'assistant', content: [] },
}))], true)
assembler.flush()
expect(assembler.prepend([], false)).toBe('immediate')
assembler.flush()
expect(consumerStart).toHaveBeenCalledTimes(2)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
})
it('replays direct dependents when an append revises their predecessor Context', () => {
const source: ConversationNodeDefinition<number> = {
kind: 'source',
match: (event) => {
if (event.type === 'user/message') return { id: 'one', role: 'start' }
if ((event.type as string) === 'source/update') return { id: 'one', role: 'update' }
return null
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
buildViewNode: () => null,
}
const consumerStart = vi.fn((
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
) => reader.previous<number>('source')?.state ?? -1)
const consumer: ConversationNodeDefinition<number> = {
kind: 'consumer',
match: event => event.type === 'assistant/message'
? { id: 'one', role: 'start' }
: null,
start: consumerStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([source, consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
input(at(2, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
], false)
assembler.flush()
expect(assembler.append(input(at(3, 'source/update', { value: 2 })))).toBe('immediate')
assembler.flush()
expect(consumerStart).toHaveBeenCalledTimes(2)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2)
})
it('replays a transitive dependency closure in start order', () => {
const sourceA: ConversationNodeDefinition<number> = {
kind: 'diamond-a',
match: (event) => {
if (event.type === 'user/message') return { id: 'one', role: 'start' }
if ((event.type as string) === 'diamond/a') return { id: 'one', role: 'update' }
return null
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
buildViewNode: () => null,
}
const sourceX: ConversationNodeDefinition<number> = {
kind: 'diamond-x',
match: (event) => {
if (event.type === 'turn/start') return { id: 'one', role: 'start' }
if ((event.type as string) === 'diamond/x') return { id: 'one', role: 'update' }
return null
},
start: () => 10,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
buildViewNode: () => null,
}
const middle: ConversationNodeDefinition<number> = {
kind: 'diamond-b',
match: event => event.type === 'assistant/message'
? { id: 'one', role: 'start' }
: null,
start: (_context, _match, reader) => (
(reader.previous<number>('diamond-a')?.state ?? 0)
+ (reader.previous<number>('diamond-x')?.state ?? 0)
),
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const consumer: ConversationNodeDefinition<number> = {
kind: 'diamond-c',
match: event => event.type === 'tool/call'
? { id: 'one', role: 'start' }
: null,
start: (_context, _match, reader) => (
(reader.previous<number>('diamond-a')?.state ?? 0) * 100
+ (reader.previous<number>('diamond-b')?.state ?? 0)
),
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([sourceA, sourceX, middle, consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
input(at(2, 'turn/start', { turn: 1 })),
input(at(3, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
input(at(4, 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })),
], false)
assembler.append(input(at(5, 'diamond/x', { value: 20 })))
assembler.append(input(at(6, 'diamond/a', { value: 2 })))
assembler.flush()
const value = [...chatSnapshot(assembler)?.nodes.values() ?? []]
.find(candidate => candidate.kind === 'diamond-c')
expect(value?.data).toBe(222)
})
it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => {
const apply = vi.fn()
const starts = vi.fn((
_context: Parameters<ConversationNodeDefinition<string>['start']>[0],
match: Parameters<ConversationNodeDefinition<string>['start']>[1],
) => match.location.kind === 'step' ? match.location.step.status : 'missing')
const definition: ConversationNodeDefinition<string> = {
kind: 'step',
match: event => event.type === 'step/start'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: starts,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView(apply)]),
)
assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'step/start', { turn: 1, step: 1 })),
], false)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open')
assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 })))
assembler.flush()
expect(starts).toHaveBeenCalledTimes(2)
expect(apply).toHaveBeenCalledOnce()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed')
})
it('lets one Context publish Step and Turn data in phase order', () => {
interface State {
readonly turn: number
readonly step: number
readonly value: number
}
const definition: ConversationNodeDefinition<State> = {
kind: 'scope-probe',
match: (event) => {
if (event.type === 'step/start') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
}
if ((event.type as string) === 'scope-probe/update') {
return { id: '1:1', role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'step/start') throw new Error('scope probe requires step/start')
return { turn: match.event.data.turn, step: match.event.data.step, value: 1 }
},
update: (_context, match) => ({
turn: 1,
step: 1,
value: (match.event.data as unknown as { value: number }).value,
}),
buildLocationData: (context, scope) => {
const state = context.state
if (state === undefined) return null
if (scope === 'step') {
return {
kind: 'step',
turn: state.turn,
step: state.step,
key: 'scope-probe',
value: { value: state.value },
}
}
const location = context.start?.location
const stepValue = location?.kind === 'step'
? location.step.data.get('scope-probe')?.value
: undefined
return {
kind: 'turn',
turn: state.turn,
key: 'scope-probe',
value: { valueSeenFromStep: stepValue ?? -1 },
}
},
buildViewNode: (context) => {
const location = context.start?.location
if (location?.kind !== 'step') return null
return node(context, {
step: location.step.data.get('scope-probe')?.value,
turn: location.turn.data.get('scope-probe')?.valueSeenFromStep,
})
},
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'step/start', { turn: 1, step: 1 })),
], false)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toEqual({ step: 1, turn: 1 })
assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 })))
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toEqual({ step: 2, turn: 2 })
})
it('updates existing turn Locations when their Step membership changes', () => {
const apply = vi.fn()
const definition: ConversationNodeDefinition<null> = {
kind: 'turn-probe',
match: event => event.type === 'turn/start'
? { id: String(event.data.turn), role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: context => node(context, context.start?.location.kind === 'turn'
? context.start.location.turn.steps.length
: -1),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView(apply)]),
)
assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0)
assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 })))
assembler.flush()
expect(apply).toHaveBeenCalledOnce()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
})
it('publishes a changed timeline even when no business Definition claims the boundary', () => {
const apply = vi.fn()
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([]),
new TestViewDefinitions([testView(apply)]),
)
assembler.replaceWindow([], false)
assembler.flush()
assembler.append(input(at(1, 'turn/start', { turn: 1 })))
assembler.flush()
expect(apply).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toEqual([])
})
it('clears the prior Step at a new Turn and honors explicit session ownership', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'location-probe',
match: (event) => {
if ((event.type as string) === 'command/run') {
return {
id: (event.data as unknown as { commandId: string }).commandId,
role: 'start',
}
}
if ((event.type as string) === 'compact/start') {
return {
id: (event.data as unknown as { compactionId: string }).compactionId,
role: 'start',
}
}
return null
},
start: () => null,
update: context => context.state,
buildViewNode: (context) => {
const location = context.start?.location
const data = location?.kind === 'step'
? `step:${location.turn.turn}:${location.step.step}`
: location?.kind === 'turn' ? `turn:${location.turn.turn}` : location?.kind
return node(context, data)
},
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'step/start', { turn: 1, step: 1 })),
input(at(3, 'turn/start', { turn: 2 })),
input(at(4, 'command/run', { commandId: 'command', name: 'x' })),
input(at(5, 'compact/start', { compactionId: 'compact', turn: null })),
], false)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
.toEqual(['turn:2', 'session'])
})
it('assigns turn boundaries to the Turn even when a Step remains open', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'turn-boundary-probe',
match: event => event.type === 'turn/end'
? { id: String(event.data.turn), role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: context => node(context, context.start?.location.kind),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'step/start', { turn: 1, step: 1 })),
], false)
assembler.flush()
assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } })))
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn')
})
it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'location-probe',
match: event => (event.type as string) === 'tool/code-dispatch-start'
? { id: String(event.seq), role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
? `${location.turn.turn}:${location.step.step}`
: location?.kind)
},
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
input(at(11, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })),
], true)
assembler.flush()
assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' })))
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
.toEqual(['2:3', '2:3'])
})
it('treats loaded end boundaries as closed when their starts precede the window', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'location-probe',
match: event => event.type === 'tool/call'
? { id: String(event.data.callId), role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
? `${location.turn.status}:${location.step.status}`
: location?.kind)
},
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
input(at(11, 'step/end', { turn: 2, step: 3 })),
input(at(12, 'turn/end', { turn: 2, reason: { kind: 'completed' } })),
], true)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toBe('closed:closed')
})
it('restarts State creation from undefined when Location changes replay a Context', () => {
const seen = vi.fn((context: Parameters<ConversationNodeDefinition<number>['start']>[0]) => {
expect(context.state).toBeUndefined()
return 1
})
const definition: ConversationNodeDefinition<number> = {
kind: 'replay-probe',
match: event => event.type === 'step/start'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: seen,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'step/start', { turn: 1, step: 1 }))], false)
assembler.flush()
assembler.append(input(at(2, 'step/end', { turn: 1, step: 1 })))
assembler.flush()
expect(seen).toHaveBeenCalledTimes(2)
})
it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed',
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 TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).not.toHaveBeenCalled()
expect(chatSnapshot(assembler)?.order).toEqual([])
})
it('rejects withdrawing a previously materialized Node during an incremental update', () => {
const definition: ConversationNodeDefinition<boolean> = {
kind: 'toggle',
match: (event) => {
if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
if ((event.type as string) === 'toggle/hide') return { id: 'one', role: 'update' }
return null
},
start: () => true,
update: () => false,
buildViewNode: context => context.state === true ? node(context, true) : null,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
assembler.append(input(at(2, 'toggle/hide', {})))
expect(() => assembler.flush()).toThrow(/withdrew materialized target "chat"/)
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('fails loud when a Definition returns undefined State', () => {
const startUndefined: ConversationNodeDefinition = {
kind: 'undefined-start',
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: () => undefined,
update: context => context.state,
buildViewNode: () => null,
}
const startAssembler = new ConversationNodeAssembler(
new TestEventDefinitions([startUndefined]),
new TestViewDefinitions([testView()]),
)
expect(() => startAssembler.replaceWindow([
input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
], false)).toThrow(/Definition "undefined-start" returned undefined from start/)
const updateUndefined: ConversationNodeDefinition<boolean> = {
kind: 'undefined-update',
match: (event) => {
if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
if ((event.type as string) === 'command/done') return { id: 'one', role: 'update' }
return null
},
start: () => true,
update: () => undefined as never,
buildViewNode: context => node(context, context.state),
}
const updateAssembler = new ConversationNodeAssembler(
new TestEventDefinitions([updateUndefined]),
new TestViewDefinitions([testView()]),
)
updateAssembler.replaceWindow([
input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
], false)
expect(() => updateAssembler.append(
input(at(2, 'command/done', { commandId: 'one', kind: 'success' })),
)).toThrow(/Definition "undefined-update" returned undefined from update/)
})
it('rejects a duplicate start before mutating the existing Context', () => {
const definition: ConversationNodeDefinition<number> = {
kind: 'single-start',
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: (_context, match) => match.event.seq,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
], false)
assembler.flush()
expect(() => assembler.append(
input(at(2, 'command/run', { commandId: 'two', name: 'x' })),
)).toThrow(/received more than one start Match/)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
})
})

View File

@@ -0,0 +1,126 @@
import { Context } from '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'
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
import type {
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
} from '../src/client/contract/conversation.ts'
import { Session } from '../src/client/sessions/session.ts'
import { SessionsService } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
return {
kind,
match: () => null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
}
function viewDefinition(target: string): ConversationViewDefinition<ConversationViewNode, null> {
return {
target,
create: () => ({
empty: null,
replace: () => null,
apply: () => null,
}),
}
}
async function bootRegistries(): Promise<{
ctx: Context
events: ConversationEventRegistry
views: ConversationViewRegistry
}> {
const ctx = new Context()
await ctx.plugin(ConversationEventRegistry).await()
await ctx.plugin(ConversationViewRegistry).await()
const events = ctx.get('conversationEvents') as ConversationEventRegistry
const views = ctx.get('conversationViews') as ConversationViewRegistry
return { ctx, events, views }
}
describe('Conversation registries', () => {
it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => {
const { events } = await bootRegistries()
const definition = eventDefinition('message')
const dispose = events.register(definition)
expect(events.entries()).toEqual([definition])
expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/)
dispose()
dispose()
expect(events.entries()).toEqual([])
})
it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => {
const { events } = await bootRegistries()
const fallback = eventDefinition('unknown')
const dispose = events.registerFallback(fallback)
expect(events.fallbackEntry()).toBe(fallback)
expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/)
dispose()
dispose()
expect(events.fallbackEntry()).toBeUndefined()
})
it('rejects duplicate view targets and disposes a view registration once', async () => {
const { views } = await bootRegistries()
const definition = viewDefinition('chat')
const dispose = views.register(definition)
expect(views.entries()).toEqual([definition])
expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/)
dispose()
dispose()
expect(views.entries()).toEqual([])
})
it('removes Event, fallback, and view contributions with their caller fiber', async () => {
const { ctx, events, views } = await bootRegistries()
const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => {
featureCtx.conversationEvents.register(eventDefinition('message'))
featureCtx.conversationEvents.registerFallback(eventDefinition('unknown'))
featureCtx.conversationViews.register(viewDefinition('chat'))
})
await feature.await()
expect(events.entries()).toHaveLength(1)
expect(events.fallbackEntry()).toBeDefined()
expect(views.entries()).toHaveLength(1)
await feature.dispose()
expect(events.entries()).toEqual([])
expect(events.fallbackEntry()).toBeUndefined()
expect(views.entries()).toEqual([])
})
it('coalesces registry changes into one rebuild of every resident Session', async () => {
const { ctx, events, views } = await bootRegistries()
const api = new FakeApiClient()
const sessionId = 'resident' as SessionId
api.onList = () => Promise.resolve(ok({
items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
}) as never)
const sessions = new SessionsService(ctx, api)
await sessions.refresh()
await Promise.resolve()
sessions.scope(sessionId)
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
events.register(eventDefinition('message'))
views.register(viewDefinition('chat'))
await Promise.resolve()
expect(rebuild).toHaveBeenCalledOnce()
rebuild.mockRestore()
})
})

View File

@@ -54,12 +54,12 @@ export const ev = {
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch-start',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
}),
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
}),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
@@ -105,7 +105,7 @@ export const ev = {
...text === undefined ? {} : { text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
} }),
/** A compaction's log-only `compact/summary` provenance record. */
/** A compaction's log-only `compact/summary` record. */
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
at(seq, { type: 'compact/summary', data: {
summary: text(summary),
@@ -140,7 +140,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
]
}
/** Wrap raw events as view-less history entries (the wire shape history now returns). */
/** Wrap raw events as view-less history entries (the wire shape history returns). */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
}

View File

@@ -3,7 +3,7 @@
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
@@ -64,7 +64,7 @@ export class FakeApiClient implements IApiClient {
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => 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-official', model: 'deepseek-v4-flash' }
readonly defaultModel: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
@@ -82,7 +82,7 @@ export class FakeApiClient implements IApiClient {
failures: [],
}))
onSelectModel: (payload: { provider: string; model: string }) =>
Promise<RpcResponse<{ selected: ModelTarget }>> =
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 }>> =
@@ -143,10 +143,14 @@ export class FakeApiClient implements IApiClient {
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
= () => Promise.resolve(ok({ accepted: true as const }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
}
readonly host: IApiClient['host'] = {

View File

@@ -12,7 +12,7 @@ const at = (seq: number, event: Record<string, unknown>): 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 provenance projection is pinned on both sides independently.
// the source projection is pinned on both sides independently.
const injected = at(0, {
type: 'user/message',
surfaceOp: 'append',
@@ -168,6 +168,37 @@ describe('projectConversationHistory', () => {
})
})
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'),

View File

@@ -842,7 +842,7 @@ describe('connected generation', () => {
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api)
const openedSession = manager.get(S1)

View File

@@ -1,5 +1,6 @@
/**
* Projection value store (session-projection RFC, push model): the single
* Projection value store (push model; session-projection subsystem page:
* docs/subsystems/session-projection.md): 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
@@ -14,7 +15,7 @@ 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
// Test-domain keys merged into the projection map (the Service Definition package's
// pure-type outlet), the same way domain host plugins merge theirs.
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {

View File

@@ -158,7 +158,6 @@ describe('queue snapshot intake', () => {
type: 'session/event', sessionId: SID, event: durable,
})
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1)
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
{ id: 's-later', body: '', placement: 'steering', message },

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
/**
* SlotsService terminal-design account (design.md §11-3 main landing):
* SlotsService terminal-design account:
* built-in 'root', the three load-time throws (duplicate declaration /
* undeclared contribution / cross-scope store handle), the renderer install
* seam (double install / not installed / non-root key), store instance
* undeclared contribution / cross-scope store handle), the renderer installation
* 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'
@@ -92,13 +92,13 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost {
return host
}
/** Minimal independent Workspace list source for the renderer host seam. */
/** Minimal independent Workspace list source for the renderer host contract. */
function fakeWorkspaces() {
const state = { items: [], phase: 'ready' as const }
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
}
/** Minimal sessions face for the host seam (list observable + current provide projection). */
/** Minimal sessions face for the host contract (list observable + current provide projection). */
function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }

View File

@@ -0,0 +1,89 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts'
import {
MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree,
} from '../src/client/sessions/tool-call-tree.ts'
const at = (seq: number, type: string, data: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
const start = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
at(seq, 'tool/code-dispatch-start', {
parentCallId, subCallId, name: 'run_code', arguments: {},
})
const settle = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
at(seq, 'tool/code-dispatch', {
parentCallId, subCallId, name: 'run_code', arguments: {},
isError: false, content: [],
})
const root = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
time: 1_700_000_000_000, callView: null, subCalls: [],
})
describe('ToolCallTree', () => {
it('rejects a self-parenting dispatch edge', () => {
const tree = new ToolCallTree()
const roots = [root('root')]
expect(tree.apply(start(0, 'root', 'root'))).toBe(true)
expect(tree.projectRunningCalls(roots)).toBe(roots)
})
it('rejects a settling edge that would close a multi-call cycle', () => {
const tree = new ToolCallTree()
tree.apply(start(0, 'a', 'b'))
tree.apply(start(1, 'b', 'c'))
expect(tree.apply(settle(2, 'c', 'a'))).toBe(true)
expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
callId: 'a',
subCalls: [{
callId: 'b',
subCalls: [{ callId: 'c', subCalls: [] }],
}],
}])
})
it('accepts an acyclic graph with a shared descendant', () => {
const tree = new ToolCallTree()
tree.apply(start(0, 'a', 'b'))
tree.apply(start(1, 'a', 'c'))
tree.apply(start(2, 'b', 'd'))
tree.apply(start(3, 'c', 'd'))
expect(tree.apply(start(4, 'root', 'a'))).toBe(true)
expect(tree.projectRunningCalls([root('root')])).toMatchObject([{
callId: 'root',
subCalls: [{
callId: 'a',
subCalls: [{ callId: 'b' }, { callId: 'c' }],
}],
}])
})
it('rejects an edge beyond the recursive depth safety limit', () => {
const tree = new ToolCallTree()
for (let depth = 1; depth < MAX_TOOL_CALL_TREE_DEPTH; depth++) {
tree.apply(start(depth, `call-${depth - 1}`, `call-${depth}`))
}
expect(tree.apply(start(
MAX_TOOL_CALL_TREE_DEPTH,
`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`,
`call-${MAX_TOOL_CALL_TREE_DEPTH}`,
))).toBe(true)
let current: ToolCallBlock = tree.projectRunningCalls([root('call-0')])[0]!
let depth = 1
while (current.subCalls.length > 0) {
current = current.subCalls[0]!
depth++
}
expect(depth).toBe(MAX_TOOL_CALL_TREE_DEPTH)
expect(current.callId).toBe(`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`)
})
})

View File

@@ -1,567 +0,0 @@
/**
* TranscriptAdapter over the raw append-only window: log-ordered projection of
* append-origin events, one marker per landed compaction, replacement copies
* hidden, command-lifecycle folding, node/array identity, call pairing, and
* host-provided wire views.
*/
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
/** A `compact/summary` provenance event (log-only, no surfaceOp). */
function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent {
return at(seq, {
type: 'compact/summary',
data: {
summary,
shadowedRange: { start: 1, end: 3 },
shadowedSeqs: [1, 3],
shadowedTokenCount: 100,
provider: 'fake',
model: 'compact-1',
},
})
}
/** The replacement user message a compaction backend lands (the checkpoint). */
function checkpoint(
seq: number,
summarySeq: number,
{ start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: {
start?: number
end?: number
sourceEventSeqs?: number[]
} = {},
): SessionEvent {
return at(seq, {
type: 'user/message',
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs,
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
})
}
describe('TranscriptAdapter', () => {
it('projects a window starting past seq 0 at its own log positions', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(100, 5, '偏移问', '偏移答'))
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
})
it('appends incrementally keeping old node references (materialize-once identity)', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'))
const first = adapter.nodes()
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second).toHaveLength(3)
expect(second[0]).toBe(first[0])
expect(second[1]).toBe(first[1])
expect(second).not.toBe(first) // a real change swaps the array
})
it('keeps the array reference across a chunk storm and swaps it when a node lands', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'))
const settled = adapter.nodes()
adapter.append(ev.chunkStart(6, 1))
expect(adapter.nodes()).toBe(settled)
adapter.append(ev.chunkText(7, 1, '流式'))
expect(adapter.nodes()).toBe(settled)
adapter.append(ev.assistant(8, 1, '流式完成'))
const finalized = adapter.nodes()
expect(finalized).not.toBe(settled)
expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 })
})
it('materializes every append-origin variant with field mapping', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
})
adapter.reset([
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(7, 0, 'c1', '结果'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
})
})
it('identifies steering on the live append path', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: 'live steer' }],
source: { kind: 'user' },
})
adapter.reset([])
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }))
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }))
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
})
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
const adapter = new TranscriptAdapter()
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
const context = createUserMessage({
content: [{ type: 'text', text: 'context' }],
source: { kind: 'plugin', plugin: 'test' },
})
adapter.reset([
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, inserted: [queued],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [canceled],
} }),
at(4, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
} }),
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
at(6, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [context],
} }),
at(7, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
])
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
})
it('materializes a skill-invocation injection as a named instructions context', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '/hidden-demo check the fixture' }],
source: { kind: 'user' },
}) }),
at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>' }],
source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never,
}) }),
])
const nodes = adapter.nodes()
// The gesture stays a user bubble; the injected body folds to a context
// row named after the skill, presented as instructions.
expect(nodes.map(node => node.kind)).toEqual(['user', 'context'])
expect(nodes[1]).toMatchObject({
provenance: { role: 'inject', label: 'hidden-demo' },
form: 'instructions',
})
})
it('skips events core does not call surface-eligible, marker or not', () => {
// The transcript is the append-origin surface, so log-only events (a chunk,
// a turn boundary, a compact/* provenance record) and a future type core
// has not admitted contribute no node.
const adapter = new TranscriptAdapter()
adapter.reset([
ev.turnStart(0, 1),
at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }),
compactSummary(2),
ev.user(3, '唯一的一条'),
ev.turnEnd(4, 1),
])
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]])
})
describe('compaction markers', () => {
it('keeps the original messages and full tool output, hiding replacement copies', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '原始问题'),
ev.assistant(1, 0, '原始回答'),
ev.toolCall(4, 0, 'c1', 'echo', '{}'),
ev.toolResult(5, 0, 'c1', '完整工具输出'),
// A pruned tool/result copy: rewrites one node for the model, marks nothing.
at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: {
turn: 0, step: 0,
message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }),
} }),
compactSummary(7),
checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }),
// A regenerated assistant/message: also a silent model-only rewrite.
at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: {
turn: 0, step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '通用 replacement 副本' }],
source: { kind: 'model', ...{ provider: 'x', model: 'copy' } },
}),
} }),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([
['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8],
])
expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] })
expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' })
})
it('adds one marker per landed compaction, in log order', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, 'a'),
compactSummary(1, [{ type: 'text', text: 'first' }]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
ev.user(3, 'b'),
compactSummary(4, [{ type: 'text', text: 'second' }]),
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
])
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first',
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
},
{
kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second',
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
},
])
})
it('renders the marker when the shadowed range is outside the window and logs nothing', () => {
// The pagination hole A1 left open: quota is no longer spent on
// replacement copies, so a page can carry a checkpoint whose
// surfaceOp.start lies below the window head. The old surface fold threw
// on the missing range and degraded with a console error; a log-ordered
// projection has no range to resolve.
const adapter = new TranscriptAdapter()
const noise = { error: console.error, warn: console.warn }
const logged: unknown[] = []
console.error = (...args: unknown[]) => logged.push(args)
console.warn = (...args: unknown[]) => logged.push(args)
try {
adapter.reset([
compactSummary(80, [{ type: 'text', text: '窗外范围' }]),
checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }),
ev.user(82, '压缩后的新问题'),
])
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' })
} finally {
console.error = noise.error
console.warn = noise.warn
}
expect(logged).toEqual([])
})
it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => {
// A session-reference card carries the same plugin source shape; only the
// replacement marker makes an event a checkpoint.
const adapter = new TranscriptAdapter()
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '注入的上下文' }],
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
}) }),
])
expect(adapter.nodes()).toMatchObject([{
kind: 'context',
seq: 0,
provenance: { role: 'inject', label: 'compact' },
form: 'instructions',
}])
})
it('ignores a foreign plugin s replacement user/message', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '保留'),
at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({
content: [{ type: 'text', text: '别的插件重写' }],
source: { kind: 'plugin', plugin: 'not-compact' },
}) }),
])
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]])
})
it.each([
['absent provenance', undefined],
['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])],
['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])],
['an empty summary array', compactSummary(1, [])],
['a non-array summary', compactSummary(1, 'plain string')],
])('degrades %s to a non-expandable marker', (_label, summary) => {
const adapter = new TranscriptAdapter()
adapter.reset([
...(summary === undefined ? [] : [summary]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toMatchObject([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
])
})
it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => {
// ContentBlock is merge-extensible and the payload type is ContentBlock[],
// so a non-text block must not discard recoverable text beside it.
const adapter = new TranscriptAdapter()
adapter.reset([
compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toEqual([
{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
},
])
})
it('leaves the summary null when the checkpoint records no provenance at all', () => {
const adapter = new TranscriptAdapter()
adapter.reset([at(2, {
type: 'user/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>x</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
})])
expect(adapter.nodes()).toEqual([{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}])
})
it('skips a non-summary provenance seq before reaching the real one', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '被压缩的问题'),
at(1, { type: 'compact/start', data: { turn: 0 } }),
compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]),
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }),
])
expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' })
})
it('resolves the summary once an older page supplies the provenance', () => {
const adapter = new TranscriptAdapter()
const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] })
adapter.reset([landed])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null })
adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' })
})
it('creates the marker on the live append path', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'))
adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }]))
adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] }))
const nodes = adapter.nodes()
// The compacted history is still there; the marker is one more row after it.
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' })
})
})
it('returns call:null for a tool-result whose call fell outside the window', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
})
it('materializes a tool-result error field when present', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
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' },
} }),
])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('attaches wire views to the materialized result node', () => {
const adapter = new TranscriptAdapter()
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
adapter.reset([
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
ev.toolResult(1, 1, 'c1', 'listing'),
], [callView, resultView] as never)
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' },
})
})
it('attaches views on the live append path and defaults to null without views', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
callView: { title: '回声' }, resultView: null,
})
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new TranscriptAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never)
expect(adapter.nodes()[0]).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 TranscriptAdapter()
adapter.reset([
ev.user(0, '先说话'),
ev.commandRun(1, 'cmd-1', 'plan'),
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
ev.assistant(3, 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 TranscriptAdapter()
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null })
})
it('represents command input omitted by the host as null', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')])
expect(adapter.nodes()[0]).toMatchObject({
kind: 'command', name: 'feedback', args: null, outcome: null,
})
})
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')])
expect(adapter.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 TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'q', 'a'))
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
const running = adapter.nodes().find(n => n.kind === 'command')
expect(running).toMatchObject({ outcome: null })
adapter.append(ev.commandDone(7, 'cmd-4'))
const settled = adapter.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 transcript node', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')])
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
})
it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '压缩前的问题'),
ev.commandRun(1, 'cmd-compact', 'compact'),
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
expect(nodes[1]).toMatchObject({
name: 'compact',
outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
})
expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
})
})
describe('assistant timing', () => {
const base = 1_700_000_000_000
it('derives step timing across a window rebuild (start + first token + completion)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.turnStart(0, 0),
ev.user(1, '问'),
ev.stepStart(2, 0),
ev.chunkStart(3, 0),
ev.chunkText(4, 0, '答'),
ev.chunkText(5, 0, '案'),
ev.assistant(6, 0, '答案'),
ev.turnEnd(7, 0),
])
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
})
})
it('derives the same timing on the live append path, first token winning once', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.user(0, '问')])
adapter.append(ev.stepStart(1, 0))
adapter.append(ev.chunkText(2, 0, '首'))
adapter.append(ev.chunkText(3, 0, '次'))
adapter.append(ev.assistant(4, 0, '首次'))
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
})
})
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
})
})
})
})

View File

@@ -1,5 +1,5 @@
/**
* Wire-to-typed-event bridge (web input-triggers cut 1): host/commands-changed
* Wire-to-typed-event bridge: host/commands-changed
* → ctx 'commands/changed'; each established connection generation →
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
*/