Skip to content

feat: add GLM 4.7 and Claude Code CLI provider support#1015

Open
Aneaire wants to merge 14 commits intopingdotgg:mainfrom
Aneaire:feat/glm-claude-providers
Open

feat: add GLM 4.7 and Claude Code CLI provider support#1015
Aneaire wants to merge 14 commits intopingdotgg:mainfrom
Aneaire:feat/glm-claude-providers

Conversation

@Aneaire
Copy link

@Aneaire Aneaire commented Mar 13, 2026

Summary

Adds two new provider backends alongside the existing Codex provider:

  • GLM 4.7 — Full provider adapter with spawn_agent sub-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.
  • Claude Code CLI — Provider adapter that wraps the Claude Code CLI binary, with session lifecycle management, stream event parsing, and error mapping. Includes health checks and model catalog for Claude 4.5/4.6.

What's included

Server (apps/server)

  • GlmAdapter.ts — GLM provider adapter with sub-agent orchestration
  • ClaudeAdapter.ts — Claude Code CLI adapter with stream handling
  • Provider registry, health checks, and session directory updated for both providers
  • Service interfaces for both adapters

Web (apps/web)

  • Agent panel UI in MessagesTimeline with grouped entries, streaming responses, and status pills
  • Provider/model picker extended with GLM and Claude options + icons
  • Settings page for custom GLM/Claude model configuration and Claude binary path
  • Composer draft store persists last selected provider/model across threads
  • Plan text rendering fixes (ordering before tool calls, per-iteration itemId)

Contracts (packages/contracts)

  • ProviderKind extended with "glm" and "claude"
  • Model catalogs, aliases, and defaults for both providers
  • Provider runtime event types extended for agent events

Shared (packages/shared)

  • Model resolution utilities for GLM and Claude

Test plan

  • Verify Codex provider still works as before (no regressions)
  • Test GLM 4.7 provider with agent delegation workflows
  • Test Claude Code CLI provider with a valid Claude binary
  • Verify provider/model picker shows all three providers
  • Verify custom model settings persist correctly
  • Run bun typecheck and bun lint (both pass)

🤖 Generated with Claude Code

Note

Add GLM 4.7 and Claude Code CLI as first-class provider adapters

  • Adds full provider adapter implementations for glm (GlmAdapter.ts) and claude (ClaudeAdapter.ts), each exposing session lifecycle, turn execution, and event streaming via the existing ProviderAdapterShape interface.
  • The GLM adapter calls the BigModel API with SSE streaming, executes local tools (file ops, shell commands, grep), and supports recursive sub-agent spawning with approval gating; the Claude adapter shells out to the claude CLI, parsing NDJSON output into canonical ProviderRuntimeEvent records.
  • Extends ProviderKind to include "glm" and "claude" throughout contracts, routing, and session logic, and registers both adapters in the server layer by default.
  • Adds inferProviderFromModel to deduce the provider from a model slug, used to default the composer provider and resolve custom model lists per provider.
  • Adds settings UI for the Claude CLI binary path and custom model slug lists for both new providers.
  • WorkLogEntry gains agentName, agentSummary, and streamingResponse fields; the MessagesTimeline component renders agent entries in dedicated scrollable panels with independent expand/collapse state.
  • Risk: the Claude adapter's readThread and rollbackThread return empty snapshots (no CLI persistence support), and respondToRequest/respondToUserInput are 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
  • line 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. [ Cross-file consolidated ]

Aneaire and others added 14 commits March 13, 2026 15:09
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>
@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 553f061c-9f2d-4fed-b2c8-149996949c04

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

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

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 biome.

@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Mar 13, 2026
Comment on lines +593 to +626
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();
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🟢 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

Comment on lines 679 to 684
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 },
]);
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 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.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.

🚀 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`.

Comment on lines +458 to +460
if (payload && typeof payload.itemType === "string") {
entry.itemType = payload.itemType;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 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).

Comment on lines +701 to +710
session.activeProcess = null;
session.status = "ready";
session.activeTurnId = null;
session.updatedAt = nowIso();

emit(
makeEvent(threadId, "session.state.changed", {
state: "ready",
}),
);
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 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.

Comment on lines +647 to +656
const getSession = (threadId: ThreadId): GlmSession => {
const session = sessions.get(threadId);
if (!session) {
throw new ProviderAdapterSessionNotFoundError({
provider: PROVIDER,
threadId,
});
}
return session;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 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

@tejas-hosamani
Copy link

Ref: #179

@Noojuno
Copy link
Contributor

Noojuno commented Mar 13, 2026

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants