From 4ad7b5a6f029630b055735d9cf5c21d2d6f99985 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 14:39:08 -0400 Subject: [PATCH 1/3] refactor(core): agent session protocol changes --- packages/core/src/agent/content-utils.test.ts | 22 +++++++ packages/core/src/agent/content-utils.ts | 22 +++++++ .../core/src/agent/event-translator.test.ts | 29 ++++----- packages/core/src/agent/event-translator.ts | 21 ++---- .../src/agent/legacy-agent-session.test.ts | 21 ++---- .../core/src/agent/legacy-agent-session.ts | 17 +++-- packages/core/src/agent/types.ts | 65 ++----------------- 7 files changed, 83 insertions(+), 114 deletions(-) diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index 7de54c56fa6..96608c82275 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'; import { geminiPartsToContentParts, contentPartsToGeminiParts, + toolResultDisplayToContentParts, buildToolResponseData, } from './content-utils.js'; import type { Part } from '@google/genai'; @@ -199,6 +200,27 @@ describe('contentPartsToGeminiParts', () => { }); }); +describe('toolResultDisplayToContentParts', () => { + it('returns undefined for undefined', () => { + expect(toolResultDisplayToContentParts(undefined)).toBeUndefined(); + }); + + it('returns undefined for null', () => { + expect(toolResultDisplayToContentParts(null)).toBeUndefined(); + }); + + it('handles string resultDisplay as-is', () => { + const result = toolResultDisplayToContentParts('File written'); + expect(result).toEqual([{ type: 'text', text: 'File written' }]); + }); + + it('stringifies object resultDisplay', () => { + const display = { type: 'FileDiff', oldPath: 'a.ts', newPath: 'b.ts' }; + const result = toolResultDisplayToContentParts(display); + expect(result).toEqual([{ type: 'text', text: JSON.stringify(display) }]); + }); +}); + describe('buildToolResponseData', () => { it('preserves outputFile and contentLength', () => { const result = buildToolResponseData({ diff --git a/packages/core/src/agent/content-utils.ts b/packages/core/src/agent/content-utils.ts index aaf191fe8e6..7b2c0fdbefa 100644 --- a/packages/core/src/agent/content-utils.ts +++ b/packages/core/src/agent/content-utils.ts @@ -6,6 +6,7 @@ import type { Part } from '@google/genai'; import type { ContentPart } from './types.js'; +import { debugLogger } from '../utils/debugLogger.js'; /** * Converts Gemini API Part objects to framework-agnostic ContentPart objects. @@ -93,6 +94,9 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { result.push({ text: part.text }); break; default: + debugLogger.warn( + `Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`, + ); // Serialize unknown ContentPart variants instead of dropping them result.push({ text: JSON.stringify(part) }); break; @@ -101,6 +105,24 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { return result; } +/** + * Converts a ToolCallResponseInfo.resultDisplay value into ContentPart[]. + * Handles string, object-valued (FileDiff, SubagentProgress, etc.), + * and undefined resultDisplay consistently. + */ +export function toolResultDisplayToContentParts( + resultDisplay: unknown, +): ContentPart[] | undefined { + if (resultDisplay === undefined || resultDisplay === null) { + return undefined; + } + const text = + typeof resultDisplay === 'string' + ? resultDisplay + : JSON.stringify(resultDisplay); + return [{ type: 'text', text }]; +} + /** * Builds the data record for a tool_response AgentEvent, preserving * all available metadata from the ToolCallResponseInfo. diff --git a/packages/core/src/agent/event-translator.test.ts b/packages/core/src/agent/event-translator.test.ts index 80ec96be109..be9d8ea40eb 100644 --- a/packages/core/src/agent/event-translator.test.ts +++ b/packages/core/src/agent/event-translator.test.ts @@ -155,10 +155,9 @@ describe('translateEvent', () => { expect(resp.content).toEqual([ { type: 'text', text: 'Permission denied to write' }, ]); - expect(resp.display?.result).toEqual({ - type: 'text', - text: 'Permission denied', - }); + expect(resp.displayContent).toEqual([ + { type: 'text', text: 'Permission denied' }, + ]); expect(resp.data).toEqual({ errorType: 'permission_denied' }); }); @@ -201,12 +200,9 @@ describe('translateEvent', () => { }; const result = translateEvent(event, state); const resp = result[0] as AgentEvent<'tool_response'>; - expect(resp.display?.result).toEqual({ - type: 'diff', - path: '/tmp/test.txt', - beforeText: 'a', - afterText: 'b', - }); + expect(resp.displayContent).toEqual([ + { type: 'text', text: JSON.stringify(objectDisplay) }, + ]); }); it('passes through string resultDisplay as-is', () => { @@ -224,10 +220,9 @@ describe('translateEvent', () => { }; const result = translateEvent(event, state); const resp = result[0] as AgentEvent<'tool_response'>; - expect(resp.display?.result).toEqual({ - type: 'text', - text: 'Command output text', - }); + expect(resp.displayContent).toEqual([ + { type: 'text', text: 'Command output text' }, + ]); }); it('preserves outputFile and contentLength in data', () => { @@ -378,7 +373,7 @@ describe('translateEvent', () => { expect(err.type).toBe('error'); expect(err.fatal).toBe(false); expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED'); - expect(err.message).toBe('Policy violation'); + expect(err.message).toBe('Agent execution blocked: Policy violation'); }); it('uses systemMessage in the final error message when available', () => { @@ -393,7 +388,9 @@ describe('translateEvent', () => { }; const result = translateEvent(event, state); const err = result[0] as AgentEvent<'error'>; - expect(err.message).toBe('Blocked by policy hook'); + expect(err.message).toBe( + 'Agent execution blocked: Blocked by policy hook', + ); }); }); diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index f60822a8e6f..cb299b494c2 100644 --- a/packages/core/src/agent/event-translator.ts +++ b/packages/core/src/agent/event-translator.ts @@ -25,13 +25,12 @@ import type { ErrorData, Usage, AgentEventType, - ToolDisplay, } from './types.js'; import { geminiPartsToContentParts, + toolResultDisplayToContentParts, buildToolResponseData, } from './content-utils.js'; -import { toolResultDisplayToDisplayContent } from './tool-display-utils.js'; // --------------------------------------------------------------------------- // Translation State @@ -210,7 +209,7 @@ export function translateEvent( out.push( makeEvent('error', state, { status: 'PERMISSION_DENIED', - message: event.value.systemMessage?.trim() || event.value.reason, + message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`, fatal: false, _meta: { code: 'AGENT_EXECUTION_BLOCKED' }, }), @@ -236,23 +235,16 @@ export function translateEvent( requestId: event.value.callId, name: event.value.name, args: event.value.args, - display: event.value.display, }), ); break; case GeminiEventType.ToolCallResponse: { ensureStreamStart(state, out); + const displayContent = toolResultDisplayToContentParts( + event.value.resultDisplay, + ); const data = buildToolResponseData(event.value); - const display: ToolDisplay | undefined = - event.value.display ?? - (event.value.resultDisplay - ? { - result: toolResultDisplayToDisplayContent( - event.value.resultDisplay, - ), - } - : undefined); out.push( makeEvent('tool_response', state, { requestId: event.value.callId, @@ -261,7 +253,7 @@ export function translateEvent( ? [{ type: 'text', text: event.value.error.message }] : geminiPartsToContentParts(event.value.responseParts), isError: event.value.error !== undefined, - ...(display ? { display } : {}), + ...(displayContent ? { displayContent } : {}), ...(data ? { data } : {}), }), ); @@ -282,6 +274,7 @@ export function translateEvent( ((x: never) => { throw new Error(`Unhandled event type: ${JSON.stringify(x)}`); })(event); + break; } return out; diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index db3c1739831..1de5d90e20a 100644 --- a/packages/core/src/agent/legacy-agent-session.test.ts +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -102,10 +102,7 @@ function makeCompletedToolCall( response: { callId, responseParts: [{ text: responseText }], - resultDisplay: responseText, - display: { - result: { type: 'text', text: responseText }, - }, + resultDisplay: undefined, error: undefined, errorType: undefined, }, @@ -203,6 +200,7 @@ describe('LegacyAgentSession', () => { expect.any(AbortSignal), 'test-prompt', undefined, + false, 'raw input', ); @@ -429,12 +427,6 @@ describe('LegacyAgentSession', () => { (e): e is AgentEvent<'tool_response'> => e.type === 'tool_response', ); expect(toolResp?.name).toBe('read_file'); - expect(toolResp?.display).toEqual( - expect.objectContaining({ - name: 'read_file', - result: { type: 'text', text: 'file contents' }, - }), - ); expect(toolResp?.content).toEqual([ { type: 'text', text: 'file contents' }, ]); @@ -497,10 +489,9 @@ describe('LegacyAgentSession', () => { expect(toolResp?.content).toEqual([ { type: 'text', text: 'Permission denied' }, ]); - expect(toolResp?.display?.result).toEqual({ - type: 'text', - text: 'Error display', - }); + expect(toolResp?.displayContent).toEqual([ + { type: 'text', text: 'Error display' }, + ]); }); it('stops on STOP_EXECUTION tool error', async () => { @@ -655,7 +646,7 @@ describe('LegacyAgentSession', () => { e.type === 'error' && e._meta?.['code'] === 'AGENT_EXECUTION_BLOCKED', ); expect(blocked?.fatal).toBe(false); - expect(blocked?.message).toBe('Blocked by hook'); + expect(blocked?.message).toBe('Agent execution blocked: Blocked by hook'); const messages = events.filter( (e): e is AgentEvent<'message'> => diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index d65c583b0b3..58523038556 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -23,8 +23,8 @@ import { buildToolResponseData, contentPartsToGeminiParts, geminiPartsToContentParts, + toolResultDisplayToContentParts, } from './content-utils.js'; -import { populateToolDisplay } from './tool-display-utils.js'; import { AgentSession } from './agent-session.js'; import { createTranslationState, @@ -166,6 +166,7 @@ export class LegacyAgentProtocol implements AgentProtocol { } else { this._emitErrorAndAgentEnd(err); } + } finally { this._clearActiveStream(); } } @@ -196,6 +197,7 @@ export class LegacyAgentProtocol implements AgentProtocol { this._abortController.signal, this._promptId, undefined, + false, currentDisplayContent, ); currentDisplayContent = undefined; @@ -261,13 +263,9 @@ export class LegacyAgentProtocol implements AgentProtocol { const content: ContentPart[] = response.error ? [{ type: 'text', text: response.error.message }] : geminiPartsToContentParts(response.responseParts); - const display = populateToolDisplay({ - name: request.name, - invocation: 'invocation' in tc ? tc.invocation : undefined, - resultDisplay: response.resultDisplay, - displayName: 'tool' in tc ? tc.tool?.displayName : undefined, - display: response.display, - }); + const displayContent = toolResultDisplayToContentParts( + response.resultDisplay, + ); const data = buildToolResponseData(response); this._emit([ @@ -276,7 +274,7 @@ export class LegacyAgentProtocol implements AgentProtocol { name: request.name, content, isError: response.error !== undefined, - ...(display ? { display } : {}), + ...(displayContent ? { displayContent } : {}), ...(data ? { data } : {}), }), ]); @@ -390,6 +388,7 @@ export class LegacyAgentProtocol implements AgentProtocol { const meta: Record = {}; if (err instanceof Error) { meta['errorName'] = err.constructor.name; + meta['stack'] = err.stack; if ('exitCode' in err && typeof err.exitCode === 'number') { meta['exitCode'] = err.exitCode; } diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 0d41c466024..19837c138e0 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { Kind } from '../tools/tools.js'; export type WithMeta = { _meta?: Record }; @@ -107,7 +106,7 @@ export interface AgentEvents { /** Updates configuration about the current session/agent. */ session_update: SessionUpdate; /** Message content provided by user, agent, or developer. */ - message: AgentMessage; + message: Message; /** Event indicating the start of agent activity on a stream. */ agent_start: AgentStart; /** Event indicating the end of agent activity on a stream. */ @@ -171,70 +170,17 @@ export type ContentPart = ) & WithMeta; -export interface AgentMessage { +export interface Message { role: 'user' | 'agent' | 'developer'; content: ContentPart[]; } -export type DisplayText = { type: 'text'; text: string }; -export type DisplayDiff = { - type: 'diff'; - path?: string; - beforeText: string; - afterText: string; -}; -export type DisplayTerminal = { - type: 'terminal'; - pid?: string; - exitCode?: number; - ansi?: AnsiOutput; -}; -export type DisplayAgent = { - type: 'agent'; - threadId: string; -}; - -export type DisplayContent = - | DisplayText - | DisplayDiff - | DisplayTerminal - | DisplayAgent; - -export type ToolDisplayFormat = - /** - * Displays as compact when user has enabled compact tools, box otherwise. - * This is the default format if none is selected. - **/ - | 'auto' - /** Always display this tool in compact format. */ - | 'compact' - /** Always display this tool in full box format. */ - | 'box' - /** Hide this tool from the event history. */ - | 'hidden' - /** Display this tool as a message-like notice. */ - | 'notice'; - -export interface ToolDisplay { - /** A display name for the tool. */ - name?: string; - /** A short description of what the tool is doing. */ - description?: string; - /** A short, one-line summary of the tool's results. */ - resultSummary?: string | null; - result?: DisplayContent | null; - /** A tool may specify its preferred display format. */ - format?: ToolDisplayFormat; -} - export interface ToolRequest { /** A unique identifier for this tool request to be correlated by the response. */ requestId: string; /** The name of the tool being requested. */ name: string; /** The arguments for the tool. */ - /** Tool-controlled display information. */ - display?: ToolDisplay; args: Record; /** UI specific metadata */ _meta?: { @@ -255,8 +201,7 @@ export interface ToolRequest { */ export interface ToolUpdate { requestId: string; - /** Tool-controlled display information. */ - display?: ToolDisplay; + displayContent?: ContentPart[]; content?: ContentPart[]; data?: Record; /** UI specific metadata */ @@ -276,8 +221,8 @@ export interface ToolUpdate { export interface ToolResponse { requestId: string; name: string; - /** Tool-controlled display information. */ - display?: ToolDisplay; + /** Content representing the tool call's outcome to be presented to the user. */ + displayContent?: ContentPart[]; /** Multi-part content to be sent to the model. */ content?: ContentPart[]; /** Structured data to be sent to the model. */ From d139bcead2d0992db47fc55d78a7168224a86ca2 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 14:52:05 -0400 Subject: [PATCH 2/3] refactor(core): isolate specific agent changes --- packages/core/src/agent/content-utils.test.ts | 22 ------- packages/core/src/agent/content-utils.ts | 18 ----- .../core/src/agent/event-translator.test.ts | 29 +++++---- packages/core/src/agent/event-translator.ts | 21 ++++-- .../src/agent/legacy-agent-session.test.ts | 21 ++++-- .../core/src/agent/legacy-agent-session.ts | 15 +++-- packages/core/src/agent/types.ts | 65 +++++++++++++++++-- 7 files changed, 114 insertions(+), 77 deletions(-) diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index 96608c82275..7de54c56fa6 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -8,7 +8,6 @@ import { describe, expect, it } from 'vitest'; import { geminiPartsToContentParts, contentPartsToGeminiParts, - toolResultDisplayToContentParts, buildToolResponseData, } from './content-utils.js'; import type { Part } from '@google/genai'; @@ -200,27 +199,6 @@ describe('contentPartsToGeminiParts', () => { }); }); -describe('toolResultDisplayToContentParts', () => { - it('returns undefined for undefined', () => { - expect(toolResultDisplayToContentParts(undefined)).toBeUndefined(); - }); - - it('returns undefined for null', () => { - expect(toolResultDisplayToContentParts(null)).toBeUndefined(); - }); - - it('handles string resultDisplay as-is', () => { - const result = toolResultDisplayToContentParts('File written'); - expect(result).toEqual([{ type: 'text', text: 'File written' }]); - }); - - it('stringifies object resultDisplay', () => { - const display = { type: 'FileDiff', oldPath: 'a.ts', newPath: 'b.ts' }; - const result = toolResultDisplayToContentParts(display); - expect(result).toEqual([{ type: 'text', text: JSON.stringify(display) }]); - }); -}); - describe('buildToolResponseData', () => { it('preserves outputFile and contentLength', () => { const result = buildToolResponseData({ diff --git a/packages/core/src/agent/content-utils.ts b/packages/core/src/agent/content-utils.ts index 7b2c0fdbefa..42b0b7fec71 100644 --- a/packages/core/src/agent/content-utils.ts +++ b/packages/core/src/agent/content-utils.ts @@ -105,24 +105,6 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { return result; } -/** - * Converts a ToolCallResponseInfo.resultDisplay value into ContentPart[]. - * Handles string, object-valued (FileDiff, SubagentProgress, etc.), - * and undefined resultDisplay consistently. - */ -export function toolResultDisplayToContentParts( - resultDisplay: unknown, -): ContentPart[] | undefined { - if (resultDisplay === undefined || resultDisplay === null) { - return undefined; - } - const text = - typeof resultDisplay === 'string' - ? resultDisplay - : JSON.stringify(resultDisplay); - return [{ type: 'text', text }]; -} - /** * Builds the data record for a tool_response AgentEvent, preserving * all available metadata from the ToolCallResponseInfo. diff --git a/packages/core/src/agent/event-translator.test.ts b/packages/core/src/agent/event-translator.test.ts index be9d8ea40eb..80ec96be109 100644 --- a/packages/core/src/agent/event-translator.test.ts +++ b/packages/core/src/agent/event-translator.test.ts @@ -155,9 +155,10 @@ describe('translateEvent', () => { expect(resp.content).toEqual([ { type: 'text', text: 'Permission denied to write' }, ]); - expect(resp.displayContent).toEqual([ - { type: 'text', text: 'Permission denied' }, - ]); + expect(resp.display?.result).toEqual({ + type: 'text', + text: 'Permission denied', + }); expect(resp.data).toEqual({ errorType: 'permission_denied' }); }); @@ -200,9 +201,12 @@ describe('translateEvent', () => { }; const result = translateEvent(event, state); const resp = result[0] as AgentEvent<'tool_response'>; - expect(resp.displayContent).toEqual([ - { type: 'text', text: JSON.stringify(objectDisplay) }, - ]); + expect(resp.display?.result).toEqual({ + type: 'diff', + path: '/tmp/test.txt', + beforeText: 'a', + afterText: 'b', + }); }); it('passes through string resultDisplay as-is', () => { @@ -220,9 +224,10 @@ describe('translateEvent', () => { }; const result = translateEvent(event, state); const resp = result[0] as AgentEvent<'tool_response'>; - expect(resp.displayContent).toEqual([ - { type: 'text', text: 'Command output text' }, - ]); + expect(resp.display?.result).toEqual({ + type: 'text', + text: 'Command output text', + }); }); it('preserves outputFile and contentLength in data', () => { @@ -373,7 +378,7 @@ describe('translateEvent', () => { expect(err.type).toBe('error'); expect(err.fatal).toBe(false); expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED'); - expect(err.message).toBe('Agent execution blocked: Policy violation'); + expect(err.message).toBe('Policy violation'); }); it('uses systemMessage in the final error message when available', () => { @@ -388,9 +393,7 @@ describe('translateEvent', () => { }; const result = translateEvent(event, state); const err = result[0] as AgentEvent<'error'>; - expect(err.message).toBe( - 'Agent execution blocked: Blocked by policy hook', - ); + expect(err.message).toBe('Blocked by policy hook'); }); }); diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index cb299b494c2..f60822a8e6f 100644 --- a/packages/core/src/agent/event-translator.ts +++ b/packages/core/src/agent/event-translator.ts @@ -25,12 +25,13 @@ import type { ErrorData, Usage, AgentEventType, + ToolDisplay, } from './types.js'; import { geminiPartsToContentParts, - toolResultDisplayToContentParts, buildToolResponseData, } from './content-utils.js'; +import { toolResultDisplayToDisplayContent } from './tool-display-utils.js'; // --------------------------------------------------------------------------- // Translation State @@ -209,7 +210,7 @@ export function translateEvent( out.push( makeEvent('error', state, { status: 'PERMISSION_DENIED', - message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`, + message: event.value.systemMessage?.trim() || event.value.reason, fatal: false, _meta: { code: 'AGENT_EXECUTION_BLOCKED' }, }), @@ -235,16 +236,23 @@ export function translateEvent( requestId: event.value.callId, name: event.value.name, args: event.value.args, + display: event.value.display, }), ); break; case GeminiEventType.ToolCallResponse: { ensureStreamStart(state, out); - const displayContent = toolResultDisplayToContentParts( - event.value.resultDisplay, - ); const data = buildToolResponseData(event.value); + const display: ToolDisplay | undefined = + event.value.display ?? + (event.value.resultDisplay + ? { + result: toolResultDisplayToDisplayContent( + event.value.resultDisplay, + ), + } + : undefined); out.push( makeEvent('tool_response', state, { requestId: event.value.callId, @@ -253,7 +261,7 @@ export function translateEvent( ? [{ type: 'text', text: event.value.error.message }] : geminiPartsToContentParts(event.value.responseParts), isError: event.value.error !== undefined, - ...(displayContent ? { displayContent } : {}), + ...(display ? { display } : {}), ...(data ? { data } : {}), }), ); @@ -274,7 +282,6 @@ export function translateEvent( ((x: never) => { throw new Error(`Unhandled event type: ${JSON.stringify(x)}`); })(event); - break; } return out; diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index 1de5d90e20a..db3c1739831 100644 --- a/packages/core/src/agent/legacy-agent-session.test.ts +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -102,7 +102,10 @@ function makeCompletedToolCall( response: { callId, responseParts: [{ text: responseText }], - resultDisplay: undefined, + resultDisplay: responseText, + display: { + result: { type: 'text', text: responseText }, + }, error: undefined, errorType: undefined, }, @@ -200,7 +203,6 @@ describe('LegacyAgentSession', () => { expect.any(AbortSignal), 'test-prompt', undefined, - false, 'raw input', ); @@ -427,6 +429,12 @@ describe('LegacyAgentSession', () => { (e): e is AgentEvent<'tool_response'> => e.type === 'tool_response', ); expect(toolResp?.name).toBe('read_file'); + expect(toolResp?.display).toEqual( + expect.objectContaining({ + name: 'read_file', + result: { type: 'text', text: 'file contents' }, + }), + ); expect(toolResp?.content).toEqual([ { type: 'text', text: 'file contents' }, ]); @@ -489,9 +497,10 @@ describe('LegacyAgentSession', () => { expect(toolResp?.content).toEqual([ { type: 'text', text: 'Permission denied' }, ]); - expect(toolResp?.displayContent).toEqual([ - { type: 'text', text: 'Error display' }, - ]); + expect(toolResp?.display?.result).toEqual({ + type: 'text', + text: 'Error display', + }); }); it('stops on STOP_EXECUTION tool error', async () => { @@ -646,7 +655,7 @@ describe('LegacyAgentSession', () => { e.type === 'error' && e._meta?.['code'] === 'AGENT_EXECUTION_BLOCKED', ); expect(blocked?.fatal).toBe(false); - expect(blocked?.message).toBe('Agent execution blocked: Blocked by hook'); + expect(blocked?.message).toBe('Blocked by hook'); const messages = events.filter( (e): e is AgentEvent<'message'> => diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index 58523038556..e8d5e56ef5c 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -23,8 +23,8 @@ import { buildToolResponseData, contentPartsToGeminiParts, geminiPartsToContentParts, - toolResultDisplayToContentParts, } from './content-utils.js'; +import { populateToolDisplay } from './tool-display-utils.js'; import { AgentSession } from './agent-session.js'; import { createTranslationState, @@ -197,7 +197,6 @@ export class LegacyAgentProtocol implements AgentProtocol { this._abortController.signal, this._promptId, undefined, - false, currentDisplayContent, ); currentDisplayContent = undefined; @@ -263,9 +262,13 @@ export class LegacyAgentProtocol implements AgentProtocol { const content: ContentPart[] = response.error ? [{ type: 'text', text: response.error.message }] : geminiPartsToContentParts(response.responseParts); - const displayContent = toolResultDisplayToContentParts( - response.resultDisplay, - ); + const display = populateToolDisplay({ + name: request.name, + invocation: 'invocation' in tc ? tc.invocation : undefined, + resultDisplay: response.resultDisplay, + displayName: 'tool' in tc ? tc.tool?.displayName : undefined, + display: response.display, + }); const data = buildToolResponseData(response); this._emit([ @@ -274,7 +277,7 @@ export class LegacyAgentProtocol implements AgentProtocol { name: request.name, content, isError: response.error !== undefined, - ...(displayContent ? { displayContent } : {}), + ...(display ? { display } : {}), ...(data ? { data } : {}), }), ]); diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 19837c138e0..0d41c466024 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { Kind } from '../tools/tools.js'; export type WithMeta = { _meta?: Record }; @@ -106,7 +107,7 @@ export interface AgentEvents { /** Updates configuration about the current session/agent. */ session_update: SessionUpdate; /** Message content provided by user, agent, or developer. */ - message: Message; + message: AgentMessage; /** Event indicating the start of agent activity on a stream. */ agent_start: AgentStart; /** Event indicating the end of agent activity on a stream. */ @@ -170,17 +171,70 @@ export type ContentPart = ) & WithMeta; -export interface Message { +export interface AgentMessage { role: 'user' | 'agent' | 'developer'; content: ContentPart[]; } +export type DisplayText = { type: 'text'; text: string }; +export type DisplayDiff = { + type: 'diff'; + path?: string; + beforeText: string; + afterText: string; +}; +export type DisplayTerminal = { + type: 'terminal'; + pid?: string; + exitCode?: number; + ansi?: AnsiOutput; +}; +export type DisplayAgent = { + type: 'agent'; + threadId: string; +}; + +export type DisplayContent = + | DisplayText + | DisplayDiff + | DisplayTerminal + | DisplayAgent; + +export type ToolDisplayFormat = + /** + * Displays as compact when user has enabled compact tools, box otherwise. + * This is the default format if none is selected. + **/ + | 'auto' + /** Always display this tool in compact format. */ + | 'compact' + /** Always display this tool in full box format. */ + | 'box' + /** Hide this tool from the event history. */ + | 'hidden' + /** Display this tool as a message-like notice. */ + | 'notice'; + +export interface ToolDisplay { + /** A display name for the tool. */ + name?: string; + /** A short description of what the tool is doing. */ + description?: string; + /** A short, one-line summary of the tool's results. */ + resultSummary?: string | null; + result?: DisplayContent | null; + /** A tool may specify its preferred display format. */ + format?: ToolDisplayFormat; +} + export interface ToolRequest { /** A unique identifier for this tool request to be correlated by the response. */ requestId: string; /** The name of the tool being requested. */ name: string; /** The arguments for the tool. */ + /** Tool-controlled display information. */ + display?: ToolDisplay; args: Record; /** UI specific metadata */ _meta?: { @@ -201,7 +255,8 @@ export interface ToolRequest { */ export interface ToolUpdate { requestId: string; - displayContent?: ContentPart[]; + /** Tool-controlled display information. */ + display?: ToolDisplay; content?: ContentPart[]; data?: Record; /** UI specific metadata */ @@ -221,8 +276,8 @@ export interface ToolUpdate { export interface ToolResponse { requestId: string; name: string; - /** Content representing the tool call's outcome to be presented to the user. */ - displayContent?: ContentPart[]; + /** Tool-controlled display information. */ + display?: ToolDisplay; /** Multi-part content to be sent to the model. */ content?: ContentPart[]; /** Structured data to be sent to the model. */ From 476e011cdd70480d8e50a548970a94f015c0bfef Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 14:56:26 -0400 Subject: [PATCH 3/3] test(core): add tests for stack trace and warning log --- packages/core/src/agent/content-utils.test.ts | 9 ++++++++- packages/core/src/agent/legacy-agent-session.test.ts | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index 7de54c56fa6..acf8a4a3294 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { geminiPartsToContentParts, contentPartsToGeminiParts, @@ -12,6 +12,7 @@ import { } from './content-utils.js'; import type { Part } from '@google/genai'; import type { ContentPart } from './types.js'; +import { debugLogger } from '../utils/debugLogger.js'; describe('geminiPartsToContentParts', () => { it('converts text parts', () => { @@ -191,11 +192,17 @@ describe('contentPartsToGeminiParts', () => { const content = [ { type: 'custom_widget', payload: 123 }, ] as unknown as ContentPart[]; + + const warnSpy = vi.spyOn(debugLogger, 'warn'); const result = contentPartsToGeminiParts(content); + + expect(warnSpy).toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0]).toEqual({ text: JSON.stringify({ type: 'custom_widget', payload: 123 }), }); + + warnSpy.mockRestore(); }); }); diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index db3c1739831..525548e292d 100644 --- a/packages/core/src/agent/legacy-agent-session.test.ts +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -1330,6 +1330,7 @@ describe('LegacyAgentSession', () => { ); expect(err?.message).toBe('Connection refused'); expect(err?.fatal).toBe(true); + expect(err?._meta?.['stack']).toBeDefined(); const streamEnd = events.find( (e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',