Skip to content

refactor(agent): decompose useAgentListeners#975

Merged
reachrazamair merged 5 commits into
rcfrom
cue-polish
May 9, 2026
Merged

refactor(agent): decompose useAgentListeners#975
reachrazamair merged 5 commits into
rcfrom
cue-polish

Conversation

@reachrazamair

@reachrazamair reachrazamair commented May 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Splits the IPC-listener god-hook in src/renderer/hooks/agent/useAgentListeners.ts per real seam — one hook per window.maestro.process.onXxx channel — and lifts the shared activeHiddenToolRef into the coordinator before splitting consumers. Pure logic from onExit (queue dequeue, git refresh, synopsis spawn, eligibility predicate) is extracted as named helpers in internal/helpers/exit*.ts.

This is the last open P0 in the Tier 4 backlog and follows the precedent set by §4.3 useBatchProcessor (2,281 → 328, −86%) and §4.2 ProcessMonitor (2,031 → 280, −86%).

  • Shell: useAgentListeners.ts 1,977 → 104 LOC (−95%).
  • Internal directory: 11 per-channel hooks + 7 pure helpers + types = 21 files, ~2,519 LOC.
  • Tests: existing 67-test integration suite stays green via the shell re-export + 110 new unit tests across 18 files = 177 tests for useAgentListeners and its internals.
  • Full project suite: 27,720 passing / 108 skipped / 0 failing (was 27,693).
  • Public API unchanged: useAgentListeners, loadAgentListenersPrompts, getAutorunSynopsisPrompt, getErrorTitleForType, BatchedUpdater, UseAgentListenersDeps all re-exported from the shell. App.tsx, services/promptInit.ts, hooks/agent/index.ts call sites are untouched.

Final shape

src/renderer/hooks/agent/
├── useAgentListeners.ts                       ← coordinator (104): hoists shared activeHiddenToolRef, composes 11 hooks
└── internal/
    ├── types.ts                               ← UseAgentListenersDeps, BatchedUpdater, ToolProgressState
    ├── helpers/
    │   ├── exitTabCleanup.ts                  ← log-cleanup primitives (showThinking exit contract)
    │   ├── agentErrorLogMatch.ts              ← per-tab agent-error log match/remove
    │   ├── errorTitles.ts                     ← getErrorTitleForType
    │   ├── exitDequeue.ts                     ← chooseNextQueuedItem (pure decision)
    │   ├── exitGitRefresh.ts                  ← refreshGitRefsAfterTerminalExit (async)
    │   ├── exitSynopsis.ts                    ← shouldRunSynopsisOnExit + runExitSynopsis
    │   └── autorunSynopsisPrompt.ts           ← lazy prompt-template cache
    ├── useAgentDataListener.ts                (onData)
    ├── useAgentExitListener.ts                (onExit)
    ├── useAgentSessionIdListener.ts           (onSessionId)
    ├── useAgentSlashCommandsListener.ts       (onSlashCommands)
    ├── useAgentStderrListener.ts              (onStderr)
    ├── useAgentCommandExitListener.ts         (onCommandExit)
    ├── useAgentUsageListener.ts               (onUsage)
    ├── useAgentErrorListener.ts               (onAgentError)
    ├── useAgentThinkingListener.ts            (onThinkingChunk)
    ├── useAgentSshRemoteListener.ts           (onSshRemote)
    └── useAgentToolExecutionListener.ts       (onToolExecution)

Key decisions

  • Per IPC channel, not per domain. The original target listed 7 grouped hooks (logs / status / completion / etc.); the actual file has 11 distinct channels and the natural seam is the channel itself. Grouping onCommandExit + onUsage into a "status" hook would couple a shell-exit-code listener to a token-counter listener with zero shared logic; grouping onData + onStderr would force two unrelated guard tables into one file. Per-channel = one file = one cleanup path = one test file. Mirrors the §4.3 deviation from the speculative shape.
  • Hoisted activeHiddenToolRef to the coordinator before splitting consumers (3 readers/writers across onData, onExit, onAgentError). Same atomic-claim hoisting pattern that landed in §4.3 (batchFlushState.ts) — centralise the subtle invariant first, then split the consumers around it. Thinking RAF refs (thinkingChunkBufferRef, thinkingChunkRafIdRef) stay encapsulated in useAgentThinkingListener since only that listener uses them.
  • onExit reducer kept inline. The 280-line setSessions(prev => prev.map(s => { ... })) body is too entangled with closure variables to extract safely; the 67-test integration suite already exercises it deeply. Extracted the surrounding side-effects (queue dequeue, git refresh, synopsis spawn, eligibility predicate) as pure helpers instead — same SRP win, much lower regression risk.
  • Listener registration order preserved (Data → Exit → SessionId → SlashCommands → Stderr → CommandExit → Usage → AgentError → Thinking → SshRemote → ToolExecution). React mounts sibling useEffects in declaration order; this preserves any cross-listener event timing the existing test suite depends on.
  • loadAgentListenersPrompts + getAutorunSynopsisPrompt moved into helpers/autorunSynopsisPrompt.ts. The shell re-exports both so services/promptInit.ts is untouched.

Performance wins (in passing)

  1. Orphan early-return in hot listeners (onSlashCommands, onStderr skip-by-format, onCommandExit, onUsage, onSshRemote, onToolExecution) — skips the no-op prev.map() allocation and Zustand subscriber notifications when the target session has been deleted (common during session-delete races).
  2. Coordinator clears activeHiddenToolRef Map on unmount so any orphan tool entries are released for GC.
  3. Smaller per-effect closures — each per-channel hook captures only the deps it actually uses (not the union of all 11 listeners' deps), which improves V8 inlining of the hot handlers.

Skipped (not real wins): wholesale setSessions → updateSessionWith migration (sugar over the same prev.map), pause-on-hidden (would drop incoming agent output, unsafe).

Tests

Existing useAgentListeners.test.ts integration suite 67 unchanged (green via shell re-export)
New unit tests across 18 files +110
Total useAgentListeners + internals coverage 177
Full project suite 27,720 / 108 skipped / 0 failing
Type errors 0
Lint errors 0

New unit tests cover each per-channel hook in isolation (subscribe/unsubscribe, primary handler path, edge cases, orphan early-return) plus 8 helper modules. The biggest single addition is helpers/exitSynopsis.test.ts (15 tests covering eligibility quadrants, prompt building, history recording, name persistence for both claude-code and non-claude-code IPC paths).

Test plan

  • npm run test — full suite green (27,720 / 0 failing)
  • npx tsc --noEmit --pretty — zero type errors
  • npx eslint 'src/renderer/hooks/agent/**/*.{ts,tsx}' — zero lint errors

Summary by CodeRabbit

  • Tests

    • Added comprehensive test coverage for agent listeners and helpers to improve reliability.
  • New Features

    • More robust agent exit handling with clearer session state transitions, user-facing logs/toasts, and queued-item behavior.
    • Improved error handling with consistent modals and safer error-log removal.
    • Automatic git refs refresh after relevant terminal commands.
    • Automatic exit synopsis generation and persistent tab naming.
    • Improved real-time streaming: thinking, stderr, tool execution, data and usage handling.
  • Refactor

    • Listener logic modularized into focused hooks for better maintainability.

Split the IPC-listener god-hook per real seam (one hook per
window.maestro.process.onXxx channel) and lift the shared
activeHiddenToolRef into the coordinator before splitting consumers.
Pure logic from onExit (queue dequeue, git refresh, synopsis spawn,
eligibility predicate) is extracted as named helpers in
internal/helpers/exit*.ts. Each per-channel hook owns its own
subscribe/unsubscribe, its own state-touching surface, and a single
unit-testable concern.

The shell preserves the original public API and listener registration
order (Data -> Exit -> SessionId -> SlashCommands -> Stderr ->
CommandExit -> Usage -> AgentError -> Thinking -> SshRemote ->
ToolExecution) so React mounts the per-channel useEffects in the same
sequence; cross-listener event timing the existing test suite depends
on is unchanged. App.tsx, services/promptInit.ts, and hooks/agent/
index.ts call sites are untouched.

Perf wins shipped in passing:
- Orphan early-return in hot listeners (Slash, Stderr, CommandExit,
  Usage, SshRemote, ToolExecution) avoids no-op prev.map allocations
  and Zustand subscriber notifications when the target session has
  been deleted (common during session-delete races).
- Coordinator clears the shared activeHiddenToolRef Map on unmount
  so any orphan tool entries are released for GC.
- Each per-channel useEffect captures only the deps it needs, not
  the union (smaller closures, better V8 inlining).

Tests: existing 67-test integration suite stays green via the shell
re-export. Added 110 new unit tests across 18 files (177 total for
useAgentListeners and its internals). Full project suite: 27,720
passing / 108 skipped / 0 failing (was 27,693). Zero type errors,
zero lint errors.
No behaviour change. Captures the lint-staged formatting pass that
ran after the previous commit.
@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d5e125a3-9c07-43a0-90c5-48035b5c9df0

📥 Commits

Reviewing files that changed from the base of the PR and between 311c2ca and 3afcbfa.

📒 Files selected for processing (2)
  • src/__tests__/renderer/hooks/agent/internal/useAgentSshRemoteListener.test.tsx
  • src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts
  • src/tests/renderer/hooks/agent/internal/useAgentSshRemoteListener.test.tsx

📝 Walkthrough

Walkthrough

This PR refactors the monolithic useAgentListeners into 11 dedicated per-IPC hooks, extracts dependency-light shared types and pure/async helper modules, hoists shared activeHiddenToolRef to the coordinator, and adds comprehensive Vitest tests for helpers and all new hooks.

Changes

Shared Types & Contracts

Layer / File(s) Summary
Shared Type Definitions
src/renderer/hooks/agent/internal/types.ts
Centralized ToolProgressState, BatchedUpdater interface (log append/delivery/usage/context/cycle methods), and UseAgentListenersDeps dependency contract.

Core & Async Helper Modules

Layer / File(s) Summary
Error Log Matching
src/renderer/hooks/agent/internal/helpers/agentErrorLogMatch.ts
Pure functions isMatchingAgentErrorLog and removeMatchingAgentErrorLog for per-tab agent error log tuple matching/removal.
Error Title Mapping
src/renderer/hooks/agent/internal/helpers/errorTitles.ts
getErrorTitleForType maps AgentError types to human-readable titles.
Exit Dequeue Decision
src/renderer/hooks/agent/internal/helpers/exitDequeue.ts
chooseNextQueuedItem implements dequeue/wait/none decision logic based on queue and tab states.
Tab Cleanup
src/renderer/hooks/agent/internal/helpers/exitTabCleanup.ts
removeHiddenProgressLog, applyExitThinkingPolicy, and cleanupExitedTabLogs compose log filtering rules for exited tabs.
Synopsis Prompt Loading
src/renderer/hooks/agent/internal/helpers/autorunSynopsisPrompt.ts
Lazy-load and cache the autorun-synopsis prompt via loadAgentListenersPrompts and getAutorunSynopsisPrompt with a test reset helper.
Git Ref Refresh
src/renderer/hooks/agent/internal/helpers/exitGitRefresh.ts
isGitRefMutatingCommand and refreshGitRefsAfterTerminalExit detect ref-mutating commands and re-fetch branches/tags.
Exit Synopsis Workflow
src/renderer/hooks/agent/internal/helpers/exitSynopsis.ts
Orchestrates synopsis spawning, parsing, history recording, tab-name persistence, and toast/refresh behavior via runExitSynopsis and helpers.

Listener Hooks

Layer / File(s) Summary
Command Exit Listener
src/renderer/hooks/agent/internal/useAgentCommandExitListener.ts
Registers onCommandExit, updates session busy/idle state, and appends exit-code system logs when applicable.
Data Listener
src/renderer/hooks/agent/internal/useAgentDataListener.ts
Routes AI vs terminal output via onData, manages hidden-progress cleanup, clears agentError on resumed data, and updates delivery/unread via batchedUpdater.
Error Listener
src/renderer/hooks/agent/internal/useAgentErrorListener.ts
Handles onAgentError across group-chat, synopsis, and per-session flows; updates session/tab error state, pauses Auto Run batches, and opens error modal when appropriate.
Exit Listener
src/renderer/hooks/agent/internal/useAgentExitListener.ts
Synthesizes AI and terminal exit workflows: computes pure state updates (log cleanup, dequeue decision), triggers git refresh, records stats, dispatches queued items, and runs exit synopsis when eligible.
Session ID Listener
src/renderer/hooks/agent/internal/useAgentSessionIdListener.ts
Stamps agentSessionId onto awaiting tabs/sessions, handles Claude Code immutability, detects resume-failure and resets context usage.
Slash Commands Listener
src/renderer/hooks/agent/internal/useAgentSlashCommandsListener.ts
Normalizes incoming slash commands and persists agentCommands (with descriptions) to matching sessions.
SSH Remote Listener
src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts
Updates sshRemote/sshRemoteId and probes git status/fetches branches & tags when attaching a new remote.
Stderr Listener
src/renderer/hooks/agent/internal/useAgentStderrListener.ts
Routes non-empty stderr chunks to batchedUpdater.appendLog with isStderr=true, mapping AI-tab ids when present.
Thinking Listener
src/renderer/hooks/agent/internal/useAgentThinkingListener.ts
Buffers thinking chunks per (session, tab), coalesces via RAF, creates/merges thinking logs, and guards against malformed concatenated tool-name payloads.
Tool Execution Listener
src/renderer/hooks/agent/internal/useAgentToolExecutionListener.ts
Merges tool execution events by toolCallId or finalization heuristics into tab logs or appends new tool logs.
Usage Listener
src/renderer/hooks/agent/internal/useAgentUsageListener.ts
Routes usage events to per-tab and per-session updates, computes or estimates context usage, and caps fallback estimates to a yellow-threshold.

Coordinator & Re-exports

Layer / File(s) Summary
Main Orchestrator
src/renderer/hooks/agent/useAgentListeners.ts
Hoists shared activeHiddenToolRef Map, composes the 11 internal listener hooks in preserved registration order, simplifies coordinator cleanup, and re-exports BatchedUpdater, UseAgentListenersDeps, getErrorTitleForType, loadAgentListenersPrompts, and getAutorunSynopsisPrompt.

Test Coverage

Layer / File(s) Summary
Helper Tests
src/__tests__/renderer/hooks/agent/internal/helpers/*
Vitest suites for agentErrorLogMatch, errorTitles, exitDequeue, exitGitRefresh, exitSynopsis, exitTabCleanup, autorun prompt caching.
Listener Hook Tests
src/__tests__/renderer/hooks/agent/internal/use*Listener.test.tsx
RTL/Vitest suites covering useAgent*Listener hooks: commandExit, data, error, exit, sessionId, slashCommands, sshRemote, stderr, thinking, toolExecution, usage.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

refactor

"🐰 I hopped through code with a twitchy nose,
Split one big hive into tidy rows.
Listeners neat and helpers spry,
Tests to catch each rabbit's cry.
Modular carrots for reviewers to bite—
A cleaner burrow, cozy and bright!"

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cue-polish

@greptile-apps

greptile-apps Bot commented May 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR decomposes the 1,977-line useAgentListeners god-hook into 11 per-IPC-channel hooks under internal/, a set of pure helper modules in internal/helpers/, and a 104-line coordinator that hoists the shared activeHiddenToolRef and composes the sub-hooks in original registration order. Public API is fully preserved via re-exports on the shell file.

  • Coordinator + 11 per-channel hooks: each hook owns exactly one window.maestro.process.onXxx subscription and its cleanup, with orphan-session early-return optimisations added on several hot paths.
  • 7 pure helpers (exitDequeue, exitGitRefresh, exitSynopsis, exitTabCleanup, agentErrorLogMatch, errorTitles, autorunSynopsisPrompt) extract the surrounding side-effects from onExit while keeping the large setSessions reducer inline per the PR's stated rationale.
  • 110 new unit tests across 18 files join the existing 67-test integration suite; all 27,720 project tests pass.

Confidence Score: 4/5

Safe to merge — this is a structural refactor with no public API changes, all 27,720 tests pass, and the observable behaviour of each IPC channel is preserved verbatim.

All issues found are style and documentation concerns carried over from the original code or introduced by the decomposition. The stale coordinator comment about activeHiddenToolRef being written by onToolExecution could mislead future developers, and the pre-computed chooseNextQueuedItem / setSessions split makes a pre-existing post-await race window more structurally visible. Neither changes observable runtime behaviour given the existing test coverage.

useAgentExitListener.ts is the densest file (603 LOC, retains the full setSessions reducer inline) and is the most likely source of future regressions; useAgentListeners.ts coordinator comment about activeHiddenToolRef warrants a correction before the next developer touches hidden-tool-progress tracking.

Important Files Changed

Filename Overview
src/renderer/hooks/agent/useAgentListeners.ts Coordinator reduced from ~1,977 to 104 LOC; composes 11 per-channel hooks in original order. The shared activeHiddenToolRef is hoisted here but its coordinator comment is stale — onToolExecution never writes to it in practice.
src/renderer/hooks/agent/internal/useAgentExitListener.ts Largest per-channel hook (603 LOC); retains the 280-line setSessions reducer inline. chooseNextQueuedItem is pre-computed before setSessions, creating a window where the two evaluations could diverge after the await getActiveProcesses() call.
src/renderer/hooks/agent/internal/useAgentSessionIdListener.ts Handles session-ID assignment and resume-failure detection. Contains a pre-existing anti-pattern: an IPC side effect and a flag mutation inside the setSessions updater function.
src/renderer/hooks/agent/internal/helpers/exitSynopsis.ts Clean extraction of synopsis-spawn lifecycle (eligibility check, prompt build, history record, name persistence, toast). Pure logic; well-tested (15 test cases).
src/renderer/hooks/agent/internal/helpers/exitDequeue.ts Pure queue-decision helper; mirrors the inline reducer logic in useAgentExitListener. The pre-computed result and the reducer can drift post-await.
src/renderer/hooks/agent/internal/useAgentDataListener.ts High-frequency stdout listener; cleanly extracted with orphan-early-return optimisation. Calls setSessions twice per chunk when recovering from an agent error — pre-existing but now explicit.
src/renderer/hooks/agent/internal/useAgentThinkingListener.ts RAF-batched thinking-chunk listener; correctly encapsulates its own buffer and RAF refs, with proper cleanup on unmount.
src/renderer/hooks/agent/internal/useAgentToolExecutionListener.ts Tool execution listener; correctly handles toolCallId merge and fallback linear search. Does not write to activeHiddenToolRef despite the coordinator comment claiming it does.
src/renderer/hooks/agent/internal/useAgentErrorListener.ts Three-branch error handler (group chat / synopsis / session); cleanly isolated with Auto Run batch-pause integration preserved.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[useAgentListeners coordinator] --> B[activeHiddenToolRef shared Map]
    A --> C[useAgentDataListener onData]
    A --> D[useAgentExitListener onExit]
    A --> E[useAgentSessionIdListener onSessionId]
    A --> F[useAgentSlashCommandsListener onSlashCommands]
    A --> G[useAgentStderrListener onStderr]
    A --> H[useAgentCommandExitListener onCommandExit]
    A --> I[useAgentUsageListener onUsage]
    A --> J[useAgentErrorListener onAgentError]
    A --> K[useAgentThinkingListener onThinkingChunk]
    A --> L[useAgentSshRemoteListener onSshRemote]
    A --> M[useAgentToolExecutionListener onToolExecution]
    B -.->|delete on data| C
    B -.->|delete on exit| D
    B -.->|delete on error| J
    D --> N[exitDequeue chooseNextQueuedItem]
    D --> O[exitTabCleanup cleanupExitedTabLogs]
    D --> P[exitGitRefresh refreshGitRefsAfterTerminalExit]
    D --> Q[exitSynopsis runExitSynopsis]
    C --> R[exitTabCleanup removeHiddenProgressLog]
    K --> S[RAF Buffer thinkingChunkBufferRef]
Loading

Reviews (1): Last reviewed commit: "style(agent): apply prettier formatting ..." | Re-trigger Greptile

Comment on lines +53 to 59
// Shared ref — written by `onToolExecution`, deleted by `onData` and
// `onAgentError`. Hoisted to the coordinator so per-channel hooks operate
// on the same Map. Inner listeners that don't read this ref shouldn't
// receive it.
const activeHiddenToolRef = useRef<
Map<string, { toolName: string; toolState?: ToolProgressState }>
>(new Map());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale comment — activeHiddenToolRef is never written by onToolExecution

The coordinator comment states the Map is "written by onToolExecution", but useAgentToolExecutionListener is called with no arguments and never receives or writes to this ref. The Map is initialised empty and only .delete() / .clear() are ever called on it — all of which are no-ops on an empty Map. As a result, the cleanup on unmount (activeHiddenTools.clear()) and all three delete calls in onData, onExit, and onAgentError are dead code. removeHiddenProgressLog uses buildHiddenProgressLogId(tabId) for its lookup and doesn't consult the ref at all, so behaviour is functionally unchanged — but the comment actively misleads anyone trying to understand the hidden-tool-progress lifecycle here.

Comment on lines +129 to +138
if (isFromAi) {
const currentSession = getSessions().find((s) => s.id === actualSessionId);
if (currentSession) {
const queueDecision = chooseNextQueuedItem(currentSession, tabIdFromSession);
if (queueDecision.action === 'dequeue' && queueDecision.item) {
queuedItemToProcess = {
sessionId: actualSessionId,
item: queueDecision.item,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 chooseNextQueuedItem result and setSessions reducer can diverge after await

queuedItemToProcess is pre-computed from currentSession (line 130) after the await getActiveProcesses() at line 93. The setSessions reducer then independently re-evaluates the same queue condition against the latest committed state (prev). If a concurrent IPC event mutates the queue between the two evaluations (for example, another onExit fires during the process-status check), queuedItemToProcess could be set while the reducer takes the wait path, or vice versa: the reducer dequeues and transitions state, but processQueuedItemRef.current is never called because queuedItemToProcess is null — leaving the item permanently stuck. This race existed in the original monolithic handler too, but the explicit side-effect computation is now more visible as a separate step that can get out of sync with the state update.

Comment on lines +44 to +50
if (!session) return prev;

window.maestro.agentSessions
.registerSessionOrigin(session.projectRoot, agentSessionId, 'user')
.catch((err) =>
logger.error('[onSessionId] Failed to register session origin:', undefined, err)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Side effect inside setSessions reducer

window.maestro.agentSessions.registerSessionOrigin(...) is called from inside the setSessions updater function (lines 44-48). In Zustand, the updater is expected to be a pure function; in React 18 concurrent mode, state updater functions can be invoked more than once, which would fire the IPC call multiple times for a single onSessionId event. The resumeFailureDetected flag mutation on line 117 shares the same anti-pattern. Both patterns pre-exist this refactor, but they are now more visible as isolated behaviour in this hook.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (8)
src/renderer/hooks/agent/internal/helpers/autorunSynopsisPrompt.ts (1)

20-20: ⚡ Quick win

Prefer ?? '' over the non-null assertion on result.content.

The ! operator bypasses TypeScript's nullability check. If result.success is true but result.content is undefined at runtime, cachedAutorunSynopsisPrompt becomes undefined (not null), so the === null guard in getAutorunSynopsisPrompt won't catch it and the function returns undefined typed as string — silently breaking the ''-fallback contract.

🛡️ Proposed fix
-	cachedAutorunSynopsisPrompt = result.content!;
+	cachedAutorunSynopsisPrompt = result.content ?? '';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/internal/helpers/autorunSynopsisPrompt.ts` at line
20, Replace the non-null assertion on result.content when assigning
cachedAutorunSynopsisPrompt with a safe nullish coalescing default (use
result.content ?? '') so cachedAutorunSynopsisPrompt is always a string; update
the assignment in autorunSynopsisPrompt helper (where
cachedAutorunSynopsisPrompt is set from result.content) and ensure
getAutorunSynopsisPrompt logic relying on a string/'' fallback remains correct.
src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts (1)

55-86: ⚡ Quick win

try/catch around gitService calls is dead code and silently swallows unexpected errors from Sentry.

Per the established codebase pattern, gitService.isRepo, gitService.getBranches, and gitService.getTags are all implemented via createIpcMethod with a defaultValue and without rethrow: true. This means IPC failures are swallowed internally and already reported to Sentry by createIpcMethod — the catch block at line 79 will never fire for IPC errors. For an unexpected runtime error (e.g., a programming bug inside the async IIFE), the logger.error call silently consumes it instead of letting the unhandled rejection propagate to Sentry, directly violating the guideline: "Do not silently swallow errors. Let unhandled exceptions bubble up to Sentry for error tracking in production."

♻️ Proposed fix — remove the try/catch
-					void (async () => {
-						try {
-							const isGitRepo = await gitService.isRepo(remoteCwd, sshRemote.id);
-							if (isGitRepo) {
-								const [gitBranches, gitTags] = await Promise.all([
-									gitService.getBranches(remoteCwd, sshRemote.id),
-									gitService.getTags(remoteCwd, sshRemote.id),
-								]);
-								const gitRefsCacheTime = Date.now();
-
-								setSessions((prev) =>
-									prev.map((s) => {
-										if (s.id !== actualSessionId) return s;
-										if (s.isGitRepo) return s;
-										return {
-											...s,
-											isGitRepo: true,
-											gitBranches,
-											gitTags,
-											gitRefsCacheTime,
-										};
-									})
-								);
-							}
-						} catch (err) {
-							logger.error(
-								`[SSH] Failed to check git repo status for ${actualSessionId}:`,
-								undefined,
-								err
-							);
-						}
-					})();
+					void (async () => {
+						const isGitRepo = await gitService.isRepo(remoteCwd, sshRemote.id);
+						if (isGitRepo) {
+							const [gitBranches, gitTags] = await Promise.all([
+								gitService.getBranches(remoteCwd, sshRemote.id),
+								gitService.getTags(remoteCwd, sshRemote.id),
+							]);
+							const gitRefsCacheTime = Date.now();
+
+							setSessions((prev) =>
+								prev.map((s) => {
+									if (s.id !== actualSessionId) return s;
+									if (s.isGitRepo) return s;
+									return {
+										...s,
+										isGitRepo: true,
+										gitBranches,
+										gitTags,
+										gitRefsCacheTime,
+									};
+								})
+							);
+						}
+					})();

Based on learnings: gitService methods (isRepo, getBranches, getTags) are implemented via createIpcMethod with defaultValue and without rethrow: true, so wrapping call sites in try/catch produces dead code for IPC failures and suppresses unexpected runtime errors that should reach Sentry. As per coding guidelines: "Do not silently swallow errors. Let unhandled exceptions bubble up to Sentry for error tracking in production."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts` around lines
55 - 86, Remove the try/catch wrapper inside the async IIFE in
useAgentSshRemoteListener so unexpected runtime errors are not silently
swallowed; call gitService.isRepo, gitService.getBranches and gitService.getTags
directly (they already handle IPC failures) and then update setSessions for
actualSessionId as before, but do not catch/log errors here—let them propagate
to Sentry per project guidelines.
src/__tests__/renderer/hooks/agent/internal/useAgentUsageListener.test.tsx (1)

44-63: 💤 Low value

Optional: assert the routing arguments to updateUsage.

expect(batched.updateUsage).toHaveBeenCalled() passes regardless of which (sessionId, tabId, usage) triple was forwarded. Asserting at least one of the two expected calls (per-tab and per-session) would catch routing regressions where the hook keeps calling but with wrong ids.

🧪 Suggested tightening
-		expect(batched.updateUsage).toHaveBeenCalled();
+		expect(batched.updateUsage).toHaveBeenCalledWith(
+			'sess-1',
+			null,
+			expect.objectContaining({ outputTokens: 50 })
+		);
 		expect(batched.updateCycleTokens).toHaveBeenCalledWith('sess-1', 50);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/renderer/hooks/agent/internal/useAgentUsageListener.test.tsx`
around lines 44 - 63, The test currently only checks that batched.updateUsage
was called but not what arguments were routed; update the test for
useAgentUsageListener to assert the specific routing by verifying
batched.updateUsage was called with the expected (sessionId, tabId, usage)
tuples after invoking handler (use the created session id 'sess-1' and the tab
id present on createMockSession or the default tab id), and also assert the
per-session/per-tab distinction (e.g., one call for the tab and one for the
session) alongside the existing expect(batched.updateCycleTokens) assertion so
routing regressions are caught; reference the test-level handler variable, the
useAgentUsageListener hook invocation, and batched.updateUsage when adding these
assertions.
src/renderer/hooks/agent/internal/useAgentThinkingListener.ts (1)

89-141: 💤 Low value

Optional: collapse the replace/append branches.

The if (lastLog?.source === 'thinking') block has two near-identical updatedTabs.map(...) arms that differ only in the chosen text value. Computing the new text once and sharing the map call would shrink the surface area and make future log-shape changes a one-line edit.

♻️ Sketch
-								if (lastLog?.source === 'thinking') {
-									const combinedText = lastLog.text + bufferedContent;
-									if (isLikelyConcatenatedToolNames(combinedText)) {
-										logger.warn(
-											'[App] Detected malformed thinking content, replacing instead of appending'
-										);
-										updatedTabs = updatedTabs.map((tab) =>
-											tab.id === chunkTabId
-												? {
-														...tab,
-														logs: [
-															...tab.logs.slice(0, -1),
-															{
-																...lastLog,
-																text: bufferedContent,
-															},
-														],
-													}
-												: tab
-										);
-									} else {
-										updatedTabs = updatedTabs.map((tab) =>
-											tab.id === chunkTabId
-												? {
-														...tab,
-														logs: [
-															...tab.logs.slice(0, -1),
-															{
-																...lastLog,
-																text: combinedText,
-															},
-														],
-													}
-												: tab
-										);
-									}
+								if (lastLog?.source === 'thinking') {
+									const combinedText = lastLog.text + bufferedContent;
+									const malformed = isLikelyConcatenatedToolNames(combinedText);
+									if (malformed) {
+										logger.warn(
+											'[App] Detected malformed thinking content, replacing instead of appending'
+										);
+									}
+									const nextText = malformed ? bufferedContent : combinedText;
+									updatedTabs = updatedTabs.map((tab) =>
+										tab.id === chunkTabId
+											? {
+													...tab,
+													logs: [...tab.logs.slice(0, -1), { ...lastLog, text: nextText }],
+												}
+											: tab
+									);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/internal/useAgentThinkingListener.ts` around lines
89 - 141, The map branches that update updatedTabs in
useAgentThinkingListener.ts duplicate logic; compute the replacement text once
(e.g., determine newText by checking lastLog?.source === 'thinking' and using
bufferedContent or lastLog.text + bufferedContent unless
isLikelyConcatenatedToolNames(combinedText) dictates using bufferedContent),
then perform a single updatedTabs = updatedTabs.map(tab => tab.id === chunkTabId
? { ...tab, logs: lastLog?.source === 'thinking' ? [...tab.logs.slice(0, -1), {
...lastLog, text: newText }] : [...tab.logs, { id: generateId(), timestamp:
Date.now(), source: 'thinking', text: newText }] : tab); this collapses the
replace/append branches while still using lastLog,
isLikelyConcatenatedToolNames, bufferedContent, chunkTabId, and generateId.
src/renderer/hooks/agent/internal/useAgentUsageListener.ts (1)

60-62: 💤 Low value

Optional: name the -5 warning gap.

yellowThreshold - 5 is the cap that keeps the fallback estimate from triggering the yellow warning prematurely. A named constant (e.g., YELLOW_WARNING_GAP_PCT) would make the intent self-documenting and ease tuning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/internal/useAgentUsageListener.ts` around lines 60 -
62, The magic number "-5" used when computing maxEstimate in
useAgentUsageListener is unclear; introduce a named constant (e.g.,
YELLOW_WARNING_GAP_PCT) and replace the literal with yellowThreshold -
YELLOW_WARNING_GAP_PCT so the intent is explicit and easy to tune; update the
calculation that sets maxEstimate and keep the call to
deps.batchedUpdater.updateContextUsage(actualSessionId, Math.min(estimated,
maxEstimate)) unchanged otherwise.
src/__tests__/renderer/hooks/agent/internal/helpers/exitSynopsis.test.ts (1)

234-246: 💤 Low value

Consider a lowercase-prefix variant.

This test only locks in the uppercase shape ('AB12CD34'). If the regex in exitSynopsis.ts is broadened to be case-insensitive (see prior comment), an additional case for 'ab12cd34' would prevent regressions in the case handling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/renderer/hooks/agent/internal/helpers/exitSynopsis.test.ts`
around lines 234 - 246, The test only asserts behavior for an uppercase
UUID-like tabName; add a lowercase variant to prevent regressions if the regex
becomes case-insensitive: update the test in exitSynopsis.test.ts (the it block
calling runExitSynopsis with makeSynopsisData and asserting
window.maestro.claude.updateSessionName not called) to also call runExitSynopsis
with tabName 'ab12cd34' (or add a second it) so both 'AB12CD34' and 'ab12cd34'
are covered.
src/renderer/hooks/agent/internal/useAgentSessionIdListener.ts (1)

42-151: ⚡ Quick win

Side effect and closure mutation inside setSessions reducer.

The reducer body fires the registerSessionOrigin IPC call (lines 46–50) and mutates the outer resumeFailureDetected flag from inside prev.map (line 113). This works today because zustand's setter invokes the function exactly once, but it couples correctness to that contract and makes the data flow harder to follow. Hoisting the IPC call and the resume-failure decision out of the reducer keeps the state update pure and the side effects explicit.

♻️ Suggested restructure
-		const unsubscribe = window.maestro.process.onSessionId(
-			async (sessionId: string, agentSessionId: string) => {
-				if (isBatchSession(sessionId)) return;
-
-				const parsed = parseSessionId(sessionId);
-				const actualSessionId = parsed.actualSessionId;
-				const tabId = parsed.tabId ?? undefined;
-
-				let resumeFailureDetected = false;
-
-				setSessions((prev) => {
-					const session = prev.find((s) => s.id === actualSessionId);
-					if (!session) return prev;
-
-					window.maestro.agentSessions
-						.registerSessionOrigin(session.projectRoot, agentSessionId, 'user')
-						.catch((err) =>
-							logger.error('[onSessionId] Failed to register session origin:', undefined, err)
-						);
-
-					return prev.map((s) => {
+		const unsubscribe = window.maestro.process.onSessionId(
+			async (sessionId: string, agentSessionId: string) => {
+				if (isBatchSession(sessionId)) return;
+
+				const parsed = parseSessionId(sessionId);
+				const actualSessionId = parsed.actualSessionId;
+				const tabId = parsed.tabId ?? undefined;
+
+				const sessionBefore = useSessionStore
+					.getState()
+					.sessions.find((s) => s.id === actualSessionId);
+				if (!sessionBefore) return;
+
+				window.maestro.agentSessions
+					.registerSessionOrigin(sessionBefore.projectRoot, agentSessionId, 'user')
+					.catch((err) =>
+						logger.error('[onSessionId] Failed to register session origin:', undefined, err)
+					);
+
+				let resumeFailureDetected = false;
+				setSessions((prev) =>
+					prev.map((s) => {

(also remove the now-unneeded outer prev.find/early-return inside the reducer)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/internal/useAgentSessionIdListener.ts` around lines
42 - 151, The reducer passed to setSessions currently performs side effects and
mutates outer state: it calls window.maestro.agentSessions.registerSessionOrigin
and sets resumeFailureDetected from inside prev.map; move those out so the
reducer remains pure. Specifically, before calling setSessions, compute the
session (using the same lookup logic that finds session by actualSessionId and
tab by tabId/getActiveTab), call
window.maestro.agentSessions.registerSessionOrigin(session.projectRoot,
agentSessionId, 'user') and catch/log errors there, and determine whether
resumeFailureDetected should be set (based on comparing existing
targetTab.agentSessionId to agentSessionId) and set that flag outside the
reducer; then call setSessions with a pure updater that only maps and returns
the new session/aiTabs (using the same transformation logic in the current
prev.map) without performing IPC calls or mutating resumeFailureDetected. Ensure
you reference setSessions, registerSessionOrigin, resumeFailureDetected,
getActiveTab, actualSessionId, tabId and agentSessionId so you update the
correct code paths.
src/__tests__/renderer/hooks/agent/internal/useAgentThinkingListener.test.tsx (1)

95-103: ⚡ Quick win

Make the RAF cleanup test prove the callback cannot flush after unmount.

This currently only proves cancelAnimationFrame was invoked. Because the mock never unschedules scheduled callbacks and the test never runs flushRaf() after unmount, a leaked post-unmount flush would still pass here. Making the mock cancellable and asserting that a post-unmount flushRaf() leaves logs unchanged would turn this into a real regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/__tests__/renderer/hooks/agent/internal/useAgentThinkingListener.test.tsx`
around lines 95 - 103, The test for useAgentThinkingListener should be changed
to ensure a scheduled RAF callback truly cannot run after unmount: update the
test to use a cancellable RAF mock (so global.requestAnimationFrame returns an
id and global.cancelAnimationFrame removes that id from the scheduled set) and
then after calling handler!('sess-1-ai-tab-1','data') and unmount(), call the
test helper flushRaf() and assert that no additional logs/side-effects occurred;
locate the test using symbols useAgentThinkingListener, handler, flushRaf, and
cancelAnimationFrame and modify the mock RAF behaviour and assertions so a
post-unmount flushRaf() does not invoke the previously scheduled callback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/__tests__/renderer/hooks/agent/internal/useAgentSshRemoteListener.test.tsx`:
- Around line 55-69: The test "does NOT re-fetch when same remote already
attached" is missing assertions and never sets session.isGitRepo, so it passes
vacuously; update the test that creates the session via createMockSession and
sets useSessionStore state, then set session.isGitRepo = true (or
createMockSession with isGitRepo: true) before rendering
useAgentSshRemoteListener(), spy or mock the git probe function used by the hook
(the probe invoked by useAgentSshRemoteListener/handler), call handler('sess-1',
{ id: 'r-1', ... }) and await the microtask, and finally add an expect asserting
the probe was not called (e.g., expect(probeMock).not.toHaveBeenCalled()) to
verify no re-fetch occurs when the same remote is already attached.

In `@src/renderer/hooks/agent/internal/helpers/exitSynopsis.ts`:
- Around line 172-196: persistTabNameAfterSynopsis uses a regex that only
matches uppercase hex when detecting UUID-prefixes; change the detection to be
case-insensitive so lowercase prefixes don't slip through. In the
persistTabNameAfterSynopsis function, update the UUID-prefix check (the
isUuidPrefix test against synopsisData.tabName) to use a case-insensitive match
(or normalize the string) so both lowercase and uppercase hex characters are
treated the same, leaving the rest of the logic (persistAgentId,
persistProjectRoot, persistSessionId and the window.maestro.* calls) unchanged.

In `@src/renderer/hooks/agent/internal/useAgentErrorListener.ts`:
- Around line 128-130: The listener currently uses parsed.baseSessionId which
strips important context; update the code in useAgentErrorListener to use
parsed.actualSessionId (the full session id returned by parseSessionId) when
assigning actualSessionId for error tracking and related updates, matching the
behavior in useAgentSessionIdListener; keep tabIdFromSession as parsed.tabId ??
undefined and ensure any downstream references that used actualSessionId
continue to receive the full parsed.actualSessionId value instead of
baseSessionId.

In `@src/renderer/hooks/agent/internal/useAgentExitListener.ts`:
- Around line 86-104: The hidden-tool cleanup
(deps.activeHiddenToolRef.current?.delete(`${actualSessionId}:${tabIdFromSession}`))
must be moved to after the SAFETY CHECK that calls
window.maestro.process.getActiveProcesses() so we only delete when verification
confirms the process is gone; in the block where processStillRunning is true,
abort the exit handler without deleting the hidden-tool entry (keep existing
logger.warn) and do not proceed to state transitions, and in the catch path
after logger.error re-throw the error (rather than continuing) so unexpected
failures bubble up to Sentry; update the on-exit flow that references isFromAi,
tabIdFromSession, actualSessionId, sessionId, deps.activeHiddenToolRef, and
window.maestro.process.getActiveProcesses accordingly.

---

Nitpick comments:
In `@src/__tests__/renderer/hooks/agent/internal/helpers/exitSynopsis.test.ts`:
- Around line 234-246: The test only asserts behavior for an uppercase UUID-like
tabName; add a lowercase variant to prevent regressions if the regex becomes
case-insensitive: update the test in exitSynopsis.test.ts (the it block calling
runExitSynopsis with makeSynopsisData and asserting
window.maestro.claude.updateSessionName not called) to also call runExitSynopsis
with tabName 'ab12cd34' (or add a second it) so both 'AB12CD34' and 'ab12cd34'
are covered.

In
`@src/__tests__/renderer/hooks/agent/internal/useAgentThinkingListener.test.tsx`:
- Around line 95-103: The test for useAgentThinkingListener should be changed to
ensure a scheduled RAF callback truly cannot run after unmount: update the test
to use a cancellable RAF mock (so global.requestAnimationFrame returns an id and
global.cancelAnimationFrame removes that id from the scheduled set) and then
after calling handler!('sess-1-ai-tab-1','data') and unmount(), call the test
helper flushRaf() and assert that no additional logs/side-effects occurred;
locate the test using symbols useAgentThinkingListener, handler, flushRaf, and
cancelAnimationFrame and modify the mock RAF behaviour and assertions so a
post-unmount flushRaf() does not invoke the previously scheduled callback.

In `@src/__tests__/renderer/hooks/agent/internal/useAgentUsageListener.test.tsx`:
- Around line 44-63: The test currently only checks that batched.updateUsage was
called but not what arguments were routed; update the test for
useAgentUsageListener to assert the specific routing by verifying
batched.updateUsage was called with the expected (sessionId, tabId, usage)
tuples after invoking handler (use the created session id 'sess-1' and the tab
id present on createMockSession or the default tab id), and also assert the
per-session/per-tab distinction (e.g., one call for the tab and one for the
session) alongside the existing expect(batched.updateCycleTokens) assertion so
routing regressions are caught; reference the test-level handler variable, the
useAgentUsageListener hook invocation, and batched.updateUsage when adding these
assertions.

In `@src/renderer/hooks/agent/internal/helpers/autorunSynopsisPrompt.ts`:
- Line 20: Replace the non-null assertion on result.content when assigning
cachedAutorunSynopsisPrompt with a safe nullish coalescing default (use
result.content ?? '') so cachedAutorunSynopsisPrompt is always a string; update
the assignment in autorunSynopsisPrompt helper (where
cachedAutorunSynopsisPrompt is set from result.content) and ensure
getAutorunSynopsisPrompt logic relying on a string/'' fallback remains correct.

In `@src/renderer/hooks/agent/internal/useAgentSessionIdListener.ts`:
- Around line 42-151: The reducer passed to setSessions currently performs side
effects and mutates outer state: it calls
window.maestro.agentSessions.registerSessionOrigin and sets
resumeFailureDetected from inside prev.map; move those out so the reducer
remains pure. Specifically, before calling setSessions, compute the session
(using the same lookup logic that finds session by actualSessionId and tab by
tabId/getActiveTab), call
window.maestro.agentSessions.registerSessionOrigin(session.projectRoot,
agentSessionId, 'user') and catch/log errors there, and determine whether
resumeFailureDetected should be set (based on comparing existing
targetTab.agentSessionId to agentSessionId) and set that flag outside the
reducer; then call setSessions with a pure updater that only maps and returns
the new session/aiTabs (using the same transformation logic in the current
prev.map) without performing IPC calls or mutating resumeFailureDetected. Ensure
you reference setSessions, registerSessionOrigin, resumeFailureDetected,
getActiveTab, actualSessionId, tabId and agentSessionId so you update the
correct code paths.

In `@src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts`:
- Around line 55-86: Remove the try/catch wrapper inside the async IIFE in
useAgentSshRemoteListener so unexpected runtime errors are not silently
swallowed; call gitService.isRepo, gitService.getBranches and gitService.getTags
directly (they already handle IPC failures) and then update setSessions for
actualSessionId as before, but do not catch/log errors here—let them propagate
to Sentry per project guidelines.

In `@src/renderer/hooks/agent/internal/useAgentThinkingListener.ts`:
- Around line 89-141: The map branches that update updatedTabs in
useAgentThinkingListener.ts duplicate logic; compute the replacement text once
(e.g., determine newText by checking lastLog?.source === 'thinking' and using
bufferedContent or lastLog.text + bufferedContent unless
isLikelyConcatenatedToolNames(combinedText) dictates using bufferedContent),
then perform a single updatedTabs = updatedTabs.map(tab => tab.id === chunkTabId
? { ...tab, logs: lastLog?.source === 'thinking' ? [...tab.logs.slice(0, -1), {
...lastLog, text: newText }] : [...tab.logs, { id: generateId(), timestamp:
Date.now(), source: 'thinking', text: newText }] : tab); this collapses the
replace/append branches while still using lastLog,
isLikelyConcatenatedToolNames, bufferedContent, chunkTabId, and generateId.

In `@src/renderer/hooks/agent/internal/useAgentUsageListener.ts`:
- Around line 60-62: The magic number "-5" used when computing maxEstimate in
useAgentUsageListener is unclear; introduce a named constant (e.g.,
YELLOW_WARNING_GAP_PCT) and replace the literal with yellowThreshold -
YELLOW_WARNING_GAP_PCT so the intent is explicit and easy to tune; update the
calculation that sets maxEstimate and keep the call to
deps.batchedUpdater.updateContextUsage(actualSessionId, Math.min(estimated,
maxEstimate)) unchanged otherwise.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f6eecfe4-048a-4cf3-b3c1-eff9e4ab3e7e

📥 Commits

Reviewing files that changed from the base of the PR and between eafff54 and c54daa1.

📒 Files selected for processing (37)
  • src/__tests__/renderer/hooks/agent/internal/helpers/agentErrorLogMatch.test.ts
  • src/__tests__/renderer/hooks/agent/internal/helpers/errorTitles.test.ts
  • src/__tests__/renderer/hooks/agent/internal/helpers/exitDequeue.test.ts
  • src/__tests__/renderer/hooks/agent/internal/helpers/exitGitRefresh.test.ts
  • src/__tests__/renderer/hooks/agent/internal/helpers/exitSynopsis.test.ts
  • src/__tests__/renderer/hooks/agent/internal/helpers/exitTabCleanup.test.ts
  • src/__tests__/renderer/hooks/agent/internal/useAgentCommandExitListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentDataListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentErrorListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentExitListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentSessionIdListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentSlashCommandsListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentSshRemoteListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentStderrListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentThinkingListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentToolExecutionListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentUsageListener.test.tsx
  • src/renderer/hooks/agent/internal/helpers/agentErrorLogMatch.ts
  • src/renderer/hooks/agent/internal/helpers/autorunSynopsisPrompt.ts
  • src/renderer/hooks/agent/internal/helpers/errorTitles.ts
  • src/renderer/hooks/agent/internal/helpers/exitDequeue.ts
  • src/renderer/hooks/agent/internal/helpers/exitGitRefresh.ts
  • src/renderer/hooks/agent/internal/helpers/exitSynopsis.ts
  • src/renderer/hooks/agent/internal/helpers/exitTabCleanup.ts
  • src/renderer/hooks/agent/internal/types.ts
  • src/renderer/hooks/agent/internal/useAgentCommandExitListener.ts
  • src/renderer/hooks/agent/internal/useAgentDataListener.ts
  • src/renderer/hooks/agent/internal/useAgentErrorListener.ts
  • src/renderer/hooks/agent/internal/useAgentExitListener.ts
  • src/renderer/hooks/agent/internal/useAgentSessionIdListener.ts
  • src/renderer/hooks/agent/internal/useAgentSlashCommandsListener.ts
  • src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts
  • src/renderer/hooks/agent/internal/useAgentStderrListener.ts
  • src/renderer/hooks/agent/internal/useAgentThinkingListener.ts
  • src/renderer/hooks/agent/internal/useAgentToolExecutionListener.ts
  • src/renderer/hooks/agent/internal/useAgentUsageListener.ts
  • src/renderer/hooks/agent/useAgentListeners.ts

Comment thread src/__tests__/renderer/hooks/agent/internal/useAgentSshRemoteListener.test.tsx Outdated
Comment on lines +172 to +196
function persistTabNameAfterSynopsis(synopsisData: SynopsisData): void {
const persistName = synopsisData.tabName;
if (!persistName) return;
const isUuidPrefix = /^[0-9A-F]{8}$/.test(persistName);
if (isUuidPrefix) return;
if (!synopsisData.agentSessionId || !synopsisData.projectRoot) return;

const persistAgentId = synopsisData.toolType || 'claude-code';
const persistProjectRoot = synopsisData.projectRoot;
const persistSessionId = synopsisData.agentSessionId;

if (persistAgentId === 'claude-code') {
window.maestro.claude
.updateSessionName(persistProjectRoot, persistSessionId, persistName)
.catch((err) =>
logger.warn('[onProcessExit] Failed to persist synopsis tab name', undefined, err)
);
} else {
window.maestro.agentSessions
.setSessionName(persistAgentId, persistProjectRoot, persistSessionId, persistName)
.catch((err) =>
logger.warn('[onProcessExit] Failed to persist synopsis tab name', undefined, err)
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where the UUID-prefix fallback tab name is generated to confirm casing.
rg -nP --type=ts -C3 'substring\(\s*0\s*,\s*8\s*\)' -g '!**/__tests__/**'
echo '---'
rg -nP --type=ts -C2 '\bgenerateId\b|crypto\.randomUUID|toUpperCase\(\).*substring|substring.*toUpperCase' -g '!**/__tests__/**'

Repository: RunMaestro/Maestro

Length of output: 50374


UUID-prefix detection should be case-insensitive to prevent fragile assumptions.

The regex /^[0-9A-F]{8}$/ only matches uppercase hex, but relies on every code path that generates the fallback UUID-prefix to apply .toUpperCase(). While current code does this consistently, the regex itself doesn't enforce it—if a code path forgets .toUpperCase(), the lowercase prefix silently persists as a user-facing name. A case-insensitive regex is a low-effort safeguard:

-	const isUuidPrefix = /^[0-9A-F]{8}$/.test(persistName);
+	const isUuidPrefix = /^[0-9a-fA-F]{8}$/.test(persistName);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function persistTabNameAfterSynopsis(synopsisData: SynopsisData): void {
const persistName = synopsisData.tabName;
if (!persistName) return;
const isUuidPrefix = /^[0-9A-F]{8}$/.test(persistName);
if (isUuidPrefix) return;
if (!synopsisData.agentSessionId || !synopsisData.projectRoot) return;
const persistAgentId = synopsisData.toolType || 'claude-code';
const persistProjectRoot = synopsisData.projectRoot;
const persistSessionId = synopsisData.agentSessionId;
if (persistAgentId === 'claude-code') {
window.maestro.claude
.updateSessionName(persistProjectRoot, persistSessionId, persistName)
.catch((err) =>
logger.warn('[onProcessExit] Failed to persist synopsis tab name', undefined, err)
);
} else {
window.maestro.agentSessions
.setSessionName(persistAgentId, persistProjectRoot, persistSessionId, persistName)
.catch((err) =>
logger.warn('[onProcessExit] Failed to persist synopsis tab name', undefined, err)
);
}
}
function persistTabNameAfterSynopsis(synopsisData: SynopsisData): void {
const persistName = synopsisData.tabName;
if (!persistName) return;
const isUuidPrefix = /^[0-9a-fA-F]{8}$/.test(persistName);
if (isUuidPrefix) return;
if (!synopsisData.agentSessionId || !synopsisData.projectRoot) return;
const persistAgentId = synopsisData.toolType || 'claude-code';
const persistProjectRoot = synopsisData.projectRoot;
const persistSessionId = synopsisData.agentSessionId;
if (persistAgentId === 'claude-code') {
window.maestro.claude
.updateSessionName(persistProjectRoot, persistSessionId, persistName)
.catch((err) =>
logger.warn('[onProcessExit] Failed to persist synopsis tab name', undefined, err)
);
} else {
window.maestro.agentSessions
.setSessionName(persistAgentId, persistProjectRoot, persistSessionId, persistName)
.catch((err) =>
logger.warn('[onProcessExit] Failed to persist synopsis tab name', undefined, err)
);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/internal/helpers/exitSynopsis.ts` around lines 172 -
196, persistTabNameAfterSynopsis uses a regex that only matches uppercase hex
when detecting UUID-prefixes; change the detection to be case-insensitive so
lowercase prefixes don't slip through. In the persistTabNameAfterSynopsis
function, update the UUID-prefix check (the isUuidPrefix test against
synopsisData.tabName) to use a case-insensitive match (or normalize the string)
so both lowercase and uppercase hex characters are treated the same, leaving the
rest of the logic (persistAgentId, persistProjectRoot, persistSessionId and the
window.maestro.* calls) unchanged.

Comment on lines +128 to +130
const parsed = parseSessionId(sessionId);
const actualSessionId = parsed.baseSessionId;
const tabIdFromSession = parsed.tabId ?? undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect parseSessionId's return shape and any parseSessionId result-field usages across the new internal listeners.
fd -t f 'sessionIdParser' --exec cat {}
echo '---'
rg -nP --type=ts -C2 'parsed\.(baseSessionId|actualSessionId|tabId)\b'

Repository: RunMaestro/Maestro

Length of output: 17537


Use parsed.actualSessionId instead of parsed.baseSessionId for error tracking.

The parseSessionId() function returns both actualSessionId (the full session ID to use for updates) and baseSessionId (the parent session for synopsis/batch). Using baseSessionId strips important context for synopsis and batch sessions—the actual session IDs would be truncated from 'session-123-synopsis-timestamp' to just 'session-123'.

Compare with useAgentSessionIdListener.ts line 37, which correctly uses parsed.actualSessionId. Align this listener to use the same field for consistency and to preserve full session ID context in error tracking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/agent/internal/useAgentErrorListener.ts` around lines 128
- 130, The listener currently uses parsed.baseSessionId which strips important
context; update the code in useAgentErrorListener to use parsed.actualSessionId
(the full session id returned by parseSessionId) when assigning actualSessionId
for error tracking and related updates, matching the behavior in
useAgentSessionIdListener; keep tabIdFromSession as parsed.tabId ?? undefined
and ensure any downstream references that used actualSessionId continue to
receive the full parsed.actualSessionId value instead of baseSessionId.

Comment thread src/renderer/hooks/agent/internal/useAgentExitListener.ts Outdated
Applies the still-valid issues from review of the §4.4 decomposition
and skips the rest with rationale below.

Behavior fixes:
- useAgentExitListener: move activeHiddenToolRef.delete() to AFTER the
  getActiveProcesses safety check so a false-positive exit event no
  longer drops bookkeeping for a process that's still alive.

Code-quality cleanups:
- useAgentSshRemoteListener: drop the redundant try/catch around the
  git probe IIFE. gitService.isRepo / getBranches / getTags route
  through createIpcMethod which already swallows IPC errors, reports
  to Sentry, and returns the configured default value, so the wrapper
  was dead code that risked masking genuine programmer errors.
- useAgentThinkingListener: collapse three near-identical
  updatedTabs.map() branches into a single map by computing the
  replacement logs once per chunk (append vs. clean-continuation vs.
  malformed-continuation). Behavior unchanged; ~30 lines lighter.
- useAgentUsageListener: replace the magic "-5" buffer with a named
  constant ESTIMATED_USAGE_YELLOW_GAP_PCT and document why we keep
  estimates below the user's yellow threshold.
- autorunSynopsisPrompt: replace `result.content!` with
  `result.content ?? ''` so cachedAutorunSynopsisPrompt always
  satisfies the `string | null` type contract even if the IPC layer
  ever violates the success-implies-content invariant.

Test improvements:
- useAgentSshRemoteListener.test: the "same remote already attached"
  case had zero assertions (vacuous pass). Now sets isGitRepo=true and
  asserts the git probe functions were not called.
- useAgentThinkingListener.test: the RAF cleanup test only checked
  cancelAnimationFrame was invoked. The mock is now cancellable
  (id -> callback Map; cancelAnimationFrame removes from the queue),
  and the test additionally calls flushRaf() AFTER unmount and asserts
  no logs were appended — proves the cancelled callback truly cannot
  fire.
- useAgentUsageListener.test: the "routes usage updates" test only
  checked that updateUsage was called. Now uses
  toHaveBeenNthCalledWith for both the per-tab and per-session calls;
  added a second test that asserts ai-tab-format ids carry the tabId
  on call #1 and null on call #2.
- exitSynopsis.test: the UUID-prefix test only covered uppercase. New
  positive test pins the case-sensitive contract by asserting that
  lowercase 8-hex tab names ARE persisted (the auto-generated fallback
  is always uppercase, so lowercase can only have been user-typed).

Skipped (with reason):
- "Make UUID-prefix regex case-insensitive" — the only fallback path
  uses `agentSessionId.substring(0, 8).toUpperCase()`, so lowercase
  8-hex names can only have come from the user typing them. Making
  the regex case-insensitive would silently drop those custom names.
- "Use parsed.actualSessionId instead of parsed.baseSessionId in
  useAgentErrorListener" — the original (pre-decomposition) code used
  baseSessionId intentionally to attribute batch-suffix errors to the
  user-visible session. Changing would break that attribution. The
  "match useAgentSessionIdListener" claim is misleading because that
  listener bails on isBatchSession() before parsing.
- "Re-throw on getActiveProcesses safety-check failure" — pre-refactor
  code swallowed too; the current logger.error path is not silent
  (forwards to main with full context). Re-throwing would leave the
  agent's tab in a permanently busy state on transient IPC failures
  for very limited Sentry value.
- "Move IPC + flag-mutation outside useAgentSessionIdListener
  reducer" — the targetTab/isClaudeCode lookup IS the bulk of the
  reducer body, so hoisting would force a duplicate pass. The IPC is
  idempotent (no harm if double-fired in StrictMode dev). Out of
  minimal-change scope.

Validation: 179 / 179 listener tests passing (was 177);
27,722 / 108 skipped / 0 failing across the full suite (+2 from the
new lowercase UUID and ai-tab routing tests). Zero type errors, zero
lint errors.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts`:
- Around line 50-85: The git probe runs unconditionally when sshRemote?.id
exists, causing redundant IPC calls even if the remote didn't change; fix by
gating the probe on an actual remote change: inside useAgentSshRemoteListener
where you locate the session (getSessions().find(s => s.id ===
actualSessionId)), capture the session's existing remote id
(session.sessionSshRemoteConfig?.id or similar) and only start the async probe
(the void async IIFE that calls gitService.isRepo / getBranches / getTags and
later calls setSessions) if that existingRemoteId !== sshRemote.id (or if
existingRemoteId is undefined and sshRemote.id is defined), otherwise skip the
probe; keep the existing setSessions shortcircuit (currentRemoteId ===
newRemoteId) as-is and reference actualSessionId, sshRemote.id, getSessions,
gitService.isRepo, gitService.getBranches, gitService.getTags, and setSessions
when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 208956d8-f463-4e9d-9dbf-8529fc537f9f

📥 Commits

Reviewing files that changed from the base of the PR and between c54daa1 and 311c2ca.

📒 Files selected for processing (9)
  • src/__tests__/renderer/hooks/agent/internal/helpers/exitSynopsis.test.ts
  • src/__tests__/renderer/hooks/agent/internal/useAgentSshRemoteListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentThinkingListener.test.tsx
  • src/__tests__/renderer/hooks/agent/internal/useAgentUsageListener.test.tsx
  • src/renderer/hooks/agent/internal/helpers/autorunSynopsisPrompt.ts
  • src/renderer/hooks/agent/internal/useAgentExitListener.ts
  • src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts
  • src/renderer/hooks/agent/internal/useAgentThinkingListener.ts
  • src/renderer/hooks/agent/internal/useAgentUsageListener.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/tests/renderer/hooks/agent/internal/useAgentUsageListener.test.tsx
  • src/renderer/hooks/agent/internal/useAgentUsageListener.ts
  • src/renderer/hooks/agent/internal/helpers/autorunSynopsisPrompt.ts
  • src/tests/renderer/hooks/agent/internal/useAgentSshRemoteListener.test.tsx
  • src/renderer/hooks/agent/internal/useAgentThinkingListener.ts
  • src/tests/renderer/hooks/agent/internal/helpers/exitSynopsis.test.ts
  • src/tests/renderer/hooks/agent/internal/useAgentThinkingListener.test.tsx
  • src/renderer/hooks/agent/internal/useAgentExitListener.ts

Comment thread src/renderer/hooks/agent/internal/useAgentSshRemoteListener.ts Outdated
The probe block in useAgentSshRemoteListener fired on every onSshRemote
event whose `sshRemote.id` was set and whose target session wasn't yet
flagged as a git repo. Reconnects and duplicate IPC events for the same
remote therefore burned 3 IPC roundtrips (isRepo + getBranches + getTags)
for nothing — and could even spawn concurrent probes if the first hadn't
flipped `isGitRepo` to true yet.

Fix: snapshot the previously-attached remote id from the store BEFORE
the setSessions call (after, Zustand has synchronously written the new
value, so we'd have no way to tell a fresh attach from a duplicate
event), and add `previousSshRemoteId !== sshRemote.id` to the probe gate.

Test added: "does NOT re-probe on a duplicate event for the SAME remote
(e.g. reconnect/replay)" — seeds a session with `sshRemote: { id: 'r-1' }`
and `isGitRepo: false`, dispatches the same `r-1` remote again, and
asserts all three gitService.* probes were skipped.

180 / 180 listener tests passing (+1); 27,723 / 108 skipped / 0 failing
across the full suite.
No behaviour change. Captures the lint-staged pass that ran after the
previous commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant