fix(tools): avoid duplicate run_code result views

This commit is contained in:
Tianyi Cui
2026-07-23 04:08:27 +08:00
parent ff96387924
commit 43a2c0a0af
3 changed files with 56 additions and 31 deletions

View File

@@ -370,10 +370,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
kind: 'execute',
rawInput: args.code,
}),
// Title omitted on the result: an update replaces only the fields it
// carries, so the pending card's program title persists through
// completion. The durable final content already includes logs plus the
// return value, failure, or post-policy spill preview.
presentResult: (_args, result) => ({ card: 'generic', content: result.content }),
// Deliberately no presentResult: the generic surface fallback keeps this
// title and reads durable result content without duplicating a large raw
// result into the host view payload.
})
}

View File

@@ -709,27 +709,41 @@ describe('the run_code dispatch bridge', () => {
})
it.each([
['logs only', 'printed', false],
['result only', 'returned', false],
['logs plus result', 'printed\nreturned', false],
['no output', '(run_code completed with no output)', false],
['spilled result', 'HEAD\n\n(Omitted 100 bytes. Full formatted result stored at: /tmp/run-code.txt.)\n\nTAIL', false],
] as const)('presents %s from the final post-policy content', async (_name, text, isError) => {
const { ctx } = await setup({ mode: 'code' })
['logs only', { logs: ['printed'] }, 'printed'],
['result only', { logs: [], value: 'returned' }, 'returned'],
['logs plus result', { logs: ['printed'], value: 'returned' }, 'printed\nreturned'],
['no output', { logs: [] }, '(run_code completed with no output)'],
] as [string, CodeRunResult, string][])('keeps %s in durable content without a result presenter', async (_name, output, text) => {
const { ctx, runtime } = await setup({ mode: 'code' })
runtime.behavior = () => Promise.resolve(output)
const result = await runCode(ctx, 'return 1')
const tool = ctx.tools.get(RUN_CODE_NAME)!
// The result omits the title — an update replaces only provided fields,
// so the pending card's program title persists through completion.
const content = [{ type: 'text' as const, text }]
expect(tool.presentResult?.({ code: 'return 1' }, {
content,
isError,
// Stale or unrelated metadata must not replace the authoritative
// post-policy content used by the card.
meta: { logs: ['stale logs-only projection'] },
})).toEqual({ card: 'generic', content })
expect(result.content).toEqual([{ type: 'text', text }])
// Surfaces keep the pending program title and render this durable content
// through their generic fallback. Omitting a result view also prevents the
// host frame from carrying the same raw content a second time.
expect('presentResult' in tool).toBe(false)
})
it('presents failure content produced by the canonical execution pipeline', async () => {
it('keeps a post-policy spill preview in durable content without a result presenter', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const preview = 'HEAD\n\n(Omitted 100 bytes. Full formatted result stored at: /tmp/run-code.txt.)\n\nTAIL'
runtime.behavior = () => Promise.resolve({ logs: ['printed'], value: 'returned' })
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
if (exec.name !== RUN_CODE_NAME) return next()
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: preview }] })
})
const result = await runCode(ctx, 'return 1')
const tool = ctx.tools.get(RUN_CODE_NAME)!
expect(result.content).toEqual([{ type: 'text', text: preview }])
expect('presentResult' in tool).toBe(false)
})
it('keeps canonical failure content durable without a result presenter', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
runtime.behavior = () => Promise.resolve({
logs: ['captured before failure'],
@@ -744,10 +758,7 @@ describe('the run_code dispatch bridge', () => {
type: 'text',
text: 'Error: code run failed (output-limit): outer output exceeded 8 bytes\nCaptured output:\ncaptured before failure',
}])
expect(tool.presentResult?.({ code: 'return 1' }, result)).toEqual({
card: 'generic',
content: result.content,
})
expect('presentResult' in tool).toBe(false)
})
it('renders non-text sub-result blocks as placeholders and truncates long event summaries', async () => {

View File

@@ -1,8 +1,9 @@
/**
* Tool-card view computation over the mux live path: three standard card types
* arrive on the frame, a presenterless tool ships no view field, and a throwing
* presenter soft-falls to no view (the event still ships). Result pairing works
* both through the live open-call table and the backscan fallback after
* arrive on the frame, a presenterless tool ships no view field, a call-only
* presenter keeps raw result content out of the view payload, and a throwing
* presenter soft-falls to no view (the event still ships). Result pairing
* works both through the live open-call table and the backscan fallback after
* turn/end cleared it.
*/
@@ -50,6 +51,9 @@ async function harness(): Promise<{ ctx: Context }> {
ctx.tools.register(tool('diffy', {
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
}))
ctx.tools.register(tool('call-only', {
presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
}))
ctx.tools.register(tool('plain', {}))
ctx.tools.register(tool('boom', {
presentCall: () => { throw new Error('presenter exploded') },
@@ -73,13 +77,16 @@ describe('mux live view computation', () => {
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 7, abort)
const collected = collect(stream, 9, abort)
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-call-only'), content: [{ type: 'text', text: rawResult }], isError: false }, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-gen'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
@@ -93,6 +100,15 @@ describe('mux live view computation', () => {
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
for: 'call',
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
})
const callOnlyResult = byCall.get('tool/result:c-call-only')
expect('view' in (callOnlyResult ?? {})).toBe(false)
const serializedResult = JSON.stringify(callOnlyResult)
expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
// No presenter → the frame carries no view property at all.
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
// Throwing presenter → soft-fall: event ships, no view.