fix(cli): prevent duplicate SessionStart systemMessage render#25827
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses a UI bug where system messages from the SessionStart hook were being rendered twice in the interactive CLI. By consolidating the rendering logic to rely solely on the event-bus listener, the application now maintains consistent behavior with other hooks like BeforeAgent and BeforeTool, ensuring a cleaner and more predictable user experience. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request prevents duplicate rendering of the systemMessage from the SessionStart hook by removing the manual addition to the history manager in AppContainer.tsx, as it is now handled by an event listener. It also includes a new test case to verify this behavior and removes an unnecessary ESLint disable comment in a useEffect hook. I have no feedback to provide.
6aee92a to
c388b3b
Compare
| ?.fireSessionStartEvent(sessionStartSource); | ||
|
|
||
| if (result) { | ||
| if (result.systemMessage) { |
There was a problem hiding this comment.
Hey @dimssu, I think there might be a small issue with removing that block.
The outputFormat === 'json' check is specific to hook outputs, but result.systemMessage in AppContainer.tsx comes from a different flow (like SessionStartEvent) and isn’t necessarily tied to hook-based JSON output.
Because of that, relying only on the HookSystemMessage listener might miss some cases where systemMessage is set directly on result. The previous logic ensured those messages were added to history regardless of hooks.
This hook-based handling is here (which explains part of the behavior), and I can also see it being rendered after that:
hookEventHandler.ts (L462–L469)
So removing this could lead to some system messages not being shown. Maybe we should verify whether all systemMessage cases are indeed covered by the hook event before removing it?
There was a problem hiding this comment.
Good catch, you're right. I traced it: plain-text hook outputs go through convertPlainTextToHookOutput in hookRunner.ts, which does set systemMessage, but the emit in hookEventHandler.ts:463 is gated by outputFormat === 'json', so those never reach the listener. My patch would silently drop them.
Side note while I was digging: BeforeAgent/BeforeTool plain-text hooks already don't render their systemMessage anywhere (no direct-render path for those either), so the same latent gap exists for them today.
My thinking: drop the outputFormat === 'json' check in hookEventHandler so the event bus carries both formats. Then the AppContainer removal stays clean, and BeforeAgent/BeforeTool also start surfacing text-hook messages consistently — covers this bug without leaving the adjacent one. Let me know if that sounds reasonable or if you'd rather keep it narrower.
There was a problem hiding this comment.
Please address this and then I will approve. Would also encourage you to get Gemini to write some tests to help verify we aren't missing cases.
There was a problem hiding this comment.
Thanks @jacob314. Just pushed the fix (force-pushed onto the same branch — head is now cb65b25ac).
On the approach: dropped the outputFormat === 'json' gate in hookEventHandler.ts so the HookSystemMessage event fires for both JSON and plain-text hooks. That way the AppContainer direct-render removal no longer regresses plain-text SessionStart hooks, and as a side effect plain-text BeforeAgent / BeforeTool hooks also start surfacing their messages (they silently dropped before).
On tests: added three cases in hookEventHandler.test.ts under a new systemMessage event emission block — JSON-format emission, text-format emission, and the no-systemMessage no-op. The existing AppContainer.test.tsx case that guards the direct-render path from coming back is still there. Also re-ran the PTY repro locally for both a JSON hook and a plain-text hook; both render exactly once now.
Happy to add more coverage if there's a specific case you'd like me to pin down.
The SessionStart hook's `systemMessage` was being rendered twice in the UI:
once via a direct `historyManager.addItem` call in AppContainer's hook-firing
effect, and again via the `HookSystemMessage` event bus listener (which drains
backlogged events after the listener mounts).
Remove the direct-render path so only the event-bus path is responsible for
surfacing the message. Matches how other hook results (BeforeAgent, BeforeTool)
are already handled.
Also broaden the emission at hookEventHandler.ts to fire for both 'json' and
'text' output formats. Plain-text hook stdout is promoted into `systemMessage`
by `convertPlainTextToHookOutput`, so gating the event on 'json' alone
dropped those messages from the UI entirely once the direct-render path was
removed. As a side benefit, plain-text BeforeAgent/BeforeTool hooks now
surface their messages consistently too, which previously never rendered
anywhere.
Covered by:
- AppContainer.test.tsx: asserts the direct-render path is not invoked.
- hookEventHandler.test.ts: asserts HookSystemMessage is emitted for both
'json' and 'text' output formats, and suppressed when systemMessage is
absent.
Closes google-gemini#25655
c388b3b to
cb65b25
Compare
|
Thanks for the review @jacob314. Addressed in the latest push:
Let me know if you'd like any additional coverage. |
Co-authored-by: Jacob Richman <jacob314@gmail.com>
…-gemini#25827) Co-authored-by: Jacob Richman <jacob314@gmail.com>

Summary
The SessionStart hook's
systemMessagewas rendering twice in the interactive UI (once with a[node "…"]source annotation, once without), while BeforeAgent/BeforeTool correctly rendered once.This PR:
AppContainerso theHookSystemMessageevent-bus listener is the single source of truth for rendering.hookEventHandler.tsto cover both'json'and'text'output formats, so plain-text hooks (whose stdout is promoted intosystemMessagebyconvertPlainTextToHookOutput) aren't silently dropped — and as a bonus, plain-textBeforeAgent/BeforeToolhooks now surface their messages too (they previously never rendered anywhere).Closes #25655.
Details
Root cause of the duplicate:
AppContainer's SessionStart-hook effect was emittingresult.systemMessagevia two paths:historyManager.addItem({ type: INFO, text: result.systemMessage }, Date.now())right afterfireSessionStartEvent.coreEvents.emitHookSystemMessage(...)fires insidehookEventHandler.tsfor every hook that returns asystemMessage;AppContainer'sHookSystemMessagelistener +coreEvents.drainBacklogs()renders it too.BeforeAgent/BeforeTool only went through the event-bus path, which is why they weren't duplicated.
Initially, removing only the direct-render path introduced a regression for plain-text SessionStart hooks (flagged by @kamal2730 in review — thanks!), because the
outputFormat === 'json'gate on the event emission meant plain-text hooks never produced aHookSystemMessageevent. Broadening the gate to cover both formats keeps the behavioural contract consistent across JSON and text hooks and removes a latent gap for BeforeAgent/BeforeTool plain-text hooks as well.Why the
eslint-disablewas also touched: the effect had an// eslint-disable-next-line react-hooks/exhaustive-depsdirective that existed specifically to suppress the warning triggered by the now-removedhistoryManager.addItemcall. With that call gone, the directive becomes an unused-disable andeslint --max-warnings 0fails on it, so it had to be dropped alongside the addItem block.Also verified:
/clear→clearCommand.tsrenders viacontext.ui.addItemAFTERcontext.ui.clear(), which wipes the event-bus-drained copy. Still exactly one render.gemini.tsx) useswriteToStderrwith no UI event bus. Unaffected.Related Issues
Closes #25655
How to Validate
Unit tests (included):
New / relevant cases:
HookEventHandler > systemMessage event emission > emits HookSystemMessage for json-format hook outputHookEventHandler > systemMessage event emission > emits HookSystemMessage for text-format hook outputHookEventHandler > systemMessage event emission > does not emit when systemMessage is absentAppContainer State Management > SessionStart Hook Rendering > does not render systemMessage directly— guards the direct-render path from coming back.End-to-end (PTY) repros I ran locally:
{"systemMessage": "hello"}.console.logs a banner.Build / lint / typecheck:
All pass locally on macOS.
Pre-Merge Checklist
systemMessagewill now render it (treated as a bug fix, not a break)