Skip to content

feat: multi-provider foundation (Claude/Gemini/OpenAI interface + 15 offline CI tests) - #38

Merged
saucam merged 14 commits into
mainfrom
fix/file-explorer-session-switch
Jun 28, 2026
Merged

feat: multi-provider foundation (Claude/Gemini/OpenAI interface + 15 offline CI tests)#38
saucam merged 14 commits into
mainfrom
fix/file-explorer-session-switch

Conversation

@saucam

@saucam saucam commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Provider interface — `AgentProvider`, `ProviderEvent` stream, `NormalizedTurnResult`, `CanonicalTurn[]` (shared history format across all providers)
  • ClaudeProvider — wraps the existing Claude Agent SDK keep-warm loop, emits normalized events, maps Anthropic result to `NormalizedTurnResult`
  • GeminiProvider — stateless, `@google/generative-ai`, converts `CanonicalTurn[]` → Gemini `Content[]` on every turn; Phase 1 inlines prior tool calls as text context
  • OpenAIProvider — stateless, `openai` SDK, converts `CanonicalTurn[]` → OpenAI messages; Phase 1 same inline-text tool approach
  • `CanonicalHistoryAccumulator` — standalone class that consumes a `ProviderEvent` stream and builds the canonical history
  • History converters — `toGeminiContent()`, `toOpenAIMessages()`, `toAnthropicMessages()` with clear Phase 2 stubs where native function_call/functionResponse rendering goes
  • MockProvider + 15 offline CI tests — zero network calls, no API keys needed; tests cover text turns, tool-call normalization (`Read`→`read_file`, `Bash`→`run_shell`), thinking accumulation, multi-provider history threading, and all three format converters
  • `bunfig.toml` — scopes bare `bun test` to `src/` so web Vitest tests aren't accidentally picked up by Bun's runner
  • `session.ts` migrated to provider architecture — all `@anthropic-ai/claude-agent-sdk` imports replaced with `ClaudeProvider.runTurn()` + `CanonicalHistoryAccumulator`; -718 lines

Architecture

session.ts
  └── #provider: ClaudeProvider (keep-warm SDK loop)
  └── #accumulator: CanonicalHistoryAccumulator (CanonicalTurn[] history)
  
#sendInner():
  1. accumulator.pushUserTurn(prompt)
  2. provider.runTurn({ history, canUseTool, ... }) → TurnRun
  3. #consumeEvents(run) — for await events, break on turn_done
  
#handleProviderEvent():
  text_delta/done → streaming assistant messages
  thinking_delta/done → thinking messages  
  tool_start → SessionMessage + approvalId→messageId mapping
  tool_complete → finalize tool message with output
  subagent_start/stop → identity registration, map update
  llm_call → primary/subagent usage split
  turn_done → accumulator.handleEvent() + #recordTurnFromResult()
  error → error message + status flip

Phase 2 upgrade path

Each history converter has an explicit comment marking where native tool calling goes:

  • Gemini: `{ functionCall }` parts + a follow-up user turn with `{ functionResponse }` parts
  • OpenAI: `tool_calls[]` in the assistant message + `{ role: "tool" }` messages
  • Anthropic: `tool_use` + `tool_result` content blocks

`CanonicalToolCall` already captures everything needed (`id`, `name`, `input`, `output`, `success`, `originalName`) so Phase 2 is purely additive.

Test plan

  • `bun test` — 537 pass, 0 fail (includes 15 new provider-switch tests)
  • `bun run typecheck` — clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Gemini and OpenAI agent providers with streamed responses and unified conversation handling.
    • Implemented multi-provider switching using a shared canonical history (including tool calls and “thinking”).
    • Files sidebar now shows the active session’s working directory.
  • Bug Fixes
    • File browsing now clears and shows the loading state immediately when switching sessions, reducing stale entries.
  • Tests
    • Added deterministic multi-provider switching and message conversion tests.
    • Added file-tree state tests for clearFirst behavior and per-session resets.
  • Documentation
    • Added a multi-provider meta-harness design specification.
  • Chores
    • Updated Bun test configuration to skip the web suite by default.

saucam and others added 2 commits June 26, 2026 03:58
Three bugs in the file-tree state on session switch:

1. resetFileTreeForSession used `setState("bySession", id, {})` which is
   a SolidJS merge (no-op) — the old session's entries were never cleared,
   so stale data persisted and memory wasn't reclaimed.
   Fixed by using produce() to perform a real replacement.

2. loadDirectory on session switch kept stale entries visible while the
   new fetch was in-flight (loading indicator only fires when entries===null).
   Added clearFirst option that wipes entries before the request, ensuring
   the "loading…" indicator always appears on session switch.

3. FileTree header showed "Files" with no workdir path, so after switching
   sessions the user couldn't tell which session's directory was shown.
   Added a workdir label under the header that updates with focusedSession.

Closes #36

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ini/OpenAI + 15 offline CI tests

Introduces the provider abstraction layer that lets codeoid sessions use
multiple LLM backends (Claude, Gemini, OpenAI) with a shared canonical
conversation history.

## What's in this PR

**Provider interface (src/daemon/providers/interface.ts)**
- `AgentProvider` — `runTurn(TurnOpts): TurnRun`, `listModels()`, `dispose()`
- `ProviderEvent` — normalized event stream (text_delta, tool_start, turn_done, …)
- `NormalizedTurnResult` — provider-agnostic turn summary (tokens, cost, model, …)
- `CanonicalTurn[] / CanonicalToolCall` — shared history format

**ClaudeProvider (src/daemon/providers/claude/)**
- Wraps the Claude Agent SDK keep-warm query loop
- Maps SDK events → ProviderEvents, Anthropic result → NormalizedTurnResult
- Tool name normalization: Read→read_file, Bash→run_shell, etc.

**GeminiProvider (src/daemon/providers/gemini/)**
- Stateless: converts CanonicalTurn[] → Gemini Content[] on every turn
- Streams via @google/generative-ai generateContentStream
- Phase 1: tool calls from prior Claude turns inlined as text context

**OpenAIProvider (src/daemon/providers/openai/)**
- Stateless: converts CanonicalTurn[] → OpenAI messages[] on every turn
- Streams via chat.completions.create with stream_options.include_usage
- Phase 1: tool calls inlined as text (Phase 2 will use tool_calls[])

**CanonicalHistoryAccumulator**
- Standalone class consuming ProviderEvents → CanonicalTurn[]
- Tracks in-progress text, thinking, tool calls; flushes on turn_done
- Will be composed into session.ts in the follow-on PR

**History converters (canonical.ts)**
- toGeminiContent(), toOpenAIMessages(), toAnthropicMessages()
- Phase 1 renders tool calls as inline text; Phase 2 stubs clearly marked
  where native function_call/functionResponse parts go

**MockProvider + 15 offline CI tests (src/tests/provider-switch.test.ts)**
- Zero network calls, fully deterministic, runs in CI without API keys
- Tests: text turns, tool-call capture, thinking, multi-provider threading,
  Gemini/OpenAI/Anthropic format conversion, splitForStateless edge cases

**bunfig.toml** — scopes bare `bun test` to src/ so web Vitest tests aren't
accidentally picked up by Bun's runner (fixes 6 false-positive CI failures)

## What's NOT in this PR (follow-on)
session.ts still uses the direct Claude Agent SDK loop. Wiring session.ts to
use ClaudeProvider + CanonicalHistoryAccumulator is the next PR, after which
`/provider gemini` and `/provider openai` will work end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a multi-provider agent runtime with canonical turn history, Claude/Gemini/OpenAI/Mock providers, provider-driven session orchestration, and offline switch tests. The file tree now clears stale session data before reload and shows the focused session workdir.

Changes

Multi-Provider Meta-Harness

Layer / File(s) Summary
Design spec and public contracts
docs/multi-provider-meta-harness.md, src/daemon/providers/interface.ts, src/daemon/providers/registry.ts, src/daemon/providers/index.ts, package.json
The design spec defines canonical history, provider switching, configuration, implementation phases, and audit notes, and the shared provider interfaces, registry, barrel exports, and Gemini dependency are added.
Canonical history and conversion
src/daemon/providers/canonical.ts
Canonical tool-call types, name normalization, truncation rules, provider-format conversion helpers, and the accumulator state machine are added.
Claude provider runtime
src/daemon/providers/claude/index.ts
ClaudeProvider wraps the Claude SDK query loop, merges MCP sources, gates tool use, translates SDK messages into provider events, and handles recovery and teardown.
Gemini and OpenAI providers
src/daemon/providers/gemini/index.ts, src/daemon/providers/openai/index.ts
Gemini and OpenAI stream stateless turns from canonical history, and their model listing and interruption paths are implemented.
Session provider orchestration
src/daemon/session.ts
Session runtime moves to provider-backed event consumption, canonical history accumulation, provider rotation and recovery, normalized usage accounting, and provider-driven tool handling.
Offline tests and Bun config
src/daemon/providers/mock/index.ts, src/tests/provider-switch.test.ts, bunfig.toml
MockProvider records scripted turn events, the provider-switch tests cover canonical accumulation and history conversion, and Bun test config excludes web tests from the default run.

File Tree Session Switch

Layer / File(s) Summary
Session loading state
web/src/state/files.ts, web/src/state/files.test.ts
loadDirectory gained a clear-first option, resetFileTreeForSession replaces the per-session node map, and tests cover clearing, loading, and reset behavior.
Sidebar session UI
web/src/components/files/FileTree.tsx
The file tree view passes clear-first on session changes and renders the focused session workdir in the sidebar header.

Sequence Diagram(s)

sequenceDiagram
  participant Session
  participant ClaudeProvider
  participant CanonicalHistoryAccumulator
  participant GeminiProvider

  Session->>CanonicalHistoryAccumulator: pushUserTurn(user content)
  Session->>ClaudeProvider: runTurn(TurnOpts)
  ClaudeProvider-->>Session: ProviderEvent stream
  Session->>CanonicalHistoryAccumulator: handleEvent(event)
  Session->>GeminiProvider: runTurn({ history: accumulator.history })
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related issues

Possibly related PRs

  • saucam/codeoid#37: Shares the file-tree clearFirst/produce state changes and the matching tests.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a multi-provider foundation plus offline CI tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/file-explorer-session-switch

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.12290% with 53 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.62%. Comparing base (5bc05ec) to head (f799a85).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/daemon/session.ts 94.25% 24 Missing ⚠️
src/daemon/providers/claude/index.ts 97.27% 12 Missing ⚠️
src/daemon/providers/registry.ts 43.75% 9 Missing ⚠️
src/daemon/providers/mock/index.ts 82.60% 8 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main      #38       +/-   ##
===========================================
+ Coverage   59.69%   80.62%   +20.93%     
===========================================
  Files          47       55        +8     
  Lines        7255     7418      +163     
===========================================
+ Hits         4331     5981     +1650     
+ Misses       2924     1437     -1487     
Flag Coverage Δ
daemon 80.62% <96.12%> (+20.93%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/daemon/providers/canonical.ts 100.00% <100.00%> (ø)
src/daemon/providers/gemini/index.ts 100.00% <100.00%> (ø)
src/daemon/providers/index.ts 100.00% <100.00%> (ø)
src/daemon/providers/mock/session-provider.ts 100.00% <100.00%> (ø)
src/daemon/providers/openai/index.ts 100.00% <100.00%> (ø)
src/daemon/providers/mock/index.ts 82.60% <82.60%> (ø)
src/daemon/providers/registry.ts 43.75% <43.75%> (ø)
src/daemon/providers/claude/index.ts 97.27% <97.27%> (ø)
src/daemon/session.ts 67.36% <94.25%> (+58.11%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@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: 6

🧹 Nitpick comments (3)
docs/multi-provider-meta-harness.md (1)

66-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: add a language to fenced code blocks.

markdownlint (MD040) flags several fenced blocks without a language hint (lines 66, 93, 261, 292, 344). Use text (or an appropriate language) to silence the warning and improve rendering.

🤖 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 `@docs/multi-provider-meta-harness.md` at line 66, Several fenced code blocks
in the multi-provider meta harness document are missing a language hint,
triggering markdownlint MD040. Update the affected fenced blocks to include an
explicit language such as text, using the same fenced block sections in the
document so the lint warning is silenced and rendering is improved.

Source: Linters/SAST tools

src/daemon/providers/openai/index.ts (1)

26-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid importing splitForStateless from the Gemini module.

OpenAIProvider depending on ../gemini/index.js couples two sibling providers and forces the whole Gemini module (and its @google/generative-ai import) to load whenever OpenAI is used. Move splitForStateless to a shared location (e.g. canonical.ts or a providers util) and import it from there.

🤖 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/daemon/providers/openai/index.ts` at line 26, The OpenAI provider
currently imports splitForStateless from the Gemini module, creating an
unnecessary sibling-provider dependency and loading Gemini-specific code when
OpenAI is used. Move splitForStateless to a shared helper location such as
canonical.ts or a common providers utility, then update OpenAIProvider to import
it from that shared module instead of ../gemini/index.js.
package.json (1)

58-58: 📐 Maintainability & Code Quality | 🔵 Trivial

Migrate the Gemini provider to @google/genai
package.json still adds the legacy @google/generative-ai, and src/daemon/providers/gemini/index.ts imports GoogleGenerativeAI from it. Switch to the supported unified SDK for new code.

🤖 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 `@package.json` at line 58, Migrate the Gemini provider off the legacy SDK:
remove the `@google/generative-ai` dependency from package.json and update
src/daemon/providers/gemini/index.ts to use the supported `@google/genai` client
instead of GoogleGenerativeAI. Refactor the Gemini provider initialization and
any model generation calls in the gemini index module to match the new SDK’s
API, keeping the provider behavior unchanged.
🤖 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/daemon/providers/canonical.ts`:
- Around line 81-99: Replace the global Infinity usages in TOOL_OUTPUT_LIMITS
and the limit check with Number.POSITIVE_INFINITY to satisfy Biome’s
useNumberNamespace rule. Update the canonical.ts constants for str_replace_file,
write_file, and multi_edit_file, and adjust limitToolOutput so the default
fallback and finite check still behave the same while using the Number namespace
form.

In `@src/daemon/providers/claude/index.ts`:
- Line 151: Remove the unnecessary `const self = this` aliases in
`claude/index.ts`; the surrounding arrow-function and async-arrow scopes already
preserve `this`, so Biome flags them as `noUselessThisAlias`. Update the
affected logic in the returned object methods, hooks, and `canUseTool` to
reference `this` directly instead of `self`, and remove the alias declarations
at the identified spots.
- Around line 608-622: `loadUserMcpServers` is trusting `.claude.json` via a
type assertion instead of validating the runtime config. Replace the raw
`JSON.parse` shape cast with a Zod schema for the expected `mcpServers` and
`projects` structure, and use the parsed result only after successful
validation. Keep the existing merge behavior in `loadUserMcpServers`, but ensure
malformed files fall back safely to an empty object.
- Line 572: The `tools[server] ??= []).push(t)` expression in the `claude`
provider triggers the `lint/suspicious/noAssignInExpressions` rule because the
assignment is nested inside a larger expression. Refactor the logic in the same
spot to first ensure `tools[server]` is initialized, then perform the `push` on
that array in a separate step, keeping the behavior unchanged while removing the
assignment from the expression.
- Line 40: `LLMCallUsage` is a type-only dependency, so the import in the claude
provider should use a type-only import to satisfy lint/style/useImportType.
Update the import in the `claude` module so it uses `import type` for
`LLMCallUsage`, and keep the rest of the file unchanged.

In `@src/daemon/providers/gemini/index.ts`:
- Around line 84-102: The Gemini streaming request in the provider’s chat flow
is not receiving the abort signal, so only local consumption stops while the
network call continues. Update the `sendMessageStream` call in the `Gemini`
provider to pass `ac.signal` from the existing abort controller, ensuring the
in-flight request is truly cancelled when `interrupt()` is triggered.

---

Nitpick comments:
In `@docs/multi-provider-meta-harness.md`:
- Line 66: Several fenced code blocks in the multi-provider meta harness
document are missing a language hint, triggering markdownlint MD040. Update the
affected fenced blocks to include an explicit language such as text, using the
same fenced block sections in the document so the lint warning is silenced and
rendering is improved.

In `@package.json`:
- Line 58: Migrate the Gemini provider off the legacy SDK: remove the
`@google/generative-ai` dependency from package.json and update
src/daemon/providers/gemini/index.ts to use the supported `@google/genai` client
instead of GoogleGenerativeAI. Refactor the Gemini provider initialization and
any model generation calls in the gemini index module to match the new SDK’s
API, keeping the provider behavior unchanged.

In `@src/daemon/providers/openai/index.ts`:
- Line 26: The OpenAI provider currently imports splitForStateless from the
Gemini module, creating an unnecessary sibling-provider dependency and loading
Gemini-specific code when OpenAI is used. Move splitForStateless to a shared
helper location such as canonical.ts or a common providers utility, then update
OpenAIProvider to import it from that shared module instead of
../gemini/index.js.
🪄 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 Plus

Run ID: 6fb64a81-36d1-4252-a46e-7a28106c7468

📥 Commits

Reviewing files that changed from the base of the PR and between 6a082a4 and ed6a929.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock, !**/*.lock, !bun.lock
📒 Files selected for processing (15)
  • bunfig.toml
  • docs/multi-provider-meta-harness.md
  • package.json
  • src/daemon/providers/canonical.ts
  • src/daemon/providers/claude/index.ts
  • src/daemon/providers/gemini/index.ts
  • src/daemon/providers/index.ts
  • src/daemon/providers/interface.ts
  • src/daemon/providers/mock/index.ts
  • src/daemon/providers/openai/index.ts
  • src/daemon/providers/registry.ts
  • src/tests/provider-switch.test.ts
  • web/src/components/files/FileTree.tsx
  • web/src/state/files.test.ts
  • web/src/state/files.ts

Comment thread src/daemon/providers/canonical.ts
Comment thread src/daemon/providers/claude/index.ts Outdated
Comment thread src/daemon/providers/claude/index.ts Outdated
Comment thread src/daemon/providers/claude/index.ts Outdated
Comment thread src/daemon/providers/claude/index.ts
Comment thread src/daemon/providers/gemini/index.ts Outdated
Session no longer imports @anthropic-ai/claude-agent-sdk directly.
All SDK interaction routes through ClaudeProvider.runTurn() / TurnRun.events,
with CanonicalHistoryAccumulator tracking canonical history for provider switching.

- Replace #query/#abortController/#inputQueue/#consumerTask with #provider/#activeRun/#eventConsumerTask/#accumulator
- Replace #ensureQueryLoop() with #ensureAgentIdentity() + #makeCanUseToolFn() + runTurn() call in #sendInner
- Replace #teardownQueryLoop() with #teardownProvider()
- Replace #handleAgentMessage(SDKMessage) with #handleProviderEvent(ProviderEvent)
- Replace #recordTurnFromResult(unknown) with NormalizedTurnResult overload
- Remove loadUserMcpServers() and extractToolResultText() (now in ClaudeProvider)
- #rotate() calls provider.resetToNewSession() + accumulator.reset()
- Net: -718 lines, 537 tests pass, typecheck clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/daemon/session.ts (3)

1246-1298: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Capture rotation context before zeroing it.

Line 1279 resets lastTurnInputTokens before Line 1297 reads it, so the rotation message and ctx_before_tokens metadata always report 0.

Suggested fix
     this.#provider.resetToNewSession(newBackingId);
     this.#accumulator.reset();
+    const ctxBefore = this.#usage.lastTurnInputTokens ?? 0;
+    const pctBefore = Math.round((ctxBefore / Session.CONTEXT_WINDOW) * 100);
     // Reset rotation-trigger inputs so the next `#shouldRotate`()
@@
     this.#usage.lastTurnInputTokens = 0;
     this.#turnsSinceLastRotation = 0;
@@
-    const ctxBefore = this.#usage.lastTurnInputTokens ?? 0;
-    const pctBefore = Math.round((ctxBefore / Session.CONTEXT_WINDOW) * 100);
-
     const infoMsg = this.#makeMessage(
🤖 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/daemon/session.ts` around lines 1246 - 1298, The rotation context is
being read after `#usage.lastTurnInputTokens` is reset, so the audit message and
`ctx_before_tokens` end up reporting zero. Capture the pre-rotation usage value
in `rotateSession` (or the surrounding rotation flow) before calling
`#accumulator.reset()` / zeroing `#usage.lastTurnInputTokens`, then use that
saved value when computing `ctxBefore`, `pctBefore`, and the `session.rotate`
audit payload.

1415-1433: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Avoid running the auto-approval policy twice.

#shouldAutoApprove() decrements #turnsRemaining and can switch modes. It now runs once in canUseTool and again while rendering tool_start, so one tool can burn two autonomous budget units or render as waiting for approval while the provider is already allowed to execute.

Suggested direction
+  `#approvalAutoDecision` = new Map<string, boolean>();
+
   `#makeCanUseToolFn`(sender: AuthContext): ToolApprovalFn {
     return async (_toolId, approvalId, toolName, inputObj) => {
       const autoApprove = this.#shouldAutoApprove(toolName);
+      this.#approvalAutoDecision.set(approvalId, autoApprove);
@@
-        const autoApprove = this.#shouldAutoApprove(event.name);
+        const autoApprove = this.#approvalAutoDecision.get(event.approvalId) ?? false;

Also applies to: 1470-1482, 1628-1660

🤖 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/daemon/session.ts` around lines 1415 - 1433, `#shouldAutoApprove` is
being evaluated more than once for the same tool, which can double-consume
`#turnsRemaining` and cause inconsistent approval UI. Update the flow in
`Session` so the auto-approval decision is computed once in `canUseTool` and
then reused when rendering `tool_start`, instead of calling
`#shouldAutoApprove(toolName)` again. Make the approval state available to the
`tool_start` rendering path and any related callers so mode changes and budget
decrements happen only once per tool invocation.

666-688: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Keep the event consumer attached after mid-turn pushes.

pushMidTurn() injects another user message into the same live TurnRun, but #consumeEvents() stops at the first turn_done. If that result belongs to the already-running turn, later events for the pushed prompt are left unread while #activeRun is cleared. Either keep consuming while the provider has queued work, or route mid-turn sends through a new run boundary.

Also applies to: 1528-1533

🤖 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/daemon/session.ts` around lines 666 - 688, Keep the event consumer
attached after mid-turn pushes because `#consumeEvents` currently stops on the
first turn_done and can clear `#activeRun` before the provider finishes processing
the injected prompt. Update the session flow around `#activeRun.pushMidTurn` and
`#consumeEvents` so mid-turn work continues to be drained until the queued prompt
is complete, or otherwise start a new run boundary for mid-turn sends. Make sure
the logic in session.ts that handles pushMidTurn and the turn_done exit
condition stays aligned so later events are not left unread.
🤖 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/daemon/session.ts`:
- Around line 1489-1513: The approval resolution in this session flow is
incorrectly setting approved tool calls to a terminal completed state before
execution; update the logic in the approval handling path around the msgId
lookup so approved calls transition to an executing state instead of phase
completed with empty output, while denied calls remain cancelled. Keep the
transcript/scrollback update and broadcast in sync with this non-terminal state,
and let the later tool_complete handling in SessionMessage state ownership
finalize completion.
- Around line 1787-1790: The turn_done handler is treating error results as
successful turns, so provider failures get hidden. Update the turn_done case in
session handling to inspect NormalizedTurnResult.isError and errorMessage before
recording usage or switching to idle. If the result is an error, surface it
through the existing error/status flow instead of calling `#setStatus`("idle"),
while keeping the normal path unchanged for non-error results. Use the existing
`#recordTurnFromResult` and `#accumulator.handleEvent` logic as the lookup points
for where to branch.
- Around line 706-709: The recovery path in session.ts is duplicating the user
turn because `#accumulator.pushUserTurn(content)` is being called again before
`#provider.runTurn()`, even though the original send already recorded it. Update
the recovery flow around `recoveryRun` so it reuses the existing canonical
history from `#accumulator.history` without pushing `content` a second time, and
keep the `runTurn` call using the recovered prompt only once.
- Around line 1528-1557: The finalizer in `#consumeEvents`(run) is clearing shared
session state unconditionally, which can clobber a newer active run if the old
consumer finishes late. Add a guard in the finally block so `#activeRun`,
`#eventConsumerTask`, and idle/status cleanup only run when the finishing run is
still the current one. Use the run parameter and the current `#activeRun`
reference in Session to ensure only the matching run performs teardown;
otherwise leave the newer run state intact.

---

Outside diff comments:
In `@src/daemon/session.ts`:
- Around line 1246-1298: The rotation context is being read after
`#usage.lastTurnInputTokens` is reset, so the audit message and
`ctx_before_tokens` end up reporting zero. Capture the pre-rotation usage value
in `rotateSession` (or the surrounding rotation flow) before calling
`#accumulator.reset()` / zeroing `#usage.lastTurnInputTokens`, then use that
saved value when computing `ctxBefore`, `pctBefore`, and the `session.rotate`
audit payload.
- Around line 1415-1433: `#shouldAutoApprove` is being evaluated more than once
for the same tool, which can double-consume `#turnsRemaining` and cause
inconsistent approval UI. Update the flow in `Session` so the auto-approval
decision is computed once in `canUseTool` and then reused when rendering
`tool_start`, instead of calling `#shouldAutoApprove(toolName)` again. Make the
approval state available to the `tool_start` rendering path and any related
callers so mode changes and budget decrements happen only once per tool
invocation.
- Around line 666-688: Keep the event consumer attached after mid-turn pushes
because `#consumeEvents` currently stops on the first turn_done and can clear
`#activeRun` before the provider finishes processing the injected prompt. Update
the session flow around `#activeRun.pushMidTurn` and `#consumeEvents` so mid-turn
work continues to be drained until the queued prompt is complete, or otherwise
start a new run boundary for mid-turn sends. Make sure the logic in session.ts
that handles pushMidTurn and the turn_done exit condition stays aligned so later
events are not left unread.
🪄 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 Plus

Run ID: b9d5a280-a5af-428f-9fc8-f032b38f7e6d

📥 Commits

Reviewing files that changed from the base of the PR and between ed6a929 and 2892583.

📒 Files selected for processing (1)
  • src/daemon/session.ts

Comment thread src/daemon/session.ts Outdated
Comment thread src/daemon/session.ts
Comment thread src/daemon/session.ts Outdated
Comment thread src/daemon/session.ts Outdated
Biome CI fixes:
- canonical.ts: Infinity → Number.POSITIVE_INFINITY (useNumberNamespace ×4)
- claude/index.ts: import type LLMCallUsage (useImportType)
- claude/index.ts: remove const self = this aliases (noUselessThisAlias ×2)
- claude/index.ts: hoist ??= out of expression (noAssignInExpressions)

Logic fixes:
- session.ts recovery path: remove duplicate accumulator.pushUserTurn(content);
  the original send() already pushed it before runTurn() — this was doubling
  the user turn in canonical history on backing-session recovery
- session.ts makeCanUseToolFn: on approval, transition to 'executing' not
  'completed' — the tool hasn't run yet; tool_complete owns the final state
- session.ts #consumeEvents: gate finally cleanup on (this.#activeRun === run)
  so a replacement run started by recovery isn't clobbered when the old
  consumer unwinds
- session.ts turn_done: check result.isError and surface an error message +
  set error status instead of silently treating a failed turn as idle

Minor:
- gemini/index.ts: pass ac.signal into sendMessageStream so interrupt()
  actually cancels the in-flight HTTP request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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: 2

🤖 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/daemon/providers/claude/index.ts`:
- Line 408: The error logging in the claude provider is too verbose and may leak
sensitive SDK details; update the log in the claude query failure path to avoid
printing raw stacks or error objects. In the code around the SDK query failure
inside the claude provider handler, replace the direct use of err.stack/err with
a sanitized, bounded message that only records safe high-level context and a
short error summary. Keep the existing sessionId-scoped prefix, but ensure the
logging logic in the relevant query/failure branch no longer emits raw stack
traces or unfiltered SDK errors.
- Around line 345-350: The tool correlation in the Claude daemon is relying on a
synthesized `sdkToolUseId` when `PreToolUse` has no `toolUseId`, which can cause
`tool_start` and `tool_complete` to use different IDs and leave the tool call
unresolved. Update the `tool_start` path in
`src/daemon/providers/claude/index.ts` to require a real SDK-provided tool ID
from the pending `PreToolUse` entry, or otherwise fail closed and skip emitting
`tool_start` when no valid ID is available. Keep the correlation logic aligned
with `#pendingToolUse`, `captured`, and the later `block.tool_use_id` usage so
both events reference the same identifier.
🪄 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 Plus

Run ID: eb21776e-ca2f-4941-94bd-1c95094f791c

📥 Commits

Reviewing files that changed from the base of the PR and between 2892583 and b7b9912.

📒 Files selected for processing (4)
  • src/daemon/providers/canonical.ts
  • src/daemon/providers/claude/index.ts
  • src/daemon/providers/gemini/index.ts
  • src/daemon/session.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/daemon/providers/gemini/index.ts
  • src/daemon/providers/canonical.ts
  • src/daemon/session.ts

Comment thread src/daemon/providers/claude/index.ts Outdated
Comment thread src/daemon/providers/claude/index.ts Outdated
saucam and others added 10 commits June 26, 2026 13:41
RACE-005: subagent tool calls now await the ZeroID registration fence
  (#subagentRegistrations map) before attributing identity, so the first
  tool call from a new sub-agent uses its real WIMSE URI, not the
  anonymous placeholder. #handleProviderEvent is now async; #consumeEvents
  awaits each event so the fence is respected in-order.

SEC-001: loadUserMcpServers() in ClaudeProvider now validates ~/.claude.json
  structurally via parseMcpServerConfig() instead of bare JSON.parse + cast.
  Entries missing a valid command/url, with non-string args, or with non-string
  env values are silently dropped.

SEC-005: onRecoveryNeeded closure uses #currentSender (updated per-send)
  rather than the closed-over sender from the original send() call, so
  recovery audit events are attributed to the turn that triggered recovery
  even when a subsequent send() has updated the active sender.

PERF-001: TurnOpts.history is now readonly CanonicalTurn[]; converter
  functions (toGeminiContent, toOpenAIMessages, toAnthropicMessages) updated
  to match. runTurn() callers pass accumulator.history directly (no spread).

PERF-004: #makeCanUseToolFn approval lookup uses #toolCallMessages.get()
  instead of scanning the scrollback buffer.

PERF-007: ClaudeProvider.teardown() clears #pendingToolUse to prevent stale
  sdkToolUseId entries causing mismatches after a model switch.

F1: auto-approve path deletes from #approvalIdToMessageId to prevent
  permanent memory leak on frequently auto-approved tools.

F6: #rotate() captures ctxBefore before zeroing lastTurnInputTokens so the
  "X% of window" log message shows the correct value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…map leak

CRITICAL — double-decrement in autonomous mode:
  #shouldAutoApprove had two call sites per tool use: once in canUseTool
  (synchronous, before its first yield) and once in the tool_start event
  handler (which runs in the same microtask batch, after the yield). In
  autonomous mode with a budget, this burned 2 slots per tool call, halved
  the effective budget, and when the last slot was consumed mid-tool, flipped
  mode to "guarded" while canUseTool had already decided to allow — causing
  a phantom approval prompt for an already-running tool.

  Fix: split into #peekAutoApprove (pure predicate, no side effects, used in
  tool_start for initial UI phase) and #shouldAutoApprove (authoritative gate
  with budget decrement, used only in canUseTool). Comments explain why the
  two cannot share the same call.

HIGH — ZeroID registration fence has no timeout:
  If identityManager.registerSubagent() hangs (ZeroID service unreachable,
  no TCP error, just no response), the fence Promise never settles. The
  tool_start handler's await would block #consumeEvents indefinitely, stalling
  ALL subsequent events for that session.

  Fix: Promise.race([fence, 5s-timeout]) where the timeout resolves (not
  rejects) so the session degrades to anonymous identity rather than hanging.

MEDIUM — stale entries in #toolCallMessages / #toolUseIdToMessageId:
  tool_complete is the only place that removed entries from these maps, but
  denied tools and interrupted tools never produce a tool_complete event.
  Over many interrupts in a long session, unreachable SessionMessage objects
  accumulate (each ~1-2 KB) — a real but slow leak.

  Fix: add #messageIdToToolUseId reverse map (populated in tool_start,
  cleared in tool_complete). _applyInterruptedStateToTool now deletes from
  all three maps (toolCallMessages, toolUseIdToMessageId, messageIdToToolUseId)
  so every cancel/interrupt path is covered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defines SessionProvider (AgentProvider superset) so Session holds the
provider by interface rather than concrete ClaudeProvider.  Adds
`_testProvider?: SessionProvider` to SessionCreateOptions, letting tests
inject MockSessionProvider — a deterministic stand-in that emits scripted
ProviderEvent sequences and calls opts.canUseTool on each tool_start to
simulate the SDK's PreToolUse hook.

Nine tests cover six previously-untested audit paths:
  T1  Async event ordering (text_delta accumulation, tool lifecycle)
  T2  Autonomous single-decrement (no double-count regression)
  T3  Map cleanup on interrupt (stale-entry corruption guard)
  T4  Recovery path (onRecoveryNeeded → resetToNewSession → second turn)
  T5  Session resume after restart (scrollback replay, re-send works)
  T6  ZeroID agent identity registration (registerSessionAgent called once)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds T7 — five tests exercising listModels, teardown, dispose,
setHasQueried, and the empty-script defaultResult fallback directly on
MockSessionProvider.  These lines were 0-hit because the integration tests
only exercise the mock through Session, which doesn't call all provider
methods.  Coverage of session-provider.ts goes from 66% to 100%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
claude/, gemini/, and openai/ provider implementations require live API
credentials to exercise — their streaming parsers and turn-loop logic
cannot run in offline CI.  They are tested indirectly through MockProvider
integration tests.  Adding them to the ignore list prevents the patch gate
from failing on code that is inherently credential-gated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Codecov coverage

Extract translateSDKMessage, parseMcpServerConfig, extractToolResultText from
ClaudeProvider as testable pure functions.  Add 44/15/10 offline unit tests for
Claude/Gemini/OpenAI providers using mock.module() — no live credentials needed.
Remove the provider-directory ignore entries from codecov.yml now that the patch
lines are covered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Log only the sanitized error message string instead of err.stack to
   prevent prompts/paths leaking into logs (security finding).
2. Fail closed in canUseTool when PreToolUse never provided a toolUseId —
   return deny rather than fall back to a random UUID that would mismatch
   with block.tool_use_id in tool_complete (functional correctness).
Update the corresponding test to fire PreToolUse before canUseTool,
matching the real SDK invocation order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eb UI

terminal/client.ts: the new streaming path emits text_delta events then
broadcasts the committed session.message at text_done — causing the full
content to be written twice. Track the messageId of any message receiving
deltas; when the final session.message for that ID arrives, skip re-printing
the content and just emit a newline to close the streaming line.

web/App.tsx: session.attach was fire-and-forget (send) so the response.ok —
which contains the session's current SessionInfo including status — was
silently discarded. Switch to request() and merge the returned SessionInfo
via mergeSession() so the status dot immediately reflects reality when the
user switches sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(a) Asserts delta and final session.message share the same messageId —
    the contract the terminal client relies on to suppress re-printing
    streamed content on text_done.
(b) Asserts toInfo() reflects live status (idle → error) so the web UI
    correctly updates from the session.attach response on session switch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
#completeActiveTools() was called at the top of the tool_start event
handler. For sequential tool calls this is a no-op (the previous tool
already closed). For parallel subagents running concurrent tool calls,
each new tool_start cancelled all previously registered in-flight tools,
producing ghost "cancelled — interrupted" messages in the chat.

Fix: remove the #completeActiveTools() call from tool_start. Cleanup is
already handled by text_done and the #consumeEvents finally block. Add
T8(c) regression guard that verifies two concurrent tool_start + two
tool_complete events both resolve as "completed", not "cancelled".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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