feat: add GLM 4.7 and Claude Code CLI provider support#1015
feat: add GLM 4.7 and Claude Code CLI provider support#1015Aneaire wants to merge 14 commits intopingdotgg:mainfrom
Conversation
The GLM agent can now spawn autonomous sub-agents to handle complex, self-contained tasks. Sub-agents get their own conversation context with the same tools (including recursive spawning up to depth 3) and return their results to the parent agent. - Max 16 iterations per sub-agent, 3 levels of nesting - Sub-agents inherit the parent model, cwd, and abort signal - Approval flow skipped for sub-agents (inherits parent's runtime mode) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Emit item.updated events during sub-agent execution so the UI shows what's happening instead of a silent wait. Progress includes iteration count, tool calls with details, and completion status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add optional `name` parameter to spawn_agent tool definition - Include agentName in item.started/updated/completed event data - Pass data field through ingestion for item.started and item.completed - Add itemType and agentName to WorkLogEntry for UI grouping - Render each named agent in its own collapsible panel with: - Status indicator (pulsing amber while running, green when done) - Fixed max-height (200px) with scrollable activity log - Agent name in header, event count, expand/collapse toggle - Collapsed state shows latest status line Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Server: - Update main session system prompt with orchestration guidelines: plan first, delegate with named agents, summarize results - Sub-agents stream their final response text to the UI in real-time (throttled to 150ms) via item.updated events with data.streamingResponse - Emit a completion event with data.summary containing the full response - Improve sub-agent system prompt to produce informative summaries UI: - Agent panels now show two sections: Activity log + Response - Streaming response renders with a typing cursor animation - Completed agents show "Result" section with full formatted text - Status badge shows Working/Responding/Done state - Panels default to expanded so users see progress immediately - Collapsed state shows response preview (120 chars) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Server: - Flush the full streamed response before emitting completion event, fixing truncated "Result" text caused by throttle skipping last chunk UI: - Extract AgentPanel into its own component with useRef + useEffect for auto-scrolling both the activity log and response area - Activity log auto-scrolls to bottom on new entries - Response area auto-scrolls as streaming text arrives Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The model sends text content alongside tool call deltas in the same SSE response. Previously, this text was emitted as streamingResponse before tool calls arrived, causing "Responding" to appear while the agent was still working. Fix: remove all streaming during the SSE parse loop. Only emit streamingResponse after the stream ends and only when there are zero tool calls (the true final response). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The "Starting: <task>" event was being mixed into the activity entries and rendered at the bottom due to timestamp ordering. Now it's extracted and shown as a persistent description line below the agent header, always visible regardless of expand/collapse state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. Emit assistant message (plan/explanation) as item.completed BEFORE executing tool calls, so the UI renders the plan text immediately instead of only after agents finish. 2. Split tool execution: regular tools run sequentially, then all spawn_agent calls run in parallel via Promise.all. This means multiple agents work concurrently instead of one at a time. 3. Refactor tool execution into a shared executePrepared() helper to avoid duplicating logic between sequential and parallel paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The content.delta events used turnId-based message identity
(assistant:{turnId}) but item.completed for the assistant message
used a separate msgItemId (assistant:msg-xxx), creating a mismatch.
The ingestion layer couldn't match the completion to the buffered
deltas, so the text only appeared after the turn ended.
Fix: emit item.completed without an itemId so it uses the same
turnId-based identity as content.delta events. Now the plan text
finalizes and renders immediately before agents start executing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each iteration of the agent loop now gets a unique itemId so the projector creates separate messages instead of merging all assistant text into one. Plan text (iteration 0) renders above tool calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements the Claude Code provider adapter that spawns `claude -p --output-format stream-json` per turn and translates NDJSON output into canonical ProviderRuntimeEvent events. Key changes: - Contract layer: add "claude" ProviderKind, Claude model catalog, ClaudeProviderStartOptions, "file_read" canonical item type - Claude adapter (Layers/ClaudeAdapter.ts): CLI process lifecycle, NDJSON stream parsing, thinking block handling, tool normalization, session continuity via --resume <session_id> - Provider resolution fixes: infer provider from thread model instead of defaulting to glm, fix ProviderSessionDirectory/ProviderCommandReactor to recognize all provider kinds, fix composerDraftStore to persist claude/glm provider selections - Web UI: Claude provider picker, settings section, icon, model catalog - Strip CLAUDECODE env var from spawned process to allow nested sessions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a user selects a model or provider, it is now saved as the "last selected" choice in the composer draft store. New threads automatically inherit this selection instead of falling back to the project default, so creating a new thread keeps the previously chosen model/provider. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The selector was creating a new object on every call when falling back to the last-selected provider/model, causing Zustand to detect a state change and re-render indefinitely. Cache the fallback draft by key so the same reference is returned for identical provider/model pairs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When any setter (setPrompt, setModel, etc.) creates a draft for the first time, it now initializes provider/model from the persisted last selection instead of null. This prevents the model from reverting to the project default when the user starts typing in a new thread. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip CodeRabbit can use your project's `biome` configuration to improve the quality of JS/TS/CSS/JSON code reviews.Add a configuration file to your project to customize how CodeRabbit runs |
| async function* parseSseStream( | ||
| response: Response, | ||
| signal: AbortSignal, | ||
| ): AsyncGenerator<SSEChunk> { | ||
| const reader = response.body?.getReader(); | ||
| if (!reader) return; | ||
| const decoder = new TextDecoder(); | ||
| let buffer = ""; | ||
|
|
||
| try { | ||
| while (!signal.aborted) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
| buffer += decoder.decode(value, { stream: true }); | ||
|
|
||
| const lines = buffer.split("\n"); | ||
| buffer = lines.pop() ?? ""; | ||
|
|
||
| for (const line of lines) { | ||
| const trimmed = line.trim(); | ||
| if (!trimmed || !trimmed.startsWith("data:")) continue; | ||
| const data = trimmed.slice(5).trim(); | ||
| if (data === "[DONE]") return; | ||
| try { | ||
| yield JSON.parse(data) as SSEChunk; | ||
| } catch { | ||
| // Skip malformed chunks | ||
| } | ||
| } | ||
| } | ||
| } finally { | ||
| reader.releaseLock(); | ||
| } | ||
| } |
There was a problem hiding this comment.
🟢 Low Layers/GlmAdapter.ts:593
After the while loop exits, any remaining content in buffer that wasn't terminated by a newline is silently discarded. If the SSE stream's final data event doesn't end with a newline character, that event would be lost. Consider processing any remaining non-empty buffer after the loop ends.
}
}
}
} finally {
reader.releaseLock();
}
+ // Process any remaining data in buffer that wasn't terminated by newline
+ const trimmed = buffer.trim();
+ if (trimmed && trimmed.startsWith("data:")) {
+ const data = trimmed.slice(5).trim();
+ if (data !== "[DONE]") {
+ try {
+ yield JSON.parse(data) as SSEChunk;
+ } catch {
+ // Skip malformed chunk
+ }
+ }
+ }
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/GlmAdapter.ts around lines 593-626:
After the `while` loop exits, any remaining content in `buffer` that wasn't terminated by a newline is silently discarded. If the SSE stream's final data event doesn't end with a newline character, that event would be lost. Consider processing any remaining non-empty buffer after the loop ends.
Evidence trail:
apps/server/src/provider/Layers/GlmAdapter.ts lines 593-625 at REVIEWED_COMMIT: The parseSseStream function (lines 594-626) shows:
- Line 603: `while (!signal.aborted)` loop
- Lines 608-609: `const lines = buffer.split("\n"); buffer = lines.pop() ?? "";`
- Lines 611-620: processes only complete lines from the split
- Line 605: `if (done) break;` exits the loop
- Lines 623-625: `finally { reader.releaseLock(); }` - no processing of remaining buffer content after loop exits
| expect(PROVIDER_OPTIONS).toEqual([ | ||
| { value: "glm", label: "GLM (z.ai)", available: true }, | ||
| { value: "codex", label: "Codex", available: true }, | ||
| { value: "claudeCode", label: "Claude Code", available: false }, | ||
| { value: "claude", label: "Claude Code", available: true }, | ||
| { value: "cursor", label: "Cursor", available: false }, | ||
| ]); |
There was a problem hiding this comment.
🔴 Critical src/session-logic.test.ts:679
The test expects PROVIDER_OPTIONS to contain a glm entry as its first element, but the implementation does not include glm at all. This causes the array length and contents assertion to fail when the test runs against the actual PROVIDER_OPTIONS export.
expect(PROVIDER_OPTIONS).toEqual([
- { value: "glm", label: "GLM (z.ai)", available: true },
{ value: "codex", label: "Codex", available: true },
{ value: "claude", label: "Claude Code", available: true },
{ value: "cursor", label: "Cursor", available: false },
]);Also found in 1 other location(s)
apps/web/src/store.test.ts:204
Test assertion contradicts test description. The test is named "falls back to the codex default for unsupported provider models without an active session" but the assertion on line 204 checks for
DEFAULT_MODEL_BY_PROVIDER.glminstead ofDEFAULT_MODEL_BY_PROVIDER.codex. Either the test name is incorrect or the expected value should beDEFAULT_MODEL_BY_PROVIDER.codex.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/session-logic.test.ts around lines 679-684:
The test expects `PROVIDER_OPTIONS` to contain a `glm` entry as its first element, but the implementation does not include `glm` at all. This causes the array length and contents assertion to fail when the test runs against the actual `PROVIDER_OPTIONS` export.
Evidence trail:
- apps/web/src/session-logic.test.ts lines 680-686 (REVIEWED_COMMIT): Test expects `PROVIDER_OPTIONS` to include `{ value: "glm", label: "GLM (z.ai)", available: true }` as the first element.
- apps/web/src/session-logic.ts lines 23-32 (REVIEWED_COMMIT): `PROVIDER_OPTIONS` is defined with only three entries (`codex`, `claude`, `cursor`), with no `glm` entry.
Also found in 1 other location(s):
- apps/web/src/store.test.ts:204 -- Test assertion contradicts test description. The test is named "falls back to the codex default for unsupported provider models without an active session" but the assertion on line 204 checks for `DEFAULT_MODEL_BY_PROVIDER.glm` instead of `DEFAULT_MODEL_BY_PROVIDER.codex`. Either the test name is incorrect or the expected value should be `DEFAULT_MODEL_BY_PROVIDER.codex`.
| if (payload && typeof payload.itemType === "string") { | ||
| entry.itemType = payload.itemType; | ||
| } |
There was a problem hiding this comment.
🟡 Medium src/session-logic.ts:458
Lines 458–460 assign payload.itemType to entry.itemType without validation, bypassing extractWorkLogItemType which uses isToolLifecycleItemType to ensure the value is a valid ToolLifecycleItemType. When payload.itemType is an invalid string, this code overwrites the correctly-rejected undefined with the invalid string, breaking the type constraint.
- if (payload && typeof payload.itemType === "string") {
- entry.itemType = payload.itemType;
- }🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/session-logic.ts around lines 458-460:
Lines 458–460 assign `payload.itemType` to `entry.itemType` without validation, bypassing `extractWorkLogItemType` which uses `isToolLifecycleItemType` to ensure the value is a valid `ToolLifecycleItemType`. When `payload.itemType` is an invalid string, this code overwrites the correctly-rejected `undefined` with the invalid string, breaking the type constraint.
Evidence trail:
apps/web/src/session-logic.ts lines 458-460 (bypass validation), lines 435 and 453-455 (correct validation usage), lines 542-548 (extractWorkLogItemType definition with isToolLifecycleItemType check). packages/contracts/src/providerRuntime.ts lines 111-113 (isToolLifecycleItemType validates against TOOL_LIFECYCLE_ITEM_TYPES).
| session.activeProcess = null; | ||
| session.status = "ready"; | ||
| session.activeTurnId = null; | ||
| session.updatedAt = nowIso(); | ||
|
|
||
| emit( | ||
| makeEvent(threadId, "session.state.changed", { | ||
| state: "ready", | ||
| }), | ||
| ); |
There was a problem hiding this comment.
🟡 Medium Layers/ClaudeAdapter.ts:701
interruptTurn sets session.activeProcess = null and session.status = "ready" immediately after proc.kill("SIGINT"), before the process has actually exited. This creates a race where sendTurn can spawn a new process while the old one is still running, causing interleaved events from both processes. Consider waiting for the close event before clearing the session state.
- session.activeProcess = null;
- session.status = "ready";
- session.activeTurnId = null;
- session.updatedAt = nowIso();
-
- emit(
- makeEvent(threadId, "session.state.changed", {
- state: "ready",
- }),
- );
+ // State updates deferred to close handler to avoid race conditions
+ // where a new turn could be started while the old process is still running
+ session.updatedAt = nowIso();
+ // session.activeProcess, status, and activeTurnId will be cleared in close handler🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/ClaudeAdapter.ts around lines 701-710:
`interruptTurn` sets `session.activeProcess = null` and `session.status = "ready"` immediately after `proc.kill("SIGINT")`, before the process has actually exited. This creates a race where `sendTurn` can spawn a new process while the old one is still running, causing interleaved events from both processes. Consider waiting for the `close` event before clearing the session state.
Evidence trail:
apps/server/src/provider/Layers/ClaudeAdapter.ts lines 672-708 (interruptTurn function): line 678 calls `proc.kill("SIGINT")`, lines 687-689 register an async `close` event handler, but lines 699-701 immediately set `session.activeProcess = null`, `session.status = "ready"`, and `session.activeTurnId = null` without waiting for the close event.
apps/server/src/provider/Layers/ClaudeAdapter.ts lines 640-668 (sendTurn function): calls `runClaudeProcess` without checking if a previous process is still running.
apps/server/src/provider/Layers/ClaudeAdapter.ts lines 490-510 (runClaudeProcess function): spawns a new process and sets `session.activeProcess = proc` without guarding against an existing process.
| const getSession = (threadId: ThreadId): GlmSession => { | ||
| const session = sessions.get(threadId); | ||
| if (!session) { | ||
| throw new ProviderAdapterSessionNotFoundError({ | ||
| provider: PROVIDER, | ||
| threadId, | ||
| }); | ||
| } | ||
| return session; | ||
| }; |
There was a problem hiding this comment.
🟡 Medium Layers/GlmAdapter.ts:647
getSession throws ProviderAdapterSessionNotFoundError directly, but it's called inside Effect.sync() at lines 1083, 1131, 1149, 1201, and 1210. This converts the typed error into an untyped defect that bypasses Effect.catchTag and similar handlers, causing sendTurn, interruptTurn, respondToRequest, readThread, and rollbackThread to die with a defect instead of returning a typed error in the Effect error channel.
const getSession = (threadId: ThreadId): Effect.Effect<GlmSession, ProviderAdapterSessionNotFoundError> =>
sessions.get(threadId)
? Effect.succeed(sessions.get(threadId)!)
: new ProviderAdapterSessionNotFoundError({
provider: PROVIDER,
threadId,
});🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/GlmAdapter.ts around lines 647-656:
`getSession` throws `ProviderAdapterSessionNotFoundError` directly, but it's called inside `Effect.sync()` at lines 1083, 1131, 1149, 1201, and 1210. This converts the typed error into an untyped defect that bypasses `Effect.catchTag` and similar handlers, causing `sendTurn`, `interruptTurn`, `respondToRequest`, `readThread`, and `rollbackThread` to die with a defect instead of returning a typed error in the Effect error channel.
Evidence trail:
GlmAdapter.ts lines 647-655 show `getSession` throwing `ProviderAdapterSessionNotFoundError` directly. Lines 1083, 1131, 1149, 1201, 1210 show calls to `getSession` inside `Effect.sync()`. Effect-TS documentation confirms that exceptions thrown inside `Effect.sync()` become defects, not typed errors: https://github.com/ethanniser/effect-workshop/blob/main/CHEATSHEET.md, https://github.com/PaulJPhilp/EffectPatterns
|
Ref: #179 |
|
@Aneaire You should probably split this up into 2 PR's, since it's two distinct changes (adding Claude Code support and adding GLM support) It's also worth noting @juliusmarminge already has a PR to add Claude Code (#179), so I imagine when it comes down to it they will be using that :) |
Summary
Adds two new provider backends alongside the existing Codex provider:
spawn_agentsub-agent delegation, real-time progress events, and streaming responses. Includes dedicated agent panels in the work log UI with auto-scroll, task descriptions, and status indicators.What's included
Server (
apps/server)GlmAdapter.ts— GLM provider adapter with sub-agent orchestrationClaudeAdapter.ts— Claude Code CLI adapter with stream handlingWeb (
apps/web)Contracts (
packages/contracts)ProviderKindextended with"glm"and"claude"Shared (
packages/shared)Test plan
bun typecheckandbun lint(both pass)🤖 Generated with Claude Code
Note
Add GLM 4.7 and Claude Code CLI as first-class provider adapters
glm(GlmAdapter.ts) andclaude(ClaudeAdapter.ts), each exposing session lifecycle, turn execution, and event streaming via the existingProviderAdapterShapeinterface.claudeCLI, parsing NDJSON output into canonicalProviderRuntimeEventrecords.ProviderKindto include"glm"and"claude"throughout contracts, routing, and session logic, and registers both adapters in the server layer by default.inferProviderFromModelto deduce the provider from a model slug, used to default the composer provider and resolve custom model lists per provider.WorkLogEntrygainsagentName,agentSummary, andstreamingResponsefields; theMessagesTimelinecomponent renders agent entries in dedicated scrollable panels with independent expand/collapse state.readThreadandrollbackThreadreturn empty snapshots (no CLI persistence support), andrespondToRequest/respondToUserInputare stubs with no behavior.📊 Macroscope summarized 91e8f10. 27 files reviewed, 9 issues evaluated, 1 issue filtered, 5 comments posted
🗂️ Filtered Issues
apps/web/src/store.test.ts — 0 comments posted, 1 evaluated, 1 filtered
DEFAULT_MODEL_BY_PROVIDER.glminstead ofDEFAULT_MODEL_BY_PROVIDER.codex. Either the test name is incorrect or the expected value should beDEFAULT_MODEL_BY_PROVIDER.codex. [ Cross-file consolidated ]