From c7d17dda49daf0dcecf800c140f0360719772849 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Fri, 9 Jan 2026 15:47:14 -0500 Subject: [PATCH] fix: properly use systemMessage for hooks in UI (#16250) --- docs/hooks/reference.md | 18 ++--- packages/cli/src/nonInteractiveCli.ts | 4 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 65 ++++++++++++++++++- packages/cli/src/ui/hooks/useGeminiStream.ts | 10 +-- packages/core/src/core/client.ts | 8 ++- packages/core/src/core/turn.ts | 2 + 6 files changed, 88 insertions(+), 19 deletions(-) diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md index b5174f827e..f65abeaf84 100644 --- a/docs/hooks/reference.md +++ b/docs/hooks/reference.md @@ -94,15 +94,15 @@ If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON. ### Common Output Fields -| Field | Type | Description | -| :------------------- | :-------- | :----------------------------------------------------------------------- | -| `decision` | `string` | One of: `allow`, `deny`, `block`, `ask`, `approve`. | -| `reason` | `string` | Explanation shown to the **agent** when a decision is `deny` or `block`. | -| `systemMessage` | `string` | Message displayed to the **user** in the CLI terminal. | -| `continue` | `boolean` | If `false`, immediately terminates the agent loop for this turn. | -| `stopReason` | `string` | Message shown to the user when `continue` is `false`. | -| `suppressOutput` | `boolean` | If `true`, the hook execution is hidden from the CLI transcript. | -| `hookSpecificOutput` | `object` | Container for event-specific data (see below). | +| Field | Type | Description | +| :------------------- | :-------- | :------------------------------------------------------------------------------------- | +| `decision` | `string` | One of: `allow`, `deny`, `block`, `ask`, `approve`. | +| `reason` | `string` | Explanation shown to the **agent** when a decision is `deny` or `block`. | +| `systemMessage` | `string` | Message displayed in Gemini CLI terminal to provide warning or context to the **user** | +| `continue` | `boolean` | If `false`, immediately terminates the agent loop for this turn. | +| `stopReason` | `string` | Message shown to the user when `continue` is `false`. | +| `suppressOutput` | `boolean` | If `true`, the hook execution is hidden from the CLI transcript. | +| `hookSpecificOutput` | `object` | Container for event-specific data (see below). | ### `hookSpecificOutput` Reference diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index d1f468ef39..7830798dd5 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -349,7 +349,7 @@ export async function runNonInteractive({ } else if (event.type === GeminiEventType.Error) { throw event.value.error; } else if (event.type === GeminiEventType.AgentExecutionStopped) { - const stopMessage = `Agent execution stopped: ${event.value.reason}`; + const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`; if (config.getOutputFormat() === OutputFormat.TEXT) { process.stderr.write(`${stopMessage}\n`); } @@ -369,7 +369,7 @@ export async function runNonInteractive({ } return; } else if (event.type === GeminiEventType.AgentExecutionBlocked) { - const blockMessage = `Agent execution blocked: ${event.value.reason}`; + const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`; if (config.getOutputFormat() === OutputFormat.TEXT) { process.stderr.write(`[WARNING] ${blockMessage}\n`); } diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 2414c340f4..bbf6412bc6 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2800,7 +2800,38 @@ describe('useGeminiStream', () => { }); describe('Agent Execution Events', () => { - it('should handle AgentExecutionStopped event', async () => { + it('should handle AgentExecutionStopped event with systemMessage', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.AgentExecutionStopped, + value: { + reason: 'hook-reason', + systemMessage: 'Custom stop message', + }, + }; + })(), + ); + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery('test stop'); + }); + + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Agent execution stopped: Custom stop message', + }, + expect.any(Number), + ); + expect(result.current.streamingState).toBe(StreamingState.Idle); + }); + }); + + it('should handle AgentExecutionStopped event by falling back to reason when systemMessage is missing', async () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { @@ -2828,7 +2859,37 @@ describe('useGeminiStream', () => { }); }); - it('should handle AgentExecutionBlocked event', async () => { + it('should handle AgentExecutionBlocked event with systemMessage', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.AgentExecutionBlocked, + value: { + reason: 'hook-reason', + systemMessage: 'Custom block message', + }, + }; + })(), + ); + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery('test block'); + }); + + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.WARNING, + text: 'Agent execution blocked: Custom block message', + }, + expect.any(Number), + ); + }); + }); + + it('should handle AgentExecutionBlocked event by falling back to reason when systemMessage is missing', async () => { mockSendMessageStream.mockReturnValue( (async function* () { yield { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 4522af13c7..113c6a08bf 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -794,7 +794,7 @@ export const useGeminiStream = ( ); const handleAgentExecutionStoppedEvent = useCallback( - (reason: string, userMessageTimestamp: number) => { + (reason: string, userMessageTimestamp: number, systemMessage?: string) => { if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); @@ -802,7 +802,7 @@ export const useGeminiStream = ( addItem( { type: MessageType.INFO, - text: `Agent execution stopped: ${reason}`, + text: `Agent execution stopped: ${systemMessage?.trim() || reason}`, }, userMessageTimestamp, ); @@ -812,7 +812,7 @@ export const useGeminiStream = ( ); const handleAgentExecutionBlockedEvent = useCallback( - (reason: string, userMessageTimestamp: number) => { + (reason: string, userMessageTimestamp: number, systemMessage?: string) => { if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); @@ -820,7 +820,7 @@ export const useGeminiStream = ( addItem( { type: MessageType.WARNING, - text: `Agent execution blocked: ${reason}`, + text: `Agent execution blocked: ${systemMessage?.trim() || reason}`, }, userMessageTimestamp, ); @@ -861,12 +861,14 @@ export const useGeminiStream = ( handleAgentExecutionStoppedEvent( event.value.reason, userMessageTimestamp, + event.value.systemMessage, ); break; case ServerGeminiEventType.AgentExecutionBlocked: handleAgentExecutionBlockedEvent( event.value.reason, userMessageTimestamp, + event.value.systemMessage, ); break; case ServerGeminiEventType.ChatCompressed: diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 3a8603ae65..67dae3f927 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -68,11 +68,11 @@ const MAX_TURNS = 100; type BeforeAgentHookReturn = | { type: GeminiEventType.AgentExecutionStopped; - value: { reason: string }; + value: { reason: string; systemMessage?: string }; } | { type: GeminiEventType.AgentExecutionBlocked; - value: { reason: string }; + value: { reason: string; systemMessage?: string }; } | { additionalContext: string | undefined } | undefined; @@ -146,6 +146,7 @@ export class GeminiClient { type: GeminiEventType.AgentExecutionStopped, value: { reason: hookOutput.getEffectiveReason(), + systemMessage: hookOutput.systemMessage, }, }; } @@ -155,6 +156,7 @@ export class GeminiClient { type: GeminiEventType.AgentExecutionBlocked, value: { reason: hookOutput.getEffectiveReason(), + systemMessage: hookOutput.systemMessage, }, }; } @@ -811,6 +813,7 @@ export class GeminiClient { type: GeminiEventType.AgentExecutionStopped, value: { reason: hookOutput.getEffectiveReason(), + systemMessage: hookOutput.systemMessage, }, }; return turn; @@ -822,6 +825,7 @@ export class GeminiClient { type: GeminiEventType.AgentExecutionBlocked, value: { reason: continueReason, + systemMessage: hookOutput.systemMessage, }, }; const continueRequest = [{ text: continueReason }]; diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index fcb8e18e04..90d6a3cbfc 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -78,6 +78,7 @@ export type ServerGeminiAgentExecutionStoppedEvent = { type: GeminiEventType.AgentExecutionStopped; value: { reason: string; + systemMessage?: string; }; }; @@ -85,6 +86,7 @@ export type ServerGeminiAgentExecutionBlockedEvent = { type: GeminiEventType.AgentExecutionBlocked; value: { reason: string; + systemMessage?: string; }; };