Merge remote-tracking branch 'origin/master' into worktree/session-reference
# Conflicts: # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # packages/compact/compact-basic/src/region.ts # packages/compact/compact/README.md # packages/compact/compact/tests/compact.spec.ts # packages/examples/acp-demo/package.json # packages/ui/tui/README.md # packages/ui/tui/package.json # packages/ui/tui/src/index.ts # packages/ui/tui/tests/harness.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers |
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
@@ -63,7 +63,7 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
|
||||
A successful implementation replaces an older surface range with one user-role summary checkpoint — a `user/message` carrying `surfaceOp: { op: 'replace', start, end }`; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -73,20 +73,6 @@ Zero direct tokens from this interface. A backend trades many retained history t
|
||||
|
||||
A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request.
|
||||
|
||||
### Transcript supplied to a compaction consumer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`renderTranscript()` joins entries with one blank line and renders them exactly as `User: <content>`, `Assistant: <content>`, `Tool result (call <callId>): <content>`, `Tool error (call <callId>): <content>`, `[Context: <content>]`, or `[Steering: <content>]`. Non-text blocks render exactly as `[reasoning: <text>]`, `[tool-call: <name>(<arguments>)]`, `[tool-result: <content>]`, `[tool-result]`, or `[<block-type>]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
|
||||
|
||||
@@ -11,22 +11,29 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactionResult } from './types.ts'
|
||||
|
||||
export type { CompactionResult } from './types.ts'
|
||||
export { renderContentBlocks, renderTranscript } from './render.ts'
|
||||
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
|
||||
|
||||
/** Canonical source for the replacement user message produced by every compaction backend. */
|
||||
|
||||
111
packages/compact/compact/src/invariant.ts
Normal file
111
packages/compact/compact/src/invariant.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type {} from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-compact'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'compact-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
interface CompactionTrace {
|
||||
turn: number
|
||||
summarized: boolean
|
||||
}
|
||||
|
||||
type CompactionTransition =
|
||||
| { kind: 'start'; turn: number }
|
||||
| { kind: 'summary'; turn: number }
|
||||
| { kind: 'end' }
|
||||
|
||||
/** Validate one compaction event without advancing committed trace state. */
|
||||
function validateCompactionEvent(
|
||||
open: CompactionTrace | undefined,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): CompactionTransition | undefined {
|
||||
if (event.type === 'compact/start') {
|
||||
if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`)
|
||||
return { kind: 'start', turn: event.data.turn }
|
||||
}
|
||||
if (event.type === 'compact/summary') {
|
||||
if (open === undefined) fail('compact/summary has no matching compact/start')
|
||||
if (open.summarized) fail('compact/summary repeated within one compaction')
|
||||
const seqs = event.data.shadowedSeqs
|
||||
if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty')
|
||||
if (seqs[0] !== event.data.shadowedRange.start || seqs.at(-1) !== event.data.shadowedRange.end) {
|
||||
fail('compact/summary shadowedRange must match the first and last shadowedSeqs')
|
||||
}
|
||||
if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) {
|
||||
fail('compact/summary shadowedTokenCount must be a non-negative safe integer')
|
||||
}
|
||||
return { kind: 'summary', turn: open.turn }
|
||||
}
|
||||
if (event.type !== 'compact/end') return undefined
|
||||
if (open === undefined) fail('compact/end has no matching compact/start')
|
||||
if (event.data.turn !== open.turn) {
|
||||
fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`)
|
||||
}
|
||||
if (event.data.error === undefined && !open.summarized) {
|
||||
fail('successful compact/end requires one compact/summary')
|
||||
}
|
||||
return { kind: 'end' }
|
||||
}
|
||||
|
||||
/** Apply one committed compaction transition. */
|
||||
function applyCompactionTransition(
|
||||
transition: CompactionTransition,
|
||||
): CompactionTrace | undefined {
|
||||
if (transition.kind === 'start') return { turn: transition.turn, summarized: false }
|
||||
if (transition.kind === 'summary') return { turn: transition.turn, summarized: true }
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Install compaction start/summary/end checks. */
|
||||
// Event owners keep precommit staging local so their vocabularies never move into a central helper.
|
||||
/* jscpd:ignore-start */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const traces = new WeakMap<Session, CompactionTrace>()
|
||||
const staged = new WeakMap<SessionEvent, { session: Session; transition: CompactionTransition }>()
|
||||
const seed = (session: Session): void => {
|
||||
let open: CompactionTrace | undefined
|
||||
for (const event of session.events) {
|
||||
const transition = validateCompactionEvent(open, event, fail)
|
||||
if (transition !== undefined) open = applyCompactionTransition(transition)
|
||||
}
|
||||
if (open !== undefined) traces.set(session, open)
|
||||
}
|
||||
const traceFor = (session: Session): CompactionTrace | undefined => traces.get(session)
|
||||
|
||||
for (const session of ctx.sessions.list()) seed(session)
|
||||
ctx.on('session/created', (session) => { seed(session) }, { global: true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return
|
||||
const candidate = staged.get(event)
|
||||
/* v8 ignore next -- internal/dispatch stages every compaction event */
|
||||
if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation')
|
||||
staged.delete(event)
|
||||
const next = applyCompactionTransition(candidate.transition)
|
||||
if (next === undefined) traces.delete(session)
|
||||
else traces.set(session, next)
|
||||
}, { global: true })
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const transition = validateCompactionEvent(traceFor(session), event, fail)
|
||||
if (transition !== undefined) staged.set(event, { session, transition })
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the compact invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Pure shared transcript projection for summarization and recall, so both
|
||||
* render the same log span byte-for-byte under replay.
|
||||
* @module @deepseek-ai/dsh-compact/render
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Render text directly, reasoning as a tagged span, and every other block as a
|
||||
* type-tagged placeholder. Tool results recurse into nested content; empty
|
||||
* blocks contribute nothing and rendered blocks join with newlines.
|
||||
*
|
||||
* @param blocks - the content blocks to render.
|
||||
* @returns the newline-joined plain-text rendering; empty string when nothing renders.
|
||||
*/
|
||||
export function renderContentBlocks(blocks: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (block.text) parts.push(block.text)
|
||||
break
|
||||
case 'reasoning':
|
||||
if (block.text) parts.push(`[reasoning: ${block.text}]`)
|
||||
break
|
||||
case 'tool-call':
|
||||
parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
|
||||
break
|
||||
case 'tool-result': {
|
||||
const inner = renderContentBlocks(block.content)
|
||||
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
|
||||
break
|
||||
}
|
||||
// ContentBlockMap is merge-extensible — render an unknown block as a
|
||||
// bare type-tagged placeholder so a plugin-added block type is still
|
||||
// signalled to the reader rather than dropped.
|
||||
default:
|
||||
parts.push(`[${(block as ContentBlock).type}]`)
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render message-producing events as a role-labeled transcript. `seqs` are
|
||||
* walked in caller-supplied surface order, which may differ from numeric log
|
||||
* order after replacement; non-surface and unknown merged events are skipped.
|
||||
*
|
||||
* @param events - the session log the seqs index into (`session.events`).
|
||||
* @param seqs - the surface-node seqs to render, in surface order.
|
||||
* @returns the transcript, entries joined by blank lines; empty string when nothing renders.
|
||||
*/
|
||||
export function renderTranscript(events: readonly SessionEvent[], seqs: readonly number[]): string {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const seq of seqs) {
|
||||
const event = events[seq]
|
||||
if (!event) continue
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`User: ${text}`)
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`Assistant: ${text}`)
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
const label = event.data.isError ? 'Tool error' : 'Tool result'
|
||||
if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`[Context: ${text}]`)
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`[Steering: ${text}]`)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
90
packages/compact/compact/tests/invariant.spec.ts
Normal file
90
packages/compact/compact/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const summary = (overrides: Record<string, unknown> = {}) => ({
|
||||
summary: [{ type: 'text' as const, text: 'short' }],
|
||||
shadowedRange: { start: 2, end: 4 },
|
||||
shadowedSeqs: [2, 3, 4],
|
||||
shadowedTokenCount: 12,
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('compaction invariants', () => {
|
||||
it('accepts successful and failed compaction lifecycles', async () => {
|
||||
const ctx = await setup()
|
||||
const success = ctx.sessions.create()
|
||||
success.append('compact/start', { turn: 1 })
|
||||
success.append('compact/summary', summary())
|
||||
success.append('compact/end', { turn: 1 })
|
||||
|
||||
const failed = ctx.sessions.create()
|
||||
failed.append('compact/start', { turn: 2 })
|
||||
failed.append('compact/end', { turn: 2, error: 'provider failed' })
|
||||
})
|
||||
|
||||
it('rebuilds an open trace when the companion loads after the session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('compact/start', { turn: 3 })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
expect(() => session.append('compact/end', { turn: 3, error: 'resume failed' })).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['summary without start', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/summary', summary())
|
||||
}, /no matching compact\/start/],
|
||||
['nested start', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/start', { turn: 2 })
|
||||
}, /still compacting/],
|
||||
['repeated summary', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/summary', summary())
|
||||
session.append('compact/summary', summary())
|
||||
}, /repeated within one compaction/],
|
||||
['empty shadow set', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/summary', summary({ shadowedSeqs: [] }))
|
||||
}, /shadowedSeqs must be non-empty/],
|
||||
['wrong endpoints', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/summary', summary({ shadowedRange: { start: 1, end: 4 } }))
|
||||
}, /shadowedRange must match/],
|
||||
['invalid token count', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/summary', summary({ shadowedTokenCount: -1 }))
|
||||
}, /non-negative safe integer/],
|
||||
['end without start', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/end', { turn: 1, error: 'failed' })
|
||||
}, /no matching compact\/start/],
|
||||
['wrong end turn', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/end', { turn: 2, error: 'failed' })
|
||||
}, /does not match/],
|
||||
['success without summary', (session: ReturnType<Context['sessions']['create']>) => {
|
||||
session.append('compact/start', { turn: 1 })
|
||||
session.append('compact/end', { turn: 1 })
|
||||
}, /requires one compact\/summary/],
|
||||
])('rejects %s', async (_name, action, message) => {
|
||||
const ctx = await setup()
|
||||
expect(() => { action(ctx.sessions.create()) }).toThrow(message)
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderContentBlocks, renderTranscript } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
function session(): Session {
|
||||
return new Session(SessionId('render-spec'))
|
||||
}
|
||||
|
||||
describe('renderContentBlocks', () => {
|
||||
it('renders text blocks verbatim and skips empty ones', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'text', text: '' },
|
||||
{ type: 'text', text: 'world' },
|
||||
])).toBe('hello\nworld')
|
||||
})
|
||||
|
||||
it('wraps reasoning, skipping empty reasoning', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'reasoning', text: 'think' },
|
||||
{ type: 'reasoning', text: '' },
|
||||
])).toBe('[reasoning: think]')
|
||||
})
|
||||
|
||||
it('renders tool-call as a name(args) placeholder', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"filePath":"a"}' },
|
||||
])).toBe('[tool-call: read({"filePath":"a"})]')
|
||||
})
|
||||
|
||||
it('renders tool-result with nested content, and bare when empty', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
|
||||
{ type: 'tool-result', toolCallId: CallId('c2'), content: [] },
|
||||
])).toBe('[tool-result: ok]\n[tool-result]')
|
||||
})
|
||||
|
||||
it('renders an unknown (merge-extended) block type as a bare type tag', () => {
|
||||
const unknown = { type: 'image', data: 'zzz' } as unknown as ContentBlock
|
||||
expect(renderContentBlocks([unknown])).toBe('[image]')
|
||||
})
|
||||
|
||||
it('returns the empty string for no blocks', () => {
|
||||
expect(renderContentBlocks([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderTranscript', () => {
|
||||
it('renders each surface event type with its label, in the seq order given', () => {
|
||||
const s = session()
|
||||
const user = s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'fix the bug' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: 'looking' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
const result = s.append('tool/result', {
|
||||
turn: 0, step: 0, callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'exit 0' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
const context = s.append('context/message', {
|
||||
content: [{ type: 'text', text: 'file changed' }],
|
||||
source: { kind: 'plugin', plugin: 'fs' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const steering = s.append('steering/message', {
|
||||
turn: 0,
|
||||
content: [{ type: 'text', text: 'stop that' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
expect(renderTranscript(s.events, [user.seq, assistant.seq, result.seq, context.seq, steering.seq])).toBe([
|
||||
'User: fix the bug',
|
||||
'Assistant: looking',
|
||||
'Tool result (call c1): exit 0',
|
||||
'[Context: file changed]',
|
||||
'[Steering: stop that]',
|
||||
].join('\n\n'))
|
||||
})
|
||||
|
||||
it('labels an error tool result "Tool error"', () => {
|
||||
const s = session()
|
||||
const result = s.append('tool/result', {
|
||||
turn: 0, step: 0, callId: CallId('c9'),
|
||||
content: [{ type: 'text', text: 'boom' }],
|
||||
isError: true,
|
||||
}, { surfaceOp: 'append' })
|
||||
expect(renderTranscript(s.events, [result.seq])).toBe('Tool error (call c9): boom')
|
||||
})
|
||||
|
||||
it('renders NON-log-order seqs in the order given (surface order after a replace)', () => {
|
||||
const s = session()
|
||||
const first = s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'first' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const second = s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'second' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
expect(renderTranscript(s.events, [second.seq, first.seq])).toBe('User: second\n\nUser: first')
|
||||
})
|
||||
|
||||
it('skips events that render to nothing, non-message events, and seqs with no event', () => {
|
||||
const s = session()
|
||||
const empty = s.append('user/message', {
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyResult = s.append('tool/result', {
|
||||
turn: 0, step: 0, callId: CallId('c3'),
|
||||
content: [{ type: 'text', text: '' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyContext = s.append('context/message', {
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'plugin', plugin: 'fs' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptySteering = s.append('steering/message', {
|
||||
turn: 0,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
// A log-only (non-surface) event type: contributes nothing to a transcript.
|
||||
const lock = s.append('compact/start', { turn: 0 })
|
||||
expect(renderTranscript(s.events, [
|
||||
empty.seq, emptyAssistant.seq, emptyResult.seq, emptyContext.seq, emptySteering.seq, lock.seq, 9999,
|
||||
])).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user