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
+47
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.
+71 -3
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',