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/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/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/GlmAdapter.ts b/apps/server/src/provider/Layers/GlmAdapter.ts new file mode 100644 index 000000000..12feda893 --- /dev/null +++ b/apps/server/src/provider/Layers/GlmAdapter.ts @@ -0,0 +1,1251 @@ +/** + * 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.", + }, + 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).", + }, + }, + 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 "collab_agent_tool_call"; + 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 ────────────────────────────────────────────── + +interface SubAgentProgressContext { + readonly threadId: ThreadId; + readonly turnId: TurnId; + readonly parentItemId: string; + readonly agentName: string; + readonly emit: (event: ProviderRuntimeEvent) => void; +} + +function emitSubAgentProgress( + ctx: SubAgentProgressContext, + detail: string, + depth: number, +) { + const label = ctx.agentName || (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: `[${label}] ${detail}`.slice(0, 180), + data: { agentName: ctx.agentName, depth }, + }, + } 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."; + } + + const apiKey = resolveApiKey(); + if (!apiKey) return "Error: GLM API key not configured."; + const baseUrl = resolveBaseUrl(); + + 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[] = [ + { + 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 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 }, + ]; + + // 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."; + + if (progress) { + emitSubAgentProgress(progress, `Iteration ${iteration + 1}/${MAX_SUB_AGENT_ITERATIONS} — thinking...`, depth); + } + + 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; + if (progress) { + 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", + 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; + } + + // 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 = {}; + } + + // 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, progress); + } 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 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, + 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; + } + + // 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`, { + 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, iterationItemId), + 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) { + emit({ + ...makeEventBase(session.threadId, turnId, iterationItemId), + 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; + } + + // 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) { + emit({ + ...makeEventBase(session.threadId, turnId, iterationItemId), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + } as ProviderRuntimeEvent); + } + + // 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 = {}; } + 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 ?? "") + : 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" && + (p.tc.function.name === "write_file" || + p.tc.function.name === "edit_file" || + p.tc.function.name === "run_command") + ) { + const requestId = `req-${nextEventId()}`; + const requestType = + p.tc.function.name === "run_command" + ? "exec_command_approval" + : "file_change_approval"; + + emit({ + ...makeEventBase(session.threadId, turnId, p.toolItemId), + type: "request.opened", + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + requestType, + detail: p.toolDetail, + args: p.parsedArgs, + }, + } as ProviderRuntimeEvent); + + const decision = await new Promise((resolve) => { + session.pendingApproval = { resolve, requestId }; + }); + session.pendingApproval = null; + + emit({ + ...makeEventBase(session.threadId, turnId, p.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: p.tc.id, + content: "Operation declined by user.", + }); + return; + } + } + + // Emit item.started + emit({ + ...makeEventBase(session.threadId, turnId, p.toolItemId), + type: "item.started", + payload: { + itemType: p.canonicalType, + title: p.isSpawnAgent && p.agentName ? `spawn_agent (${p.agentName})` : p.tc.function.name, + detail: p.toolDetail, + ...(agentData ? { data: agentData } : {}), + }, + } as ProviderRuntimeEvent); + + // Execute + let toolResult: string; + try { + 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: p.toolItemId, + agentName: p.agentName, + emit, + }; + toolResult = await runSubAgent(subTask, subCwd, session.model, signal, 0, progressCtx); + } else { + toolResult = await executeToolCall(p.tc.function.name, p.parsedArgs, session.cwd); + } + } catch (error) { + toolResult = `Error: ${error instanceof Error ? error.message : String(error)}`; + } + + // Emit item.completed + emit({ + ...makeEventBase(session.threadId, turnId, p.toolItemId), + type: "item.completed", + payload: { + itemType: p.canonicalType, + status: "completed", + 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: 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(); + // 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; + }), + ); +} 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,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAgKADAAQAAAABAAAAgAAAAABIjgR3AAAjOElEQVR4Ae1dCYxkV3W9tfdW3T0z3bMvPcxge2zGy2BjY0MwxEAWEBACihSCEiMUSFCiRJEsRygIiIKySUlYAkmMQSxJQAKi4AQngDcMAe87nsF4xuPxrJ6ll+rqri3n3Pfv71e/f1XP4u7+Zdeb+XXvu+/9999/57z7lv+rOiVnF1ILnNYuvV3aAsW+JJIbbe6yXRpPWyh9XtFnCsZC+ePS42ysSCv7vEq2MbwQZbQp/rSTzrjhY0puVUacPc7mF7lQepj3TBqwVd44e9QWjbMCcbawYqeR7uftBH0hUOLSo7ZonPcdZ2tnb2qrhUBg5nZ5/DRfj57XLs0qFM1j9herjAMuavPjvs428eO+Hm2vdmltwWVBrUDx7S+U7lfcL9O3d7reCgzf/kLpflv5Zfr2lgAzUxwIUZvFo9I/v12an4+6BTvH4i8WGQeEb4vTzWaSbWF6VFo7md3i/jm+LRZkZogDwLeZfq4y7lpWZlNFX0SRKDh+3PRzlWwuK8Nvunm2uMZeyGbpvjwdnRXx8/nxqM44g+V3sc79nNfwuBXfZno76ae10tlClhbVGWfw0yXrbOFnXIObrZ1kmp++UJwX9PNbBcxmcZOt7JaeVNnU2F4lfbvpcZI2Hrx/01mMxam3Cv55fp6mc/2G9XU7wWxxkjbfbvGoZFmp3t6+wtDwim25XO6KdDp9JUyXpFKpMaT143gph6lGo7EX+D5Ur9d/XKlU7jl18sRT09OlGTQKQWQw8ONkNN3icZI2C1q2D6AlmIym+XHqCx4AOr127YZduXz+9wD2r1nBXblwC4AU36jMzn7m0KED94MYdZwRB34rGy+gAMdIplloGIhmMOmDTZufz3Rfpr08KQK/bv2mX0Rv/wLsL/UejiY4pzAFr/DbB5/b/70YIrQiBi9o5DDdl9Q1GIgWp6SNwZdRnfEm0C2+es26rXD3X0KPv4iFdMML0wLwCI9hWPitI4cPPo0SCW4U/GicF16QBATSwOUJpvsyqvvAm65y46axd2ez2c+yoG5YnBaoVqsfeHb/3q+hdJ8EPvi+zkq0JUEGGXyAeQIDbb7d4gY449T1gMfPbNq89eOZTOajsHXDIrYA2votg0PDxYmJU3fAK/BKhpNdNRpvmyeOACzACjGd0sAPgadNwd809knI63mlblj8FsDwesXg4NAmkOA7AQnsoj5uZjNpaRZXaQSwRF9S949YAqDnf6wLflObLkkEJNhZHBwaOHXqxO1neEEfY4kjgA+66bHgb9y05V2ZTPajZ1iBbvYXqAXoCQaKxafHx089EVOkAR2TNGdiJoLLYGCbbqBbHsbDY3T1mq39/cV7mLkblrcFpqYmrjh65PDTqAUngNHDJoE2OWRlzdYEPhMYokSweCix0M/09Q180WXvfi53CxALYuJhF2IV2FhFs1FnYDwkgBksky/NE4S9f926Da+H+7mQJ3XD8rcAsSAmqEmIEXRiaNj5eJquFbcMGvE+LJNJy4drpTP5fOEmL29XTUALEBNig6rMw8yzRWuaIrAMdpLJOBvzptesWbcLsru9yxZKVugPsFGcUDXD0iRra7rJpiHAbscSfWkeIJ0vFH7XMnZlslogwCbECrXzMTS9qdLM7AdmYrDMviT4Baz53+aydD+T1gLEhhihXkYCHz8f27Dq0YxMiJ4UxoeGVmwLz+wqiWyBAKMQsxg8We8wPc4DMJHBMoUkwUTjlS6p+5nUFggwmoddUF+zh9X3CWDAM9F0O4H50njYc0V4Zgcq3P2o46OGLZEaJHWN0xboHXhbTVUOMFK8kGD4MY9h2qTzncBoJovPk1gD7uTZnRb40IxAF3tExlaKbMexoS8txXRKGpW0jJdSsn+iIXvG6/JMqS4lsAFJTS3WKfccYDQPO9Tft6E13O35L4UygwU/c6ij8M2WoVMkX6ZaMSDyhleIvGmHyAUrUjKMOVJ2Ni2pckZSM+gskNXptJycEnnkeF1uOTQrdxyflcmqI0Kn3CvrGWAUYkaTdzALA20kQexbwUxk8E80vePW/688PyXXvzEluzaK5Kvo8eWUVMtpqYEEqfBISQpdfmUhLW8Yyco1xYLcdawin3m2JI+XKjqldk3SEZ/EyPDyJStvcQWfBvMATPBDXDxq8/MnSufdZdCx33x1Rt73K2lZ3ScAPYVx3kGOHTO4eOjcB1Md8XRa6rDVcGQyKbluVY9sL+Tlr56ZkNtOlbXlEnWT7StjQPu5ovgx3rA5ADPaSZbRj5vuF5hYneP3L12bk+vfnpMBbI7yBWv2d2AbAk/w08ioZIC0eAaZ+KpsA3JLb1o+snlI0s+IfK+zSGB4+TKKsc4DzANEwfRPpM5g0sUS+snJ3tVX5OXd7+iVNLZESrMOfPR1RwLchjp/EIBgZxR82OAB6DUaOEgMOAElwmg+LTduGJaT1RNy7+QMiJLQG2+ultWS0j+acyHGdmGwE6K6xa0QxhMbOOHbsiUr7/z1ouT681Kq56XcwCF5mZaCHmXIciovM6kcDidn03mppLNSSWWlClnFM5UqCFHDQbmukJM/Xjck6/IZfdie2AaYq5jh5ePKVD+uuu8B7CTLGI3PFZ9AjUu93t6U/Orbh2TFuh51++zCKdjTerDnu7Gfj8zoBbLqATAR0h7fgMRyEUcWJzVwkhsKnH7JQI/8zuig/PXBE7pnkMAmiFbJwDYco3G0ytwkMHqyH/dP9O2J0kmAS68akPMuK2IdD/et4IMAwT+6OhIACz/n+nFXdPMkQY6gA/A6QeeRwYGxRHXEuYmQAineivXknRNluWt8Ws9NVAM0V+a0MfMngVaEncy46SYtT6IkwR9elZVXv2mF1LJ5mZ1FdclvDOipOiiAQ8d93A5JwJsm+DkcJEAN4JIAuYAA9XQdZKirjSQQ6CREEUz5zZEheWhqRiYx3iS6UeKxi1YZg158YMZ5meOzJsN68dVDsnLzgExV09r76QGUAPQECrsjgXoAWIwAJEEVBCD4tQB46iRAo+kAERp1uXygD/sEffLfJyeT7AXisIvaFLgoAeIyxdmSgTpqwd4/uDInF75mlcxislcB4A0M5O4g3I4AKXiDNNIyONjrs3ARFdwZ3T8m+iBBzREgVQ+J0EjXQhI4L1CTApjzjlVDcvdEqZO8gI9XE55GABqbEvwzFkiLZF3aKL8Ysf2VwzK0gWM/Zu4NzNQBdkMPTPcgObNLKwE4B4D7BwlyJAF6fh5EqOHOqwC7FoBPT0Dw695Bb5DDikAaNXlF/4Dsgie4/dQEvEC7ZlvatohcrV3FmMZDN4Ii5zVF2xXSlHFZIuj9Pf1Z2X7ViHApNwv3X6uDADgaPAISsPenQAybB2SDeUAu8AIVEKFgHgBA16HX01UlgPMC9AQ8qlpmIZuRN64Ylh9OTIFwqESyQ1sMzQP4t9DqhFZ2/9wl1eto/LUvL8qKrcMyXc1JFSBXSQDwug5dSYA4pvUggPMCpAEHhixw483ncQBqDAHuqCkRHAEIOL2AAg8de8QoCxJe4NLikGztOSa7S9O6u7ikN77wxVphNc8eRwArfl5mS0iKTGM83nL5qNTzPTJbceBX61l4ARAARyMgAR2d8wIAHzYlAO4uhxvB86E58NH7awC6RtefIvjuUCJkKiiPcQ4vVRnExOHVg8NKgKS0R5t6tMSyFQH8E3y9zTWWNoljf3GkV1ZfOCLlWk4q6OnVOrxAQIAavQBJQA8AmYJMYS5AAnA+QA9AAjgSzPV+Hfc98EmCRprgo6kCEmD6CB9Sk1cNr5JvHjsqE7Va2wnU0rZM09V87Hw9zNSKAGGGxCrY9l19/grJrxqUchXbtyRBDRIkUA+AeA06vQAJICBAWsGn5CogpTByHoB+7SaAAJXjP72AAx5SQac3qbihBXEdXuAFNvUPy/a+otw3fiLJk8G2EHYsAdJYv63ZuQbuG+4f4FYANg/1AAC/Dt0Ogu/mAZj+2VwAm0M5EAH9G7DzIAkcAZzrB9AZEADAqwcg8KCMDiuYC9AL9KQysmt4RB6YONm2kZOc2JEEoPvvH+mToW2j6P05EAAPcvDgh16ARw06wVcPwGGAc4DAC3A1kCEJ1Im7/QDuBHIJqO4fE7xGPfAA9TnwG41ZLaeRQXnYccA+IbaHq3Lh0GoZPLhPxqscFjovdCQBgICs3DYimaEhmali8wegV2oggHqAvAJfBwlqsDd4EHydCHIe4OYA3AvgW0EcAgA9/mHvn74AXkAnfXDxbuxnr8ehwNMLgAiYRmLzGEdFRvtXyZb+IXn45FEQovMo0JEESOPB/Yod65z7x7iv4JMAetALYHsHh/MAJACmeuYBgk0h0EBXA5hK6BBQhwdo0AtwAqhDQUAAHSQIugOcPZ86iVDHTnoPHh/vGF6rBOi8/u+WwqdT7+RQG+4/P9wrA2NrZAYgzwJc5/4dAWoEHkedB7wAwacH4KErAbh/Lgk5kbNXwV3vhydg71ciYK2PiaA0uEagB8D50On6HREQB/iN2izeKMvJthUbpe/ZJ2W6lqhh4LQw6zgPAPxlYNMqyQyvxNqf7p9HQcf9qrp99nwHfl3dvyOAbnoCdM4BHAG4JMRzg+Af0MbmKPyBEgDPk7n8094PoINe38Ckj+6fPb8Bz6MS84SR4lq8dzgke8c7bxjoOAJwmB3cvh6Pffvx2JfLP/Z8EMCkjf1KAuf+dQ4AwNxKwBEAT/gBJjZ19B+SMBFs4GkfPYBgI0gwERQMBw24eDpK3QeAVD0FTxCSYFZ6erOyZXg9CHAE6afV8ZAvGaGzCIBOmuktSN/YRpnVnu+7/YKSga5fx35IrgTU/Sv4uFVMAFOcDBJ44FxXD6B9H2gEHoAJGOEFAOswAC/gRkrECToPkILunzo3m1KYMI6t2ip3P/soyuT5nRM6igBc/hXwQkZ2dI1UAvdfC3q/Ss/1O/CdB1D3H+wFcB3PIYAeAM/22PUDH8AIgceBLWEgizj2D9QDkARw/xwCeKAsmwPUmY5VwujKLfjmUVFOlk521GqgowjAXtq3Zb00eoYC188JXwETPjcHcJM/BzoJIOoBnBeg+0fXxUECuLcECD5dNocBGKHbEZBAz5kDX8tQz4Bmg1dREtTx2jG8Ss/AWlkztE5OlE6gxM4ZBjqKACnswPWMjeGpXw8mftjoUXdfQI80InD3j0tAgMNZu5KAOm5TPQAJwN7vXg6xl7roCbgSwBpANZIhRW+gPoJeACRQ4kDHqoC67gVwdQCPwKePPXgcvX5kuzx5MO4X23BaQkPnEADuPzuI173Xb5JKFT0eO4AhAXS8tzEfJODyLRj/2Tv1gZD2ZrcEZK/n+M9nAoSZcbAEn84D0Ko6wOUugQ4WWCJiqxD8oSfB0pBDATeLVLIZa7J69HzJ5/4X9eNP/XdG6BgCcPzPr10jqcFRqVYIPlw/x3yAz00ft9530sDXnq/AEzQCBgAD9083TcgZ1BNwCMA1aKpxIse5AIcMfIGE3x5SPXgjSL0Iy9U5AsrlnAAEGFgxJkMDq+XoiX3uHC092R8dQwBusxY2b4UnL0oNBAg3ehT8ZgLosi90+wSeBAhAJPAggev1BMeRwPV9bguTBG5QcJ4AVGH+gAicQGpZBF9JBRl4g1zfiIyMbJMjJ/YGpSYbfNaucwiQz0tu03Zd83Pp5cZ6fPdLXb0RwI374SPgoNcTKAcagAzBdw4f6ClKJEA9mAhS0CvUAiI4b0ALwFdvwIkk5gWBTk/AnUXBN41G1u6UzFN3gGQsMfmhMwgAl5xZsUrSqzbq2K87fOj5c+AHOsd7r+eHwIe91iNApI86uBwpnBfAAMF5ATwPHxo5SnDGQMJwiKAksbhjyIkldNiHR3dIT8+wlErH9VwYEx06hAB4c2f9mDR6V8H1Y4IXuH28C4bxNxj3FXjcDryDAR91/WHvByQc/wmhAW8a4/yauObQ1QBzOiJU9Qyk6fyAebhzSHJw/QAigKj5oY0yiLlAqXQMdpIi2aEzCIDlX3bzBWjuXl3iEXg73ESPM/1m8I0EoetX8AIXDj3AWK0+RJwFMPBbxkqRYFhwBKFl7p8NDcxJLyBYKeQL+ILKuovl0IH7/GITqyefAHDD6f5Byazd7nq/ru2dy2dv53rfPeq1nu9m+44A7KXsocFRLUl98oDUTu2TRukwVnPTSMPInu/HNdbg/YIxyQ6sx44vfkwIkOpQoDmcR1CvwDjZE8wPOECwDJKBqwyqw+suk2zu3zFcufKRIbGhIwiQWb0Zyz/sANL9KwFMBqCHbt8Dn0go8LDBHVf2fVdmf/4/Ujv+pNTLeIULT/GAmAOGYGZyku5ZKbnRi6Sw7ZelZ/PrJYPezMlcSAQU6eYDPM8NC+ol1GtwOODcoCE9q14ufYMbZPz5PYmfBySfAGj0zKYLsZ8ziNZHdZUABD44FHwAaBM9zsYJPiX28yv7bpXyg/8s1aOP4XyATrDV/yOPSscBptUnD8nMxHMy88ydUl53uQzs+qAUNrwaJLDJIUvGeQq+I4FG+WFlYh6Q7RuVIawGxo/t5gmJDmiNJAf0skK/ZDbu1Mne3AOe6JgPsJuWeojD/ZYf+EeZuu1GqR5+EOnondzF80GP3jrTmAePgmf33y0nv/tHMvXYVzAf4I4fZ/puZ0B/Swg6vUENE70q0qqQFayq9UcmMgUZ2vgqPEfCUJXwkGwPwPEfS78UHrU2agSGHsD1fL7dwzGXL3gQfKeTz4gD/Ol7/l7Kj3wJ+TFG65buGSIBItQxdEz86C8xV5iS4sXvAznYXJwkslvz0JkiJK8bvFkQEKxv7SVSwAOi6VP7wbnk9rPk1gxNypDefLFIYQU6MIAF+G6TB7oCTzL44ON28FZv+aGbHPjQ2/Z4d4nWn+z1mChO3vtpmXry6wq9rvkxJJAGulkEIlTpCZCXB3V+QzmDyeQAhgFUuHX5CUhJMAHQcPk+yWzehTZ0j3RtyacPeJQAbm8/7JEAYPZnt4AAn3c9X3vpObYyejQ9wAQ8ysyBu7FH4IAPhwNcg4+L+BN0KnVYABkyvTK4+Ro4H3qN5IbkEkDd/2ZJj5yHyRt7NhpSvQB7P6vtHbr8wncCj/1Upu/9lDRm8ZOf7cb6M8UDxKpPHZHxH/+tVDFJVBKAn+YF2Mf5+4LcKCIJKPkWQR8mknkMAzr/ONNrLlH+5BIADZDZgh8nh/t3z/Kdu3eun8MBScGDYzE8AUAv3/8ZqZ/aC/AX4bYwj6gcfkgmH/oXLC648xesDIC+9v7QA5hHwIOWwU0ysOEKeDBSJJlhEVrqhbhRNC9er0pvuRKF5TDRI/gEO+j90PXBTAA+CTD71C0yu/f7AB95Fi2kpPTkN2Vm/52BFwAJQk8ATpIEJARqp6+cYzVQ3Pp6zB35NxySGZJJAP4A05oLAvePHk7A6f6VBKwyx35K9n7M1sf3yczDX0DL4/WsxQycD8yckknsK9SmTzQNAdw65mHg61AAQy+GgcKKrag7B4zkhWQSAMut7PbXSipXDJd5oevn2I+ephsySoiazGCtXju+Z3FcfxQzeJjZg/fK9O5vcBHYTALGSYTAA/DXQ9J9a/AllmujpSQmnjwCoKek8I59evNV6PUEmwfdOt0+e3xw0P0DjNrh+2V2938E9iVqV8wBph77qlRPPqUk4DAQzglQBSOBeYSBl70Jr7PjjxQkcC6QPAKgATNbr5F0cQMaDJADfL6Fo2/lkASh7pZnZbj+xvTzIAOJsUQBk8zayb0gwZd1QqirAfZ8EkE9gEcCGHOrdkjfxiuRlrxhIFkEQOul+lZK9rzr0M85+QP4dPnB4UgQeAD0/uq+70kF+/aLMutfkEspKe/+T6kcuiccCugFQjLgfJ0QkhScDJ73Njx1xO/Wa+4FC1+yDMkiAJovs/VqyYxcoI/abdLn3uNnVekRnOtvTOHBzSNf0G1f2pc8wOPUy8dlCruOdSxBreeHQ4F5AlSMP2ZVWH8lJoTc1EqWF0gOAdj7e1dIbsdb0aHxPN5cv3kAAs+DYCPvzE+/LtUjjyK6mMu+BWjFCeH+H0j557do1ZQEBB71jHoCKQxKcce7sCTkuwbJCckhAHv/y16Hv02L/XO0Hl2/ewM36PVoVP7Tid/Rh2X28X8DEZa/N/E7gqWHb5bqqWcc6MAWHABH5zaKlBioas+ma6VnA/Y2+IwiISEZBGDvH1gjuZ3vxFyuJ5josWoBCSgJPr3B7ITMPPhP2Jo9BDIkoPqoQ/X53VJ65PPgI18QDQgAqZ6AHkF1PC0sFKW48734QxZ4tyEhc4EEtCDaAuNp7qK3Y+zfAfAR1aUfQWf1Ag9AAiDf7J5vyuy+26Avo+tHrZoC6lXGDuEs3jriKOWWgUYEDAdGAjChsOEa6cMbRzppaCpkeSKnSwDcwiIFuMMMXqLMXYTejy3fOfDRkmxN7fkEH2v+Iw/j7Z6bML3Gmz2JCnD3s5Mydf+n8b7hM24+gPqx0bT34x7c/ABeAN8hHLjkfZJd/N3B08LsdAmwOM1N148NkvwV79cdM53hc52vPd/r/XCzXOuX7/07qU88CzIsb7VjG4NDwdHHpXT/J/FC0XQwD3A5jQgqcc+Z4e1SvOyD2OnsRYbTwin2ki+EcRlbEjeOt27yl71Hshuvcl3FwNchwFw/qogeX37wc1LBa1qJcv1RBDgU7Pm2lB//ivZ4QsueT8n5gHoB1TEh3P5W6cOqYLnD8hEArZK74C2Sf8VvoMc71689X0lg4HMIwLzvp1/DrP9fobEpkxxQX5C1dP9nZWbvrXNDgZEgGAr08TCHgl2/L4XNr8NtcaBYnrA8BMANZ7HkK1z5IX3gw2erDnxKNKKN/Rj3K09/By95/EP4Dv/yNNMZXBVeoFE+IaUffUIqB38yRwIUQfpyo4gPi7g5lOoZkcGrP4xvPe9CwvIsDZeeAAR/6y9Iz2tv1HGfs6S5no/qeOBX998h0z/8BMb/48kc91vxAvOB2vh+mbzrz/ASyQO8QQ2OAEYEDgt4Wji0VYq/8DHJrca7j8vgCVoRgHW14OtmOzuJG8ysu0R6XnODpAfwsIfg63hv63y2FA5OqPZ9X0p3fQTv6h/U+NldcBnPIgmO75bJO/4Uzwvui/UEOifAR2blDim+9qOYHI690CTwsfP1sGFaEYAZYk8IzzxThS4PD3p6rvwDSQ+O6V6/gm+bPNrzWR38AOueb0npzg/jRY+EzvhP9965dMU3kSZvvwEPre7Qs6xRKTkc6EESjF4ifZe8H49CF+W7BHbZeTWPI0CrzK3s8wqNN+Bv7oxdK9m1l4cPetyIjx5P8PnjS3ioMvPA56T0g4/rS5iJXO7F31xrK0lw8mklwcwTX8VQj5+XQW72fpO2Y5gfuw77A9uQcM6TwlZYzbOj1duGeSe0zd0uES4xg+/dSQYPQ/QPLBjwcP/46RW+0VO+71P4/t6tGBr4Pn8cN9tdIMFpuJd66ahM/fAvpAqP0HvpBzAErlMS6JePrer5QcyLRkGAx3H/Zjxn2RZDIwAztctoaSXk40PtMw+gfO3oE9rLU1m86sU7xMy3Pn0MW6jfl5mHbpbaiacc8JhJv+gCSNColaX86FewYfSI9Fx8PX7z4BrsDQ+D6xgKsF9cPXQvCMLvE54T+YkRg2HmYs2fId5GAEuOOym0Yf26H1+NPt8yn5HETVV2fxvf1D2OYeAy3CR+5mViP276AQD/M7e9e243fkbVWZ7MIDb+V/l6+W03SHblyyWDt4VS+SJWOvgG83M/xtB3GHnOvgMQI+/eQuxa2aIEsHwhQ8wA2ajX649mMpmzIwALwKPTyl68xcPXtxk4EPJmCfyLHnx3y/rJe0VbVPFsg2Rwwdrh7MFnOcQIIgp8HJ56WfqauMya6KVpnlqter8lnLXEpCgEXL+te07u7qyrkYgT2Rb8wqkebJdzA5/35GFkuJpksq9r/HRa305qzJTL+J51NyS5BQKMQswWqqtPAJ7knxiNy/g41jPdkOgW8DBqh6Wl6XNX3lBoiOiWxvRGBQHfi/svGrsheS1AbIgRaqZ4BdKv6DycfQ8QzRgtROPTpdLNfsaunpwWCLAx3Fgx003OqywJwEQGyxQXD9OOHcPUVcTWmnpi9yMRLVAKsAmxQq18nZWcFzcPwAQ/xMX1ZKwz69PTpT/0M3f15W8BYkJsUJMoyFa5OEzDOYCfyTL6BTXpRw4fvAsXw5ZVNyShBYgFMUFdmnCKxFlVS6euwTwAI0y0YBmjUhlGpp06eeJDlrkrl7cFiIXX+6NewMfQKhpijd2H0AvYLgTlQke6XJ6e6O3t25/NZt9opXbl0rcA1v03HDt2+Ce4MoHna0WUcUeUCBo3AkTBRxlKDCMC49TpMcyWmpwc3zNQHCym0+lLmaEblrYFqtXqF5878MxNuGoUcCMCQWaaSVbQiECdP6rXBCptIcCBHmezPOmJiVP/VywOrQcJLmDGbliaFqjVat868Oy+P8fVCLABHiWCgW+gmwwrSQIQTAumG8BRyXxmM28gE+Onbh8YKPan05muJ7CWXERZrVa+SPAx7vvA+7r1+DgCsGZGhOCnN+ZAZWIrEliaEcDy0Y5t4lM/6untxZwgd50auh+L0gKYe9343IH9N6NwAm5Hq54fJQDrRPDDEB0CfFCjQFua2VmI6SonJyf2gJW39PT0vgrvDawKr9JVzrkF0K57Tp44/t7njx2xCZ8Put/7DXSTYW9HJZrAZ6UIHEnA4INp7p0yejC/ESdWB/iZ0dE11/T1D/wN8p7dG0Q4sRu0BUqlqck/OXr08N2ey/d7fivdJ4iRwaSRQkE3sH0CmG7gG1Es7hOAttg4iJBeuXJkJ4jwHrxI8uYuoKffApjk3Qrgv3z8+LFHALyBaT19Icn8zEOg7VwDvUkSWCMA1CYv4KcZ8CYN8DjwfRt1LQf7BXksGbdgeLgY84RLM5n0DvADXw7A34F5aYdp4HugVqs/gcndgxjjH56cGN+HJR5/9NB6rIFowBvAvozqdo6RoAn4oMnDl/EZJ1A8GHzwTTfw46RPCJ8A/rmmU/qHXc+XUZ3xVoFlLWdgw55O8POZ7kvqdhA86lEQfQK0AtzOMRktk3U1m/7dQEbYiJQM1qAWp406C2wXmMc/h/lJBjuXOm0++KbDHF7Xru/bqHdy8NvFdF9Sjx5sK9oofT1KAkuPymh5KEaD2Rnhj/DOC8wQDSzcAIymxcUtv12MoJrNQDfJ8w30qIwr28/fKn257HFt59fF0uOktRUl28ri1O2wNIu3kpbPyvClXx/1ADQwAxvfpNkoLTCNgZIXtsB49LCyLC/JQxvPo4weMKnNl9QtMH8nBt5/NJjNl9TjDrYX7Qa0r8fZ4sowm9WDcQaV7TwAG90y8wRe0LyAD4jpdiFKO9d0O5d2/0B0HvBWHtMYonFn7ZxPvw1Za4vHSdrsYJv5usUpfZ15fJvF7VxKBou7WPDpE4AZ/Mb241YIL0QSMFBnsHNMMi/zUFp+plGnjB4wzSuDNgYr08U699Paz+7A4r6kHnew3cxueivJfJZG3YKv0xbGDQzLSGmN7suozri5dep+vJXd8pm0azFueqCGdbB4K2nntkpfKnvYoAtc0M9nOmVUNxulD6gfb2W3c1mVqG42Sg0+GGajtIb1ZVS3cymjoFtanN3KtzwWp2SgPRribNE8SYwTgGjwbaYbUHFx2qJgW/5Wdl7T8pjuS+oaOAQwY7SBzeZLd0b8JytigPrSzvdtLMHipvuSOgPzvJgC28IPFvdlVGf8bA5exy/Lj1O3oBtBFolrcLPFSdp8u8WjkuX7Nj9uOiWDledi7jPO5qcnXTcg/Hr6NtMpo3pc3PKZZLmm+/nN7kvqFjRvtHGjcWY2WzvJND99oXhcub6Nuh+sbN/WCboBEq2rbzc9TtLm2xeK8zp+fj9O3YLlCUGzBMq4xvZtpvvydHS/bD+/XdtsFvfz+7ZO1MMG9yrv20xvJ/20VjqLt7Sobpf202PBZsaFwLD0c5Vx17IyrcIvNtkEAG7Oj5t+rpJtZmX47TfP9v9tVpxWeBtrbgAAAABJRU5ErkJggg=="; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e30801041..55df1a208 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -40,6 +40,168 @@ 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 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); + 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 */} + + + {/* Task description — always visible below header */} + {taskDescription && ( +
+

+ {taskDescription} +

+
+ )} + + {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; @@ -295,40 +457,76 @@ 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) => ( + + ))} +
)} -
- {visibleEntries.map((workEntry) => ( - - ))} -
+ + {/* Agent panels — each agent gets its own fixed-height scrollable panel */} + {Array.from(agentGroups.entries()).map(([agentKey, agentEntries]) => ( + + ))}
); })()} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..c09780e9c 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[] = []; @@ -210,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, @@ -231,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 && @@ -246,7 +260,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 { @@ -386,7 +400,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 +474,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, + lastSelectedProvider: normalizeProviderKind(candidate.lastSelectedProvider), + lastSelectedModel: typeof candidate.lastSelectedModel === "string" ? candidate.lastSelectedModel : null, }; } @@ -563,6 +585,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + lastSelectedProvider: null, + lastSelectedModel: null, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -803,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; @@ -825,23 +849,25 @@ export const useComposerDraftStore = create()( set((state) => { const existing = state.draftsByThreadId[threadId]; if (!existing && normalizedProvider === null) { - return state; - } - const base = existing ?? createEmptyThreadDraft(); - if (base.provider === normalizedProvider) { - return state; + return normalizedProvider !== state.lastSelectedProvider + ? { lastSelectedProvider: normalizedProvider } + : state; } + 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; } - return { draftsByThreadId: nextDraftsByThreadId }; + return { + draftsByThreadId: nextDraftsByThreadId, + ...(normalizedProvider !== null ? { lastSelectedProvider: normalizedProvider } : {}), + }; }); }, setModel: (threadId, model) => { @@ -852,9 +878,11 @@ 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(); + const base = existing ?? createEmptyThreadDraft(state); if (base.model === normalizedModel) { return state; } @@ -863,12 +891,15 @@ export const useComposerDraftStore = create()( model: normalizedModel, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { + if (shouldRemoveDraft(nextDraft, state)) { delete nextDraftsByThreadId[threadId]; } else { nextDraftsByThreadId[threadId] = nextDraft; } - return { draftsByThreadId: nextDraftsByThreadId }; + return { + draftsByThreadId: nextDraftsByThreadId, + ...(normalizedModel !== null ? { lastSelectedModel: normalizedModel } : {}), + }; }); }, setRuntimeMode: (threadId, runtimeMode) => { @@ -882,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; } @@ -891,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; @@ -910,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; } @@ -919,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; @@ -942,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; } @@ -951,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; @@ -969,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; } @@ -978,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; @@ -997,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)), @@ -1058,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; @@ -1081,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; @@ -1108,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; @@ -1135,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; @@ -1161,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; @@ -1218,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 && @@ -1255,6 +1286,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, + lastSelectedProvider: state.lastSelectedProvider, + lastSelectedModel: state.lastSelectedModel, }; }, merge: (persistedState, currentState) => { @@ -1265,19 +1298,39 @@ 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, }; }, }, ), ); +/** 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) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); + return useComposerDraftStore((state) => { + const draft = state.draftsByThreadId[threadId]; + if (draft) return draft; + return getLastSelectedFallback(state.lastSelectedProvider, state.lastSelectedModel); + }); } /** 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 e389f10e2..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 }, ]; @@ -41,6 +41,11 @@ export interface WorkLogEntry { toolTitle?: string; 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 { @@ -450,6 +455,21 @@ 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; + } + 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; }); } 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 };