From cb36a72662ac7ae3850aef79ac6f8ca7d840e9d7 Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 10:42:15 +0800 Subject: [PATCH 01/14] Add spawn_agent tool to GLM adapter for sub-agent delegation 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 --- apps/server/src/provider/Layers/GlmAdapter.ts | 1078 +++++++++++++++++ 1 file changed, 1078 insertions(+) create mode 100644 apps/server/src/provider/Layers/GlmAdapter.ts diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts new file mode 100644 index 000000000..8954a508f --- /dev/null +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -0,0 +1,1078 @@ +/** + * GlmAdapterLive - HTTP-based live implementation for the GLM (z.ai) provider adapter. + * + * Implements an agent loop via GLM's OpenAI-compatible chat completions API + * with SSE streaming and local tool execution. + * + * @module GlmAdapterLive + */ +import { + type CanonicalItemType, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + EventId, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Effect, Layer, Queue, Stream } from "effect"; + +import { + ProviderAdapterSessionNotFoundError, +} from "../Errors.ts"; +import { GlmAdapter, type GlmAdapterShape } from "../Services/GlmAdapter.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import type { + ProviderAdapterCapabilities, + ProviderThreadSnapshot, +} from "../Services/ProviderAdapter.ts"; + +const PROVIDER = "glm" as const; +const DEFAULT_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; +const MAX_AGENT_LOOP_ITERATIONS = 32; +const MAX_SUB_AGENT_ITERATIONS = 16; +const MAX_SUB_AGENT_DEPTH = 3; + +// ── Types ───────────────────────────────────────────────────────── + +interface ChatMessage { + role: "system" | "user" | "assistant" | "tool"; + content?: string | null; + tool_calls?: ToolCall[]; + tool_call_id?: string; + name?: string; +} + +interface ToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +interface GlmSession { + threadId: ThreadId; + model: string; + cwd: string; + messages: ChatMessage[]; + activeTurnAbort: AbortController | null; + pendingApproval: { + resolve: (decision: string) => void; + requestId: string; + } | null; + status: "ready" | "running" | "stopped"; + createdAt: string; + updatedAt: string; + runtimeMode: "full-access" | "approval-required"; +} + +// ── Tool definitions ────────────────────────────────────────────── + +const TOOL_DEFINITIONS = [ + { + type: "function" as const, + function: { + name: "read_file", + description: "Read the contents of a file at the given path.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Absolute or relative file path to read." }, + }, + required: ["path"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "write_file", + description: "Write content to a file, creating it if it doesn't exist.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path to write to." }, + content: { type: "string", description: "Content to write." }, + }, + required: ["path", "content"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "edit_file", + description: "Edit a file by replacing old_text with new_text.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path to edit." }, + old_text: { type: "string", description: "Text to find and replace." }, + new_text: { type: "string", description: "Replacement text." }, + }, + required: ["path", "old_text", "new_text"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "run_command", + description: "Execute a shell command and return the output.", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "Shell command to execute." }, + cwd: { type: "string", description: "Working directory (optional, defaults to session cwd)." }, + }, + required: ["command"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "list_directory", + description: "List files and directories in a path.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Directory path to list." }, + }, + required: ["path"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "search_files", + description: "Search for a pattern in files using grep.", + parameters: { + type: "object", + properties: { + pattern: { type: "string", description: "Search pattern (regex)." }, + path: { type: "string", description: "Directory to search in (optional)." }, + }, + required: ["pattern"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "spawn_agent", + description: + "Spawn a sub-agent to handle a complex, self-contained task autonomously. " + + "The sub-agent gets its own conversation with the same tools and works independently. " + + "Use this to parallelize work or delegate tasks like: researching a codebase question, " + + "implementing a well-scoped feature, running and fixing tests, or refactoring a module. " + + "The sub-agent returns its final result as text.", + parameters: { + type: "object", + properties: { + task: { + type: "string", + description: + "A detailed description of the task for the sub-agent. Be specific about what to do, " + + "which files to look at, and what the expected outcome is.", + }, + cwd: { + type: "string", + description: "Working directory for the sub-agent (optional, defaults to parent session cwd).", + }, + }, + required: ["task"], + }, + }, + }, +]; + +// ── Helpers ─────────────────────────────────────────────────────── + +let eventCounter = 0; +function nextEventId(): string { + return `glm-evt-${Date.now()}-${++eventCounter}`; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function makeEventBase( + threadId: ThreadId, + turnId?: TurnId, + itemId?: string, +): Omit { + const base: Record = { + eventId: EventId.makeUnsafe(nextEventId()), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + }; + if (turnId) base.turnId = turnId; + if (itemId) base.itemId = RuntimeItemId.makeUnsafe(itemId); + return base as Omit; +} + +function toolCallToCanonicalItemType(toolName: string): CanonicalItemType { + switch (toolName) { + case "read_file": + case "write_file": + case "edit_file": + return "file_change"; + case "run_command": + return "command_execution"; + case "list_directory": + case "search_files": + return "file_change"; + case "spawn_agent": + return "command_execution"; + default: + return "unknown"; + } +} + +function resolveApiKey(): string | undefined { + return process.env.GLM_API_KEY ?? process.env.ZAI_API_KEY; +} + +function resolveBaseUrl(overrideUrl?: string): string { + return overrideUrl ?? process.env.GLM_BASE_URL ?? DEFAULT_BASE_URL; +} + +// ── Tool execution ──────────────────────────────────────────────── + +async function executeToolCall( + toolName: string, + args: Record, + cwd: string, +): Promise { + const { execSync } = await import("node:child_process"); + const fs = await import("node:fs"); + const path = await import("node:path"); + + const resolvePath = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p)); + + switch (toolName) { + case "read_file": { + const filePath = resolvePath(String(args.path ?? "")); + return fs.readFileSync(filePath, "utf-8"); + } + case "write_file": { + const filePath = resolvePath(String(args.path ?? "")); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, String(args.content ?? ""), "utf-8"); + return `File written: ${filePath}`; + } + case "edit_file": { + const filePath = resolvePath(String(args.path ?? "")); + const existing = fs.readFileSync(filePath, "utf-8"); + const oldText = String(args.old_text ?? ""); + const newText = String(args.new_text ?? ""); + if (!existing.includes(oldText)) { + return `Error: old_text not found in ${filePath}`; + } + fs.writeFileSync(filePath, existing.replace(oldText, newText), "utf-8"); + return `File edited: ${filePath}`; + } + case "run_command": { + const command = String(args.command ?? ""); + const cmdCwd = args.cwd ? resolvePath(String(args.cwd)) : cwd; + const result = execSync(command, { + cwd: cmdCwd, + timeout: 60_000, + maxBuffer: 1024 * 1024, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + return String(result); + } + case "list_directory": { + const dirPath = resolvePath(String(args.path ?? ".")); + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + return entries + .map((e) => `${e.isDirectory() ? "[dir]" : "[file]"} ${e.name}`) + .join("\n"); + } + case "search_files": { + const pattern = String(args.pattern ?? ""); + const searchPath = args.path ? resolvePath(String(args.path)) : cwd; + const result = execSync(`grep -rn --include='*' ${JSON.stringify(pattern)} ${JSON.stringify(searchPath)} || true`, { + cwd, + timeout: 30_000, + maxBuffer: 1024 * 1024, + encoding: "utf-8", + }); + return typeof result === "string" ? result.slice(0, 10_000) : ""; + } + default: + return `Unknown tool: ${toolName}`; + } +} + +// ── Sub-agent runner ────────────────────────────────────────────── + +async function runSubAgent( + task: string, + cwd: string, + model: string, + signal: AbortSignal, + depth: number, +): Promise { + if (depth >= MAX_SUB_AGENT_DEPTH) { + return "Error: Maximum sub-agent nesting depth reached."; + } + + const apiKey = resolveApiKey(); + if (!apiKey) return "Error: GLM API key not configured."; + const baseUrl = resolveBaseUrl(); + + const messages: ChatMessage[] = [ + { + role: "system", + content: + `You are a focused sub-agent handling a specific task. ` + + `You have access to tools to read, write, and edit files, run commands, list directories, search files, and spawn further sub-agents. ` + + `The working directory is: ${cwd}\n\n` + + `Complete the task and provide a clear, concise summary of what you did and the result. ` + + `Do NOT ask questions — make reasonable decisions and proceed.`, + }, + { role: "user", content: task }, + ]; + + // Sub-agents can spawn further sub-agents but with reduced depth budget + const subAgentTools = TOOL_DEFINITIONS; + let finalResponse = ""; + + for (let iteration = 0; iteration < MAX_SUB_AGENT_ITERATIONS; iteration++) { + if (signal.aborted) return "Sub-agent interrupted."; + + let response: Response; + try { + response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages, + tools: subAgentTools, + stream: true, + }), + signal, + }); + } catch (error) { + if (signal.aborted) return "Sub-agent interrupted."; + return `Sub-agent API error: ${error instanceof Error ? error.message : String(error)}`; + } + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + return `Sub-agent API error ${response.status}: ${errorBody.slice(0, 300)}`; + } + + let assistantContent = ""; + const toolCalls: Map = new Map(); + + for await (const chunk of parseSseStream(response, signal)) { + const choice = chunk.choices?.[0]; + if (!choice?.delta) continue; + if (choice.delta.content) { + assistantContent += choice.delta.content; + } + if (choice.delta.tool_calls) { + for (const tc of choice.delta.tool_calls) { + const existing = toolCalls.get(tc.index); + if (existing) { + if (tc.function?.arguments) { + existing.function.arguments += tc.function.arguments; + } + } else { + toolCalls.set(tc.index, { + id: tc.id ?? `sub-call-${tc.index}`, + type: "function", + function: { + name: tc.function?.name ?? "", + arguments: tc.function?.arguments ?? "", + }, + }); + } + } + } + } + + const assistantMsg: ChatMessage = { + role: "assistant", + content: assistantContent || null, + }; + const resolvedToolCalls = Array.from(toolCalls.values()); + if (resolvedToolCalls.length > 0) { + assistantMsg.tool_calls = resolvedToolCalls; + } + messages.push(assistantMsg); + + // No tool calls — sub-agent is done + if (resolvedToolCalls.length === 0) { + finalResponse = assistantContent; + break; + } + + // Execute tool calls + for (const tc of resolvedToolCalls) { + if (signal.aborted) return "Sub-agent interrupted."; + + let parsedArgs: Record = {}; + try { + parsedArgs = JSON.parse(tc.function.arguments); + } catch { + parsedArgs = {}; + } + + let toolResult: string; + if (tc.function.name === "spawn_agent") { + // Recursive sub-agent + const subTask = String(parsedArgs.task ?? ""); + const subCwd = parsedArgs.cwd ? String(parsedArgs.cwd) : cwd; + toolResult = await runSubAgent(subTask, subCwd, model, signal, depth + 1); + } else { + try { + toolResult = await executeToolCall(tc.function.name, parsedArgs, cwd); + } catch (error) { + toolResult = `Error: ${error instanceof Error ? error.message : String(error)}`; + } + } + + messages.push({ + role: "tool", + tool_call_id: tc.id, + content: toolResult.slice(0, 50_000), + }); + } + } + + return finalResponse || "Sub-agent completed without a final response."; +} + +// ── SSE parser ──────────────────────────────────────────────────── + +interface SSEChunk { + choices?: Array<{ + delta?: { + content?: string | null; + tool_calls?: Array<{ + index: number; + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; + }>; + }; + finish_reason?: string | null; + }>; +} + +async function* parseSseStream( + response: Response, + signal: AbortSignal, +): AsyncGenerator { + 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(); + } +} + +// ── Adapter implementation ──────────────────────────────────────── + +export interface GlmAdapterLiveOptions { + readonly nativeEventLogger?: EventNdjsonLogger; +} + +export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { + return Layer.effect( + GlmAdapter, + Effect.gen(function* () { + const sessions = new Map(); + const eventQueue = yield* Queue.unbounded(); + + const emit = (event: ProviderRuntimeEvent) => + Effect.runSync(Queue.offer(eventQueue, event)); + + const streamEvents: GlmAdapterShape["streamEvents"] = + Stream.fromQueue(eventQueue); + + const getSession = (threadId: ThreadId): GlmSession => { + const session = sessions.get(threadId); + if (!session) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return session; + }; + + const capabilities: ProviderAdapterCapabilities = { + sessionModelSwitch: "in-session", + }; + + const startSession: GlmAdapterShape["startSession"] = (input) => + Effect.sync(() => { + const now = nowIso(); + const threadId = input.threadId; + const model = input.model ?? "glm-4.7"; + const cwd = input.cwd ?? process.cwd(); + + const session: GlmSession = { + threadId, + model, + cwd, + messages: [ + { + role: "system", + content: `You are a helpful coding assistant. You have access to tools to read, write, and edit files, run commands, list directories, and search files. The working directory is: ${cwd}`, + }, + ], + activeTurnAbort: null, + pendingApproval: null, + status: "ready", + createdAt: now, + updatedAt: now, + runtimeMode: input.runtimeMode ?? "full-access", + }; + sessions.set(threadId, session); + + emit({ + ...makeEventBase(threadId), + type: "session.started", + payload: { message: `GLM session started with model ${model}` }, + } as ProviderRuntimeEvent); + + emit({ + ...makeEventBase(threadId), + type: "session.state.changed", + payload: { state: "ready" }, + } as ProviderRuntimeEvent); + + return { + provider: PROVIDER, + status: "ready", + runtimeMode: session.runtimeMode, + cwd, + model, + threadId, + createdAt: now, + updatedAt: now, + } as ProviderSession; + }); + + const runAgentLoop = async ( + session: GlmSession, + turnId: TurnId, + signal: AbortSignal, + ) => { + const apiKey = resolveApiKey(); + if (!apiKey) { + emit({ + ...makeEventBase(session.threadId, turnId), + type: "runtime.error", + payload: { + message: "GLM API key not configured. Set GLM_API_KEY or ZAI_API_KEY environment variable.", + class: "provider_error", + }, + } as ProviderRuntimeEvent); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "failed", errorMessage: "API key not configured" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + + const baseUrl = resolveBaseUrl(); + + for (let iteration = 0; iteration < MAX_AGENT_LOOP_ITERATIONS; iteration++) { + if (signal.aborted) { + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "interrupted" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + + let response: Response; + try { + response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: session.model, + messages: session.messages, + tools: TOOL_DEFINITIONS, + stream: true, + }), + signal, + }); + } catch (error) { + if (signal.aborted) { + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "interrupted" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + emit({ + ...makeEventBase(session.threadId, turnId), + type: "runtime.error", + payload: { + message: `GLM API request failed: ${error instanceof Error ? error.message : String(error)}`, + class: "transport_error", + }, + } as ProviderRuntimeEvent); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "failed", errorMessage: "API request failed" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "runtime.error", + payload: { + message: `GLM API error ${response.status}: ${errorBody.slice(0, 500)}`, + class: "provider_error", + }, + } as ProviderRuntimeEvent); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "failed", errorMessage: `API error ${response.status}` }, + } as ProviderRuntimeEvent); + session.status = "ready"; + return; + } + + // Parse streaming response + let assistantContent = ""; + const toolCalls: Map = new Map(); + + for await (const chunk of parseSseStream(response, signal)) { + const choice = chunk.choices?.[0]; + if (!choice?.delta) continue; + + // Text content + if (choice.delta.content) { + assistantContent += choice.delta.content; + emit({ + ...makeEventBase(session.threadId, turnId), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: choice.delta.content, + }, + } as ProviderRuntimeEvent); + } + + // Tool calls + if (choice.delta.tool_calls) { + for (const tc of choice.delta.tool_calls) { + const existing = toolCalls.get(tc.index); + if (existing) { + if (tc.function?.arguments) { + existing.function.arguments += tc.function.arguments; + } + } else { + toolCalls.set(tc.index, { + id: tc.id ?? `call-${tc.index}`, + type: "function", + function: { + name: tc.function?.name ?? "", + arguments: tc.function?.arguments ?? "", + }, + }); + } + } + } + } + + // Build assistant message + const assistantMsg: ChatMessage = { + role: "assistant", + content: assistantContent || null, + }; + const resolvedToolCalls = Array.from(toolCalls.values()); + if (resolvedToolCalls.length > 0) { + assistantMsg.tool_calls = resolvedToolCalls; + } + session.messages.push(assistantMsg); + + // If no tool calls, turn is complete + if (resolvedToolCalls.length === 0) { + if (assistantContent) { + const msgItemId = `msg-${nextEventId()}`; + emit({ + ...makeEventBase(session.threadId, turnId, msgItemId), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + } as ProviderRuntimeEvent); + } + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "completed" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + session.updatedAt = nowIso(); + return; + } + + // Execute tool calls + for (const tc of resolvedToolCalls) { + if (signal.aborted) break; + + const toolItemId = `tool-${tc.id}`; + const canonicalType = toolCallToCanonicalItemType(tc.function.name); + + let parsedArgs: Record = {}; + try { + parsedArgs = JSON.parse(tc.function.arguments); + } catch { + parsedArgs = {}; + } + + const toolDetail = + tc.function.name === "run_command" + ? String(parsedArgs.command ?? "") + : tc.function.name === "read_file" || tc.function.name === "write_file" || tc.function.name === "edit_file" + ? String(parsedArgs.path ?? "") + : tc.function.name; + + // Approval flow for write/execute operations in approval-required mode + if ( + session.runtimeMode === "approval-required" && + (tc.function.name === "write_file" || + tc.function.name === "edit_file" || + tc.function.name === "run_command") + ) { + const requestId = `req-${nextEventId()}`; + const requestType = + tc.function.name === "run_command" + ? "exec_command_approval" + : "file_change_approval"; + + emit({ + ...makeEventBase(session.threadId, turnId, toolItemId), + type: "request.opened", + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + requestType, + detail: toolDetail, + args: parsedArgs, + }, + } as ProviderRuntimeEvent); + + // Wait for approval + const decision = await new Promise((resolve) => { + session.pendingApproval = { resolve, requestId }; + }); + session.pendingApproval = null; + + emit({ + ...makeEventBase(session.threadId, turnId, toolItemId), + type: "request.resolved", + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + requestType, + decision, + }, + } as ProviderRuntimeEvent); + + if (decision === "decline" || decision === "cancel") { + session.messages.push({ + role: "tool", + tool_call_id: tc.id, + content: "Operation declined by user.", + }); + continue; + } + } + + // Emit item.started + emit({ + ...makeEventBase(session.threadId, turnId, toolItemId), + type: "item.started", + payload: { + itemType: canonicalType, + title: tc.function.name, + detail: toolDetail, + }, + } as ProviderRuntimeEvent); + + // Execute + let toolResult: string; + try { + if (tc.function.name === "spawn_agent") { + const subTask = String(parsedArgs.task ?? ""); + const subCwd = parsedArgs.cwd ? String(parsedArgs.cwd) : session.cwd; + toolResult = await runSubAgent(subTask, subCwd, session.model, signal, 0); + } else { + toolResult = await executeToolCall(tc.function.name, parsedArgs, session.cwd); + } + } catch (error) { + toolResult = `Error: ${error instanceof Error ? error.message : String(error)}`; + } + + // Emit item.completed + emit({ + ...makeEventBase(session.threadId, turnId, toolItemId), + type: "item.completed", + payload: { + itemType: canonicalType, + status: "completed", + title: tc.function.name, + detail: toolDetail, + }, + } as ProviderRuntimeEvent); + + session.messages.push({ + role: "tool", + tool_call_id: tc.id, + content: toolResult.slice(0, 50_000), + }); + } + + session.updatedAt = nowIso(); + // Loop continues: send tool results back to model + } + + // If we hit the max iterations + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "completed", stopReason: "max_iterations" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + }; + + const sendTurn: GlmAdapterShape["sendTurn"] = (input) => + Effect.sync(() => { + const session = getSession(input.threadId); + const turnId = TurnId.makeUnsafe(`turn-${nextEventId()}`); + + if (input.model) { + session.model = input.model; + } + + if (input.input) { + session.messages.push({ role: "user", content: input.input }); + } + + session.status = "running"; + const abortController = new AbortController(); + session.activeTurnAbort = abortController; + + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.started", + payload: { model: session.model }, + } as ProviderRuntimeEvent); + + // Run agent loop in the background + void runAgentLoop(session, turnId, abortController.signal).catch((error) => { + emit({ + ...makeEventBase(session.threadId, turnId), + type: "runtime.error", + payload: { + message: `Agent loop failed: ${error instanceof Error ? error.message : String(error)}`, + class: "unknown", + }, + } as ProviderRuntimeEvent); + emit({ + ...makeEventBase(session.threadId, turnId), + type: "turn.completed", + payload: { state: "failed" }, + } as ProviderRuntimeEvent); + session.status = "ready"; + session.activeTurnAbort = null; + }); + + return { + threadId: session.threadId, + turnId, + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: GlmAdapterShape["interruptTurn"] = (threadId) => + Effect.sync(() => { + const session = getSession(threadId); + if (session.activeTurnAbort) { + session.activeTurnAbort.abort(); + session.activeTurnAbort = null; + } + if (session.pendingApproval) { + session.pendingApproval.resolve("cancel"); + session.pendingApproval = null; + } + session.status = "ready"; + }); + + const respondToRequest: GlmAdapterShape["respondToRequest"] = ( + threadId, + _requestId, + decision, + ) => + Effect.sync(() => { + const session = getSession(threadId); + if (session.pendingApproval) { + session.pendingApproval.resolve(decision); + } + }); + + const respondToUserInput: GlmAdapterShape["respondToUserInput"] = () => + Effect.void; + + const stopSession: GlmAdapterShape["stopSession"] = (threadId) => + Effect.sync(() => { + const session = sessions.get(threadId); + if (session) { + if (session.activeTurnAbort) { + session.activeTurnAbort.abort(); + } + if (session.pendingApproval) { + session.pendingApproval.resolve("cancel"); + } + session.status = "stopped"; + sessions.delete(threadId); + + emit({ + ...makeEventBase(threadId), + type: "session.exited", + payload: { reason: "stopped", exitKind: "graceful" }, + } as ProviderRuntimeEvent); + } + }); + + const listSessions: GlmAdapterShape["listSessions"] = () => + Effect.sync(() => + Array.from(sessions.values()).map( + (s) => + ({ + provider: PROVIDER, + status: s.status === "running" ? "running" : "ready", + runtimeMode: s.runtimeMode, + cwd: s.cwd, + model: s.model, + threadId: s.threadId, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + }) as ProviderSession, + ), + ); + + const hasSession: GlmAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: GlmAdapterShape["readThread"] = (threadId) => + Effect.sync(() => { + getSession(threadId); + return { + threadId, + turns: [], + } satisfies ProviderThreadSnapshot; + }); + + const rollbackThread: GlmAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.sync(() => { + const session = getSession(threadId); + // Simple rollback: remove last N user+assistant message pairs + for (let i = 0; i < numTurns; i++) { + while (session.messages.length > 1) { + const last = session.messages[session.messages.length - 1]; + if (!last) break; + session.messages.pop(); + if (last.role === "user") break; + } + } + return { + threadId, + turns: [], + } satisfies ProviderThreadSnapshot; + }); + + const stopAll: GlmAdapterShape["stopAll"] = () => + Effect.gen(function* () { + for (const threadId of sessions.keys()) { + yield* stopSession(ThreadId.makeUnsafe(threadId)); + } + }); + + return { + provider: PROVIDER, + capabilities, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents, + } satisfies GlmAdapterShape; + }), + ); +} From 8f4ceeed98a8485c7bca0701384a493176f3691f Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 11:16:34 +0800 Subject: [PATCH 02/14] Add real-time progress events for spawn_agent sub-agents 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 --- apps/server/src/provider/Layers/GlmAdapter.ts | 75 ++++++++++++++++++- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts index 8954a508f..ca8f5c07b 100644 --- a/apps/server/src/provider/Layers/GlmAdapter.ts +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -231,7 +231,7 @@ function toolCallToCanonicalItemType(toolName: string): CanonicalItemType { case "search_files": return "file_change"; case "spawn_agent": - return "command_execution"; + return "collab_agent_tool_call"; default: return "unknown"; } @@ -318,12 +318,38 @@ async function executeToolCall( // ── Sub-agent runner ────────────────────────────────────────────── +interface SubAgentProgressContext { + readonly threadId: ThreadId; + readonly turnId: TurnId; + readonly parentItemId: string; + readonly emit: (event: ProviderRuntimeEvent) => void; +} + +function emitSubAgentProgress( + ctx: SubAgentProgressContext, + detail: string, + depth: number, +) { + const prefix = depth > 0 ? `[sub-agent L${depth}] ` : "[sub-agent] "; + ctx.emit({ + ...makeEventBase(ctx.threadId, ctx.turnId, ctx.parentItemId), + type: "item.updated", + payload: { + itemType: "collab_agent_tool_call" as CanonicalItemType, + status: "inProgress", + title: "spawn_agent", + detail: `${prefix}${detail}`.slice(0, 180), + }, + } as ProviderRuntimeEvent); +} + async function runSubAgent( task: string, cwd: string, model: string, signal: AbortSignal, depth: number, + progress?: SubAgentProgressContext, ): Promise { if (depth >= MAX_SUB_AGENT_DEPTH) { return "Error: Maximum sub-agent nesting depth reached."; @@ -333,6 +359,17 @@ async function runSubAgent( if (!apiKey) return "Error: GLM API key not configured."; const baseUrl = resolveBaseUrl(); + progress?.emit({ + ...makeEventBase(progress.threadId, progress.turnId, progress.parentItemId), + type: "item.updated", + payload: { + itemType: "collab_agent_tool_call" as CanonicalItemType, + status: "inProgress", + title: "spawn_agent", + detail: `[sub-agent] Starting: ${task.slice(0, 140)}`, + }, + } as ProviderRuntimeEvent); + const messages: ChatMessage[] = [ { role: "system", @@ -353,6 +390,10 @@ async function runSubAgent( for (let iteration = 0; iteration < MAX_SUB_AGENT_ITERATIONS; iteration++) { if (signal.aborted) return "Sub-agent interrupted."; + if (progress) { + emitSubAgentProgress(progress, `Iteration ${iteration + 1}/${MAX_SUB_AGENT_ITERATIONS} — thinking...`, depth); + } + let response: Response; try { response = await fetch(`${baseUrl}/chat/completions`, { @@ -422,6 +463,9 @@ async function runSubAgent( // No tool calls — sub-agent is done if (resolvedToolCalls.length === 0) { finalResponse = assistantContent; + if (progress) { + emitSubAgentProgress(progress, `Completed after ${iteration + 1} iteration(s)`, depth); + } break; } @@ -436,12 +480,27 @@ async function runSubAgent( parsedArgs = {}; } + // Emit progress for each tool the sub-agent uses + if (progress) { + const toolBrief = + tc.function.name === "run_command" + ? `run_command: ${String(parsedArgs.command ?? "").slice(0, 80)}` + : tc.function.name === "read_file" || tc.function.name === "write_file" || tc.function.name === "edit_file" + ? `${tc.function.name}: ${String(parsedArgs.path ?? "").slice(0, 80)}` + : tc.function.name === "search_files" + ? `search_files: ${String(parsedArgs.pattern ?? "").slice(0, 80)}` + : tc.function.name === "spawn_agent" + ? `spawn_agent: ${String(parsedArgs.task ?? "").slice(0, 60)}` + : tc.function.name; + emitSubAgentProgress(progress, toolBrief, depth); + } + let toolResult: string; if (tc.function.name === "spawn_agent") { // Recursive sub-agent const subTask = String(parsedArgs.task ?? ""); const subCwd = parsedArgs.cwd ? String(parsedArgs.cwd) : cwd; - toolResult = await runSubAgent(subTask, subCwd, model, signal, depth + 1); + toolResult = await runSubAgent(subTask, subCwd, model, signal, depth + 1, progress); } else { try { toolResult = await executeToolCall(tc.function.name, parsedArgs, cwd); @@ -796,7 +855,9 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { ? String(parsedArgs.command ?? "") : tc.function.name === "read_file" || tc.function.name === "write_file" || tc.function.name === "edit_file" ? String(parsedArgs.path ?? "") - : tc.function.name; + : tc.function.name === "spawn_agent" + ? String(parsedArgs.task ?? "").slice(0, 120) + : tc.function.name; // Approval flow for write/execute operations in approval-required mode if ( @@ -865,7 +926,13 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { if (tc.function.name === "spawn_agent") { const subTask = String(parsedArgs.task ?? ""); const subCwd = parsedArgs.cwd ? String(parsedArgs.cwd) : session.cwd; - toolResult = await runSubAgent(subTask, subCwd, session.model, signal, 0); + const progressCtx: SubAgentProgressContext = { + threadId: session.threadId, + turnId, + parentItemId: toolItemId, + emit, + }; + toolResult = await runSubAgent(subTask, subCwd, session.model, signal, 0, progressCtx); } else { toolResult = await executeToolCall(tc.function.name, parsedArgs, session.cwd); } From 08de181c93b87ad0e1e21aa8160b300045568467 Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 11:19:58 +0800 Subject: [PATCH 03/14] Add dedicated agent panels with names in the work log UI - 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 --- .../Layers/ProviderRuntimeIngestion.ts | 2 + apps/server/src/provider/Layers/GlmAdapter.ts | 50 ++++-- .../src/components/chat/MessagesTimeline.tsx | 146 +++++++++++++++--- apps/web/src/session-logic.ts | 10 ++ 4 files changed, 170 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 417e93c8d..6a595c8b6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -442,6 +442,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -463,6 +464,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts index ca8f5c07b..9039a15a9 100644 --- a/apps/server/src/provider/Layers/GlmAdapter.ts +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -181,6 +181,12 @@ const TOOL_DEFINITIONS = [ "A detailed description of the task for the sub-agent. Be specific about what to do, " + "which files to look at, and what the expected outcome is.", }, + name: { + type: "string", + description: + "A short, descriptive name for this sub-agent (e.g. 'test-runner', 'file-analyzer', 'docs-writer'). " + + "This is shown to the user to identify what the agent is doing.", + }, cwd: { type: "string", description: "Working directory for the sub-agent (optional, defaults to parent session cwd).", @@ -322,6 +328,7 @@ interface SubAgentProgressContext { readonly threadId: ThreadId; readonly turnId: TurnId; readonly parentItemId: string; + readonly agentName: string; readonly emit: (event: ProviderRuntimeEvent) => void; } @@ -330,7 +337,7 @@ function emitSubAgentProgress( detail: string, depth: number, ) { - const prefix = depth > 0 ? `[sub-agent L${depth}] ` : "[sub-agent] "; + const label = ctx.agentName || (depth > 0 ? `sub-agent-L${depth}` : "sub-agent"); ctx.emit({ ...makeEventBase(ctx.threadId, ctx.turnId, ctx.parentItemId), type: "item.updated", @@ -338,7 +345,8 @@ function emitSubAgentProgress( itemType: "collab_agent_tool_call" as CanonicalItemType, status: "inProgress", title: "spawn_agent", - detail: `${prefix}${detail}`.slice(0, 180), + detail: `[${label}] ${detail}`.slice(0, 180), + data: { agentName: ctx.agentName, depth }, }, } as ProviderRuntimeEvent); } @@ -359,16 +367,20 @@ async function runSubAgent( if (!apiKey) return "Error: GLM API key not configured."; const baseUrl = resolveBaseUrl(); - progress?.emit({ - ...makeEventBase(progress.threadId, progress.turnId, progress.parentItemId), - type: "item.updated", - payload: { - itemType: "collab_agent_tool_call" as CanonicalItemType, - status: "inProgress", - title: "spawn_agent", - detail: `[sub-agent] Starting: ${task.slice(0, 140)}`, - }, - } as ProviderRuntimeEvent); + if (progress) { + const label = progress.agentName || "sub-agent"; + progress.emit({ + ...makeEventBase(progress.threadId, progress.turnId, progress.parentItemId), + type: "item.updated", + payload: { + itemType: "collab_agent_tool_call" as CanonicalItemType, + status: "inProgress", + title: "spawn_agent", + detail: `[${label}] Starting: ${task.slice(0, 140)}`, + data: { agentName: progress.agentName, depth }, + }, + } as ProviderRuntimeEvent); + } const messages: ChatMessage[] = [ { @@ -909,27 +921,34 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { } } + // For spawn_agent, extract name for event data + const isSpawnAgent = tc.function.name === "spawn_agent"; + const agentName = isSpawnAgent ? String(parsedArgs.name ?? "") : ""; + const agentData = isSpawnAgent ? { agentName } : undefined; + // Emit item.started emit({ ...makeEventBase(session.threadId, turnId, toolItemId), type: "item.started", payload: { itemType: canonicalType, - title: tc.function.name, + title: isSpawnAgent && agentName ? `spawn_agent (${agentName})` : tc.function.name, detail: toolDetail, + ...(agentData ? { data: agentData } : {}), }, } as ProviderRuntimeEvent); // Execute let toolResult: string; try { - if (tc.function.name === "spawn_agent") { + if (isSpawnAgent) { const subTask = String(parsedArgs.task ?? ""); const subCwd = parsedArgs.cwd ? String(parsedArgs.cwd) : session.cwd; const progressCtx: SubAgentProgressContext = { threadId: session.threadId, turnId, parentItemId: toolItemId, + agentName, emit, }; toolResult = await runSubAgent(subTask, subCwd, session.model, signal, 0, progressCtx); @@ -947,8 +966,9 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { payload: { itemType: canonicalType, status: "completed", - title: tc.function.name, + title: isSpawnAgent && agentName ? `spawn_agent (${agentName})` : tc.function.name, detail: toolDetail, + ...(agentData ? { data: agentData } : {}), }, } as ProviderRuntimeEvent); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e30801041..2e9e9468b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -295,40 +295,140 @@ export const MessagesTimeline = memo(function MessagesTimeline({ (() => { const groupId = row.id; const groupedEntries = row.groupedEntries; + + // Separate agent entries from regular tool entries + const agentGroups = new Map(); + const regularEntries: TimelineWorkEntry[] = []; + + for (const entry of groupedEntries) { + if (entry.itemType === "collab_agent_tool_call" && entry.agentName) { + const existing = agentGroups.get(entry.agentName) ?? []; + existing.push(entry); + agentGroups.set(entry.agentName, existing); + } else if (entry.itemType === "collab_agent_tool_call" && !entry.agentName) { + const key = "__unnamed_agent__"; + const existing = agentGroups.get(key) ?? []; + existing.push(entry); + agentGroups.set(key, existing); + } else { + regularEntries.push(entry); + } + } + const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const hasOverflow = regularEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + ? regularEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : regularEntries; + const hiddenCount = regularEntries.length - visibleEntries.length; + const onlyToolEntries = regularEntries.every((entry) => entry.tone === "tool"); const showHeader = hasOverflow || !onlyToolEntries; const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; return ( -
- {showHeader && ( -
-

- {groupLabel} ({groupedEntries.length}) -

- {hasOverflow && ( +
+ {regularEntries.length > 0 && ( +
+ {showHeader && ( +
+

+ {groupLabel} ({regularEntries.length}) +

+ {hasOverflow && ( + + )} +
+ )} +
+ {visibleEntries.map((workEntry) => ( + + ))} +
+
+ )} + + {/* Agent panels — each agent gets its own fixed-height scrollable panel */} + {Array.from(agentGroups.entries()).map(([agentKey, agentEntries]) => { + const displayName = agentKey === "__unnamed_agent__" ? "Sub-agent" : agentKey; + const agentGroupId = `${groupId}:agent:${agentKey}`; + const isAgentExpanded = expandedWorkGroups[agentGroupId] ?? false; + const latestEntry = agentEntries[agentEntries.length - 1]; + const isCompleted = latestEntry?.label?.includes("complete") ?? false; + const statusDot = isCompleted + ? "bg-emerald-400/70" + : "bg-amber-400/70 animate-pulse"; + + return ( +
+ {/* Agent header */} - )} -
- )} -
- {visibleEntries.map((workEntry) => ( - - ))} -
+ + {/* Agent activity log */} + {isAgentExpanded && ( +
+
+ {agentEntries.map((workEntry) => ( +
+ +

+ {workEntry.detail ? ( + + {workEntry.detail} + + ) : ( + workEntry.label + )} +

+
+ ))} +
+
+ )} + + {/* Collapsed: show latest status */} + {!isAgentExpanded && latestEntry && ( +
+

+ {latestEntry.detail ?? latestEntry.label} +

+
+ )} +
+ ); + })}
); })()} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e389f10e2..8479960e2 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -41,6 +41,7 @@ export interface WorkLogEntry { toolTitle?: string; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; + agentName?: string; } export interface PendingApproval { @@ -450,6 +451,15 @@ export function deriveWorkLogEntries( if (requestKind) { entry.requestKind = requestKind; } + if (payload && typeof payload.itemType === "string") { + entry.itemType = payload.itemType; + } + if (payload && typeof payload.data === "object" && payload.data !== null) { + const data = payload.data as Record; + if (typeof data.agentName === "string" && data.agentName.length > 0) { + entry.agentName = data.agentName; + } + } return entry; }); } From eb70ecaaca4716dd2502e9e309e0794dda379c2d Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 11:32:41 +0800 Subject: [PATCH 04/14] Add manager orchestration pattern and stream sub-agent responses 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 --- apps/server/src/provider/Layers/GlmAdapter.ts | 61 +++++++++- .../src/components/chat/MessagesTimeline.tsx | 104 +++++++++++++----- apps/web/src/session-logic.ts | 10 ++ 3 files changed, 143 insertions(+), 32 deletions(-) diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts index 9039a15a9..ba8a5227c 100644 --- a/apps/server/src/provider/Layers/GlmAdapter.ts +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -389,7 +389,11 @@ async function runSubAgent( `You are a focused sub-agent handling a specific task. ` + `You have access to tools to read, write, and edit files, run commands, list directories, search files, and spawn further sub-agents. ` + `The working directory is: ${cwd}\n\n` + - `Complete the task and provide a clear, concise summary of what you did and the result. ` + + `Complete the task and then write a clear, detailed summary of:\n` + + `- What you did (files changed, commands run, etc.)\n` + + `- The outcome (success/failure, key findings)\n` + + `- Any issues encountered or things to note\n\n` + + `Your final response is shown directly to the user, so make it informative and well-formatted. ` + `Do NOT ask questions — make reasonable decisions and proceed.`, }, { role: "user", content: task }, @@ -434,14 +438,40 @@ async function runSubAgent( let assistantContent = ""; const toolCalls: Map = new Map(); + let hasToolCalls = false; + let lastStreamEmitAt = 0; + const STREAM_THROTTLE_MS = 150; for await (const chunk of parseSseStream(response, signal)) { const choice = chunk.choices?.[0]; if (!choice?.delta) continue; if (choice.delta.content) { assistantContent += choice.delta.content; + + // Stream the sub-agent's response text to the UI (throttled) + const now = Date.now(); + if (progress && !hasToolCalls && now - lastStreamEmitAt >= STREAM_THROTTLE_MS) { + lastStreamEmitAt = now; + const label = progress.agentName || "sub-agent"; + progress.emit({ + ...makeEventBase(progress.threadId, progress.turnId, progress.parentItemId), + type: "item.updated", + payload: { + itemType: "collab_agent_tool_call" as CanonicalItemType, + status: "inProgress", + title: "spawn_agent", + detail: `[${label}] responding...`.slice(0, 180), + data: { + agentName: progress.agentName, + depth, + streamingResponse: assistantContent, + }, + }, + } as ProviderRuntimeEvent); + } } if (choice.delta.tool_calls) { + hasToolCalls = true; for (const tc of choice.delta.tool_calls) { const existing = toolCalls.get(tc.index); if (existing) { @@ -476,7 +506,23 @@ async function runSubAgent( if (resolvedToolCalls.length === 0) { finalResponse = assistantContent; if (progress) { - emitSubAgentProgress(progress, `Completed after ${iteration + 1} iteration(s)`, depth); + // Emit final completion with the full summary + const label = progress.agentName || "sub-agent"; + progress.emit({ + ...makeEventBase(progress.threadId, progress.turnId, progress.parentItemId), + type: "item.updated", + payload: { + itemType: "collab_agent_tool_call" as CanonicalItemType, + status: "completed" as const, + title: "spawn_agent", + detail: `[${label}] Completed after ${iteration + 1} iteration(s)`, + data: { + agentName: progress.agentName, + depth, + summary: assistantContent, + }, + }, + } as ProviderRuntimeEvent); } break; } @@ -635,7 +681,16 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { messages: [ { role: "system", - content: `You are a helpful coding assistant. You have access to tools to read, write, and edit files, run commands, list directories, and search files. The working directory is: ${cwd}`, + content: + `You are a helpful coding assistant and orchestrator. The working directory is: ${cwd}\n\n` + + `You have access to tools to read, write, and edit files, run commands, list directories, search files, and spawn sub-agents.\n\n` + + `## Orchestration Guidelines\n` + + `When facing complex tasks that involve multiple files or steps, act as a **manager**:\n` + + `1. **Plan first** — briefly explain to the user what you're about to do and which agents you'll create.\n` + + `2. **Delegate** — use \`spawn_agent\` with a clear, descriptive \`name\` (e.g. "test-runner", "schema-migrator") and a detailed \`task\`.\n` + + `3. **Summarize** — after all agents complete, synthesize their results into a clear summary for the user.\n\n` + + `Always give each agent a unique, descriptive name so the user can track what each agent is working on.\n` + + `Prefer spawning focused agents for distinct sub-tasks rather than one agent for everything.`, }, ], activeTurnAbort: null, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2e9e9468b..9c7cde186 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -358,12 +358,34 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {Array.from(agentGroups.entries()).map(([agentKey, agentEntries]) => { const displayName = agentKey === "__unnamed_agent__" ? "Sub-agent" : agentKey; const agentGroupId = `${groupId}:agent:${agentKey}`; - const isAgentExpanded = expandedWorkGroups[agentGroupId] ?? false; + const isAgentExpanded = expandedWorkGroups[agentGroupId] ?? true; const latestEntry = agentEntries[agentEntries.length - 1]; const isCompleted = latestEntry?.label?.includes("complete") ?? false; + + // Find the latest summary or streaming response + let agentResponseText: string | undefined; + for (let ei = agentEntries.length - 1; ei >= 0; ei--) { + const e = agentEntries[ei]; + if (e?.agentSummary) { agentResponseText = e.agentSummary; break; } + if (e?.streamingResponse) { agentResponseText = e.streamingResponse; break; } + } + const isStreaming = !isCompleted && !!latestEntry?.streamingResponse; + const statusDot = isCompleted ? "bg-emerald-400/70" - : "bg-amber-400/70 animate-pulse"; + : isStreaming + ? "bg-sky-400/70 animate-pulse" + : "bg-amber-400/70 animate-pulse"; + const statusLabel = isCompleted + ? "Done" + : isStreaming + ? "Responding..." + : "Working..."; + + // Separate activity entries from the response + const activityEntries = agentEntries.filter( + (e) => !e.streamingResponse && !e.agentSummary, + ); return (
{displayName}

- - ({agentEntries.length} {agentEntries.length === 1 ? "event" : "events"}) + + {statusLabel}
@@ -390,39 +412,63 @@ export const MessagesTimeline = memo(function MessagesTimeline({ - {/* Agent activity log */} {isAgentExpanded && ( -
-
- {agentEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( - - {workEntry.detail} - - ) : ( - workEntry.label - )} -

+
+ {/* Activity log — scrollable fixed height */} + {activityEntries.length > 0 && ( +
+

+ Activity +

+
+ {activityEntries.map((workEntry) => ( +
+ +

+ {workEntry.detail ? ( + + {workEntry.detail} + + ) : ( + workEntry.label + )} +

+
+ ))}
- ))} -
+
+ )} + + {/* Agent response / summary — streamed in real-time */} + {agentResponseText && ( +
+

+ {isCompleted ? "Result" : "Responding"} +

+
+ {agentResponseText} + {isStreaming && ( + + )} +
+
+ )}
)} {/* Collapsed: show latest status */} - {!isAgentExpanded && latestEntry && ( + {!isAgentExpanded && (

- {latestEntry.detail ?? latestEntry.label} + {agentResponseText + ? agentResponseText.slice(0, 120) + (agentResponseText.length > 120 ? "..." : "") + : latestEntry?.detail ?? latestEntry?.label ?? ""}

)} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 8479960e2..bed8945db 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -42,6 +42,10 @@ export interface WorkLogEntry { itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; agentName?: string; + /** Full summary text from a completed sub-agent. */ + agentSummary?: string; + /** Partial streaming response text from a sub-agent in progress. */ + streamingResponse?: string; } export interface PendingApproval { @@ -459,6 +463,12 @@ export function deriveWorkLogEntries( if (typeof data.agentName === "string" && data.agentName.length > 0) { entry.agentName = data.agentName; } + if (typeof data.summary === "string" && data.summary.length > 0) { + entry.agentSummary = data.summary; + } + if (typeof data.streamingResponse === "string" && data.streamingResponse.length > 0) { + entry.streamingResponse = data.streamingResponse; + } } return entry; }); From df49239db97faba6ccde9e06b6dd46c12a38f878 Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 11:54:29 +0800 Subject: [PATCH 05/14] Fix agent response truncation and add auto-scroll to agent panels 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 --- apps/server/src/provider/Layers/GlmAdapter.ts | 20 +- .../src/components/chat/MessagesTimeline.tsx | 275 ++++++++++-------- 2 files changed, 174 insertions(+), 121 deletions(-) diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts index ba8a5227c..e272c752b 100644 --- a/apps/server/src/provider/Layers/GlmAdapter.ts +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -506,8 +506,26 @@ async function runSubAgent( if (resolvedToolCalls.length === 0) { finalResponse = assistantContent; if (progress) { - // Emit final completion with the full summary const label = progress.agentName || "sub-agent"; + // Flush the full streamed response (throttle may have skipped the last chunk) + if (assistantContent) { + progress.emit({ + ...makeEventBase(progress.threadId, progress.turnId, progress.parentItemId), + type: "item.updated", + payload: { + itemType: "collab_agent_tool_call" as CanonicalItemType, + status: "inProgress", + title: "spawn_agent", + detail: `[${label}] responding...`.slice(0, 180), + data: { + agentName: progress.agentName, + depth, + streamingResponse: assistantContent, + }, + }, + } as ProviderRuntimeEvent); + } + // Emit final completion with the full summary progress.emit({ ...makeEventBase(progress.threadId, progress.turnId, progress.parentItemId), type: "item.updated", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9c7cde186..6605531ee 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -40,6 +40,151 @@ import { formatTimestamp } from "../../timestampFormat"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; +// ── Agent Panel Component ──────────────────────────────────────── +// Extracted so each agent panel can manage its own auto-scroll refs. + +function AgentPanel({ + agentKey, + agentEntries, + groupId, + isExpanded, + onToggle, +}: { + agentKey: string; + agentEntries: TimelineWorkEntry[]; + groupId: string; + isExpanded: boolean; + onToggle: (groupId: string) => void; +}) { + const displayName = agentKey === "__unnamed_agent__" ? "Sub-agent" : agentKey; + const agentGroupId = `${groupId}:agent:${agentKey}`; + const latestEntry = agentEntries[agentEntries.length - 1]; + const isCompleted = latestEntry?.label?.includes("complete") ?? false; + + // Find the latest summary or streaming response + let agentResponseText: string | undefined; + for (let ei = agentEntries.length - 1; ei >= 0; ei--) { + const e = agentEntries[ei]; + if (e?.agentSummary) { agentResponseText = e.agentSummary; break; } + if (e?.streamingResponse) { agentResponseText = e.streamingResponse; break; } + } + const isStreaming = !isCompleted && !!latestEntry?.streamingResponse; + + const statusDot = isCompleted + ? "bg-emerald-400/70" + : isStreaming + ? "bg-sky-400/70 animate-pulse" + : "bg-amber-400/70 animate-pulse"; + const statusLabel = isCompleted + ? "Done" + : isStreaming + ? "Responding..." + : "Working..."; + + // Separate activity entries from the response + const activityEntries = agentEntries.filter( + (e) => !e.streamingResponse && !e.agentSummary, + ); + + // Auto-scroll refs + const activityScrollRef = useRef(null); + const responseScrollRef = useRef(null); + + // Auto-scroll activity log to bottom when new entries arrive + useEffect(() => { + const el = activityScrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [activityEntries.length]); + + // Auto-scroll response area to bottom as text streams in + useEffect(() => { + const el = responseScrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [agentResponseText]); + + return ( +
+ {/* Agent header */} + + + {isExpanded && ( +
+ {/* Activity log — scrollable fixed height, auto-scrolls */} + {activityEntries.length > 0 && ( +
+

+ Activity +

+
+ {activityEntries.map((workEntry) => ( +
+ +

+ {workEntry.detail ? ( + + {workEntry.detail} + + ) : ( + workEntry.label + )} +

+
+ ))} +
+
+ )} + + {/* Agent response / summary — streamed in real-time, auto-scrolls */} + {agentResponseText && ( +
+

+ {isCompleted ? "Result" : "Responding"} +

+
+ {agentResponseText} + {isStreaming && ( + + )} +
+
+ )} +
+ )} + + {/* Collapsed: show latest status */} + {!isExpanded && ( +
+

+ {agentResponseText + ? agentResponseText.slice(0, 120) + (agentResponseText.length > 120 ? "..." : "") + : latestEntry?.detail ?? latestEntry?.label ?? ""} +

+
+ )} +
+ ); +} + interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; @@ -355,126 +500,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )} {/* Agent panels — each agent gets its own fixed-height scrollable panel */} - {Array.from(agentGroups.entries()).map(([agentKey, agentEntries]) => { - const displayName = agentKey === "__unnamed_agent__" ? "Sub-agent" : agentKey; - const agentGroupId = `${groupId}:agent:${agentKey}`; - const isAgentExpanded = expandedWorkGroups[agentGroupId] ?? true; - const latestEntry = agentEntries[agentEntries.length - 1]; - const isCompleted = latestEntry?.label?.includes("complete") ?? false; - - // Find the latest summary or streaming response - let agentResponseText: string | undefined; - for (let ei = agentEntries.length - 1; ei >= 0; ei--) { - const e = agentEntries[ei]; - if (e?.agentSummary) { agentResponseText = e.agentSummary; break; } - if (e?.streamingResponse) { agentResponseText = e.streamingResponse; break; } - } - const isStreaming = !isCompleted && !!latestEntry?.streamingResponse; - - const statusDot = isCompleted - ? "bg-emerald-400/70" - : isStreaming - ? "bg-sky-400/70 animate-pulse" - : "bg-amber-400/70 animate-pulse"; - const statusLabel = isCompleted - ? "Done" - : isStreaming - ? "Responding..." - : "Working..."; - - // Separate activity entries from the response - const activityEntries = agentEntries.filter( - (e) => !e.streamingResponse && !e.agentSummary, - ); - - return ( -
- {/* Agent header */} - - - {isAgentExpanded && ( -
- {/* Activity log — scrollable fixed height */} - {activityEntries.length > 0 && ( -
-

- Activity -

-
- {activityEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( - - {workEntry.detail} - - ) : ( - workEntry.label - )} -

-
- ))} -
-
- )} - - {/* Agent response / summary — streamed in real-time */} - {agentResponseText && ( -
-

- {isCompleted ? "Result" : "Responding"} -

-
- {agentResponseText} - {isStreaming && ( - - )} -
-
- )} -
- )} - - {/* Collapsed: show latest status */} - {!isAgentExpanded && ( -
-

- {agentResponseText - ? agentResponseText.slice(0, 120) + (agentResponseText.length > 120 ? "..." : "") - : latestEntry?.detail ?? latestEntry?.label ?? ""} -

-
- )} -
- ); - })} + {Array.from(agentGroups.entries()).map(([agentKey, agentEntries]) => ( + + ))}
); })()} From 8ccda02ae4b97024034329a79a6861f02e1c3f41 Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 12:00:57 +0800 Subject: [PATCH 06/14] Fix premature agent response display during tool execution 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 --- apps/server/src/provider/Layers/GlmAdapter.ts | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts index e272c752b..461debaa5 100644 --- a/apps/server/src/provider/Layers/GlmAdapter.ts +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -438,40 +438,14 @@ async function runSubAgent( let assistantContent = ""; const toolCalls: Map = new Map(); - let hasToolCalls = false; - let lastStreamEmitAt = 0; - const STREAM_THROTTLE_MS = 150; for await (const chunk of parseSseStream(response, signal)) { const choice = chunk.choices?.[0]; if (!choice?.delta) continue; if (choice.delta.content) { assistantContent += choice.delta.content; - - // Stream the sub-agent's response text to the UI (throttled) - const now = Date.now(); - if (progress && !hasToolCalls && now - lastStreamEmitAt >= STREAM_THROTTLE_MS) { - lastStreamEmitAt = now; - const label = progress.agentName || "sub-agent"; - progress.emit({ - ...makeEventBase(progress.threadId, progress.turnId, progress.parentItemId), - type: "item.updated", - payload: { - itemType: "collab_agent_tool_call" as CanonicalItemType, - status: "inProgress", - title: "spawn_agent", - detail: `[${label}] responding...`.slice(0, 180), - data: { - agentName: progress.agentName, - depth, - streamingResponse: assistantContent, - }, - }, - } as ProviderRuntimeEvent); - } } if (choice.delta.tool_calls) { - hasToolCalls = true; for (const tc of choice.delta.tool_calls) { const existing = toolCalls.get(tc.index); if (existing) { From 4b040a64de7eec4d3c6569c01e6cfb8561e247fc Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 12:10:50 +0800 Subject: [PATCH 07/14] Show agent task description as header instead of in activity log The "Starting: " 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 --- .../src/components/chat/MessagesTimeline.tsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6605531ee..55df1a208 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -81,10 +81,18 @@ function AgentPanel({ ? "Responding..." : "Working..."; - // Separate activity entries from the response - const activityEntries = agentEntries.filter( - (e) => !e.streamingResponse && !e.agentSummary, - ); + // Separate task description, activity entries, and response entries + let taskDescription: string | undefined; + const activityEntries: TimelineWorkEntry[] = []; + for (const e of agentEntries) { + if (e.streamingResponse || e.agentSummary) continue; + // The "Starting:" entry contains the task — extract and show as header + if (!taskDescription && e.detail?.includes("] Starting: ")) { + taskDescription = e.detail.replace(/^\[[^\]]*\]\s*Starting:\s*/, ""); + continue; + } + activityEntries.push(e); + } // Auto-scroll refs const activityScrollRef = useRef(null); @@ -124,8 +132,17 @@ function AgentPanel({ + {/* Task description — always visible below header */} + {taskDescription && ( +
+

+ {taskDescription} +

+
+ )} + {isExpanded && ( -
+
{/* Activity log — scrollable fixed height, auto-scrolls */} {activityEntries.length > 0 && (
From 15accf7000dbe1606b46c373c106c413b43c214a Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 12:22:15 +0800 Subject: [PATCH 08/14] Render plan text before agents and run spawn_agent calls in parallel 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 --- apps/server/src/provider/Layers/GlmAdapter.ts | 123 ++++++++++++------ 1 file changed, 80 insertions(+), 43 deletions(-) diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts index 461debaa5..0cd3ba02b 100644 --- a/apps/server/src/provider/Layers/GlmAdapter.ts +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -895,61 +895,92 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { return; } - // Execute tool calls - for (const tc of resolvedToolCalls) { - if (signal.aborted) break; + // If the model produced plan/explanation text alongside tool calls, + // emit it as a completed assistant message so the UI renders it + // before the tool calls start executing. + if (assistantContent) { + const msgItemId = `msg-${nextEventId()}`; + emit({ + ...makeEventBase(session.threadId, turnId, msgItemId), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + } as ProviderRuntimeEvent); + } - const toolItemId = `tool-${tc.id}`; - const canonicalType = toolCallToCanonicalItemType(tc.function.name); + // Prepare all tool calls with parsed args and metadata + type PreparedToolCall = { + tc: ToolCall; + parsedArgs: Record; + toolItemId: string; + canonicalType: CanonicalItemType; + toolDetail: string; + isSpawnAgent: boolean; + agentName: string; + }; + const prepared: PreparedToolCall[] = resolvedToolCalls.map((tc) => { let parsedArgs: Record = {}; - try { - parsedArgs = JSON.parse(tc.function.arguments); - } catch { - parsedArgs = {}; - } - + try { parsedArgs = JSON.parse(tc.function.arguments); } catch { parsedArgs = {}; } + const toolItemId = `tool-${tc.id}`; + const canonicalType = toolCallToCanonicalItemType(tc.function.name); + const isSpawnAgent = tc.function.name === "spawn_agent"; + const agentName = isSpawnAgent ? String(parsedArgs.name ?? "") : ""; const toolDetail = tc.function.name === "run_command" ? String(parsedArgs.command ?? "") : tc.function.name === "read_file" || tc.function.name === "write_file" || tc.function.name === "edit_file" ? String(parsedArgs.path ?? "") - : tc.function.name === "spawn_agent" + : isSpawnAgent ? String(parsedArgs.task ?? "").slice(0, 120) : tc.function.name; + return { tc, parsedArgs, toolItemId, canonicalType, toolDetail, isSpawnAgent, agentName }; + }); + + // Split into spawn_agent calls (run in parallel) and other tools (run sequentially) + const spawnAgentCalls = prepared.filter((p) => p.isSpawnAgent); + const otherToolCalls = prepared.filter((p) => !p.isSpawnAgent); + + // Helper to execute a single prepared tool call + const executePrepared = async (p: PreparedToolCall): Promise => { + if (signal.aborted) return; + + const agentData = p.isSpawnAgent ? { agentName: p.agentName } : undefined; // Approval flow for write/execute operations in approval-required mode if ( session.runtimeMode === "approval-required" && - (tc.function.name === "write_file" || - tc.function.name === "edit_file" || - tc.function.name === "run_command") + (p.tc.function.name === "write_file" || + p.tc.function.name === "edit_file" || + p.tc.function.name === "run_command") ) { const requestId = `req-${nextEventId()}`; const requestType = - tc.function.name === "run_command" + p.tc.function.name === "run_command" ? "exec_command_approval" : "file_change_approval"; emit({ - ...makeEventBase(session.threadId, turnId, toolItemId), + ...makeEventBase(session.threadId, turnId, p.toolItemId), type: "request.opened", requestId: RuntimeRequestId.makeUnsafe(requestId), payload: { requestType, - detail: toolDetail, - args: parsedArgs, + detail: p.toolDetail, + args: p.parsedArgs, }, } as ProviderRuntimeEvent); - // Wait for approval const decision = await new Promise((resolve) => { session.pendingApproval = { resolve, requestId }; }); session.pendingApproval = null; emit({ - ...makeEventBase(session.threadId, turnId, toolItemId), + ...makeEventBase(session.threadId, turnId, p.toolItemId), type: "request.resolved", requestId: RuntimeRequestId.makeUnsafe(requestId), payload: { @@ -961,26 +992,21 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { if (decision === "decline" || decision === "cancel") { session.messages.push({ role: "tool", - tool_call_id: tc.id, + tool_call_id: p.tc.id, content: "Operation declined by user.", }); - continue; + return; } } - // For spawn_agent, extract name for event data - const isSpawnAgent = tc.function.name === "spawn_agent"; - const agentName = isSpawnAgent ? String(parsedArgs.name ?? "") : ""; - const agentData = isSpawnAgent ? { agentName } : undefined; - // Emit item.started emit({ - ...makeEventBase(session.threadId, turnId, toolItemId), + ...makeEventBase(session.threadId, turnId, p.toolItemId), type: "item.started", payload: { - itemType: canonicalType, - title: isSpawnAgent && agentName ? `spawn_agent (${agentName})` : tc.function.name, - detail: toolDetail, + itemType: p.canonicalType, + title: p.isSpawnAgent && p.agentName ? `spawn_agent (${p.agentName})` : p.tc.function.name, + detail: p.toolDetail, ...(agentData ? { data: agentData } : {}), }, } as ProviderRuntimeEvent); @@ -988,19 +1014,19 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { // Execute let toolResult: string; try { - if (isSpawnAgent) { - const subTask = String(parsedArgs.task ?? ""); - const subCwd = parsedArgs.cwd ? String(parsedArgs.cwd) : session.cwd; + if (p.isSpawnAgent) { + const subTask = String(p.parsedArgs.task ?? ""); + const subCwd = p.parsedArgs.cwd ? String(p.parsedArgs.cwd) : session.cwd; const progressCtx: SubAgentProgressContext = { threadId: session.threadId, turnId, - parentItemId: toolItemId, - agentName, + parentItemId: p.toolItemId, + agentName: p.agentName, emit, }; toolResult = await runSubAgent(subTask, subCwd, session.model, signal, 0, progressCtx); } else { - toolResult = await executeToolCall(tc.function.name, parsedArgs, session.cwd); + toolResult = await executeToolCall(p.tc.function.name, p.parsedArgs, session.cwd); } } catch (error) { toolResult = `Error: ${error instanceof Error ? error.message : String(error)}`; @@ -1008,22 +1034,33 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { // Emit item.completed emit({ - ...makeEventBase(session.threadId, turnId, toolItemId), + ...makeEventBase(session.threadId, turnId, p.toolItemId), type: "item.completed", payload: { - itemType: canonicalType, + itemType: p.canonicalType, status: "completed", - title: isSpawnAgent && agentName ? `spawn_agent (${agentName})` : tc.function.name, - detail: toolDetail, + title: p.isSpawnAgent && p.agentName ? `spawn_agent (${p.agentName})` : p.tc.function.name, + detail: p.toolDetail, ...(agentData ? { data: agentData } : {}), }, } as ProviderRuntimeEvent); session.messages.push({ role: "tool", - tool_call_id: tc.id, + tool_call_id: p.tc.id, content: toolResult.slice(0, 50_000), }); + }; + + // Execute other tools sequentially first + for (const p of otherToolCalls) { + if (signal.aborted) break; + await executePrepared(p); + } + + // Execute spawn_agent calls in parallel + if (spawnAgentCalls.length > 0 && !signal.aborted) { + await Promise.all(spawnAgentCalls.map((p) => executePrepared(p))); } session.updatedAt = nowIso(); From 73835944bb9ea592c7d0e3d094071adedc1e8ad8 Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 12:27:48 +0800 Subject: [PATCH 09/14] Fix plan text not rendering before tool calls 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 --- apps/server/src/provider/Layers/GlmAdapter.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts index 0cd3ba02b..8790b5729 100644 --- a/apps/server/src/provider/Layers/GlmAdapter.ts +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -874,9 +874,9 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { // If no tool calls, turn is complete if (resolvedToolCalls.length === 0) { if (assistantContent) { - const msgItemId = `msg-${nextEventId()}`; + // Use turnId-based identity (no itemId) to match content.delta events emit({ - ...makeEventBase(session.threadId, turnId, msgItemId), + ...makeEventBase(session.threadId, turnId), type: "item.completed", payload: { itemType: "assistant_message", @@ -898,10 +898,10 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { // If the model produced plan/explanation text alongside tool calls, // emit it as a completed assistant message so the UI renders it // before the tool calls start executing. + // Use turnId-based identity (no itemId) to match content.delta events. if (assistantContent) { - const msgItemId = `msg-${nextEventId()}`; emit({ - ...makeEventBase(session.threadId, turnId, msgItemId), + ...makeEventBase(session.threadId, turnId), type: "item.completed", payload: { itemType: "assistant_message", From ca2a2a106a865748a02f1fdc14280caa3c4ce2ea Mon Sep 17 00:00:00 2001 From: Aneaire Date: Sun, 8 Mar 2026 12:38:20 +0800 Subject: [PATCH 10/14] Fix plan text merging with final response by using per-iteration itemId 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 --- apps/server/src/provider/Layers/GlmAdapter.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts index 8790b5729..12feda893 100644 --- a/apps/server/src/provider/Layers/GlmAdapter.ts +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -755,6 +755,10 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { return; } + // Each iteration gets a unique itemId so plan text (iteration 0) and + // final response become separate messages in the timeline. + const iterationItemId = `msg-iter-${iteration}-${turnId}`; + let response: Response; try { response = await fetch(`${baseUrl}/chat/completions`, { @@ -829,7 +833,7 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { if (choice.delta.content) { assistantContent += choice.delta.content; emit({ - ...makeEventBase(session.threadId, turnId), + ...makeEventBase(session.threadId, turnId, iterationItemId), type: "content.delta", payload: { streamKind: "assistant_text", @@ -874,9 +878,8 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { // If no tool calls, turn is complete if (resolvedToolCalls.length === 0) { if (assistantContent) { - // Use turnId-based identity (no itemId) to match content.delta events emit({ - ...makeEventBase(session.threadId, turnId), + ...makeEventBase(session.threadId, turnId, iterationItemId), type: "item.completed", payload: { itemType: "assistant_message", @@ -898,10 +901,9 @@ export function makeGlmAdapterLive(_options?: GlmAdapterLiveOptions) { // If the model produced plan/explanation text alongside tool calls, // emit it as a completed assistant message so the UI renders it // before the tool calls start executing. - // Use turnId-based identity (no itemId) to match content.delta events. if (assistantContent) { emit({ - ...makeEventBase(session.threadId, turnId), + ...makeEventBase(session.threadId, turnId, iterationItemId), type: "item.completed", payload: { itemType: "assistant_message", From fc0d39b470fe42fae20ed0227dea7d7742949f5d Mon Sep 17 00:00:00 2001 From: Aneaire Date: Mon, 9 Mar 2026 12:15:55 +0800 Subject: [PATCH 11/14] Add Claude Code CLI provider support 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 - 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 --- .../Layers/ProviderCommandReactor.ts | 2 +- .../Layers/ProviderRuntimeIngestion.test.ts | 4 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 131 +++ .../src/provider/Layers/ClaudeAdapter.ts | 819 ++++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 58 +- .../Layers/ProviderAdapterRegistry.ts | 7 +- .../Layers/ProviderSessionDirectory.ts | 6 +- .../src/provider/Services/ClaudeAdapter.ts | 26 + .../src/provider/Services/GlmAdapter.ts | 26 + apps/server/src/serverLayers.ts | 10 + apps/web/src/appSettings.ts | 21 + apps/web/src/components/ChatView.tsx | 21 +- apps/web/src/components/Icons.tsx | 13 + apps/web/src/composerDraftStore.ts | 2 +- apps/web/src/routes/_chat.settings.tsx | 65 ++ apps/web/src/session-logic.test.ts | 11 +- apps/web/src/session-logic.ts | 4 +- apps/web/src/store.test.ts | 15 +- apps/web/src/store.ts | 13 +- packages/contracts/src/model.ts | 37 + packages/contracts/src/orchestration.ts | 2 +- packages/contracts/src/provider.ts | 6 + packages/contracts/src/providerRuntime.ts | 2 + packages/shared/src/model.ts | 20 + 24 files changed, 1290 insertions(+), 31 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/ClaudeAdapter.ts create mode 100644 apps/server/src/provider/Services/ClaudeAdapter.ts create mode 100644 apps/server/src/provider/Services/GlmAdapter.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe0218845..298f68eb4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -206,7 +206,7 @@ const make = Effect.gen(function* () { const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + thread.session?.providerName != null ? (thread.session.providerName as ProviderKind) : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b6b48c7ed..c97fd413b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { OrchestrationReadModel, ProviderKind, ProviderRuntimeEvent } from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts new file mode 100644 index 000000000..599bde516 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -0,0 +1,131 @@ +import { it, assert } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { makeClaudeAdapterLive } from "./ClaudeAdapter.ts"; +import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; +import { ThreadId } from "@t3tools/contracts"; + +const layer = it.layer( + Layer.mergeAll(makeClaudeAdapterLive(), NodeServices.layer), +); + +layer("ClaudeAdapterLive", (it) => { + it.effect("startSession creates a session and emits session.started", () => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const threadId = ThreadId.makeUnsafe("test-thread-1"); + + const session = yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + cwd: "/tmp/test", + model: "claude-sonnet-4-6", + }); + + assert.equal(session.provider, "claude"); + assert.equal(session.threadId, threadId); + assert.equal(session.model, "claude-sonnet-4-6"); + + const has = yield* adapter.hasSession(threadId); + assert.ok(has); + + // Clean up + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("stopSession cleans up the session", () => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const threadId = ThreadId.makeUnsafe("test-thread-2"); + + yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + cwd: "/tmp/test", + }); + + assert.ok(yield* adapter.hasSession(threadId)); + + yield* adapter.stopSession(threadId); + + assert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("listSessions returns active sessions", () => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const threadId = ThreadId.makeUnsafe("test-thread-3"); + + yield* adapter.startSession({ + threadId, + runtimeMode: "approval-required", + cwd: "/tmp/test", + model: "claude-opus-4-6", + }); + + const sessionList = yield* adapter.listSessions(); + const found = sessionList.find((s) => s.threadId === threadId); + assert.ok(found); + assert.equal(found?.model, "claude-opus-4-6"); + assert.equal(found?.provider, "claude"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("readThread returns an empty snapshot for an active session", () => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const threadId = ThreadId.makeUnsafe("test-thread-4"); + + yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + cwd: "/tmp/test", + }); + + const snapshot = yield* adapter.readThread(threadId); + assert.equal(snapshot.threadId, threadId); + assert.deepEqual(snapshot.turns, []); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("capabilities reports in-session model switch support", () => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + assert.equal(adapter.capabilities.sessionModelSwitch, "in-session"); + }), + ); + + it.effect("stopAll stops all sessions", () => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const threadId1 = ThreadId.makeUnsafe("test-thread-5a"); + const threadId2 = ThreadId.makeUnsafe("test-thread-5b"); + + yield* adapter.startSession({ + threadId: threadId1, + runtimeMode: "full-access", + cwd: "/tmp/test", + }); + yield* adapter.startSession({ + threadId: threadId2, + runtimeMode: "full-access", + cwd: "/tmp/test", + }); + + assert.ok(yield* adapter.hasSession(threadId1)); + assert.ok(yield* adapter.hasSession(threadId2)); + + yield* adapter.stopAll(); + + assert.equal(yield* adapter.hasSession(threadId1), false); + assert.equal(yield* adapter.hasSession(threadId2), false); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts new file mode 100644 index 000000000..2db535344 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -0,0 +1,819 @@ +/** + * ClaudeAdapterLive - CLI-based live implementation for the Claude Code provider adapter. + * + * Spawns `claude -p --output-format stream-json` per turn, parses NDJSON + * stdout into canonical ProviderRuntimeEvent events, and manages session + * continuity via `--session-id`. + * + * @module ClaudeAdapterLive + */ +import { spawn, type ChildProcess } from "node:child_process"; +import crypto from "node:crypto"; + +import { + type CanonicalItemType, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + type RuntimeMode, + EventId, + RuntimeItemId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Effect, Layer, Queue, Stream } from "effect"; + +import { ProviderAdapterSessionNotFoundError } from "../Errors.ts"; +import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import type { + ProviderAdapterCapabilities, + ProviderThreadSnapshot, +} from "../Services/ProviderAdapter.ts"; + +const PROVIDER = "claude" as const; +const SIGINT_TIMEOUT_MS = 5_000; + +// ── Types ───────────────────────────────────────────────────────── + +interface ClaudeSession { + threadId: ThreadId; + model: string; + cwd: string; + /** Claude session ID from the `system` init message, used for `--session-id`. */ + claudeSessionId: string | null; + activeTurnId: TurnId | null; + activeProcess: ChildProcess | null; + status: "ready" | "running" | "stopped"; + runtimeMode: RuntimeMode; + createdAt: string; + updatedAt: string; +} + +// ── Stream message types ────────────────────────────────────────── + +interface ClaudeSystemMessage { + type: "system"; + subtype: "init"; + session_id: string; + tools: Array<{ name: string; type?: string }>; + model?: string; + cwd?: string; +} + +interface ClaudeContentBlockText { + type: "text"; + text: string; +} + +interface ClaudeContentBlockThinking { + type: "thinking"; + thinking: string; +} + +interface ClaudeContentBlockToolUse { + type: "tool_use"; + id: string; + name: string; + input: unknown; +} + +type ClaudeContentBlock = + | ClaudeContentBlockText + | ClaudeContentBlockThinking + | ClaudeContentBlockToolUse; + +interface ClaudeAssistantMessage { + type: "assistant"; + message: { + id: string; + content: ClaudeContentBlock[]; + model?: string; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + stop_reason?: string | null; + }; +} + +interface ClaudeToolResultMessage { + type: "tool_result"; + tool_use_id: string; + content?: string; + is_error?: boolean; +} + +interface ClaudeResultMessage { + type: "result"; + subtype: "success" | "error"; + session_id: string; + cost_usd?: number; + duration_ms?: number; + duration_api_ms?: number; + total_cost_usd?: number; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + result?: string; + error?: string; +} + +type ClaudeStreamMessage = + | ClaudeSystemMessage + | ClaudeAssistantMessage + | ClaudeToolResultMessage + | ClaudeResultMessage; + +// ── Tool normalization ──────────────────────────────────────────── + +function normalizeToolName(name: string): CanonicalItemType { + switch (name) { + case "Read": + case "Glob": + case "Grep": + return "file_read"; + case "Edit": + case "Write": + case "MultiEdit": + case "NotebookEdit": + return "file_change"; + case "Bash": + return "command_execution"; + case "Agent": + case "Skill": + return "collab_agent_tool_call"; + case "WebSearch": + case "WebFetch": + return "web_search"; + default: + if (name.startsWith("mcp__")) return "mcp_tool_call"; + return "unknown"; + } +} + +// ── Helpers ─────────────────────────────────────────────────────── + +function nowIso(): string { + return new Date().toISOString(); +} + +function nextEventId(): string { + return crypto.randomUUID(); +} + +function makeEvent( + threadId: ThreadId, + type: ProviderRuntimeEvent["type"], + payload: ProviderRuntimeEvent["payload"], + extra?: Partial< + Pick + >, +): ProviderRuntimeEvent { + return { + eventId: EventId.makeUnsafe(nextEventId()), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + type, + payload, + ...extra, + } as ProviderRuntimeEvent; +} + +// ── NDJSON line parser ──────────────────────────────────────────── + +function parseNdjsonLines(buffer: string): { lines: string[]; remainder: string } { + const parts = buffer.split("\n"); + const remainder = parts.pop() ?? ""; + return { lines: parts.filter((line) => line.trim().length > 0), remainder }; +} + +function tryParseJson(line: string): ClaudeStreamMessage | null { + try { + return JSON.parse(line) as ClaudeStreamMessage; + } catch { + return null; + } +} + +// ── CLI arg builder ─────────────────────────────────────────────── + +function buildCliArgs(options: { + model: string; + userMessage: string; + claudeSessionId: string | null; + runtimeMode: RuntimeMode; +}): string[] { + const args: string[] = [ + "-p", + "--output-format", + "stream-json", + "--verbose", + "--model", + options.model, + ]; + + if (options.claudeSessionId) { + args.push("--resume", options.claudeSessionId); + } + + if (options.runtimeMode === "full-access") { + args.push("--dangerously-skip-permissions"); + } + + args.push(options.userMessage); + return args; +} + +// ── Adapter implementation ──────────────────────────────────────── + +export interface ClaudeAdapterLiveOptions { + readonly nativeEventLogger?: EventNdjsonLogger; + readonly binaryPath?: string; +} + +export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { + return Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const sessions = new Map(); + const eventQueue = yield* Queue.unbounded(); + const nativeEventLogger = options?.nativeEventLogger; + const binaryPath = options?.binaryPath || "claude"; + + const emit = (event: ProviderRuntimeEvent) => { + Effect.runSync(Queue.offer(eventQueue, event)); + if (nativeEventLogger) { + Effect.runFork(nativeEventLogger.write(event, event.threadId)); + } + }; + + const streamEvents: ClaudeAdapterShape["streamEvents"] = Stream.fromQueue(eventQueue); + + const getSession = (threadId: ThreadId): Effect.Effect => + Effect.gen(function* () { + const session = sessions.get(threadId); + if (!session) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + return session; + }); + + const capabilities: ProviderAdapterCapabilities = { + sessionModelSwitch: "in-session", + }; + + // ── Stream message handler ──────────────────────────────── + + function handleStreamMessages( + session: ClaudeSession, + turnId: TurnId, + ): (message: ClaudeStreamMessage) => void { + let currentTextItemId: string | null = null; + let currentThinkingItemId: string | null = null; + + return (message: ClaudeStreamMessage) => { + const threadId = session.threadId; + + switch (message.type) { + case "system": { + if (message.subtype === "init") { + session.claudeSessionId = message.session_id; + if (message.model) session.model = message.model; + if (message.cwd) session.cwd = message.cwd; + } + break; + } + + case "assistant": { + const content = message.message?.content; + if (!Array.isArray(content)) break; + + for (const block of content) { + switch (block.type) { + case "text": { + if (!currentTextItemId) { + currentTextItemId = nextEventId(); + emit( + makeEvent( + threadId, + "item.started", + { itemType: "assistant_message", title: "Response" }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(currentTextItemId), + }, + ), + ); + } + if (block.text) { + emit( + makeEvent( + threadId, + "content.delta", + { streamKind: "assistant_text", delta: block.text }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(currentTextItemId), + }, + ), + ); + } + break; + } + + case "thinking": { + if (!currentThinkingItemId) { + currentThinkingItemId = nextEventId(); + emit( + makeEvent( + threadId, + "item.started", + { itemType: "reasoning", title: "Thinking" }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(currentThinkingItemId), + }, + ), + ); + } + if (block.thinking) { + emit( + makeEvent( + threadId, + "content.delta", + { streamKind: "reasoning_text", delta: block.thinking }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(currentThinkingItemId), + }, + ), + ); + } + break; + } + + case "tool_use": { + // Close open text/thinking items before tool use + if (currentTextItemId) { + emit( + makeEvent( + threadId, + "item.completed", + { itemType: "assistant_message", status: "completed" }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(currentTextItemId), + }, + ), + ); + currentTextItemId = null; + } + if (currentThinkingItemId) { + emit( + makeEvent( + threadId, + "item.completed", + { itemType: "reasoning", status: "completed" }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(currentThinkingItemId), + }, + ), + ); + currentThinkingItemId = null; + } + + const canonicalType = normalizeToolName(block.name); + const toolItemId = block.id; + emit( + makeEvent( + threadId, + "item.started", + { + itemType: canonicalType, + title: block.name, + data: block.input, + }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(toolItemId), + }, + ), + ); + break; + } + } + } + break; + } + + case "tool_result": { + emit( + makeEvent( + threadId, + "item.completed", + { + itemType: "unknown", + status: message.is_error ? "failed" : "completed", + detail: message.content, + }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(message.tool_use_id), + }, + ), + ); + break; + } + + case "result": { + // Close any open items + if (currentTextItemId) { + emit( + makeEvent( + threadId, + "item.completed", + { itemType: "assistant_message", status: "completed" }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(currentTextItemId), + }, + ), + ); + currentTextItemId = null; + } + if (currentThinkingItemId) { + emit( + makeEvent( + threadId, + "item.completed", + { itemType: "reasoning", status: "completed" }, + { + turnId, + itemId: RuntimeItemId.makeUnsafe(currentThinkingItemId), + }, + ), + ); + currentThinkingItemId = null; + } + + const isError = message.subtype === "error"; + emit( + makeEvent( + threadId, + "turn.completed", + { + state: isError ? "failed" : "completed", + stopReason: isError ? (message.error ?? "error") : "end_turn", + totalCostUsd: message.total_cost_usd ?? message.cost_usd, + usage: message.usage, + ...(isError && message.error ? { errorMessage: message.error } : {}), + }, + { turnId }, + ), + ); + break; + } + } + }; + } + + // ── Process runner ────────────────────────────────────────── + + function runClaudeProcess(session: ClaudeSession, turnId: TurnId, userMessage: string): void { + const args = buildCliArgs({ + model: session.model, + userMessage, + claudeSessionId: session.claudeSessionId, + runtimeMode: session.runtimeMode, + }); + + const { CLAUDECODE: _, ...cleanEnv } = process.env; + const proc = spawn(binaryPath, args, { + cwd: session.cwd, + stdio: ["pipe", "pipe", "pipe"], + env: cleanEnv, + }); + + session.activeProcess = proc; + session.status = "running"; + session.updatedAt = nowIso(); + + // Close stdin so the CLI does not wait for interactive input. + proc.stdin?.end(); + + const handler = handleStreamMessages(session, turnId); + let buffer = ""; + + proc.stdout?.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf-8"); + buffer += text; + const { lines, remainder } = parseNdjsonLines(buffer); + buffer = remainder; + + for (const line of lines) { + if (nativeEventLogger) { + Effect.runFork( + nativeEventLogger.write( + { source: "claude.sdk.stream-json", payload: line }, + session.threadId, + ), + ); + } + + const parsed = tryParseJson(line); + if (parsed) { + handler(parsed); + } + } + }); + + proc.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf-8").trim(); + if (text) { + emit( + makeEvent(session.threadId, "runtime.warning", { + message: text, + }, { turnId }), + ); + } + }); + + proc.on("error", (error) => { + emit( + makeEvent(session.threadId, "runtime.error", { + message: error.message, + class: "transport_error", + }, { turnId }), + ); + emit( + makeEvent(session.threadId, "session.exited", { + reason: error.message, + exitKind: "error", + recoverable: false, + }), + ); + session.activeProcess = null; + session.status = "ready"; + session.updatedAt = nowIso(); + }); + + proc.on("close", (code) => { + session.activeProcess = null; + session.status = "ready"; + session.updatedAt = nowIso(); + + // Process any remaining buffer + if (buffer.trim()) { + const parsed = tryParseJson(buffer.trim()); + if (parsed) { + handler(parsed); + } + } + + // If exit code is non-zero and no result event was emitted, emit error + if (code !== null && code !== 0) { + emit( + makeEvent(session.threadId, "runtime.error", { + message: `Claude process exited with code ${code}`, + class: "transport_error", + }, { turnId }), + ); + } + }); + } + + // ── Adapter methods ───────────────────────────────────────── + + const startSession: ClaudeAdapterShape["startSession"] = (input) => + Effect.sync(() => { + const now = nowIso(); + const threadId = input.threadId; + const model = input.model ?? "claude-sonnet-4-6"; + const cwd = input.cwd ?? process.cwd(); + const runtimeMode = input.runtimeMode ?? "full-access"; + + const session: ClaudeSession = { + threadId, + model, + cwd, + claudeSessionId: null, + activeTurnId: null, + activeProcess: null, + status: "ready", + runtimeMode, + createdAt: now, + updatedAt: now, + }; + sessions.set(threadId, session); + + emit( + makeEvent(threadId, "session.started", { + message: `Claude Code session started (model: ${model})`, + }), + ); + emit( + makeEvent(threadId, "session.state.changed", { + state: "ready", + }), + ); + + return { + provider: PROVIDER, + status: "ready", + runtimeMode, + cwd, + model, + threadId, + createdAt: now, + updatedAt: now, + } as ProviderSession; + }); + + const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const session = yield* getSession(input.threadId); + const turnId = TurnId.makeUnsafe(`turn-${nextEventId()}`); + + if (input.model) { + session.model = input.model; + } + + session.activeTurnId = turnId; + session.updatedAt = nowIso(); + + emit( + makeEvent(session.threadId, "session.state.changed", { + state: "running", + }), + ); + emit( + makeEvent(session.threadId, "turn.started", { + model: session.model, + }, { turnId }), + ); + + const userMessage = input.input ?? ""; + runClaudeProcess(session, turnId, userMessage); + + return { + threadId: session.threadId, + turnId, + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: ClaudeAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const session = yield* getSession(threadId); + const proc = session.activeProcess; + if (!proc) return; + + proc.kill("SIGINT"); + + // If still alive after timeout, force kill + const killTimeout = setTimeout(() => { + if (!proc.killed) { + proc.kill("SIGKILL"); + } + }, SIGINT_TIMEOUT_MS); + + proc.once("close", () => { + clearTimeout(killTimeout); + }); + + const turnId = session.activeTurnId; + if (turnId) { + emit( + makeEvent(threadId, "turn.completed", { + state: "interrupted", + stopReason: "user_interrupt", + }, { turnId }), + ); + } + + session.activeProcess = null; + session.status = "ready"; + session.activeTurnId = null; + session.updatedAt = nowIso(); + + emit( + makeEvent(threadId, "session.state.changed", { + state: "ready", + }), + ); + }); + + const respondToRequest: ClaudeAdapterShape["respondToRequest"] = () => + Effect.void; + + const respondToUserInput: ClaudeAdapterShape["respondToUserInput"] = () => + Effect.void; + + const stopSession: ClaudeAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const session = sessions.get(threadId); + if (!session) return; + + if (session.activeProcess) { + session.activeProcess.kill("SIGINT"); + // Wait briefly then force kill + const proc = session.activeProcess; + const killTimeout = setTimeout(() => { + if (proc && !proc.killed) { + proc.kill("SIGKILL"); + } + }, SIGINT_TIMEOUT_MS); + proc.once("close", () => clearTimeout(killTimeout)); + session.activeProcess = null; + } + + session.status = "stopped"; + session.updatedAt = nowIso(); + sessions.delete(threadId); + + emit( + makeEvent(threadId, "session.state.changed", { + state: "stopped", + reason: "stopped", + }), + ); + emit( + makeEvent(threadId, "session.exited", { + reason: "stopped", + exitKind: "graceful", + }), + ); + }); + + const listSessions: ClaudeAdapterShape["listSessions"] = () => + Effect.sync(() => + Array.from(sessions.values()).map( + (s) => + ({ + provider: PROVIDER, + status: s.status === "stopped" ? "closed" : s.status, + runtimeMode: s.runtimeMode, + cwd: s.cwd, + model: s.model, + threadId: s.threadId, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + }) as ProviderSession, + ), + ); + + const hasSession: ClaudeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: ClaudeAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + yield* getSession(threadId); + return { + threadId, + turns: [], + } satisfies ProviderThreadSnapshot; + }); + + const rollbackThread: ClaudeAdapterShape["rollbackThread"] = (threadId) => + Effect.gen(function* () { + yield* getSession(threadId); + // Claude CLI does not support turn-level rollback; return empty snapshot. + return { + threadId, + turns: [], + } satisfies ProviderThreadSnapshot; + }); + + const stopAll: ClaudeAdapterShape["stopAll"] = () => + Effect.gen(function* () { + for (const threadId of sessions.keys()) { + yield* stopSession(ThreadId.makeUnsafe(threadId)); + } + }); + + return { + provider: PROVIDER, + capabilities, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents, + } satisfies ClaudeAdapterShape; + }), + ); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index c6f4a3c08..1e2ef0359 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,7 +4,9 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; -import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { GlmAdapter, type GlmAdapterShape } from "../Services/GlmAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -27,9 +29,50 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeGlmAdapter: GlmAdapterShape = { + provider: "glm", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + +const fakeClaudeAdapter: ClaudeAdapterShape = { + provider: "claude", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( - Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)), + Layer.provide( + ProviderAdapterRegistryLive, + Layer.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(GlmAdapter, fakeGlmAdapter), + Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + ), + ), NodeServices.layer, ), ); @@ -41,8 +84,17 @@ layer("ProviderAdapterRegistryLive", (it) => { const codex = yield* registry.getByProvider("codex"); assert.equal(codex, fakeCodexAdapter); + const glm = yield* registry.getByProvider("glm"); + assert.equal(glm, fakeGlmAdapter); + + const claude = yield* registry.getByProvider("claude"); + assert.equal(claude, fakeClaudeAdapter); + const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.equal(providers.length, 3); + assert.ok(providers.includes("codex")); + assert.ok(providers.includes("glm")); + assert.ok(providers.includes("claude")); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 3062ed790..8b182d7c7 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,7 +15,9 @@ import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { GlmAdapter } from "../Services/GlmAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -23,7 +25,10 @@ export interface ProviderAdapterRegistryLiveOptions { const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => Effect.gen(function* () { - const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; + const adapters = + options?.adapters !== undefined + ? options.adapters + : [yield* CodexAdapter, yield* GlmAdapter, yield* ClaudeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 38e097e1c..21821c95d 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -18,12 +18,14 @@ function toPersistenceError(operation: string) { }); } +const KNOWN_PROVIDER_KINDS = new Set(["codex", "glm", "claude"]); + function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { - return Effect.succeed(providerName); + if (KNOWN_PROVIDER_KINDS.has(providerName)) { + return Effect.succeed(providerName as ProviderKind); } return Effect.fail( new ProviderSessionDirectoryPersistenceError({ diff --git a/apps/server/src/provider/Services/ClaudeAdapter.ts b/apps/server/src/provider/Services/ClaudeAdapter.ts new file mode 100644 index 000000000..b96ca164b --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeAdapter.ts @@ -0,0 +1,26 @@ +/** + * ClaudeAdapter - Claude Code CLI implementation of the generic provider adapter contract. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * shared provider-adapter error channel with `provider: "claude"` context. + * + * @module ClaudeAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * ClaudeAdapterShape - Service API for the Claude Code provider adapter. + */ +export interface ClaudeAdapterShape extends ProviderAdapterShape { + readonly provider: "claude"; +} + +/** + * ClaudeAdapter - Service tag for Claude Code provider adapter operations. + */ +export class ClaudeAdapter extends ServiceMap.Service()( + "t3/provider/Services/ClaudeAdapter", +) {} diff --git a/apps/server/src/provider/Services/GlmAdapter.ts b/apps/server/src/provider/Services/GlmAdapter.ts new file mode 100644 index 000000000..db56dad58 --- /dev/null +++ b/apps/server/src/provider/Services/GlmAdapter.ts @@ -0,0 +1,26 @@ +/** + * GlmAdapter - GLM implementation of the generic provider adapter contract. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * shared provider-adapter error channel with `provider: "glm"` context. + * + * @module GlmAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * GlmAdapterShape - Service API for the GLM provider adapter. + */ +export interface GlmAdapterShape extends ProviderAdapterShape { + readonly provider: "glm"; +} + +/** + * GlmAdapter - Service tag for GLM provider adapter operations. + */ +export class GlmAdapter extends ServiceMap.Service()( + "t3/provider/Services/GlmAdapter", +) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96..b0c01e00a 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,7 +19,9 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; +import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeGlmAdapterLive } from "./provider/Layers/GlmAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -58,8 +60,16 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const glmAdapterLayer = makeGlmAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); + const claudeAdapterLayer = makeClaudeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(glmAdapterLayer), + Layer.provide(claudeAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..726b4a432 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -12,6 +12,8 @@ export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + glm: new Set(getModelOptions("glm").map((option) => option.slug)), + claude: new Set(getModelOptions("claude").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -21,6 +23,9 @@ const AppSettingsSchema = Schema.Struct({ codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), + claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( Schema.withConstructorDefault(() => Option.some("local")), ), @@ -34,6 +39,12 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customGlmModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + customClaudeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -73,6 +84,16 @@ export function normalizeCustomModelSlugs( return normalizedModels; } +function normalizeAppSettings(settings: AppSettings): AppSettings { + return { + ...settings, + customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + customGlmModels: normalizeCustomModelSlugs(settings.customGlmModels, "glm"), + customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claude"), + }; +} + + export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762c..b855a1d3a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,6 +25,7 @@ import { getDefaultModel, getDefaultReasoningEffort, getReasoningEffortOptions, + inferProviderFromModel, normalizeModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; @@ -494,12 +495,18 @@ export default function ChatView({ threadId }: ChatViewProps) { const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const inferredProvider = inferProviderFromModel(activeThread?.model ?? activeProject?.model); + const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? inferredProvider; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = settings.customCodexModels; + const customModelsForSelectedProvider = + selectedProvider === "claude" + ? settings.customClaudeModels + : selectedProvider === "glm" + ? settings.customGlmModels + : settings.customCodexModels; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -2878,9 +2885,15 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } setComposerDraftProvider(activeThread.id, provider); + const customModels = + provider === "claude" + ? settings.customClaudeModels + : provider === "glm" + ? settings.customGlmModels + : settings.customCodexModels; setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection(provider, customModels, model), ); scheduleComposerFocus(); }, @@ -2891,6 +2904,8 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftModel, setComposerDraftProvider, settings.customCodexModels, + settings.customGlmModels, + settings.customClaudeModels, ], ); const onEffortSelect = useCallback( diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 9a3ddbaa0..62e31d227 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -287,6 +287,19 @@ export const Gemini: Icon = (props) => ( ); +export const GlmIcon: Icon = (props) => ( + + + + +); + const ANTIGRAVITY_ICON_DATA_URL = "data:image/png;base64,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"; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..82f36bd93 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -246,7 +246,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" ? value : null; + return value === "codex" || value === "glm" || value === "claude" ? value : null; } function revokeObjectPreviewUrl(previewUrl: string): void { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..3c8865cff 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -54,6 +54,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, + { + provider: "claude", + title: "Claude Code", + description: "Save additional Claude model slugs for the picker and /model command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-4-7-20260301", + }, ] as const; const TIMESTAMP_FORMAT_LABELS = { @@ -67,6 +74,10 @@ function getCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "glm": + return settings.customGlmModels; + case "claude": + return settings.customClaudeModels; case "codex": default: return settings.customCodexModels; @@ -78,6 +89,10 @@ function getDefaultCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "glm": + return defaults.customGlmModels; + case "claude": + return defaults.customClaudeModels; case "codex": default: return defaults.customCodexModels; @@ -86,6 +101,10 @@ function getDefaultCustomModelsForProvider( function patchCustomModels(provider: ProviderKind, models: string[]) { switch (provider) { + case "glm": + return { customGlmModels: models }; + case "claude": + return { customClaudeModels: models }; case "codex": default: return { customCodexModels: models }; @@ -102,6 +121,8 @@ function SettingsRouteView() { Record >({ codex: "", + glm: "", + claude: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -369,6 +390,50 @@ function SettingsRouteView() {
+
+
+

Claude Code

+

+ Path to the Claude Code CLI binary. Authentication is managed by the CLI itself + (run claude auth to configure). +

+
+ +
+ + +
+

+ Binary source:{" "} + {settings.claudeBinaryPath || "PATH"} +

+ +
+
+
+

Models

diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 74ba3a814..836e3769c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -673,18 +673,19 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { - const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); + it("lists available providers and keeps Cursor as an unavailable placeholder", () => { + const claude = PROVIDER_OPTIONS.find((option) => option.value === "claude"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); 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 }, ]); expect(claude).toEqual({ - value: "claudeCode", + value: "claude", label: "Claude Code", - available: false, + available: true, }); expect(cursor).toEqual({ value: "cursor", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index bed8945db..314300525 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -18,7 +18,7 @@ import type { TurnDiffSummary, } from "./types"; -export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; +export type ProviderPickerKind = ProviderKind | "cursor"; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; @@ -26,7 +26,7 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { 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 }, ]; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 92d084f2d..2cbdc1354 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -192,6 +192,19 @@ describe("store pure functions", () => { describe("store read model sync", () => { it("falls back to the codex default for unsupported provider models without an active session", () => { + const initialState = makeState(makeThread()); + const readModel = makeReadModel( + makeReadModelThread({ + model: "nonexistent-model-xyz", + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.glm); + }); + + it("resolves a claude model without falling back", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( makeReadModelThread({ @@ -201,7 +214,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); + expect(next.threads[0]?.model).toBe("claude-opus-4-6"); }); it("preserves the current project order when syncing incoming read model updates", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0f..b9957fd33 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { getModelOptions, + inferProviderFromModel, normalizeModelSlug, resolveModelSlug, resolveModelSlugForProvider, @@ -189,26 +190,20 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "glm" || providerName === "claude") { return providerName; } return "codex"; } -const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); - function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null; }): ProviderKind { - if (input.sessionProviderName === "codex") { + if (input.sessionProviderName === "codex" || input.sessionProviderName === "glm" || input.sessionProviderName === "claude") { return input.sessionProviderName; } - const normalizedCodex = normalizeModelSlug(input.model, "codex"); - if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { - return "codex"; - } - return "codex"; + return inferProviderFromModel(input.model); } function resolveWsHttpOrigin(): string { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09d..9385c0784 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -10,8 +10,16 @@ export const CodexModelOptions = Schema.Struct({ }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const GlmModelOptions = Schema.Struct({}); +export type GlmModelOptions = typeof GlmModelOptions.Type; + +export const ClaudeModelOptions = Schema.Struct({}); +export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), + glm: Schema.optional(GlmModelOptions), + claude: Schema.optional(ClaudeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -28,6 +36,16 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + glm: [ + { slug: "glm-4.7", name: "GLM 4.7" }, + { slug: "glm-4.7-flash", name: "GLM 4.7 Flash" }, + { slug: "glm-5", name: "GLM 5" }, + ], + claude: [ + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -36,6 +54,8 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + glm: "glm-4.7", + claude: "claude-sonnet-4-6", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -46,12 +66,29 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + glm: { + "4.7-flash": "glm-4.7-flash", + "4.7": "glm-4.7", + "5": "glm-5", + }, + claude: { + "sonnet": "claude-sonnet-4-6", + "opus": "claude-opus-4-6", + "haiku": "claude-haiku-4-5-20251001", + "sonnet-4.6": "claude-sonnet-4-6", + "opus-4.6": "claude-opus-4-6", + "haiku-4.5": "claude-haiku-4-5-20251001", + }, } as const satisfies Record>; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, + glm: [], + claude: [], } as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", + glm: null, + claude: null, } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17c5eb21d..be9f91e14 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literal("codex"); +export const ProviderKind = Schema.Literals(["codex", "glm", "claude"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9d2a198b6..06e755d87 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -52,8 +52,14 @@ const CodexProviderStartOptions = Schema.Struct({ homePath: Schema.optional(TrimmedNonEmptyStringSchema), }); +const ClaudeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + apiKey: Schema.optional(TrimmedNonEmptyStringSchema), +}); + export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + claude: Schema.optional(ClaudeProviderStartOptions), }); export type ProviderStartOptions = typeof ProviderStartOptions.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index d76475ab5..0473e5f7a 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -20,6 +20,7 @@ const RuntimeEventRawSource = Schema.Literals([ "codex.app-server.request", "codex.eventmsg", "codex.sdk.thread-event", + "claude.sdk.stream-json", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; @@ -95,6 +96,7 @@ export type RuntimeErrorClass = typeof RuntimeErrorClass.Type; export const TOOL_LIFECYCLE_ITEM_TYPES = [ "command_execution", + "file_read", "file_change", "mcp_tool_call", "dynamic_tool_call", diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb9..c44cec040 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,6 +12,8 @@ type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + glm: new Set(MODEL_OPTIONS_BY_PROVIDER.glm.map((option) => option.slug)), + claude: new Set(MODEL_OPTIONS_BY_PROVIDER.claude.map((option) => option.slug)), }; export function getModelOptions(provider: ProviderKind = "codex") { @@ -75,4 +77,22 @@ export function getDefaultReasoningEffort( return provider === "codex" ? "high" : null; } +const PROVIDER_KINDS: readonly ProviderKind[] = ["codex", "claude", "glm"] as const; + +/** + * Infer the provider kind from a model slug. Returns the first provider + * whose catalog contains a matching slug (after alias resolution), or + * falls back to `"glm"`. + */ +export function inferProviderFromModel(model: string | null | undefined): ProviderKind { + if (!model) return "glm"; + for (const provider of PROVIDER_KINDS) { + const normalized = normalizeModelSlug(model, provider); + if (normalized && MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)) { + return provider; + } + } + return "glm"; +} + export { CODEX_REASONING_EFFORT_OPTIONS }; From 90caa86201ddf203190d767b1031218a66c63418 Mon Sep 17 00:00:00 2001 From: Aneaire Date: Mon, 9 Mar 2026 12:21:29 +0800 Subject: [PATCH 12/14] Persist last selected model/provider for new threads 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 --- apps/web/src/composerDraftStore.ts | 57 +++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 82f36bd93..4e9ecd9ba 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -97,6 +97,8 @@ interface PersistedComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; + lastSelectedProvider?: ProviderKind | null; + lastSelectedModel?: string | null; } interface ComposerThreadDraftState { @@ -130,6 +132,8 @@ interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; + lastSelectedProvider: ProviderKind | null; + lastSelectedModel: string | null; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( @@ -185,6 +189,8 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + lastSelectedProvider: null, + lastSelectedModel: null, }; const EMPTY_IMAGES: ComposerImageAttachment[] = []; @@ -386,7 +392,13 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer } } if (!rawDraftMap || typeof rawDraftMap !== "object") { - return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; + return { + draftsByThreadId: {}, + draftThreadsByThreadId, + projectDraftThreadIdByProjectId, + lastSelectedProvider: normalizeProviderKind(candidate.lastSelectedProvider), + lastSelectedModel: typeof candidate.lastSelectedModel === "string" ? candidate.lastSelectedModel : null, + }; } const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record)) { @@ -454,6 +466,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, + lastSelectedProvider: normalizeProviderKind(candidate.lastSelectedProvider), + lastSelectedModel: typeof candidate.lastSelectedModel === "string" ? candidate.lastSelectedModel : null, }; } @@ -563,6 +577,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + lastSelectedProvider: null, + lastSelectedModel: null, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -825,12 +841,11 @@ export const useComposerDraftStore = create()( set((state) => { const existing = state.draftsByThreadId[threadId]; if (!existing && normalizedProvider === null) { - return state; + return normalizedProvider !== state.lastSelectedProvider + ? { lastSelectedProvider: normalizedProvider } + : state; } const base = existing ?? createEmptyThreadDraft(); - if (base.provider === normalizedProvider) { - return state; - } const nextDraft: ComposerThreadDraftState = { ...base, provider: normalizedProvider, @@ -841,7 +856,10 @@ export const useComposerDraftStore = create()( } else { nextDraftsByThreadId[threadId] = nextDraft; } - return { draftsByThreadId: nextDraftsByThreadId }; + return { + draftsByThreadId: nextDraftsByThreadId, + ...(normalizedProvider !== null ? { lastSelectedProvider: normalizedProvider } : {}), + }; }); }, setModel: (threadId, model) => { @@ -852,7 +870,9 @@ export const useComposerDraftStore = create()( set((state) => { const existing = state.draftsByThreadId[threadId]; if (!existing && normalizedModel === null) { - return state; + return normalizedModel !== state.lastSelectedModel + ? { lastSelectedModel: normalizedModel } + : state; } const base = existing ?? createEmptyThreadDraft(); if (base.model === normalizedModel) { @@ -868,7 +888,10 @@ export const useComposerDraftStore = create()( } else { nextDraftsByThreadId[threadId] = nextDraft; } - return { draftsByThreadId: nextDraftsByThreadId }; + return { + draftsByThreadId: nextDraftsByThreadId, + ...(normalizedModel !== null ? { lastSelectedModel: normalizedModel } : {}), + }; }); }, setRuntimeMode: (threadId, runtimeMode) => { @@ -1255,6 +1278,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, + lastSelectedProvider: state.lastSelectedProvider, + lastSelectedModel: state.lastSelectedModel, }; }, merge: (persistedState, currentState) => { @@ -1265,11 +1290,14 @@ export const useComposerDraftStore = create()( toHydratedThreadDraft(draft), ]), ); + const raw = persistedState as Partial | null; return { ...currentState, draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, + lastSelectedProvider: normalizeProviderKind(raw?.lastSelectedProvider), + lastSelectedModel: typeof raw?.lastSelectedModel === "string" ? raw.lastSelectedModel : null, }; }, }, @@ -1277,7 +1305,18 @@ export const useComposerDraftStore = create()( ); export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { - return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); + return useComposerDraftStore((state) => { + const draft = state.draftsByThreadId[threadId]; + if (draft) return draft; + if (state.lastSelectedProvider || state.lastSelectedModel) { + return { + ...EMPTY_THREAD_DRAFT, + provider: state.lastSelectedProvider, + model: state.lastSelectedModel, + }; + } + return EMPTY_THREAD_DRAFT; + }); } /** From 9886d9a8177882648952e4a76ce8f3224b48d4aa Mon Sep 17 00:00:00 2001 From: Aneaire Date: Mon, 9 Mar 2026 12:58:57 +0800 Subject: [PATCH 13/14] Fix infinite re-render loop in useComposerThreadDraft 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 --- apps/web/src/composerDraftStore.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 4e9ecd9ba..39b54ce7b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1304,18 +1304,24 @@ export const useComposerDraftStore = create()( ), ); +/** Cached fallback draft keyed by `provider|model` to avoid creating new objects in selectors. */ +const lastSelectedFallbackCache = new Map(); +function getLastSelectedFallback(provider: ProviderKind | null, model: string | null): ComposerThreadDraftState { + if (!provider && !model) return EMPTY_THREAD_DRAFT; + const key = `${provider ?? ""}|${model ?? ""}`; + let cached = lastSelectedFallbackCache.get(key); + if (!cached) { + cached = { ...EMPTY_THREAD_DRAFT, provider, model }; + lastSelectedFallbackCache.set(key, cached); + } + return cached; +} + export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { return useComposerDraftStore((state) => { const draft = state.draftsByThreadId[threadId]; if (draft) return draft; - if (state.lastSelectedProvider || state.lastSelectedModel) { - return { - ...EMPTY_THREAD_DRAFT, - provider: state.lastSelectedProvider, - model: state.lastSelectedModel, - }; - } - return EMPTY_THREAD_DRAFT; + return getLastSelectedFallback(state.lastSelectedProvider, state.lastSelectedModel); }); } From 91e8f10da3e2ba49240be4b1cd7176bff050870e Mon Sep 17 00:00:00 2001 From: Aneaire Date: Mon, 9 Mar 2026 13:03:47 +0800 Subject: [PATCH 14/14] Seed new thread drafts with last selected provider/model 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 --- apps/web/src/composerDraftStore.ts | 64 +++++++++++++++++------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 39b54ce7b..c09780e9c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -216,14 +216,17 @@ const REASONING_EFFORT_VALUES = new Set( REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, ); -function createEmptyThreadDraft(): ComposerThreadDraftState { +function createEmptyThreadDraft(lastSelected?: { + lastSelectedProvider: ProviderKind | null; + lastSelectedModel: string | null; +}): ComposerThreadDraftState { return { prompt: "", images: [], nonPersistedImageIds: [], persistedAttachments: [], - provider: null, - model: null, + provider: lastSelected?.lastSelectedProvider ?? null, + model: lastSelected?.lastSelectedModel ?? null, runtimeMode: null, interactionMode: null, effort: null, @@ -237,13 +240,18 @@ function composerImageDedupKey(image: ComposerImageAttachment): string { return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`; } -function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { +function shouldRemoveDraft( + draft: ComposerThreadDraftState, + lastSelected?: { lastSelectedProvider: ProviderKind | null; lastSelectedModel: string | null }, +): boolean { + const isDefaultProvider = draft.provider === null || draft.provider === (lastSelected?.lastSelectedProvider ?? null); + const isDefaultModel = draft.model === null || draft.model === (lastSelected?.lastSelectedModel ?? null); return ( draft.prompt.length === 0 && draft.images.length === 0 && draft.persistedAttachments.length === 0 && - draft.provider === null && - draft.model === null && + isDefaultProvider && + isDefaultModel && draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null && @@ -819,13 +827,13 @@ export const useComposerDraftStore = create()( return; } set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); + const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(state); const nextDraft: ComposerThreadDraftState = { ...existing, prompt, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -845,13 +853,13 @@ export const useComposerDraftStore = create()( ? { lastSelectedProvider: normalizedProvider } : state; } - const base = existing ?? createEmptyThreadDraft(); + const base = existing ?? createEmptyThreadDraft(state); const nextDraft: ComposerThreadDraftState = { ...base, provider: normalizedProvider, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -874,7 +882,7 @@ export const useComposerDraftStore = create()( ? { lastSelectedModel: normalizedModel } : state; } - const base = existing ?? createEmptyThreadDraft(); + const base = existing ?? createEmptyThreadDraft(state); if (base.model === normalizedModel) { return state; } @@ -883,7 +891,7 @@ export const useComposerDraftStore = create()( model: normalizedModel, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -905,7 +913,7 @@ export const useComposerDraftStore = create()( if (!existing && nextRuntimeMode === null) { return state; } - const base = existing ?? createEmptyThreadDraft(); + const base = existing ?? createEmptyThreadDraft(state); if (base.runtimeMode === nextRuntimeMode) { return state; } @@ -914,7 +922,7 @@ export const useComposerDraftStore = create()( runtimeMode: nextRuntimeMode, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -933,7 +941,7 @@ export const useComposerDraftStore = create()( if (!existing && nextInteractionMode === null) { return state; } - const base = existing ?? createEmptyThreadDraft(); + const base = existing ?? createEmptyThreadDraft(state); if (base.interactionMode === nextInteractionMode) { return state; } @@ -942,7 +950,7 @@ export const useComposerDraftStore = create()( interactionMode: nextInteractionMode, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -965,7 +973,7 @@ export const useComposerDraftStore = create()( if (!existing && nextEffort === null) { return state; } - const base = existing ?? createEmptyThreadDraft(); + const base = existing ?? createEmptyThreadDraft(state); if (base.effort === nextEffort) { return state; } @@ -974,7 +982,7 @@ export const useComposerDraftStore = create()( effort: nextEffort, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -992,7 +1000,7 @@ export const useComposerDraftStore = create()( if (!existing && nextCodexFastMode === false) { return state; } - const base = existing ?? createEmptyThreadDraft(); + const base = existing ?? createEmptyThreadDraft(state); if (base.codexFastMode === nextCodexFastMode) { return state; } @@ -1001,7 +1009,7 @@ export const useComposerDraftStore = create()( codexFastMode: nextCodexFastMode, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -1020,7 +1028,7 @@ export const useComposerDraftStore = create()( return; } set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); + const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(state); const existingIds = new Set(existing.images.map((image) => image.id)); const existingDedupKeys = new Set( existing.images.map((image) => composerImageDedupKey(image)), @@ -1081,7 +1089,7 @@ export const useComposerDraftStore = create()( ), }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -1104,7 +1112,7 @@ export const useComposerDraftStore = create()( nonPersistedImageIds: [], }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -1131,7 +1139,7 @@ export const useComposerDraftStore = create()( ), }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -1158,7 +1166,7 @@ export const useComposerDraftStore = create()( nonPersistedImageIds, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -1184,7 +1192,7 @@ export const useComposerDraftStore = create()( persistedAttachments: [], }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; @@ -1241,8 +1249,8 @@ export const useComposerDraftStore = create()( if ( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && - draft.provider === null && - draft.model === null && + (draft.provider === null || draft.provider === state.lastSelectedProvider) && + (draft.model === null || draft.model === state.lastSelectedModel) && draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null &&