From 43a2c0a0af16ad1e85e539bf4976b31e3f416e3c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:08:27 +0800 Subject: [PATCH] fix(tools): avoid duplicate run_code result views --- packages/core/tools/src/code-mode.ts | 8 +-- packages/core/tools/tests/code-mode.spec.ts | 55 +++++++++++-------- .../host/runtime/tests/api-proxy-view.spec.ts | 24 ++++++-- 3 files changed, 56 insertions(+), 31 deletions(-) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 8f7bb904f7..30e85c3ad7 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -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. }) } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 18e6a2e185..0681ffc571 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -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 => { + 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 () => { diff --git a/packages/host/runtime/tests/api-proxy-view.spec.ts b/packages/host/runtime/tests/api-proxy-view.spec.ts index 266213ecc2..72c4b51d05 100644 --- a/packages/host/runtime/tests/api-proxy-view.spec.ts +++ b/packages/host/runtime/tests/api-proxy-view.spec.ts @@ -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.