Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

This commit is contained in:
Yichen Jiang
2026-08-08 22:51:17 +08:00
910 changed files with 17011 additions and 6270 deletions

View File

@@ -36,7 +36,10 @@ 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 }])
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", () => {

View File

@@ -92,8 +92,19 @@ export const ev = {
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
commandDone: (
seq: number,
commandId: string,
kind: 'success' | 'error' = 'success',
text?: string,
sourceEventSeq?: number,
): SessionEvent =>
at(seq, { type: 'command/done', data: {
commandId,
kind,
...text === undefined ? {} : { text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
} }),
/** A compaction's log-only `compact/summary` provenance record. */
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
at(seq, { type: 'compact/summary', data: {

View File

@@ -140,10 +140,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'] = {
@@ -198,6 +202,7 @@ export class FakeApiClient implements IApiClient {
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),

View File

@@ -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

@@ -639,7 +639,7 @@ describe('paging', () => {
})
describe('prompt and cancel errors', () => {
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
@@ -650,7 +650,7 @@ describe('prompt and cancel errors', () => {
const cancelled = await session.cancel()
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
])
@@ -660,15 +660,37 @@ describe('prompt and cancel errors', () => {
content: [{ type: 'text', text: '继续' }],
},
])
expect(api.callsOf('subagent.interrupt')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
// A successful interrupt leaves no stop error behind.
expect(session.getSnapshot().promptError).toBeNull()
expect(session.getSnapshot().subagent).toEqual({
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
})
it('lands an interrupt business failure in promptError with op=stop', async () => {
const api = new FakeApiClient()
api.onSubagentInterrupt = () => Promise.resolve(err({
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
}) as never)
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
await session.open()
const cancelled = await session.cancel()
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } })
expect(session.getSnapshot().promptError).toMatchObject({
op: 'stop', error: { code: 'subagent-unauthorized' },
})
})
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
@@ -676,12 +698,16 @@ describe('prompt and cancel errors', () => {
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
const cancelled = await session.cancel()
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([])
expect(api.callsOf('subagent.interrupt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
})
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
@@ -1151,7 +1177,17 @@ describe('resync', () => {
})
describe('run_code sub-dispatch indexing', () => {
describe('nested run_code sub-dispatches', () => {
const subCallsOf = (session: Session, callId: string) => {
const snapshot = session.getSnapshot()
const running = snapshot.runningCalls.find(call => call.callId === callId)
if (running !== undefined) return running.subCalls
for (const node of snapshot.nodes) {
if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls
}
return undefined
}
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
@@ -1161,19 +1197,19 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
const live = session.getSnapshot().codeDispatches.get('p1')
const live = subCallsOf(session, 'p1')
expect(live).toHaveLength(2)
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
const mixed = session.getSnapshot().codeDispatches.get('p1')
const mixed = subCallsOf(session, 'p1')
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
// The settle carries the paired start's time as callTime (duration source).
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
const settled = session.getSnapshot().codeDispatches.get('p1')
const settled = subCallsOf(session, 'p1')
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
})
@@ -1187,7 +1223,7 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
const subs = session.getSnapshot().codeDispatches.get('p1')
const subs = subCallsOf(session, 'p1')
expect(subs).toHaveLength(2)
expect(subs?.[0]).toMatchObject({
kind: 'tool-result', callId: 'p1:code:1',
@@ -1205,23 +1241,29 @@ describe('run_code sub-dispatch indexing', () => {
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
})
it('rebuilds the same index from a history window (replay parity)', async () => {
it('rebuilds the same nested tree from a history window (replay parity)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([
...plainTurn(0, 0, '问', '答'),
ev.turnStart(6, 1),
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'),
ev.toolResult(9, 1, 'p1', '{"done":true}'),
ev.turnEnd(10, 1),
ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }),
ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'),
ev.toolResult(11, 1, 'p1', '{"done":true}'),
ev.turnEnd(12, 1),
])
await session.open()
const subs = session.getSnapshot().codeDispatches.get('p1')
const subs = subCallsOf(session, 'p1')
expect(subs).toHaveLength(1)
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
expect(subs?.[0]).toMatchObject({
callId: 'p1:code:1',
call: { name: 'run_code' },
subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }],
})
})
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => {
it('keeps an unaffected root reference and path-copies it on a new child', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
@@ -1230,13 +1272,48 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
const before = session.getSnapshot()
const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')!
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '流式'))
const after = session.getSnapshot()
expect(after.codeDispatches).toBe(before.codeDispatches)
const afterRoot = after.runningCalls.find(call => call.callId === 'p1')!
expect(afterRoot).toBe(beforeRoot)
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')!
expect(changedRoot).not.toBe(afterRoot)
expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0])
expect(changedRoot.subCalls).toHaveLength(2)
})
it('path-copies only the owning branch when a nested child changes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}'))
feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}'))
feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child'))
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling'))
feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two'))
const before = session.getSnapshot()
const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')!
const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')!
const beforeChild = beforeFirst.subCalls[0]!
const beforeSibling = beforeFirst.subCalls[1]!
feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf'))
const after = session.getSnapshot()
const afterFirst = after.runningCalls.find(call => call.callId === 'p1')!
const afterSecond = after.runningCalls.find(call => call.callId === 'p2')!
expect(afterFirst).not.toBe(beforeFirst)
expect(afterSecond).toBe(beforeSecond)
expect(afterFirst.subCalls[0]).not.toBe(beforeChild)
expect(afterFirst.subCalls[1]).toBe(beforeSibling)
expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([
{ callId: 'p1:code:1:code:1', call: { name: 'read' } },
])
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { indexSubagentDescendants } from '@deepseek-ai/dsh-client-runtime/client'
const sid = (id: string) => id as SessionId
function summary(
id: string,
parentId?: SessionId,
origin?: 'subagent',
running = false,
): SessionSummary {
return {
id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0,
...(parentId === undefined ? {} : { parentId }),
...(origin === undefined ? {} : { origin }),
}
}
function index(...summaries: SessionSummary[]) {
return indexSubagentDescendants(Object.fromEntries(
summaries.map(item => [item.id, item]),
))
}
describe('indexSubagentDescendants', () => {
it('counts every nested descendant and its exact running state', () => {
const owner = summary('owner')
const child = summary('child', owner.id, 'subagent')
const grandchild = summary('grandchild', child.id, 'subagent', true)
const result = index(owner, child, grandchild)
expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 })
expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 })
})
it('stops at ordinary forks and fails soft on cycles and missing parents', () => {
const owner = summary('owner')
const child = summary('child', owner.id, 'subagent', true)
const fork = summary('fork', child.id)
const forkChild = summary('fork-child', fork.id, 'subagent', true)
const orphan = summary('orphan', sid('missing'), 'subagent', true)
const cycleA = summary('cycle-a', sid('cycle-b'), 'subagent')
const cycleB = summary('cycle-b', sid('cycle-a'), 'subagent')
const result = index(owner, child, fork, forkChild, orphan, cycleA, cycleB)
expect(result.get(owner.id)).toEqual({ count: 1, runningCount: 1 })
expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 })
expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 })
expect(result.get(cycleA.id)).toEqual({ count: 2, runningCount: 0 })
expect(result.get(cycleB.id)).toEqual({ count: 2, runningCount: 0 })
})
})

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

@@ -164,6 +164,28 @@ describe('TranscriptAdapter', () => {
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
@@ -223,8 +245,14 @@ describe('TranscriptAdapter', () => {
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' },
{ kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' },
{
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,
},
])
})
@@ -296,7 +324,7 @@ describe('TranscriptAdapter', () => {
...(summary === undefined ? [] : [summary]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toEqual([
expect(adapter.nodes()).toMatchObject([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
])
})
@@ -310,7 +338,10 @@ describe('TranscriptAdapter', () => {
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: '可用摘要' },
{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
},
])
})
@@ -324,7 +355,10 @@ describe('TranscriptAdapter', () => {
source: { kind: 'plugin', plugin: 'compact' },
}),
})])
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }])
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', () => {
@@ -468,20 +502,22 @@ describe('TranscriptAdapter', () => {
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
})
it('renders the /compact row alongside the marker its own command produced', () => {
// The row that reports the compaction is a command node; dropping command
// folding would delete it together with every other slash-command row.
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', '已压缩'),
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: '已压缩' } })
expect(nodes[1]).toMatchObject({
name: 'compact',
outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
})
expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
})
})