fix(compact): harden pruning integration (round 2)

This commit is contained in:
Hypatia May
2026-07-16 18:28:31 +08:00
parent ce96104a77
commit 171bae5c20
19 changed files with 372 additions and 64 deletions

View File

@@ -15,8 +15,8 @@ This backend owns the compaction policy:
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Overflow recovery** — below-threshold overflow bypasses normal retention and first prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including pruning-only progress on an otherwise indivisible surface; no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error.
- **Overflow recovery** — below-threshold overflow bypasses normal retention and first prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.

View File

@@ -100,15 +100,28 @@ export class BasicCompactService extends CompactService {
|| retryAttempt >= this.config.maxOverflowRetries
|| signal.aborted) return next()
let generation: number
const generation = agent.session.surface.replaceGeneration
let result: CompactionResult | null
try {
generation = agent.session.surface.replaceGeneration
result = await this.compactIfNeeded(agent, 'context-overflow', signal)
} catch (recoveryError: unknown) {
const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
// A model-free prune can land before later summary work fails. That
// durable reduction is sufficient retry proof; do not discard it just
// because the optional second phase threw. Cancellation still wins.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
ctx.logger.warn(
`context-overflow compaction failed after durable surface progress: ${message}; `
+ 'retrying from the replacement surface',
)
return { action: 'retry' }
}
ctx.logger.warn(
`context-overflow compaction failed: ${message}; preserving the original request error`,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
`context-overflow compaction failed: ${message}; ${signal.aborted
? 'cancellation prevents retry'
: 'preserving the original request error'}`,
)
return next()
}

View File

@@ -1022,6 +1022,51 @@ describe('automatic listener and loader composition', () => {
expect(compact.calls[0]!.text).toContain('tool result middle pruned')
})
it('retries from a durable prune when later overflow summarization throws', async () => {
const ctx = createContext(10_000)
const warnings: string[] = []
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
void new ToolResultPruneService(ctx, {
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
const compact = new TestCompactService(ctx, {
thresholdRatio: 1,
retainTokens: 900,
})
compact.error = new Error('summary unavailable after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
.toMatchObject({ error: 'summary unavailable after prune' })
expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface'))
})
it('lets cancellation win when summary throws after a durable prune', async () => {
const ctx = createContext(10_000)
const controller = new AbortController()
void new ToolResultPruneService(ctx, {
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
const compact = new TestCompactService(ctx, {
thresholdRatio: 1,
retainTokens: 900,
})
compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') }
compact.error = new Error('summary cancelled after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
.toEqual({ action: 'fail' })
expect(session.surface.replaceGeneration).toBe(1)
})
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
const ctx = createContext()
void new TestCompactService(ctx, {

View File

@@ -31,7 +31,7 @@ Session log (per session):
- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns.
- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call, while a provenance-backed single-node `replace` is a turn-enclosed surface rewrite of an already-executed result. A `tool/call` may still have no result when the execution pipeline throws.
- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call. A replacement exemption applies only to a provenance-backed rewrite of one current `tool/result` node whose complete data is identical except for `content`; it must still be turn-enclosed. A `tool/call` may still have no result when the execution pipeline throws.
- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance.
Agent status (per agent):

View File

@@ -7,6 +7,7 @@
* @module @deepseek-ai/dsh-invariants
*/
import { isDeepStrictEqual } from 'node:util'
import type { Context } from 'cordis'
import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
@@ -50,13 +51,14 @@ interface SessionTrace {
pendingCalls: Set<CallId>
/** Every seq seen so far — validates `sourceEventSeqs` references. */
knownSeqs: Set<number>
/**
* The seqs currently on the surface linked list, in linked-list order
* (head to tail). A replace reorders this relative to seq order (the new
* node takes the replaced range's position), so range validation is
* positional, not by seq comparison.
*/
surface: number[]
/** Current surface nodes in linked-list order, with immutable event identity. */
surface: SurfaceTraceNode[]
}
/** Immutable identity retained only while an event is on the current surface. */
interface SurfaceTraceNode {
seq: number
event: SessionEvent<SurfaceEventType>
}
/** One accepted event's deferred mutation of a live session trace. */
@@ -70,8 +72,9 @@ interface SessionTraceTransition {
| { kind: 'clear' }
/** The event's mutation of the derived surface order. */
surface:
| { kind: 'none' | 'append' }
| { kind: 'replace'; start: number; count: number }
| { kind: 'none' }
| { kind: 'append'; node: SurfaceTraceNode }
| { kind: 'replace'; start: number; count: number; node: SurfaceTraceNode }
/** The committed event sequence to add to the known-sequence set. */
seq: number
}
@@ -85,6 +88,18 @@ function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step:
}
}
/** Compare future-safe tool-result data while deliberately excluding content. */
function sameToolResultDataExceptContent(
original: SessionEvent<'tool/result'>['data'],
replacement: SessionEvent<'tool/result'>['data'],
): boolean {
const originalRest = { ...original } as Record<string, unknown>
const replacementRest = { ...replacement } as Record<string, unknown>
delete originalRest['content']
delete replacementRest['content']
return isDeepStrictEqual(originalRest, replacementRest)
}
/** Validate one candidate event without mutating the committed session trace. */
function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition {
// seq is strictly monotonic — the spine of replay equivalence. lastSeq
@@ -139,14 +154,14 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
// positional range — every shadowed node must appear in sourceEventSeqs.
if (se.surfaceOp !== undefined) {
if (se.surfaceOp === 'append') {
surface = { kind: 'append' }
surface = { kind: 'append', node: { seq: event.seq, event: se } }
} else {
const { start, end } = se.surfaceOp
const startIdx = trace.surface.indexOf(start)
const startIdx = trace.surface.findIndex(node => node.seq === start)
if (startIdx === -1) {
throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)
}
const endIdx = trace.surface.indexOf(end)
const endIdx = trace.surface.findIndex(node => node.seq === end)
if (endIdx === -1) {
throw new InvariantError(`surface replace: end seq ${end} is not on the surface`)
}
@@ -155,13 +170,18 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
}
// Every node the replace shadows (surface positions [startIdx, endIdx]
// inclusive) must appear in sourceEventSeqs — the provenance contract.
const shadowed = trace.surface.slice(startIdx, endIdx + 1)
const shadowed = trace.surface.slice(startIdx, endIdx + 1).map(node => node.seq)
const recorded = new Set(se.sourceEventSeqs ?? [])
const missing = shadowed.filter(seq => !recorded.has(seq))
if (missing.length > 0) {
throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
}
surface = { kind: 'replace', start: startIdx, count: shadowed.length }
surface = {
kind: 'replace',
start: startIdx,
count: shadowed.length,
node: { seq: event.seq, event: se },
}
}
}
@@ -232,15 +252,26 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
break
}
case 'tool/result': {
// A replacement rewrites an already-executed result whose recorded
// turn/step can be closed. Surface provenance above validates the rewrite;
// only fresh appends consume an open step's pending call.
// Only a content-only rewrite of one CURRENT tool-result node may bypass
// open-step/pending-call checks. The trace retains immutable surface event
// identity, so this validation never indexes a mutable or stale session.
if (se.surfaceOp !== undefined && se.surfaceOp !== 'append') {
if (trace.openTurn === null) {
throw new InvariantError(
'tool/result surface replacement appended outside any open turn',
)
}
const { start, end } = se.surfaceOp
if (start !== end) {
throw new InvariantError('tool/result surface replacement must rewrite exactly one current node')
}
const original = trace.surface.find(node => node.seq === start)?.event
if (original?.type !== 'tool/result') {
throw new InvariantError('tool/result surface replacement must target a current tool/result')
}
if (!sameToolResultDataExceptContent(original.data, event.data)) {
throw new InvariantError('tool/result surface replacement may change only content')
}
break
}
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step)
@@ -302,10 +333,14 @@ function applyTransition(trace: SessionTrace, transition: SessionTraceTransition
case 'none':
break
case 'append':
trace.surface.push(transition.seq)
trace.surface.push(transition.surface.node)
break
case 'replace':
trace.surface.splice(transition.surface.start, transition.surface.count, transition.seq)
trace.surface.splice(
transition.surface.start,
transition.surface.count,
transition.surface.node,
)
break
/* v8 ignore next -- validateEvent produces this closed transition union */
default:

View File

@@ -478,6 +478,39 @@ describe('HMR safety', () => {
})
describe('surface invariants', () => {
async function toolResultRewriteFixture() {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const unrelated = session.append('user/message', {
content: [{ type: 'text', text: 'request' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
name: 'echo',
arguments: '{}',
})
const originalData = {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text' as const, text: 'original' }],
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { presentation: { kind: 'terminal', output: 'full output' } },
futureField: { nested: ['preserve', 1] },
}
const original = session.append('tool/result', originalData, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
return { session, unrelated, original }
}
it('accepts well-formed surface metadata', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
@@ -501,27 +534,7 @@ describe('surface invariants', () => {
})
it('treats a provenance-backed tool-result replacement as a turn-enclosed rewrite', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
name: 'echo',
arguments: '{}',
})
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const { session, original } = await toolResultRewriteFixture()
expect(() => session.append('tool/result', {
...original.data,
@@ -532,6 +545,47 @@ describe('surface invariants', () => {
})).not.toThrow()
})
it('rejects a tool-result replacement targeting an unrelated current node', async () => {
const { session, unrelated, original } = await toolResultRewriteFixture()
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'forged' }],
}, {
surfaceOp: { op: 'replace', start: unrelated.seq, end: unrelated.seq },
sourceEventSeqs: [unrelated.seq],
})).toThrow(/must target a current tool\/result/)
})
it('rejects a multi-node tool-result replacement even with complete provenance', async () => {
const { session, unrelated, original } = await toolResultRewriteFixture()
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'forged' }],
}, {
surfaceOp: { op: 'replace', start: unrelated.seq, end: original.seq },
sourceEventSeqs: [unrelated.seq, original.seq],
})).toThrow(/must rewrite exactly one current node/)
})
it.each([
['callId', { callId: CallId('forged') }],
['turn', { turn: 2 }],
['step', { step: 2 }],
['error', { error: { name: 'ExitError', code: 'DIFFERENT' } }],
['meta', { meta: { presentation: { kind: 'generic' } } }],
['future data', { futureField: { nested: ['changed'] } }],
])('rejects a content rewrite with altered %s', async (_label, altered) => {
const { session, original } = await toolResultRewriteFixture()
expect(() => session.append('tool/result', {
...original.data,
...altered,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).toThrow(/may change only content/)
})
it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()

View File

@@ -99,7 +99,7 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa
**What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only.
**Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens.
**Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. A replacement `tool/result` still changes the model-facing session surface, but live and replayed ACP feeds ignore it as an execution update so the original terminal or diff completion is not overwritten.
### Permission preset switches

View File

@@ -82,7 +82,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `agent_thought_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` reasoning-delta. |
| `user_message_chunk` | S | ✅ | ✅ | ✅ | Emitted during `session/load` replay to reconstruct the user side. |
| `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). |
| `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. |
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |

View File

@@ -904,7 +904,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
* loaded transcript reconstructs the USER side of each turn without echoing
* a live `session/prompt` back to the client
* - `tool/call` → `tool_call` (pending)
* - `tool/result` → `tool_call_update` (completed/failed)
* - appended `tool/result` → `tool_call_update` (completed/failed)
* - replacement `tool/result` → no update (context rewrite, not execution)
*
* Tool-call presentation (title/kind/rawInput, and the completed-state content)
* is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
@@ -965,6 +966,10 @@ export function streamSessionEventUpdate(
return
}
case 'tool/result': {
// Replacements (for example model-free pruning) are transcript rewrites,
// not repeated tool executions. Re-presenting one would consume no
// pending call and could clobber the original terminal/diff completion.
if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return
const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta)
notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) })
return

View File

@@ -162,6 +162,53 @@ describe('acp bridge — session/load replay', () => {
expect(meta.terminal_exit?.exit_code).toBe(0)
})
it('keeps one terminal completion live and on replay when a pruning replacement is logged', async () => {
live = await makeBridgeHarness({
storageDir,
withBash: true,
script: [toolCallResponse('c1', 'bash', { command: 'echo full', description: 'Print full output' }), textResponse('done')],
})
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
const session = live.ctx.agents.get(AgentId(sessionId))!.session
const original = session.events.find(event => event.type === 'tool/result')
if (original?.type !== 'tool/result') throw new Error('expected original tool/result')
const liveCompletions = () => live!.updates.filter(update =>
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
expect(liveCompletions()).toHaveLength(1)
expect((liveCompletions()[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
.toBe('full\n')
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
// The replacement is durable but is not another live completion.
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
expect(JSON.stringify(session.deriveMessages())).toContain('tool result middle pruned')
expect(liveCompletions()).toHaveLength(1)
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const replayed = loader.updates.filter(update =>
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
expect(replayed).toHaveLength(1)
expect((replayed[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
.toBe('full\n')
})
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
// Stall persistence so transport closes while resume is pending. Whether the SDK rejects first
// or the bridge's post-await guard fires, no agent may survive for the dead connection.

View File

@@ -105,6 +105,22 @@ describe('streamSessionEventUpdate', () => {
expect((failed[0] as { status: string }).status).toBe('failed')
})
it('emits no execution update for a tool-result surface replacement', () => {
const replacement = {
...evt('tool/result', {
turn: 1,
step: 1,
callId: CallId('c1'),
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
isError: false,
}),
seq: 2,
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
} as SessionEvent
expect(updatesFor(replacement)).toEqual([])
})
it('drops non-text tool-result content (text-only)', () => {
const update = updatesFor(evt('tool/result', {
turn: 1, step: 1, callId: CallId('c1'),
@@ -450,6 +466,16 @@ describe('terminal-card mapping (capability-gated)', () => {
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
const prunedResultEvent = {
...resultEvent,
seq: 2,
data: {
...resultEvent.data,
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
},
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
} as SessionEvent
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
const presenter = new ToolPresenter(registryOf(tool))
@@ -477,6 +503,27 @@ describe('terminal-card mapping (capability-gated)', () => {
})
})
it('live/replay translation preserves the original terminal completion across a pruning rewrite', () => {
const updates = termUpdates(
termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }),
true,
'/work/proj',
callEvent,
resultEvent,
prunedResultEvent,
)
expect(updates).toHaveLength(2)
expect(updates[1]).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
_meta: {
terminal_output: { terminal_id: 'c1', data: 'hi\n' },
terminal_exit: { terminal_id: 'c1', exit_code: 0 },
},
})
})
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
@@ -633,17 +680,38 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
// call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`,
// which presentResult narrows into a `diff` result card the bridge forwards as `{ type:
// 'diff' }` content blocks. The real tool is required because its result metadata is the contract.
it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
it('live/replay translation keeps the applied diff when a pruning rewrite follows', async () => {
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
// The applied hunk the tool would compute and persist on the result meta.
const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
const [, resultUpdate] = updatesWith(
const originalResult = evt('tool/result', {
turn: 1,
step: 1,
callId: CallId('e1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
meta,
})
const replacement = {
...originalResult,
seq: 3,
data: {
...originalResult.data,
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
},
surfaceOp: { op: 'replace', start: 2, end: 2 },
sourceEventSeqs: [2],
} as SessionEvent
const updates = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
originalResult,
replacement,
)
expect(updates).toHaveLength(2)
const resultUpdate = updates[1]
expect(resultUpdate).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'e1',

View File

@@ -27,7 +27,7 @@ The plugin seeds display labels from the live agent registry, then tracks `agent
**What the model sees**: Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running.
**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens.
**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. A replacement `tool/result` remains model-visible through the session surface but is not rendered as a second execution; stdio keeps the original full-fidelity result line.
### Terminal user-interaction answers

View File

@@ -124,6 +124,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
inReasoning = false
output.write(`\n [tool call] ${toolName}(${args})`)
} else if (event.type === 'tool/result') {
// A surface replacement changes future model context; it is not another
// execution. Keep the original full-fidelity terminal presentation and
// suppress duplicate output during live delivery or log replay.
if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return
const { content } = event.data
const text = content.filter(block => block.type === 'text').map(block => block.text).join('')
output.write(`\n [tool result] ${text}\n `)

View File

@@ -288,6 +288,43 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('[tool result] file.txt')
})
it('renders one full-fidelity result whether the event feed is live or replayed', async () => {
const { ctx, out } = await setup()
const session = makeSession('main')
const original = {
type: 'tool/result',
seq: 2,
time: 0,
data: {
turn: 1,
step: 1,
callId: 'c1',
content: [{ type: 'text', text: 'full terminal output' }],
isError: false,
meta: { terminal: { output: 'full terminal output' } },
},
surfaceOp: 'append',
} as SessionEvent
const replacement = {
...original,
seq: 3,
data: {
...original.data,
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
},
surfaceOp: { op: 'replace', start: 2, end: 2 },
sourceEventSeqs: [2],
} as SessionEvent
// Stdio consumes the same session/event shape whether a host forwards a
// live append or replays a stored log through the rendering feed.
for (const event of [original, replacement]) ctx.emit('session/event', session, event)
expect(out.text().match(/\[tool result\]/g)).toHaveLength(1)
expect(out.text()).toContain('full terminal output')
expect(out.text()).not.toContain('tool result middle pruned')
})
it('renders a todo/write session event as a glyphed checklist', async () => {
const { ctx, out } = await setup()
const session = {} as Session