diff --git a/README.md b/README.md index 1e37fb781..4ec36c19b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents. Currently Codex-first, with Claude Code support coming soon. +T3 Code is a minimal web GUI for coding agents, with support for Codex and Claude Code. ## How to use > [!WARNING] -> You need to have [Codex CLI](https://github.com/openai/codex) installed and authorized for T3 Code to work. +> Install and authorize the provider you plan to use before starting T3 Code. +> Codex users need [Codex CLI](https://github.com/openai/codex). +> Claude users need Claude Code set up through Claude's native login or API-key flow. ```bash npx t3 diff --git a/apps/server/src/claudeCodeServerManager.test.ts b/apps/server/src/claudeCodeServerManager.test.ts new file mode 100644 index 000000000..11e8eac7f --- /dev/null +++ b/apps/server/src/claudeCodeServerManager.test.ts @@ -0,0 +1,442 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; + +import { ThreadId, type ProviderEvent } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), +})); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual( + "node:child_process", + ); + return { + ...actual, + spawn: spawnMock, + }; +}); + +import { ClaudeCodeServerManager } from "./claudeCodeServerManager.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); + +type FakeChildProcess = EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType; +}; + +function createFakeChildProcess(): FakeChildProcess { + const child = new EventEmitter() as FakeChildProcess; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + return child; +} + +function writeNativeLine(child: FakeChildProcess, payload: unknown): void { + child.stdout.write(`${JSON.stringify(payload)}\n`); +} + +function closeChild(child: FakeChildProcess, code = 0, signal: NodeJS.Signals | null = null): void { + child.stdout.end(); + child.stderr.end(); + queueMicrotask(() => { + child.emit("close", code, signal); + }); +} + +describe("ClaudeCodeServerManager", () => { + afterEach(() => { + spawnMock.mockReset(); + }); + + it("preserves exact whitespace in assistant and reasoning deltas while ignoring noop stream families", async () => { + const child = createFakeChildProcess(); + spawnMock.mockReturnValueOnce(child); + + const manager = new ClaudeCodeServerManager(); + const events: ProviderEvent[] = []; + manager.on("event", (event: ProviderEvent) => { + events.push(event); + }); + + const threadId = asThreadId("thread-whitespace"); + await manager.startSession({ + threadId, + runtimeMode: "full-access", + }); + await manager.sendTurn({ + threadId, + input: "Preserve spacing", + }); + + writeNativeLine(child, { + type: "stream_event", + event: { type: "message_start", message: { id: "msg_1" } }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { type: "content_block_start", index: 0, content_block: { type: "text" } }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: " let" }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { type: "content_block_start", index: 1, content_block: { type: "thinking" } }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_delta", + index: 1, + delta: { type: "thinking_delta", thinking: " check" }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { type: "message_delta", delta: { stop_reason: null } }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_delta", + index: 1, + delta: { type: "signature_delta", signature: "sig_1" }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { type: "ping" }, + }); + writeNativeLine(child, { + type: "result", + subtype: "success", + session_id: "sess_whitespace", + }); + closeChild(child); + + await vi.waitFor(() => { + expect(events.some((event) => event.method === "turn/completed")).toBe(true); + }); + + expect( + events + .filter((event) => event.method === "turn/content-delta") + .map((event) => event.payload), + ).toEqual([ + { + streamKind: "assistant_text", + delta: " let", + }, + { + streamKind: "reasoning_text", + delta: " check", + }, + ]); + expect(events.some((event) => event.method === "runtime/error")).toBe(false); + + manager.stopAll(); + }); + + it("accumulates tool input json across mixed Claude delta sequences", async () => { + const child = createFakeChildProcess(); + spawnMock.mockReturnValueOnce(child); + + const manager = new ClaudeCodeServerManager(); + const events: ProviderEvent[] = []; + manager.on("event", (event: ProviderEvent) => { + events.push(event); + }); + + const threadId = asThreadId("thread-tool-json"); + await manager.startSession({ + threadId, + runtimeMode: "full-access", + }); + await manager.sendTurn({ + threadId, + input: "Run the tool", + }); + + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "toolu_1", + name: "Bash", + input: {}, + }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"command":"echo' }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { type: "ping" }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: ' hi"}' }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { type: "content_block_stop", index: 0 }, + }); + writeNativeLine(child, { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "toolu_1", + content: "ok", + }, + ], + }, + }); + writeNativeLine(child, { + type: "result", + subtype: "success", + session_id: "sess_tool_json", + }); + closeChild(child); + + await vi.waitFor(() => { + expect(events.some((event) => event.method === "item/tool/completed")).toBe(true); + }); + + const started = events.find((event) => event.method === "item/tool/started"); + const completed = events.find((event) => event.method === "item/tool/completed"); + + expect(started?.payload).toEqual({ + item: { + type: "tool_use", + toolName: "Bash", + input: { + command: "echo hi", + }, + summary: "echo hi", + }, + }); + expect(completed?.payload).toEqual({ + item: { + type: "tool_use", + toolName: "Bash", + status: "completed", + input: { + command: "echo hi", + }, + result: "ok", + summary: "ok", + }, + }); + + manager.stopAll(); + }); + + it("surfaces server tool use and web search results while safely ignoring unknown rich-content families", async () => { + const child = createFakeChildProcess(); + spawnMock.mockReturnValueOnce(child); + + const manager = new ClaudeCodeServerManager(); + const events: ProviderEvent[] = []; + manager.on("event", (event: ProviderEvent) => { + events.push(event); + }); + + const threadId = asThreadId("thread-web-search"); + await manager.startSession({ + threadId, + runtimeMode: "full-access", + }); + await manager.sendTurn({ + threadId, + input: "Search the web", + }); + + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "server_tool_use", + id: "srvtoolu_1", + name: "web_search", + input: {}, + }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"weather nyc"}' }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { type: "content_block_stop", index: 0 }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "future_block", + }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { type: "content_block_stop", index: 1 }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { + type: "content_block_start", + index: 2, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_1", + content: [ + { + type: "web_search_result", + title: "Weather in NYC - Example", + url: "https://example.com/weather", + encrypted_content: "enc_1", + }, + ], + }, + }, + }); + writeNativeLine(child, { + type: "stream_event", + event: { type: "content_block_stop", index: 2 }, + }); + writeNativeLine(child, { + type: "result", + subtype: "success", + session_id: "sess_web_search", + }); + closeChild(child); + + await vi.waitFor(() => { + expect(events.some((event) => event.method === "item/tool/completed")).toBe(true); + }); + + const updated = events.find((event) => event.method === "item/tool/updated"); + const completed = events.find((event) => event.method === "item/tool/completed"); + + expect( + events.filter((event) => event.method.startsWith("item/tool/")).map((event) => event.method), + ).toEqual(["item/tool/updated", "item/tool/completed"]); + expect(updated?.payload).toEqual({ + item: { + type: "server_tool_use", + toolName: "web_search", + input: { + query: "weather nyc", + }, + summary: "weather nyc", + }, + }); + expect(completed?.payload).toEqual({ + item: { + type: "web_search_tool_result", + toolName: "web_search", + status: "completed", + input: { + query: "weather nyc", + }, + result: [ + { + type: "web_search_result", + title: "Weather in NYC - Example", + url: "https://example.com/weather", + encrypted_content: "enc_1", + }, + ], + summary: "Weather in NYC - Example", + }, + }); + expect(events.some((event) => event.method === "runtime/error")).toBe(false); + + manager.stopAll(); + }); + + it("surfaces Claude stream error events immediately as runtime errors", async () => { + const child = createFakeChildProcess(); + spawnMock.mockReturnValueOnce(child); + + const manager = new ClaudeCodeServerManager(); + const events: ProviderEvent[] = []; + manager.on("event", (event: ProviderEvent) => { + events.push(event); + }); + + const threadId = asThreadId("thread-stream-error"); + await manager.startSession({ + threadId, + runtimeMode: "full-access", + }); + await manager.sendTurn({ + threadId, + input: "Trigger an error", + }); + + writeNativeLine(child, { + type: "stream_event", + event: { + type: "error", + error: { + type: "overloaded_error", + message: "Claude stream overloaded", + }, + }, + }); + closeChild(child, 1); + + await vi.waitFor(() => { + expect(events.some((event) => event.method === "runtime/error")).toBe(true); + }); + + expect( + events.find((event) => event.method === "runtime/error"), + ).toMatchObject({ + kind: "error", + method: "runtime/error", + message: "Claude stream overloaded", + payload: { + class: "provider_error", + nativeType: "overloaded_error", + }, + }); + + manager.stopAll(); + }); +}); \ No newline at end of file diff --git a/apps/server/src/claudeCodeServerManager.ts b/apps/server/src/claudeCodeServerManager.ts new file mode 100644 index 000000000..1163dc56c --- /dev/null +++ b/apps/server/src/claudeCodeServerManager.ts @@ -0,0 +1,1017 @@ +import { type ChildProcessByStdio, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { EventEmitter } from "node:events"; +import readline from "node:readline"; +import type { Readable } from "node:stream"; + +import { + EventId, + ProviderItemId, + type ProviderApprovalDecision, + type ProviderEvent, + type ProviderInteractionMode, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, + RuntimeMode, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +type ClaudePermissionMode = "acceptEdits" | "auto" | "bypassPermissions" | "default" | "dontAsk" | "plan"; + +interface ClaudeCodeProviderOptionsShape { + readonly binaryPath?: string; + readonly homePath?: string; +} + +interface ClaudeToolUseState { + readonly id: string; + readonly name: string; + readonly type: "tool_use" | "server_tool_use"; + partialJson: string; + input?: unknown; +} + +interface ClaudeTurnRuntimeState { + readonly turnId: TurnId; + readonly events: ProviderEvent[]; + readonly toolUsesById: Map; + readonly toolUsesByIndex: Map; + resultSeen: boolean; + interrupted: boolean; +} + +interface ClaudeSessionContext { + session: ProviderSession; + readonly binaryPath: string; + readonly homePath?: string; + useResumeOnNextTurn: boolean; + activeChild: ClaudeCodeChildProcess | undefined; + activeOutput: readline.Interface | undefined; + activeTurn: ClaudeTurnRuntimeState | undefined; + turns: ClaudeCodeThreadTurnSnapshot[]; +} + +type ClaudeCodeChildProcess = ChildProcessByStdio; + +export interface ClaudeCodeServerStartSessionInput { + readonly threadId: ThreadId; + readonly provider?: "claudeCode"; + readonly cwd?: string; + readonly model?: string; + readonly resumeCursor?: unknown; + readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; + readonly runtimeMode: RuntimeMode; +} + +export interface ClaudeCodeServerSendTurnInput { + readonly threadId: ThreadId; + readonly input?: string; + readonly attachments?: ProviderSendTurnInput["attachments"]; + readonly model?: string; + readonly interactionMode?: ProviderInteractionMode; +} + +export interface ClaudeCodeThreadTurnSnapshot { + readonly id: TurnId; + readonly items: ReadonlyArray; +} + +export interface ClaudeCodeThreadSnapshot { + readonly threadId: ThreadId; + readonly turns: ReadonlyArray; +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function nonEmptyTrimmed(value: string | undefined | null): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function nonEmptyString(value: string | undefined | null): string | undefined { + if (typeof value !== "string") { + return undefined; + } + return value.length > 0 ? value : undefined; +} + +function normalizeClaudeModelSlug(model: string | undefined | null): string | undefined { + const normalized = normalizeModelSlug(model, "claudeCode"); + return normalized ?? nonEmptyTrimmed(model); +} + +function readClaudeCodeProviderOptions( + providerOptions: ProviderSessionStartInput["providerOptions"] | undefined, +): ClaudeCodeProviderOptionsShape { + const candidate = asObject(asObject(providerOptions)?.claudeCode); + const binaryPath = nonEmptyTrimmed(asString(candidate?.binaryPath)); + const homePath = nonEmptyTrimmed(asString(candidate?.homePath)); + return { + ...(binaryPath ? { binaryPath } : {}), + ...(homePath ? { homePath } : {}), + }; +} + +export function toClaudePermissionMode( + runtimeMode: RuntimeMode, + interactionMode?: ProviderInteractionMode, +): ClaudePermissionMode { + if (interactionMode === "plan") { + return "plan"; + } + return runtimeMode === "full-access" ? "bypassPermissions" : "default"; +} + +function parsePartialJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +function toolInputSummary(input: unknown): string | undefined { + const record = asObject(input); + const command = nonEmptyTrimmed(asString(record?.command)); + if (command) { + return command; + } + const description = nonEmptyTrimmed(asString(record?.description)); + if (description) { + return description; + } + const prompt = nonEmptyTrimmed(asString(record?.prompt)); + if (prompt) { + return prompt; + } + const question = nonEmptyTrimmed(asString(record?.question)); + if (question) { + return question; + } + const query = nonEmptyTrimmed(asString(record?.query)); + if (query) { + return query; + } + return undefined; +} + +function resultSummary(result: unknown): string | undefined { + const trimmed = nonEmptyTrimmed(asString(result)); + if (trimmed) { + return trimmed; + } + const entries = asArray(result); + if (entries && entries.length > 0) { + const first = asObject(entries[0]); + return ( + nonEmptyTrimmed(asString(first?.title)) ?? + nonEmptyTrimmed(asString(first?.url)) ?? + `Search returned ${entries.length} result${entries.length === 1 ? "" : "s"}` + ); + } + const record = asObject(result); + return ( + nonEmptyTrimmed(asString(record?.error_code)) ?? + nonEmptyTrimmed(asString(record?.stdout)) ?? + nonEmptyTrimmed(asString(record?.stderr)) ?? + nonEmptyTrimmed(asString(record?.output)) ?? + undefined + ); +} + +function isWebSearchToolResultError(result: unknown): boolean { + const record = asObject(result); + return asString(record?.type) === "web_search_tool_result_error"; +} + +export class ClaudeCodeServerManager extends EventEmitter { + readonly #sessions = new Map(); + + #emitEvent(context: ClaudeSessionContext, event: ProviderEvent): void { + if (context.activeTurn && event.turnId === context.activeTurn.turnId) { + context.activeTurn.events.push(event); + } + this.emit("event", event); + } + + #makeEvent(input: { + readonly threadId: ThreadId; + readonly kind: ProviderEvent["kind"]; + readonly method: string; + readonly message?: string; + readonly payload?: unknown; + readonly turnId?: TurnId; + readonly itemId?: ProviderItemId; + }): ProviderEvent { + return { + id: EventId.makeUnsafe(randomUUID()), + kind: input.kind, + provider: "claudeCode", + threadId: input.threadId, + createdAt: new Date().toISOString(), + method: input.method, + ...(input.message ? { message: input.message } : {}), + ...(input.payload !== undefined ? { payload: input.payload } : {}), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: input.itemId } : {}), + }; + } + + #getSessionContext(threadId: ThreadId): ClaudeSessionContext { + const context = this.#sessions.get(threadId); + if (!context) { + throw new Error(`Unknown provider session: ${String(threadId)}`); + } + return context; + } + + async startSession(input: ClaudeCodeServerStartSessionInput): Promise { + const now = new Date().toISOString(); + const options = readClaudeCodeProviderOptions(input.providerOptions); + const resumeCursor = nonEmptyTrimmed(asString(input.resumeCursor)) ?? randomUUID(); + const session: ProviderSession = { + provider: "claudeCode", + status: "ready", + runtimeMode: input.runtimeMode, + threadId: input.threadId, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(normalizeClaudeModelSlug(input.model) ? { model: normalizeClaudeModelSlug(input.model) } : {}), + resumeCursor, + createdAt: now, + updatedAt: now, + }; + + const context: ClaudeSessionContext = { + session, + binaryPath: options.binaryPath ?? "claude", + ...(options.homePath ? { homePath: options.homePath } : {}), + useResumeOnNextTurn: nonEmptyTrimmed(asString(input.resumeCursor)) !== undefined, + activeChild: undefined, + activeOutput: undefined, + activeTurn: undefined, + turns: [], + }; + this.#sessions.set(input.threadId, context); + + this.#emitEvent( + context, + this.#makeEvent({ + threadId: input.threadId, + kind: "session", + method: "session/started", + payload: { + resume: resumeCursor, + }, + }), + ); + this.#emitEvent( + context, + this.#makeEvent({ + threadId: input.threadId, + kind: "session", + method: "session/configured", + payload: { + config: { + provider: "claudeCode", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(session.model ? { model: session.model } : {}), + permissionMode: toClaudePermissionMode(input.runtimeMode), + ...(options.binaryPath ? { binaryPath: options.binaryPath } : {}), + ...(options.homePath ? { homePath: options.homePath } : {}), + }, + }, + }), + ); + this.#emitEvent( + context, + this.#makeEvent({ + threadId: input.threadId, + kind: "session", + method: "thread/started", + payload: { + providerThreadId: resumeCursor, + }, + }), + ); + + return session; + } + + async sendTurn(input: ClaudeCodeServerSendTurnInput): Promise { + const context = this.#getSessionContext(input.threadId); + if (context.activeTurn || context.activeChild) { + throw new Error(`Provider session is busy: ${String(input.threadId)}`); + } + if (input.attachments && input.attachments.length > 0) { + throw new Error("Claude Code attachments are not supported by the server adapter yet."); + } + const prompt = nonEmptyTrimmed(input.input); + if (!prompt) { + throw new Error("Claude Code requires a non-empty turn input."); + } + + const model = normalizeClaudeModelSlug(input.model) ?? context.session.model; + const permissionMode = toClaudePermissionMode(context.session.runtimeMode, input.interactionMode); + const turnId = TurnId.makeUnsafe(randomUUID()); + + const { lastError: _lastError, ...readySession } = context.session; + context.session = { + ...readySession, + status: "running", + updatedAt: new Date().toISOString(), + activeTurnId: turnId, + ...(model ? { model } : {}), + }; + context.activeTurn = { + turnId, + events: [], + toolUsesById: new Map(), + toolUsesByIndex: new Map(), + resultSeen: false, + interrupted: false, + }; + + this.#emitEvent( + context, + this.#makeEvent({ + threadId: input.threadId, + kind: "session", + method: "turn/started", + turnId, + payload: { + turn: { + ...(model ? { model } : {}), + ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), + permissionMode, + }, + }, + }), + ); + + const args = [ + "-p", + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "--permission-mode", + permissionMode, + ]; + if (model) { + args.push("--model", model); + } + + const resumeCursor = nonEmptyTrimmed(asString(context.session.resumeCursor)); + if (resumeCursor) { + if (context.useResumeOnNextTurn) { + args.push("--resume", resumeCursor); + } else { + args.push("--session-id", resumeCursor); + } + } + args.push("--", prompt); + + const child: ClaudeCodeChildProcess = spawn(context.binaryPath, args, { + cwd: context.session.cwd, + env: { + ...process.env, + ...(context.homePath ? { CLAUDE_CONFIG_DIR: context.homePath } : {}), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + context.activeChild = child; + context.activeOutput = readline.createInterface({ input: child.stdout }); + + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + + context.activeOutput.on("line", (line) => { + this.#handleStreamJsonLine(context, line); + }); + child.on("error", (error) => { + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "error", + method: "runtime/error", + turnId, + message: error.message, + payload: { class: "process_error" }, + }), + ); + }); + child.on("close", (code, signal) => { + const activeTurn = context.activeTurn; + if (!activeTurn || activeTurn.resultSeen) { + context.activeChild = undefined; + context.activeOutput = undefined; + return; + } + + const interrupted = activeTurn.interrupted || signal === "SIGINT"; + const errorMessage = interrupted + ? "Claude Code turn interrupted" + : resultSummary(stderr) ?? `Claude Code exited with code ${code ?? 1}.`; + this.#emitTurnCompleted(context, { + turnId, + state: interrupted ? "interrupted" : "failed", + ...(signal ? { stopReason: signal } : {}), + errorMessage, + }); + }); + + return { + threadId: input.threadId, + turnId, + ...(resumeCursor ? { resumeCursor } : {}), + }; + } + + #emitTurnCompleted( + context: ClaudeSessionContext, + input: { + readonly turnId: TurnId; + readonly state: "completed" | "failed" | "interrupted" | "cancelled"; + readonly stopReason?: string; + readonly usage?: unknown; + readonly totalCostUsd?: number; + readonly errorMessage?: string; + }, + ): void { + const activeTurn = context.activeTurn; + if (!activeTurn) { + return; + } + activeTurn.resultSeen = true; + const snapshotEvents = activeTurn.events.slice(); + + context.turns.push({ + id: input.turnId, + items: snapshotEvents, + }); + const { activeTurnId: _activeTurnId, lastError: _lastSessionError, ...restSession } = + context.session; + context.session = { + ...restSession, + status: input.state === "failed" ? "error" : "ready", + updatedAt: new Date().toISOString(), + ...(input.errorMessage ? { lastError: input.errorMessage } : {}), + }; + context.useResumeOnNextTurn = true; + context.activeTurn = undefined; + context.activeOutput?.close(); + context.activeOutput = undefined; + context.activeChild = undefined; + + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "session", + method: "turn/completed", + turnId: input.turnId, + payload: { + turn: { + status: input.state, + ...(input.stopReason ? { stopReason: input.stopReason } : {}), + ...(input.usage !== undefined ? { usage: input.usage } : {}), + ...(input.totalCostUsd !== undefined ? { totalCostUsd: input.totalCostUsd } : {}), + ...(input.errorMessage ? { error: { message: input.errorMessage } } : {}), + }, + }, + }), + ); + } + + #handleSystemEvent(context: ClaudeSessionContext, line: Record): void { + const subtype = asString(line.subtype); + const turnId = context.activeTurn?.turnId; + + if (subtype === "task_started") { + const taskId = nonEmptyTrimmed(asString(line.task_id)); + if (!taskId) { + return; + } + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "notification", + method: "task/started", + ...(turnId ? { turnId } : {}), + payload: { + taskId, + ...(nonEmptyTrimmed(asString(line.description)) + ? { description: nonEmptyTrimmed(asString(line.description)) } + : {}), + ...(nonEmptyTrimmed(asString(line.task_type)) + ? { taskType: nonEmptyTrimmed(asString(line.task_type)) } + : {}), + ...(nonEmptyTrimmed(asString(line.tool_use_id)) + ? { toolUseId: nonEmptyTrimmed(asString(line.tool_use_id)) } + : {}), + }, + }), + ); + return; + } + + if (subtype === "task_notification") { + const taskId = nonEmptyTrimmed(asString(line.task_id)); + if (!taskId) { + return; + } + const status = nonEmptyTrimmed(asString(line.status)); + if (status === "completed" || status === "failed" || status === "stopped") { + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "notification", + method: "task/completed", + ...(turnId ? { turnId } : {}), + payload: { + taskId, + status, + ...(nonEmptyTrimmed(asString(line.summary)) + ? { summary: nonEmptyTrimmed(asString(line.summary)) } + : {}), + ...(line.usage !== undefined ? { usage: line.usage } : {}), + }, + }), + ); + return; + } + + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "notification", + method: "task/progress", + ...(turnId ? { turnId } : {}), + payload: { + taskId, + description: nonEmptyTrimmed(asString(line.summary)) ?? status ?? "Task update", + ...(line.usage !== undefined ? { usage: line.usage } : {}), + }, + }), + ); + return; + } + + if (subtype === "hook_started") { + const hookId = nonEmptyTrimmed(asString(line.hook_id)); + const hookName = nonEmptyTrimmed(asString(line.hook_name)); + const hookEvent = nonEmptyTrimmed(asString(line.hook_event)); + if (!hookId || !hookName || !hookEvent) { + return; + } + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "notification", + method: "hook/started", + ...(turnId ? { turnId } : {}), + payload: { + hookId, + hookName, + hookEvent, + }, + }), + ); + return; + } + + if (subtype === "hook_response") { + const hookId = nonEmptyTrimmed(asString(line.hook_id)); + if (!hookId) { + return; + } + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "notification", + method: "hook/completed", + ...(turnId ? { turnId } : {}), + payload: { + hookId, + outcome: nonEmptyTrimmed(asString(line.outcome)) ?? "success", + ...(line.output !== undefined ? { output: line.output } : {}), + ...(line.stdout !== undefined ? { stdout: line.stdout } : {}), + ...(line.stderr !== undefined ? { stderr: line.stderr } : {}), + ...(asNumber(line.exit_code) !== undefined ? { exitCode: asNumber(line.exit_code) } : {}), + }, + }), + ); + } + } + + #handleStreamEvent(context: ClaudeSessionContext, line: Record): void { + const turn = context.activeTurn; + if (!turn) { + return; + } + const event = asObject(line.event); + const type = asString(event?.type); + if (!type) { + return; + } + + switch (type) { + case "message_start": + case "message_delta": + case "message_stop": + case "ping": + return; + case "error": { + const nativeError = asObject(event?.error); + const message = nonEmptyTrimmed( + asString(nativeError?.message) ?? asString(event?.message), + ); + if (!message) { + return; + } + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "error", + method: "runtime/error", + turnId: turn.turnId, + message, + payload: { + class: "provider_error", + ...(asString(nativeError?.type) ? { nativeType: asString(nativeError?.type) } : {}), + ...(nativeError ? { error: nativeError } : { error: event }), + }, + }), + ); + return; + } + case "content_block_start": { + const contentBlock = asObject(event?.content_block); + const contentBlockType = asString(contentBlock?.type); + if (contentBlockType === "web_search_tool_result") { + const toolUseId = nonEmptyTrimmed(asString(contentBlock?.tool_use_id)); + if (!toolUseId) { + return; + } + const toolState = turn.toolUsesById.get(toolUseId); + const result = contentBlock?.content; + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "notification", + method: "item/tool/completed", + turnId: turn.turnId, + itemId: ProviderItemId.makeUnsafe(toolUseId), + payload: { + item: { + type: "web_search_tool_result", + toolName: toolState?.name ?? "web_search", + status: isWebSearchToolResultError(result) ? "failed" : "completed", + input: toolState?.input, + result, + summary: resultSummary(result), + }, + }, + }), + ); + return; + } + + if (contentBlockType !== "tool_use" && contentBlockType !== "server_tool_use") { + return; + } + const toolUseId = nonEmptyTrimmed(asString(contentBlock?.id)); + const toolName = nonEmptyTrimmed(asString(contentBlock?.name)); + const contentIndex = asNumber(event?.index); + if (!toolUseId || !toolName || contentIndex === undefined) { + return; + } + const toolState = { + id: toolUseId, + name: toolName, + type: contentBlockType, + partialJson: "", + ...(contentBlock?.input !== undefined ? { input: contentBlock.input } : {}), + } satisfies ClaudeToolUseState; + turn.toolUsesById.set(toolUseId, toolState); + turn.toolUsesByIndex.set(contentIndex, toolState); + return; + } + case "content_block_delta": { + const delta = asObject(event?.delta); + const deltaType = asString(delta?.type); + if (deltaType === "text_delta" || deltaType === "thinking_delta") { + const text = nonEmptyString(asString(delta?.text) ?? asString(delta?.thinking)); + if (!text) { + return; + } + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "notification", + method: "turn/content-delta", + turnId: turn.turnId, + payload: { + streamKind: deltaType === "thinking_delta" ? "reasoning_text" : "assistant_text", + delta: text, + }, + }), + ); + return; + } + + if (deltaType === "signature_delta") { + return; + } + + if (deltaType !== "input_json_delta") { + return; + } + + const contentIndex = asNumber(event?.index); + if (contentIndex === undefined) { + return; + } + const toolState = turn.toolUsesByIndex.get(contentIndex); + if (!toolState) { + return; + } + toolState.partialJson += asString(delta?.partial_json) ?? ""; + return; + } + case "content_block_stop": { + const contentIndex = asNumber(event?.index); + if (contentIndex === undefined) { + return; + } + const toolState = turn.toolUsesByIndex.get(contentIndex); + if (!toolState) { + return; + } + toolState.input = + toolState.partialJson.length > 0 ? parsePartialJson(toolState.partialJson) : toolState.input; + const itemId = ProviderItemId.makeUnsafe(toolState.id); + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "notification", + method: toolState.type === "server_tool_use" ? "item/tool/updated" : "item/tool/started", + turnId: turn.turnId, + itemId, + payload: { + item: { + type: toolState.type, + toolName: toolState.name, + input: toolState.input, + summary: toolInputSummary(toolState.input), + }, + }, + }), + ); + return; + } + default: + return; + } + } + + #handleUserEvent(context: ClaudeSessionContext, line: Record): void { + const turn = context.activeTurn; + if (!turn) { + return; + } + const message = asObject(line.message); + const content = asArray(message?.content) ?? []; + for (const entry of content) { + const toolResult = asObject(entry); + if (asString(toolResult?.type) !== "tool_result") { + continue; + } + const toolUseId = nonEmptyTrimmed(asString(toolResult?.tool_use_id)); + if (!toolUseId) { + continue; + } + const itemId = ProviderItemId.makeUnsafe(toolUseId); + const toolState = turn.toolUsesById.get(toolUseId); + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "notification", + method: "item/tool/completed", + turnId: turn.turnId, + itemId, + payload: { + item: { + type: "tool_use", + toolName: toolState?.name, + status: toolResult?.is_error === true ? "failed" : "completed", + input: toolState?.input, + result: line.tool_use_result ?? toolResult?.content, + summary: resultSummary(line.tool_use_result ?? toolResult?.content), + }, + }, + }), + ); + } + } + + #handleResultEvent(context: ClaudeSessionContext, line: Record): void { + const turn = context.activeTurn; + if (!turn) { + return; + } + const sessionId = nonEmptyTrimmed(asString(line.session_id)); + if (sessionId) { + context.session = { + ...context.session, + resumeCursor: sessionId, + }; + } + const subtype = nonEmptyTrimmed(asString(line.subtype)); + const isError = line.is_error === true || subtype === "error"; + const totalCostUsd = asNumber(line.total_cost_usd); + this.#emitTurnCompleted(context, { + turnId: turn.turnId, + state: turn.interrupted ? "interrupted" : isError ? "failed" : "completed", + ...(subtype ? { stopReason: subtype } : {}), + ...(line.usage !== undefined ? { usage: line.usage } : {}), + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), + ...(isError ? { errorMessage: resultSummary(line.result) ?? "Claude Code turn failed." } : {}), + }); + } + + #handleStreamJsonLine(context: ClaudeSessionContext, line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (cause) { + this.#emitEvent( + context, + this.#makeEvent({ + threadId: context.session.threadId, + kind: "error", + method: "runtime/error", + ...(context.activeTurn?.turnId ? { turnId: context.activeTurn.turnId } : {}), + message: cause instanceof Error ? cause.message : "Failed to parse Claude Code JSON line", + payload: { + class: "invalid_native_event", + line, + }, + }), + ); + return; + } + + const record = asObject(parsed); + const type = asString(record?.type); + if (!record || !type) { + return; + } + + switch (type) { + case "system": + this.#handleSystemEvent(context, record); + return; + case "stream_event": + this.#handleStreamEvent(context, record); + return; + case "user": + this.#handleUserEvent(context, record); + return; + case "result": + this.#handleResultEvent(context, record); + return; + default: + return; + } + } + + async interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise { + const context = this.#getSessionContext(threadId); + const activeTurn = context.activeTurn; + if (!activeTurn || !context.activeChild) { + return; + } + if (turnId && activeTurn.turnId !== turnId) { + return; + } + activeTurn.interrupted = true; + context.activeChild.kill("SIGINT"); + } + + async respondToRequest( + _threadId: ThreadId, + _requestId: string, + _decision: ProviderApprovalDecision, + ): Promise { + throw new Error("Claude Code approval request responses are not wired in the server adapter yet."); + } + + async respondToUserInput( + _threadId: ThreadId, + _requestId: string, + _answers: ProviderUserInputAnswers, + ): Promise { + throw new Error("Claude Code user-input responses are not wired in the server adapter yet."); + } + + stopSession(threadId: ThreadId): void { + const context = this.#sessions.get(threadId); + if (!context) { + return; + } + if (context.activeTurn) { + context.activeTurn.interrupted = true; + } + context.activeChild?.kill("SIGINT"); + context.activeOutput?.close(); + this.#sessions.delete(threadId); + } + + listSessions(): ProviderSession[] { + return Array.from(this.#sessions.values(), ({ session }) => session); + } + + hasSession(threadId: ThreadId): boolean { + return this.#sessions.has(threadId); + } + + async readThread(threadId: ThreadId): Promise { + const context = this.#getSessionContext(threadId); + return { + threadId, + turns: context.turns, + }; + } + + async rollbackThread(threadId: ThreadId, numTurns: number): Promise { + const context = this.#getSessionContext(threadId); + if (numTurns <= 0) { + return { threadId, turns: context.turns }; + } + if (numTurns < context.turns.length) { + throw new Error("Claude Code rollback currently supports full-thread reset only."); + } + context.turns = []; + context.useResumeOnNextTurn = false; + const { activeTurnId: _clearedActiveTurnId, lastError: _clearedLastError, ...rollbackSession } = + context.session; + context.session = { + ...rollbackSession, + resumeCursor: randomUUID(), + updatedAt: new Date().toISOString(), + status: "ready", + }; + return { + threadId, + turns: context.turns, + }; + } + + stopAll(): void { + for (const threadId of Array.from(this.#sessions.keys())) { + this.stopSession(threadId); + } + } +} \ No newline at end of file diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4f352435f..d719a8e11 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -93,7 +93,7 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - input.provider === "codex" + (input.provider === "codex" || input.provider === "claudeCode") ? input.provider : "codex"; const resumeCursor = @@ -315,6 +315,12 @@ describe("ProviderCommandReactor", () => { fastMode: true, }, }, + providerOptions: { + codex: { + binaryPath: "/usr/local/bin/codex", + homePath: "/tmp/.codex", + }, + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -331,6 +337,12 @@ describe("ProviderCommandReactor", () => { fastMode: true, }, }, + providerOptions: { + codex: { + binaryPath: "/usr/local/bin/codex", + homePath: "/tmp/.codex", + }, + }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), @@ -513,6 +525,56 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("restarts claudeCode sessions without falling back to codex when runtime mode changes", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-runtime-mode-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-runtime-mode-1"), + role: "user", + text: "first claude turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeCode", + model: "claude-sonnet-4-5", + runtimeMode: "approval-required", + }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + provider: "claudeCode", + resumeCursor: { opaque: "cursor-1" }, + runtimeMode: "full-access", + }); + }); + it("does not stop the active session when restart fails before rebind", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d34791bc2..b09e6e309 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -3,8 +3,10 @@ import { CommandId, EventId, type OrchestrationEvent, + ProviderKind as ProviderKindSchema, type ProviderModelOptions, type ProviderKind, + type ProviderSessionStartInput, type ProviderServiceTier, type OrchestrationSession, ThreadId, @@ -43,6 +45,11 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined return normalized && normalized.length > 0 ? normalized : undefined; } +function toSupportedProviderKind(value: string | null | undefined): ProviderKind | undefined { + if (typeof value !== "string") return undefined; + return Schema.is(ProviderKindSchema)(value) ? value : undefined; +} + function mapProviderSessionStatusToOrchestrationStatus( status: "connecting" | "ready" | "running" | "error" | "closed", ): OrchestrationSession["status"] { @@ -202,6 +209,7 @@ const make = Effect.gen(function* () { readonly provider?: ProviderKind; readonly model?: string; readonly modelOptions?: ProviderModelOptions; + readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; readonly serviceTier?: ProviderServiceTier | null; }, ) { @@ -212,8 +220,7 @@ const make = Effect.gen(function* () { } const desiredRuntimeMode = thread.runtimeMode; - const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + const currentProvider = toSupportedProviderKind(thread.session?.providerName); const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ @@ -239,6 +246,7 @@ const make = Effect.gen(function* () { ...(desiredModel ? { model: desiredModel } : {}), ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), + ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -325,6 +333,7 @@ const make = Effect.gen(function* () { readonly model?: string; readonly serviceTier?: ProviderServiceTier | null; readonly modelOptions?: ProviderModelOptions; + readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { @@ -337,6 +346,7 @@ const make = Effect.gen(function* () { ...(input.model !== undefined ? { model: input.model } : {}), ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -472,6 +482,9 @@ const make = Effect.gen(function* () { ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}), ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), + ...(event.payload.providerOptions !== undefined + ? { providerOptions: event.payload.providerOptions } + : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 96242b846..f93ebe451 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -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: ProviderRuntimeEvent["provider"]; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -563,6 +563,62 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("finalizes buffered assistant text on turn completion without trimming Claude-style whitespace deltas", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-claude-message-delta-1"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-1"), + itemId: asItemId("claude-item-1"), + payload: { + streamKind: "assistant_text", + delta: " let", + }, + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-claude-message-delta-2"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-1"), + itemId: asItemId("claude-item-1"), + payload: { + streamKind: "assistant_text", + delta: " check", + }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-claude-turn-completed"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-1"), + payload: { + state: "completed", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:claude-item-1" && !message.streaming, + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:claude-item-1", + ); + + expect(message?.text).toBe(" let check"); + expect(message?.streaming).toBe(false); + }); + it("uses assistant item completion detail when no assistant deltas were streamed", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1276,6 +1332,102 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); + it("projects Claude web_search item lifecycle into tool update/completion activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "item.updated", + eventId: asEventId("evt-claude-web-search-updated"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-web-search"), + itemId: asItemId("srvtoolu_1"), + payload: { + itemType: "web_search", + status: "inProgress", + title: "web_search", + detail: "weather nyc", + data: { + item: { + type: "server_tool_use", + toolName: "web_search", + input: { query: "weather nyc" }, + summary: "weather nyc", + }, + }, + }, + }); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-claude-web-search-completed"), + provider: "claudeCode", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-web-search"), + itemId: asItemId("srvtoolu_1"), + payload: { + itemType: "web_search", + status: "completed", + title: "web_search", + detail: "Weather in NYC - Example", + data: { + item: { + type: "web_search_tool_result", + toolName: "web_search", + status: "completed", + input: { query: "weather nyc" }, + result: [ + { + type: "web_search_result", + title: "Weather in NYC - Example", + url: "https://example.com/weather", + }, + ], + summary: "Weather in NYC - Example", + }, + }, + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.updated", + ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.completed", + ), + ); + + const updated = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-claude-web-search-updated", + ); + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-claude-web-search-completed", + ); + const updatedPayload = + updated?.payload && typeof updated.payload === "object" + ? (updated.payload as Record) + : undefined; + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(updated?.kind).toBe("tool.updated"); + expect(updatedPayload?.itemType).toBe("web_search"); + expect(updatedPayload?.status).toBe("inProgress"); + expect(updatedPayload?.detail).toBe("weather nyc"); + + expect(completed?.kind).toBe("tool.completed"); + expect(completedPayload?.itemType).toBe("web_search"); + expect(completedPayload?.detail).toBe("Weather in NYC - Example"); + }); + it("projects Codex task lifecycle chunks into thread activities", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f03641642..47bfc3d92 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -303,6 +303,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.model !== undefined ? { model: command.model } : {}), ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), + ...(command.providerOptions !== undefined + ? { providerOptions: command.providerOptions } + : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts new file mode 100644 index 000000000..030e8cd32 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -0,0 +1,291 @@ +import assert from "node:assert/strict"; + +import { + EventId, + ProviderItemId, + RuntimeItemId, + ThreadId, + TurnId, + type ProviderEvent, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; + +import { Effect, Fiber, Layer, Option, Stream } from "effect"; + +import { ClaudeCodeServerManager } from "../../claudeCodeServerManager.ts"; +import { ServerConfig } from "../../config.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { makeClaudeCodeAdapterLive } from "./ClaudeCodeAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); + +const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { + upsert: () => Effect.void, + getProvider: () => + Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), + getBinding: () => Effect.succeed(Option.none()), + remove: () => Effect.void, + listThreadIds: () => Effect.succeed([]), +}); + +const lifecycleManager = new ClaudeCodeServerManager(); +const lifecycleLayer = it.layer( + makeClaudeCodeAdapterLive({ manager: lifecycleManager }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +lifecycleLayer("ClaudeCodeAdapterLive lifecycle", (it) => { + it.effect( + "preserves exact whitespace in Claude content deltas while ignoring unrelated provider methods", + () => + Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const eventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-ignored"), + kind: "notification", + provider: "claudeCode", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "stream/ping", + payload: {}, + } satisfies ProviderEvent); + + lifecycleManager.emit("event", { + id: asEventId("evt-delta"), + kind: "notification", + provider: "claudeCode", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-1"), + method: "turn/content-delta", + payload: { + streamKind: "assistant_text", + delta: " let", + }, + } satisfies ProviderEvent); + + const runtimeEvent = yield* Fiber.join(eventFiber); + assert.equal(runtimeEvent._tag, "Some"); + if (runtimeEvent._tag !== "Some") { + return; + } + + assert.deepStrictEqual(runtimeEvent.value, { + eventId: asEventId("evt-delta"), + provider: "claudeCode", + threadId: asThreadId("thread-1"), + createdAt: runtimeEvent.value.createdAt, + turnId: asTurnId("turn-1"), + raw: { + source: "claude-code.stream-json", + method: "turn/content-delta", + payload: { + streamKind: "assistant_text", + delta: " let", + }, + }, + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: " let", + }, + }); + }), + ); + + it.effect("maps Claude runtime/error provider events into canonical runtime errors", () => + Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const eventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-runtime-error"), + kind: "error", + provider: "claudeCode", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-2"), + method: "runtime/error", + message: "Claude stream overloaded", + payload: { + class: "provider_error", + nativeType: "overloaded_error", + }, + } satisfies ProviderEvent); + + const runtimeEvent = yield* Fiber.join(eventFiber); + assert.equal(runtimeEvent._tag, "Some"); + if (runtimeEvent._tag !== "Some") { + return; + } + + assert.equal(runtimeEvent.value.type, "runtime.error"); + assert.deepStrictEqual(runtimeEvent.value.payload, { + message: "Claude stream overloaded", + class: "provider_error", + detail: { + class: "provider_error", + nativeType: "overloaded_error", + }, + }); + }), + ); + + it.effect( + "maps server tool use and web search result provider events into canonical web_search lifecycle events", + () => + Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const eventFiber = yield* adapter.streamEvents.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + const createdAt = new Date().toISOString(); + + lifecycleManager.emit("event", { + id: asEventId("evt-web-search-updated"), + kind: "notification", + provider: "claudeCode", + threadId: asThreadId("thread-1"), + createdAt, + turnId: asTurnId("turn-3"), + itemId: ProviderItemId.makeUnsafe("srvtoolu_1"), + method: "item/tool/updated", + payload: { + item: { + type: "server_tool_use", + toolName: "web_search", + input: { + query: "weather nyc", + }, + summary: "weather nyc", + }, + }, + } satisfies ProviderEvent); + + lifecycleManager.emit("event", { + id: asEventId("evt-web-search-completed"), + kind: "notification", + provider: "claudeCode", + threadId: asThreadId("thread-1"), + createdAt, + turnId: asTurnId("turn-3"), + itemId: ProviderItemId.makeUnsafe("srvtoolu_1"), + method: "item/tool/completed", + payload: { + item: { + type: "web_search_tool_result", + toolName: "web_search", + status: "completed", + input: { + query: "weather nyc", + }, + result: [ + { + type: "web_search_result", + title: "Weather in NYC - Example", + url: "https://example.com/weather", + }, + ], + summary: "Weather in NYC - Example", + }, + }, + } satisfies ProviderEvent); + + const runtimeEvents = Array.from(yield* Fiber.join(eventFiber)); + + assert.equal(runtimeEvents.length, 2); + assert.equal(runtimeEvents[0]?.type, "item.updated"); + assert.equal(runtimeEvents[0]?.itemId, RuntimeItemId.makeUnsafe("srvtoolu_1")); + assert.deepStrictEqual(runtimeEvents[0]?.raw, { + source: "claude-code.stream-json", + method: "item/tool/updated", + payload: { + item: { + type: "server_tool_use", + toolName: "web_search", + input: { + query: "weather nyc", + }, + summary: "weather nyc", + }, + }, + }); + assert.deepStrictEqual(runtimeEvents[0]?.payload, { + itemType: "web_search", + status: "inProgress", + title: "web_search", + detail: "weather nyc", + data: { + item: { + type: "server_tool_use", + toolName: "web_search", + input: { + query: "weather nyc", + }, + summary: "weather nyc", + }, + }, + }); + + assert.equal(runtimeEvents[1]?.type, "item.completed"); + assert.equal(runtimeEvents[1]?.itemId, RuntimeItemId.makeUnsafe("srvtoolu_1")); + assert.deepStrictEqual(runtimeEvents[1]?.raw, { + source: "claude-code.stream-json", + method: "item/tool/completed", + payload: { + item: { + type: "web_search_tool_result", + toolName: "web_search", + status: "completed", + input: { + query: "weather nyc", + }, + result: [ + { + type: "web_search_result", + title: "Weather in NYC - Example", + url: "https://example.com/weather", + }, + ], + summary: "Weather in NYC - Example", + }, + }, + }); + assert.deepStrictEqual(runtimeEvents[1]?.payload, { + itemType: "web_search", + status: "completed", + title: "web_search", + detail: "Weather in NYC - Example", + data: { + item: { + type: "web_search_tool_result", + toolName: "web_search", + status: "completed", + input: { + query: "weather nyc", + }, + result: [ + { + type: "web_search_result", + title: "Weather in NYC - Example", + url: "https://example.com/weather", + }, + ], + summary: "Weather in NYC - Example", + }, + }, + }); + }), + ); +}); \ No newline at end of file diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts new file mode 100644 index 000000000..78fac56ab --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -0,0 +1,598 @@ +import { + type CanonicalItemType, + type ProviderEvent, + type ProviderRuntimeEvent, + type RuntimeErrorClass, + RuntimeItemId, + RuntimeTaskId, + ThreadId, +} from "@t3tools/contracts"; +import { Effect, Layer, Queue, ServiceMap, Stream } from "effect"; + +import { + ClaudeCodeServerManager, + type ClaudeCodeServerSendTurnInput, + type ClaudeCodeServerStartSessionInput, +} from "../../claudeCodeServerManager.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; + +const PROVIDER = "claudeCode" as const; + +export interface ClaudeCodeAdapterLiveOptions { + readonly manager?: ClaudeCodeServerManager; + readonly makeManager?: (services?: ServiceMap.ServiceMap) => ClaudeCodeServerManager; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function toSessionError( + threadId: ThreadId, + cause: unknown, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + if (normalized.includes("unknown provider session") || normalized.includes("unknown session")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + cause, + }); + } + if (normalized.includes("session is closed") || normalized.includes("session is busy")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + cause, + }); + } + return undefined; +} + +function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { + const sessionError = toSessionError(threadId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function toRuntimeErrorClass(value: unknown): RuntimeErrorClass | undefined { + const errorClass = asString(value); + switch (errorClass) { + case "provider_error": + case "transport_error": + case "permission_error": + case "validation_error": + case "unknown": + return errorClass; + default: + return undefined; + } +} + +function toCanonicalItemType(toolName: string | undefined): CanonicalItemType { + switch (toolName) { + case "Bash": + return "command_execution"; + case "Edit": + case "Write": + case "NotebookEdit": + return "file_change"; + case "Task": + case "TaskOutput": + return "collab_agent_tool_call"; + case "WebSearch": + case "web_search": + case "WebFetch": + case "web_fetch": + return "web_search"; + default: + return toolName?.startsWith("mcp__") ? "mcp_tool_call" : "dynamic_tool_call"; + } +} + +function runtimeEventBase( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): Omit { + return { + eventId: event.id, + provider: event.provider, + threadId: canonicalThreadId, + createdAt: event.createdAt, + ...(event.turnId ? { turnId: event.turnId } : {}), + ...(event.itemId ? { itemId: RuntimeItemId.makeUnsafe(event.itemId) } : {}), + raw: { + source: "claude-code.stream-json", + method: event.method, + payload: event.payload ?? {}, + }, + }; +} + +function mapItemLifecycle( + event: ProviderEvent, + canonicalThreadId: ThreadId, + lifecycle: "item.started" | "item.updated" | "item.completed", +): ProviderRuntimeEvent | undefined { + const payload = asObject(event.payload); + const item = asObject(payload?.item); + if (!item || !event.itemId) { + return undefined; + } + const toolName = asString(item.toolName); + const detail = asString(item.summary) ?? asString(item.toolName); + const status = + lifecycle === "item.completed" + ? asString(item.status) === "failed" + ? "failed" + : "completed" + : "inProgress"; + + return { + ...runtimeEventBase(event, canonicalThreadId), + type: lifecycle, + payload: { + itemType: toCanonicalItemType(toolName), + status, + ...(toolName ? { title: toolName } : {}), + ...(detail ? { detail } : {}), + data: event.payload ?? {}, + }, + }; +} + +function mapToRuntimeEvents( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): ReadonlyArray { + const payload = asObject(event.payload); + const turn = asObject(payload?.turn); + + if (event.kind === "error") { + return event.message + ? [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "runtime.error", + payload: { + message: event.message, + class: toRuntimeErrorClass(payload?.class) ?? "provider_error", + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ] + : []; + } + + if (event.method === "session/started") { + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "session.started", + payload: { + ...(event.message ? { message: event.message } : {}), + ...(payload?.resume !== undefined ? { resume: payload.resume } : {}), + }, + }, + ]; + } + + if (event.method === "session/configured") { + const config = asObject(payload?.config); + if (!config) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "session.configured", + payload: { + config, + }, + }, + ]; + } + + if (event.method === "thread/started") { + const providerThreadId = asString(payload?.providerThreadId); + if (!providerThreadId) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "thread.started", + payload: { + providerThreadId, + }, + }, + ]; + } + + if (event.method === "turn/started") { + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "turn.started", + payload: { + ...(asString(turn?.model) ? { model: asString(turn?.model) } : {}), + }, + }, + ]; + } + + if (event.method === "turn/completed") { + const errorMessage = asString(asObject(turn?.error)?.message); + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "turn.completed", + payload: { + state: + asString(turn?.status) === "failed" || + asString(turn?.status) === "interrupted" || + asString(turn?.status) === "cancelled" + ? (asString(turn?.status) as "failed" | "interrupted" | "cancelled") + : "completed", + ...(asString(turn?.stopReason) ? { stopReason: asString(turn?.stopReason) } : {}), + ...(turn?.usage !== undefined ? { usage: turn.usage } : {}), + ...(asNumber(turn?.totalCostUsd) !== undefined ? { totalCostUsd: asNumber(turn?.totalCostUsd) } : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + }, + ]; + } + + if (event.method === "turn/content-delta") { + const streamKind = asString(payload?.streamKind); + const delta = asString(payload?.delta); + if (!streamKind || delta === undefined) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "content.delta", + payload: { + streamKind: + streamKind === "reasoning_text" ? "reasoning_text" : "assistant_text", + delta, + }, + }, + ]; + } + + if (event.method === "item/tool/started") { + const mapped = mapItemLifecycle(event, canonicalThreadId, "item.started"); + return mapped ? [mapped] : []; + } + + if (event.method === "item/tool/updated") { + const mapped = mapItemLifecycle(event, canonicalThreadId, "item.updated"); + return mapped ? [mapped] : []; + } + + if (event.method === "item/tool/completed") { + const mapped = mapItemLifecycle(event, canonicalThreadId, "item.completed"); + return mapped ? [mapped] : []; + } + + if (event.method === "task/started") { + const taskId = asString(payload?.taskId); + if (!taskId) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "task.started", + payload: { + taskId: RuntimeTaskId.makeUnsafe(taskId), + ...(asString(payload?.description) ? { description: asString(payload?.description) } : {}), + ...(asString(payload?.taskType) ? { taskType: asString(payload?.taskType) } : {}), + }, + }, + ]; + } + + if (event.method === "task/progress") { + const taskId = asString(payload?.taskId); + const description = asString(payload?.description); + if (!taskId || !description) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "task.progress", + payload: { + taskId: RuntimeTaskId.makeUnsafe(taskId), + description, + ...(payload?.usage !== undefined ? { usage: payload.usage } : {}), + }, + }, + ]; + } + + if (event.method === "task/completed") { + const taskId = asString(payload?.taskId); + const status = asString(payload?.status); + if (!taskId || (status !== "completed" && status !== "failed" && status !== "stopped")) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "task.completed", + payload: { + taskId: RuntimeTaskId.makeUnsafe(taskId), + status, + ...(asString(payload?.summary) ? { summary: asString(payload?.summary) } : {}), + ...(payload?.usage !== undefined ? { usage: payload.usage } : {}), + }, + }, + ]; + } + + if (event.method === "hook/started") { + const hookId = asString(payload?.hookId); + const hookName = asString(payload?.hookName); + const hookEvent = asString(payload?.hookEvent); + if (!hookId || !hookName || !hookEvent) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "hook.started", + payload: { + hookId, + hookName, + hookEvent, + }, + }, + ]; + } + + if (event.method === "hook/completed") { + const hookId = asString(payload?.hookId); + const outcome = asString(payload?.outcome); + if (!hookId || (outcome !== "success" && outcome !== "error" && outcome !== "cancelled")) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "hook.completed", + payload: { + hookId, + outcome, + ...(asString(payload?.output) !== undefined ? { output: asString(payload?.output) } : {}), + ...(asString(payload?.stdout) !== undefined ? { stdout: asString(payload?.stdout) } : {}), + ...(asString(payload?.stderr) !== undefined ? { stderr: asString(payload?.stderr) } : {}), + ...(asNumber(payload?.exitCode) !== undefined ? { exitCode: asNumber(payload?.exitCode) } : {}), + }, + }, + ]; + } + + return []; +} + +const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) => + Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + + const manager = yield* Effect.acquireRelease( + Effect.gen(function* () { + if (options?.manager) { + return options.manager; + } + const services = yield* Effect.services(); + return options?.makeManager?.(services) ?? new ClaudeCodeServerManager(); + }), + (manager) => + Effect.sync(() => { + try { + manager.stopAll(); + } catch { + return undefined; + } + }), + ); + + const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => + Effect.tryPromise({ + try: () => + manager.startSession({ + provider: "claudeCode", + threadId: input.threadId, + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), + runtimeMode: input.runtimeMode, + } satisfies ClaudeCodeServerStartSessionInput), + catch: (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: toMessage(cause, "Claude Code session start failed"), + cause, + }), + }); + + const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => + Effect.tryPromise({ + try: () => + manager.sendTurn({ + threadId: input.threadId, + ...(input.input !== undefined ? { input: input.input } : {}), + ...(input.attachments !== undefined ? { attachments: input.attachments } : {}), + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), + } satisfies ClaudeCodeServerSendTurnInput), + catch: (cause) => toRequestError(input.threadId, "sendTurn", cause), + }); + + const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.tryPromise({ + try: () => manager.interruptTurn(threadId, turnId), + catch: (cause) => toRequestError(threadId, "interruptTurn", cause), + }); + + const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.tryPromise({ + try: () => manager.respondToRequest(threadId, requestId, decision), + catch: (cause) => toRequestError(threadId, "respondToRequest", cause), + }); + + const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.tryPromise({ + try: () => manager.respondToUserInput(threadId, requestId, answers), + catch: (cause) => toRequestError(threadId, "respondToUserInput", cause), + }); + + const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) => + Effect.try({ + try: () => manager.stopSession(threadId), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "stopSession failed"), + cause, + }), + }); + + const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => + Effect.sync(() => manager.listSessions()); + + const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => manager.hasSession(threadId)); + + const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) => + Effect.tryPromise({ + try: () => manager.readThread(threadId), + catch: (cause) => toRequestError(threadId, "readThread", cause), + }); + + const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.tryPromise({ + try: () => manager.rollbackThread(threadId, numTurns), + catch: (cause) => toRequestError(threadId, "rollbackThread", cause), + }); + + const stopAll: ClaudeCodeAdapterShape["stopAll"] = () => + Effect.sync(() => { + manager.stopAll(); + }); + + const runtimeEventQueue = yield* Queue.unbounded(); + + yield* Effect.acquireRelease( + Effect.gen(function* () { + const writeNativeEvent = (event: ProviderEvent) => + Effect.gen(function* () { + if (!nativeEventLogger) { + return; + } + yield* nativeEventLogger.write(event, event.threadId); + }); + + const services = yield* Effect.services(); + const listener = (event: ProviderEvent) => + Effect.gen(function* () { + yield* writeNativeEvent(event); + const runtimeEvents = mapToRuntimeEvents(event, event.threadId); + if (runtimeEvents.length === 0) { + return; + } + yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); + }).pipe(Effect.runPromiseWith(services)); + + manager.on("event", listener); + return listener; + }), + (listener) => + Effect.gen(function* () { + yield* Effect.sync(() => { + manager.off("event", listener); + }); + yield* Queue.shutdown(runtimeEventQueue); + }), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + readThread, + rollbackThread, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies ClaudeCodeAdapterShape; + }); + +export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter()); + +export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) { + return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options)); +} \ No newline at end of file diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index a50112a62..429413458 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,6 +4,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; +import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; @@ -27,11 +28,31 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeClaudeCodeAdapter: ClaudeCodeAdapterShape = { + provider: "claudeCode", + 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.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(ClaudeCodeAdapter, fakeClaudeCodeAdapter), + ), ), NodeServices.layer, ), @@ -42,10 +63,12 @@ layer("ProviderAdapterRegistryLive", (it) => { Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); + const claudeCode = yield* registry.getByProvider("claudeCode"); assert.equal(codex, fakeCodexAdapter); + assert.equal(claudeCode, fakeClaudeCodeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "claudeCode"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 4f2c7f2c7..61fa2d18c 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,6 +15,7 @@ import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { @@ -26,7 +27,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter]; + : [yield* CodexAdapter, yield* ClaudeCodeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 90df9b691..5f1d46d15 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -4,7 +4,15 @@ import { Effect, Layer, Sink, Stream } from "effect"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth"; +import { + checkClaudeCodeProviderStatus, + checkCodexProviderStatus, + parseAuthStatusFromOutput, + parseClaudeCodeAuthStatusFromOutput, +} from "./ProviderHealth"; + +const CLAUDE_UNAUTHENTICATED_MESSAGE = + "Claude Code is not authenticated. Use `claude auth login` for Max/Pro, or configure Claude-native API key mode outside T3 Code, then try again."; // ── Test helpers ──────────────────────────────────────────────────── @@ -183,6 +191,67 @@ it.effect("returns warning when login status command is unsupported", () => ), ); +it.effect("returns ready when Claude Code is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeCodeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.70\n", stderr: "", code: 0 }; + if (joined === "auth status --json") { + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai","subscriptionType":"max"}\n', + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), +); + +it.effect("returns unavailable when Claude Code is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeCodeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.message, "Claude Code CLI (`claude`) is not installed or not on PATH."); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), +); + +it.effect("returns unauthenticated when Claude auth status reports login required", () => + Effect.gen(function* () { + const status = yield* checkClaudeCodeProviderStatus; + assert.strictEqual(status.provider, "claudeCode"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + CLAUDE_UNAUTHENTICATED_MESSAGE, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.70\n", stderr: "", code: 0 }; + if (joined === "auth status --json") { + return { stdout: "", stderr: "Not logged in. Run claude auth login.", code: 1 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), +); + // ── Pure function tests ───────────────────────────────────────────── it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => { @@ -210,3 +279,24 @@ it("parseAuthStatusFromOutput: JSON without auth marker is warning", () => { assert.strictEqual(parsed.status, "warning"); assert.strictEqual(parsed.authStatus, "unknown"); }); + +it("parseClaudeCodeAuthStatusFromOutput: JSON with loggedIn=false is unauthenticated", () => { + const parsed = parseClaudeCodeAuthStatusFromOutput({ + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + assert.strictEqual(parsed.message, CLAUDE_UNAUTHENTICATED_MESSAGE); +}); + +it("parseClaudeCodeAuthStatusFromOutput: JSON without auth marker is warning", () => { + const parsed = parseClaudeCodeAuthStatusFromOutput({ + stdout: '{"ok":true}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); +}); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 59f41edf8..3ec3b0505 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -25,6 +25,7 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; +const CLAUDE_CODE_PROVIDER = "claudeCode" as const; // ── Pure helpers ──────────────────────────────────────────────────── @@ -40,12 +41,12 @@ function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function isCommandMissingCause(error: unknown): boolean { +function isCommandMissingCause(error: unknown, commandName: string): boolean { if (!(error instanceof Error)) return false; const lower = error.message.toLowerCase(); return ( - lower.includes("command not found: codex") || - lower.includes("spawn codex enoent") || + lower.includes(`command not found: ${commandName}`) || + lower.includes(`spawn ${commandName} enoent`) || lower.includes("enoent") || lower.includes("notfound") ); @@ -167,6 +168,72 @@ export function parseAuthStatusFromOutput(result: CommandResult): { }; } +export function parseClaudeCodeAuthStatusFromOutput(result: CommandResult): { + readonly status: ServerProviderStatusState; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const unauthenticatedMessage = + "Claude Code is not authenticated. Use `claude auth login` for Max/Pro, or configure Claude-native API key mode outside T3 Code, then try again."; + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `claude auth login`") || + lowerOutput.includes("run claude auth login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: unauthenticatedMessage, + }; + } + + const trimmed = result.stdout.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const loggedIn = extractAuthBoolean(JSON.parse(trimmed)); + if (loggedIn === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (loggedIn === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: unauthenticatedMessage, + }; + } + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Claude Code authentication status from JSON output (missing auth marker).", + }; + } catch { + return { + status: "warning", + authStatus: "unknown", + message: "Could not parse Claude Code authentication status output.", + }; + } + } + + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Claude Code authentication status. ${detail}` + : "Could not verify Claude Code authentication status.", + }; +} + // ── Effect-native command execution ───────────────────────────────── const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => @@ -176,10 +243,10 @@ const collectStreamAsString = (stream: Stream.Stream): Effect. (acc, chunk) => acc + new TextDecoder().decode(chunk), ); -const runCodexCommand = (args: ReadonlyArray) => +const runProviderCommand = (commandName: string, args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...args], { + const command = ChildProcess.make(commandName, [...args], { shell: process.platform === "win32", }); @@ -197,6 +264,9 @@ const runCodexCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); +const runCodexCommand = (args: ReadonlyArray) => runProviderCommand("codex", args); +const runClaudeCodeCommand = (args: ReadonlyArray) => runProviderCommand("claude", args); + // ── Health check ──────────────────────────────────────────────────── export const checkCodexProviderStatus: Effect.Effect< @@ -220,7 +290,7 @@ export const checkCodexProviderStatus: Effect.Effect< available: false, authStatus: "unknown" as const, checkedAt, - message: isCommandMissingCause(error) + message: isCommandMissingCause(error, "codex") ? "Codex CLI (`codex`) is not installed or not on PATH." : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, }; @@ -307,14 +377,109 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkClaudeCodeProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + const versionProbe = yield* runClaudeCodeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CLAUDE_CODE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause(error, "claude") + ? "Claude Code CLI (`claude`) is not installed or not on PATH." + : `Failed to execute Claude Code health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + provider: CLAUDE_CODE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "Claude Code CLI is installed but failed to run. Timed out while running command.", + }; + } + + const version = versionProbe.success.value; + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CLAUDE_CODE_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: detail + ? `Claude Code CLI is installed but failed to run. ${detail}` + : "Claude Code CLI is installed but failed to run.", + }; + } + + const authProbe = yield* runClaudeCodeCommand(["auth", "status", "--json"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return { + provider: CLAUDE_CODE_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Could not verify Claude Code authentication status: ${error.message}.` + : "Could not verify Claude Code authentication status.", + }; + } + + if (Option.isNone(authProbe.success)) { + return { + provider: CLAUDE_CODE_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Could not verify Claude Code authentication status. Timed out while running command.", + }; + } + + const parsed = parseClaudeCodeAuthStatusFromOutput(authProbe.success.value); + return { + provider: CLAUDE_CODE_PROVIDER, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, + checkedAt, + ...(parsed.message ? { message: parsed.message } : {}), + } satisfies ServerProviderStatus; +}); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { const codexStatus = yield* checkCodexProviderStatus; + const claudeCodeStatus = yield* checkClaudeCodeProviderStatus; return { - getStatuses: Effect.succeed([codexStatus]), + getStatuses: Effect.succeed([codexStatus, claudeCodeStatus]), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 63b41d6b0..eb30051e9 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -212,6 +212,22 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { }; } +function makeRegistry( + ...adapters: ReadonlyArray> +): typeof ProviderAdapterRegistry.Service { + const adaptersByProvider = new Map(adapters.map((adapter) => [adapter.adapter.provider, adapter.adapter])); + + return { + getByProvider: (provider) => { + const adapter = adaptersByProvider.get(provider); + return adapter + ? Effect.succeed(adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })); + }, + listProviders: () => Effect.succeed(Array.from(adaptersByProvider.keys())), + }; +} + const sleep = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); @@ -418,6 +434,162 @@ it.effect( }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect( + "ProviderServiceLive prefers persisted claudeCode binding when restarting without an explicit provider", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-claude-start-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const codex = makeFakeCodexAdapter(); + const claudeCode = makeFakeCodexAdapter("claudeCode"); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, makeRegistry(codex, claudeCode))), + Layer.provide(directoryLayer), + Layer.provide(AnalyticsService.layerTest), + ); + const threadId = asThreadId("thread-claude-start"); + + yield* Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + yield* directory.upsert({ + provider: "claudeCode", + threadId, + runtimeMode: "approval-required", + status: "stopped", + resumeCursor: { opaque: "persisted-claude-cursor" }, + runtimePayload: { cwd: "/tmp/provider-service-claude-start" }, + }); + }).pipe(Effect.provide(directoryLayer)); + + claudeCode.startSession.mockClear(); + codex.startSession.mockClear(); + + const session = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(threadId, { + threadId, + cwd: "/tmp/provider-service-claude-start", + runtimeMode: "approval-required", + }); + }).pipe(Effect.provide(providerLayer)); + + assert.equal(session.provider, "claudeCode"); + assert.equal(claudeCode.startSession.mock.calls.length, 1); + assert.equal(codex.startSession.mock.calls.length, 0); + const resumedStartInput = claudeCode.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + runtimeMode?: string; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/provider-service-claude-start"); + assert.equal(startPayload.runtimeMode, "approval-required"); + assert.equal(startPayload.threadId, threadId); + } + + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "ProviderServiceLive restores claudeCode rollback routing after restart using persisted thread mapping", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-claude-restart-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + + const firstCodex = makeFakeCodexAdapter(); + const firstClaudeCode = makeFakeCodexAdapter("claudeCode"); + const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry, + makeRegistry(firstCodex, firstClaudeCode), + ), + ), + Layer.provide(firstDirectoryLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + const startedSession = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = asThreadId("thread-claude-1"); + return yield* provider.startSession(threadId, { + provider: "claudeCode", + cwd: "/tmp/claude-project", + runtimeMode: "full-access", + threadId, + }); + }).pipe(Effect.provide(firstProviderLayer)); + + const secondCodex = makeFakeCodexAdapter(); + const secondClaudeCode = makeFakeCodexAdapter("claudeCode"); + const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry, + makeRegistry(secondCodex, secondClaudeCode), + ), + ), + Layer.provide(secondDirectoryLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + secondClaudeCode.startSession.mockClear(); + secondClaudeCode.rollbackThread.mockClear(); + secondCodex.startSession.mockClear(); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.rollbackConversation({ + threadId: startedSession.threadId, + numTurns: 1, + }); + }).pipe(Effect.provide(secondProviderLayer)); + + assert.equal(secondClaudeCode.startSession.mock.calls.length, 1); + assert.equal(secondCodex.startSession.mock.calls.length, 0); + const resumedStartInput = secondClaudeCode.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/claude-project"); + assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor); + assert.equal(startPayload.threadId, startedSession.threadId); + } + assert.equal(secondClaudeCode.rollbackThread.mock.calls.length, 1); + + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), +); + routing.layer("ProviderServiceLive routing", (it) => { it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 398a26fb7..8931570be 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -10,6 +10,7 @@ * @module ProviderServiceLive */ import { + DEFAULT_PROVIDER_KIND, NonNegativeInt, ThreadId, ProviderInterruptTurnInput, @@ -258,10 +259,15 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => payload: rawInput, }); + const persistedBinding = + parsed.provider === undefined + ? Option.getOrUndefined(yield* directory.getBinding(threadId)) + : undefined; + const input = { ...parsed, threadId, - provider: parsed.provider ?? "codex", + provider: parsed.provider ?? persistedBinding?.provider ?? DEFAULT_PROVIDER_KIND, }; const adapter = yield* registry.getByProvider(input.provider); const session = yield* adapter.startSession(input); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1882c1cc0..9f7bd65fc 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -202,6 +202,43 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(legacyTableRows.length, 0); }).pipe(Effect.provide(directoryLayer)); + fs.rmSync(tempDir, { recursive: true, force: true }); + })); + + it("decodes persisted claudeCode mappings across layer restart", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-claude-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); + const threadId = ThreadId.makeUnsafe("thread-claude-restart"); + + yield* Effect.gen(function* () { + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + yield* runtimeRepository.upsert({ + threadId, + providerName: "claudeCode", + adapterKey: "claudeCode", + runtimeMode: "approval-required", + status: "stopped", + lastSeenAt: new Date().toISOString(), + resumeCursor: { opaque: "cursor-claude" }, + runtimePayload: { cwd: "/tmp/claude-project" }, + }); + }).pipe(Effect.provide(directoryLayer)); + + yield* Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + + const provider = yield* directory.getProvider(threadId); + assert.equal(provider, "claudeCode"); + + const resolvedBinding = yield* directory.getBinding(threadId); + assertSome(resolvedBinding, { + threadId, + provider: "claudeCode", + }); + }).pipe(Effect.provide(directoryLayer)); + fs.rmSync(tempDir, { recursive: true, force: true }); })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 69e1e439b..acc327865 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,5 +1,5 @@ -import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; -import { Effect, Layer, Option } from "effect"; +import { type ProviderKind, ProviderKind as ProviderKindSchema, type ThreadId } from "@t3tools/contracts"; +import { Effect, Layer, Option, Schema } from "effect"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { @@ -25,7 +25,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { + if (Schema.is(ProviderKindSchema)(providerName)) { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/ClaudeCodeAdapter.ts b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts new file mode 100644 index 000000000..f5b2de907 --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts @@ -0,0 +1,17 @@ +/** + * ClaudeCodeAdapter - Claude Code implementation of the generic provider adapter contract. + * + * @module ClaudeCodeAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface ClaudeCodeAdapterShape extends ProviderAdapterShape { + readonly provider: "claudeCode"; +} + +export class ClaudeCodeAdapter extends ServiceMap.Service()( + "t3/provider/Services/ClaudeCodeAdapter", +) {} \ No newline at end of file diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55b..a56d8ddc1 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -18,6 +18,7 @@ import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/Proj import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { ProviderUnsupportedError } from "./provider/Errors"; +import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; @@ -54,10 +55,14 @@ export function makeServerProviderLayer(): Layer.Layer< const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); + const claudeCodeAdapterLayer = makeClaudeCodeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( + Layer.provide(claudeCodeAdapterLayer), Layer.provide(codexAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 5ab5d3c90..161ae385a 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -22,6 +22,15 @@ describe("normalizeCustomModelSlugs", () => { ]), ).toEqual(["custom/internal-model"]); }); + + it("filters Claude Code built-ins and aliases while preserving custom slugs", () => { + expect( + normalizeCustomModelSlugs( + [" sonnet ", "claude-sonnet-4-5-20250929", "team/internal-claude"], + "claudeCode", + ), + ).toEqual(["team/internal-claude"]); + }); }); describe("getAppModelOptions", () => { @@ -47,6 +56,18 @@ describe("getAppModelOptions", () => { isCustom: true, }); }); + + it("supports saved Claude Code custom models", () => { + const options = getAppModelOptions("claudeCode", ["anthropic/sonnet-max"]); + + expect(options.map((option) => option.slug)).toEqual([ + "sonnet", + "opus", + "haiku", + "sonnet[1m]", + "anthropic/sonnet-max", + ]); + }); }); describe("resolveAppModelSelection", () => { @@ -59,6 +80,12 @@ describe("resolveAppModelSelection", () => { it("falls back to the provider default when no model is selected", () => { expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); }); + + it("preserves saved Claude Code custom models", () => { + expect(resolveAppModelSelection("claudeCode", ["anthropic/sonnet-max"], "anthropic/sonnet-max")).toBe( + "anthropic/sonnet-max", + ); + }); }); describe("getSlashModelOptions", () => { @@ -83,6 +110,17 @@ describe("getSlashModelOptions", () => { expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); }); + + it("includes Claude Code custom models in /model suggestions", () => { + const options = getSlashModelOptions( + "claudeCode", + ["anthropic/sonnet-max"], + "max", + "sonnet", + ); + + expect(options.map((option) => option.slug)).toEqual(["anthropic/sonnet-max"]); + }); }); describe("resolveAppServiceTier", () => { diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e58f7af4a..2f75654b4 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -28,9 +28,16 @@ const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ + claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), @@ -42,6 +49,9 @@ const AppSettingsSchema = Schema.Struct({ Schema.withConstructorDefault(() => Option.some(false)), ), codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))), + customClaudeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), @@ -107,6 +117,7 @@ export function normalizeCustomModelSlugs( function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, + customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeCode"), customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), }; } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index da44045c9..e58ffffdf 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -232,7 +232,18 @@ function SuspenseShikiCodeBlock({ ); } -function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { +function StreamingChatText({ text }: Pick) { + return ( +
+ {text} +
+ ); +} + +function MarkdownChatText({ text, cwd }: Pick) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownComponents = useMemo( @@ -274,7 +285,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { className={codeBlock.className} code={codeBlock.code} themeName={diffThemeName} - isStreaming={isStreaming} + isStreaming={false} /> @@ -282,11 +293,14 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); }, }), - [cwd, diffThemeName, isStreaming], + [cwd, diffThemeName], ); return ( -
+
{text} @@ -294,4 +308,8 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); } +function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { + return isStreaming ? : ; +} + export default memo(ChatMarkdown); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e2fd573fe..4b96e2239 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,9 +2,11 @@ import "../index.css"; import { + EventId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, + type OrchestrationThreadActivity, type ProjectId, type ServerConfig, type ThreadId, @@ -134,15 +136,80 @@ function createUserMessage(options: { }; } -function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number }) { +function createAssistantMessage(options: { + id: MessageId; + text: string; + offsetSeconds: number; + streaming?: boolean; + updatedOffsetSeconds?: number; +}) { return { id: options.id, role: "assistant" as const, text: options.text, turnId: null, - streaming: false, + streaming: options.streaming ?? false, createdAt: isoAt(options.offsetSeconds), - updatedAt: isoAt(options.offsetSeconds + 1), + updatedAt: isoAt(options.updatedOffsetSeconds ?? options.offsetSeconds + 1), + }; +} + +function createSnapshotWithMessages( + messages: OrchestrationReadModel["threads"][number]["messages"], +): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-bootstrap" as MessageId, + targetText: "bootstrap", + }); + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + Object.assign({}, thread, { + messages, + latestTurn: null, + activities: [], + proposedPlans: [], + checkpoints: [], + updatedAt: NOW_ISO, + }), + ), + }; +} + +function createSnapshotWithMessagesAndActivities(options: { + messages: OrchestrationReadModel["threads"][number]["messages"]; + activities: OrchestrationThreadActivity[]; +}): OrchestrationReadModel { + const snapshot = createSnapshotWithMessages(options.messages); + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + Object.assign({}, thread, { + activities: options.activities, + updatedAt: NOW_ISO, + }), + ), + }; +} + +function createActivity(overrides: { + id: string; + createdAt: string; + kind: OrchestrationThreadActivity["kind"]; + summary: string; + tone: OrchestrationThreadActivity["tone"]; + payload?: Record; +}): OrchestrationThreadActivity { + return { + id: EventId.makeUnsafe(overrides.id), + createdAt: overrides.createdAt, + kind: overrides.kind, + summary: overrides.summary, + tone: overrides.tone, + payload: overrides.payload ?? {}, + turnId: null, }; } @@ -748,6 +815,170 @@ describe("ChatView timeline estimator parity (full app)", () => { }, ); + it("renders streaming assistant text as literal pre-wrapped text without markdown emphasis", async () => { + const assistantMessageId = "msg-assistant-streaming-literal" as MessageId; + const streamingText = "First line\n **keep literal bold markers**"; + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithMessages([ + createUserMessage({ + id: "msg-user-streaming-context" as MessageId, + text: "Show me the live reply", + offsetSeconds: 0, + }), + createAssistantMessage({ + id: assistantMessageId, + text: streamingText, + offsetSeconds: 3, + streaming: true, + }), + ]), + }); + + try { + const row = await waitForElement( + () => + document.querySelector( + `[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`, + ), + "Unable to locate targeted streaming assistant row.", + ); + const markdown = await waitForElement( + () => row.querySelector(".chat-markdown"), + "Unable to locate streaming assistant markdown container.", + ); + + expect(markdown.dataset.chatMarkdownMode).toBe("plain-text"); + expect(markdown.textContent).toBe(streamingText); + expect(row.querySelector("strong")).toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + + it("omits sub-second completed assistant durations from message metadata while keeping markdown rendering", async () => { + const assistantMessageId = "msg-assistant-complete-meta" as MessageId; + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithMessages([ + createUserMessage({ + id: "msg-user-complete-context" as MessageId, + text: "Finish the reply", + offsetSeconds: 0, + }), + createAssistantMessage({ + id: assistantMessageId, + text: "**Bold when complete**", + offsetSeconds: 3, + updatedOffsetSeconds: 3.001, + }), + ]), + }); + + try { + const row = await waitForElement( + () => + document.querySelector( + `[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`, + ), + "Unable to locate targeted completed assistant row.", + ); + const markdown = await waitForElement( + () => row.querySelector(".chat-markdown"), + "Unable to locate completed assistant markdown container.", + ); + const meta = await waitForElement( + () => row.querySelector('[data-message-meta="assistant"]'), + "Unable to locate assistant metadata label.", + ); + + expect(markdown.dataset.chatMarkdownMode).toBe("markdown"); + expect(row.querySelector("strong")?.textContent).toBe("Bold when complete"); + expect(meta.textContent?.trim().length ?? 0).toBeGreaterThan(0); + expect(meta.textContent).not.toContain("•"); + expect(meta.textContent).not.toContain("ms"); + } finally { + await mounted.cleanup(); + } + }); + + it("renders Claude rich web search activity as visible work-log rows", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithMessagesAndActivities({ + messages: [ + createUserMessage({ + id: "msg-user-web-search" as MessageId, + text: "Search the web for weather", + offsetSeconds: 0, + }), + createAssistantMessage({ + id: "msg-assistant-web-search" as MessageId, + text: "Here is the answer.", + offsetSeconds: 6, + }), + ], + activities: [ + createActivity({ + id: "activity-web-search-updated", + createdAt: isoAt(2), + kind: "tool.updated", + summary: "web_search", + tone: "tool", + payload: { + itemType: "web_search", + detail: "weather nyc", + data: { + item: { + type: "server_tool_use", + toolName: "web_search", + summary: "weather nyc", + }, + }, + }, + }), + createActivity({ + id: "activity-web-search-completed", + createdAt: isoAt(4), + kind: "tool.completed", + summary: "web_search complete", + tone: "tool", + payload: { + itemType: "web_search", + detail: "Weather in NYC - Example", + data: { + item: { + type: "web_search_tool_result", + toolName: "web_search", + summary: "Weather in NYC - Example", + }, + }, + }, + }), + ], + }), + }); + + try { + const workRow = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-timeline-row-kind="work"]')).find((row) => + row.textContent?.includes("Searching the web"), + ) ?? null, + "Unable to locate rendered Claude rich-content work row.", + ); + const workText = workRow.textContent?.replace(/\s+/g, " ").trim() ?? ""; + + expect(workText).toContain("Tool calls (2)"); + expect(workText).toContain("Searching the web"); + expect(workText).toContain("weather nyc"); + expect(workText).toContain("Web search complete"); + expect(workText).toContain("Weather in NYC - Example"); + } finally { + await mounted.cleanup(); + } + }); + it("opens the project cwd for draft threads without a worktree path", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { diff --git a/apps/web/src/components/ChatView.providerOptions.test.ts b/apps/web/src/components/ChatView.providerOptions.test.ts new file mode 100644 index 000000000..69ea3e3b9 --- /dev/null +++ b/apps/web/src/components/ChatView.providerOptions.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + getAssistantDeliveryModeForDispatch, + getProviderOptionsForDispatch, + getSendTimeAssistantDeliveryMode, +} from "./ChatView.providerOptions"; + +beforeEach(() => { + window.localStorage.clear(); +}); + +const buildPersistedAppSettings = (enableAssistantStreaming: boolean) => ({ + claudeBinaryPath: "", + claudeHomePath: "", + codexBinaryPath: "", + codexHomePath: "", + confirmThreadDelete: true, + enableAssistantStreaming, + codexServiceTier: "auto", + customClaudeModels: [], + customCodexModels: [], +}); + +describe("getProviderOptionsForDispatch", () => { + it("returns Claude Code overrides when configured", () => { + expect( + getProviderOptionsForDispatch( + { + claudeBinaryPath: "/usr/local/bin/claude", + claudeHomePath: "/tmp/.claude", + codexBinaryPath: "", + codexHomePath: "", + }, + "claudeCode", + ), + ).toEqual({ + claudeCode: { + binaryPath: "/usr/local/bin/claude", + homePath: "/tmp/.claude", + }, + }); + }); + + it("omits provider overrides when the selected provider has no values", () => { + expect( + getProviderOptionsForDispatch( + { + claudeBinaryPath: "/usr/local/bin/claude", + claudeHomePath: "/tmp/.claude", + codexBinaryPath: "", + codexHomePath: "", + }, + "codex", + ), + ).toBeUndefined(); + }); +}); + +describe("getAssistantDeliveryModeForDispatch", () => { + it("maps enabled assistant streaming to streaming delivery", () => { + expect(getAssistantDeliveryModeForDispatch({ enableAssistantStreaming: true })).toBe("streaming"); + expect(getAssistantDeliveryModeForDispatch({ enableAssistantStreaming: false })).toBe("buffered"); + }); + + it("reads the latest persisted same-tab app settings snapshot at send time", () => { + window.localStorage.setItem( + "t3code:app-settings:v1", + JSON.stringify(buildPersistedAppSettings(false)), + ); + expect(getSendTimeAssistantDeliveryMode()).toBe("buffered"); + + window.localStorage.setItem( + "t3code:app-settings:v1", + JSON.stringify(buildPersistedAppSettings(true)), + ); + expect(getSendTimeAssistantDeliveryMode()).toBe("streaming"); + }); +}); \ No newline at end of file diff --git a/apps/web/src/components/ChatView.providerOptions.ts b/apps/web/src/components/ChatView.providerOptions.ts new file mode 100644 index 000000000..310e690ea --- /dev/null +++ b/apps/web/src/components/ChatView.providerOptions.ts @@ -0,0 +1,41 @@ +import type { AssistantDeliveryMode, ProviderKind } from "@t3tools/contracts"; + +import { getAppSettingsSnapshot, type AppSettings } from "../appSettings"; + +export function getProviderOptionsForDispatch( + settings: Pick, + provider: ProviderKind, +) { + const providerSettings = + provider === "claudeCode" + ? { + binaryPath: settings.claudeBinaryPath, + homePath: settings.claudeHomePath, + } + : { + binaryPath: settings.codexBinaryPath, + homePath: settings.codexHomePath, + }; + const normalizedOptions = { + ...(providerSettings.binaryPath ? { binaryPath: providerSettings.binaryPath } : {}), + ...(providerSettings.homePath ? { homePath: providerSettings.homePath } : {}), + }; + + if (Object.keys(normalizedOptions).length === 0) { + return undefined; + } + + return provider === "claudeCode" + ? { claudeCode: normalizedOptions } + : { codex: normalizedOptions }; +} + +export function getAssistantDeliveryModeForDispatch( + settings: Pick, +): AssistantDeliveryMode { + return settings.enableAssistantStreaming ? "streaming" : "buffered"; +} + +export function getSendTimeAssistantDeliveryMode(): AssistantDeliveryMode { + return getAssistantDeliveryModeForDispatch(getAppSettingsSnapshot()); +} \ No newline at end of file diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1693f419..077c6a7de 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -87,7 +87,7 @@ import { setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { useStore } from "../store"; +import { inferProviderFromModel, resolveEffectiveProvider, useStore } from "../store"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -114,6 +114,10 @@ import { summarizeTurnDiffStats, type TurnDiffTreeNode, } from "../lib/turnDiffTree"; +import { + getProviderOptionsForDispatch, + getSendTimeAssistantDeliveryMode, +} from "./ChatView.providerOptions"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { @@ -224,6 +228,8 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { return `${formatTimestamp(createdAt)} • ${duration}`; } +const MIN_VISIBLE_ASSISTANT_MESSAGE_DURATION_MS = 1_000; + function formatWorkingTimer(startIso: string, endIso: string): string | null { const startedAtMs = Date.parse(startIso); const endedAtMs = Date.parse(endIso); @@ -794,16 +800,28 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread.session !== null), ); const selectedServiceTierSetting = settings.codexServiceTier; - const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting); + const modelOptionsByProvider = useMemo( + () => getCustomModelOptionsByProvider(settings), + [settings], + ); + const inferredActiveThreadProvider = inferProviderFromModel(activeThread?.model ?? null, modelOptionsByProvider); + const preferredSelectedProvider = resolveEffectiveProvider({ + explicitProvider: selectedProviderByThreadId, + sessionProviderName: sessionProvider, + model: composerDraft.model ?? activeThread?.model ?? activeProject?.model ?? null, + modelOptionsByProvider, + }); const lockedProvider: ProviderKind | null = hasThreadStarted - ? (sessionProvider ?? selectedProviderByThreadId ?? null) + ? (sessionProvider ?? inferredActiveThreadProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const selectedProvider: ProviderKind = lockedProvider ?? preferredSelectedProvider; + const customModelsForSelectedProvider = getCustomModelSlugsByProvider(settings, selectedProvider); + const selectedServiceTier = + selectedProvider === "codex" ? resolveAppServiceTier(selectedServiceTierSetting) : null; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = settings.customCodexModels; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -830,11 +848,11 @@ export default function ChatView({ threadId }: ChatViewProps) { }; return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); - const selectedModelForPicker = selectedModel; - const modelOptionsByProvider = useMemo( - () => getCustomModelOptionsByProvider(settings), - [settings], + const selectedProviderOptionsForDispatch = useMemo( + () => getProviderOptionsForDispatch(settings, selectedProvider), + [selectedProvider, settings], ); + const selectedModelForPicker = selectedModel; const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; return currentOptions.some((option) => option.slug === selectedModelForPicker) @@ -1273,10 +1291,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; - const activeProvider = activeThread?.session?.provider ?? "codex"; - const activeProviderStatus = useMemo( - () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, - [activeProvider, providerStatuses], + const selectedProviderStatus = useMemo( + () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, + [providerStatuses, selectedProvider], ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; @@ -2626,8 +2643,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(selectedProviderOptionsForDispatch + ? { providerOptions: selectedProviderOptionsForDispatch } + : {}), provider: selectedProvider, - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + assistantDeliveryMode: getSendTimeAssistantDeliveryMode(), runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2903,7 +2923,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + ...(selectedProviderOptionsForDispatch + ? { providerOptions: selectedProviderOptionsForDispatch } + : {}), + assistantDeliveryMode: getSendTimeAssistantDeliveryMode(), runtimeMode, interactionMode: nextInteractionMode, createdAt: messageCreatedAt, @@ -2933,10 +2956,10 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedModel, selectedModelOptionsForDispatch, + selectedProviderOptionsForDispatch, selectedProvider, setComposerDraftInteractionMode, setThreadError, - settings.enableAssistantStreaming, ], ); @@ -3003,7 +3026,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + ...(selectedProviderOptionsForDispatch + ? { providerOptions: selectedProviderOptionsForDispatch } + : {}), + assistantDeliveryMode: getSendTimeAssistantDeliveryMode(), runtimeMode, interactionMode: "default", createdAt, @@ -3052,8 +3078,8 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedModel, selectedModelOptionsForDispatch, + selectedProviderOptionsForDispatch, selectedProvider, - settings.enableAssistantStreaming, syncServerReadModel, ]); @@ -3067,7 +3093,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection(provider, getCustomModelSlugsByProvider(settings, provider), model), ); scheduleComposerFocus(); }, @@ -3077,7 +3103,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, - settings.customCodexModels, + settings, ], ); const onEffortSelect = useCallback( @@ -3411,7 +3437,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} - + @@ -4098,15 +4124,15 @@ const ProviderHealthBanner = memo(function ProviderHealthBanner({ const defaultMessage = status.status === "error" - ? `${status.provider} provider is unavailable.` - : `${status.provider} provider has limited availability.`; + ? `${getProviderDisplayName(status.provider)} provider is unavailable.` + : `${getProviderDisplayName(status.provider)} provider has limited availability.`; return (
- {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} + {`${getProviderDisplayName(status.provider)} provider status`} {status.message ?? defaultMessage} @@ -5193,12 +5219,14 @@ const MessagesTimeline = memo(function MessagesTimeline({
); })()} -

+

{formatMessageMeta( row.message.createdAt, - row.message.streaming - ? formatElapsed(row.message.createdAt, nowIso) - : formatElapsed(row.message.createdAt, row.message.completedAt), + formatElapsed( + row.message.createdAt, + row.message.streaming ? nowIso : row.message.completedAt, + { minimumVisibleMs: MIN_VISIBLE_ASSISTANT_MESSAGE_DURATION_MS }, + ), )}

@@ -5285,7 +5313,7 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o label: string; available: true; } { - return option.available && option.value !== "claudeCode"; + return option.available; } const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); @@ -5296,13 +5324,26 @@ const COMING_SOON_PROVIDER_OPTIONS = [ ] as const; function getCustomModelOptionsByProvider(settings: { + customClaudeModels: readonly string[]; customCodexModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels), }; } +function getCustomModelSlugsByProvider( + settings: { customClaudeModels: readonly string[]; customCodexModels: readonly string[] }, + provider: ProviderKind, +): readonly string[] { + return provider === "claudeCode" ? settings.customClaudeModels : settings.customCodexModels; +} + +function getProviderDisplayName(provider: ProviderKind): string { + return provider === "claudeCode" ? "Claude Code" : "Codex"; +} + const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeCode: ClaudeAI, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index f133d377f..8bc76eaac 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,7 +1,11 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; +import { + COMPOSER_DRAFT_STORAGE_KEY, + type ComposerImageAttachment, + useComposerDraftStore, +} from "./composerDraftStore"; function makeImage(input: { id: string; @@ -166,6 +170,7 @@ describe("composerDraftStore project draft thread mapping", () => { draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); + window.localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); }); it("stores and reads project draft thread ids via actions", () => { @@ -394,19 +399,67 @@ describe("composerDraftStore setProvider", () => { it("persists provider-only selection even when prompt/model are empty", () => { const store = useComposerDraftStore.getState(); - store.setProvider(threadId, "codex"); + store.setProvider(threadId, "claudeCode"); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("codex"); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("claudeCode"); }); it("removes empty provider-only draft when provider is reset", () => { const store = useComposerDraftStore.getState(); - store.setProvider(threadId, "codex"); + store.setProvider(threadId, "claudeCode"); store.setProvider(threadId, null); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); + + it("stores Claude models against a Claude provider selection", () => { + const store = useComposerDraftStore.getState(); + + store.setProvider(threadId, "claudeCode"); + store.setModel(threadId, "sonnet"); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + provider: "claudeCode", + model: "sonnet", + }); + }); + + it("rehydrates persisted Claude provider selections from storage", async () => { + // Reset in-memory state first — persist middleware will write empty state to storage. + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + + // Write test data AFTER setState so the persist middleware does not overwrite it. + window.localStorage.setItem( + COMPOSER_DRAFT_STORAGE_KEY, + JSON.stringify({ + state: { + draftsByThreadId: { + [threadId]: { + prompt: "", + attachments: [], + provider: "claudeCode", + model: "sonnet", + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }, + version: 1, + }), + ); + + await useComposerDraftStore.persist.rehydrate(); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + provider: "claudeCode", + model: "sonnet", + }); + }); }); describe("composerDraftStore runtime and interaction settings", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2ac03a3ed..7e5956659 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,5 +1,6 @@ import { DEFAULT_REASONING_EFFORT_BY_PROVIDER, + MODEL_OPTIONS_BY_PROVIDER, ProjectId, REASONING_EFFORT_OPTIONS_BY_PROVIDER, ThreadId, @@ -171,6 +172,10 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ const REASONING_EFFORT_VALUES = new Set( REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, ); +const MODEL_SLUG_SET_BY_PROVIDER: Record> = { + codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)), +}; function createEmptyThreadDraft(): ComposerThreadDraftState { return { @@ -208,7 +213,25 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" ? value : null; + return value === "codex" || value === "claudeCode" ? value : null; +} + +function inferProviderKindFromModel(model: string | null | undefined): ProviderKind | null { + for (const provider of ["claudeCode", "codex"] satisfies readonly ProviderKind[]) { + const normalizedModel = normalizeModelSlug(model, provider); + if (normalizedModel && MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalizedModel)) { + return provider; + } + } + return null; +} + +function normalizeDraftModel( + model: string | null | undefined, + provider: ProviderKind | null, +): string | null { + const providerForModel = provider ?? inferProviderKindFromModel(model) ?? "codex"; + return normalizeModelSlug(model, providerForModel) ?? null; } function revokeObjectPreviewUrl(previewUrl: string): void { @@ -366,11 +389,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer return normalized ? [normalized] : []; }) : []; - const provider = normalizeProviderKind(draftCandidate.provider); - const model = - typeof draftCandidate.model === "string" - ? normalizeModelSlug(draftCandidate.model, provider ?? "codex") - : null; + const modelInput = typeof draftCandidate.model === "string" ? draftCandidate.model : null; + const provider = + normalizeProviderKind(draftCandidate.provider) ?? inferProviderKindFromModel(modelInput); + const model = normalizeDraftModel(modelInput, provider); const runtimeMode = draftCandidate.runtimeMode === "approval-required" || draftCandidate.runtimeMode === "full-access" @@ -809,9 +831,9 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const normalizedModel = normalizeModelSlug(model) ?? null; set((state) => { const existing = state.draftsByThreadId[threadId]; + const normalizedModel = normalizeDraftModel(model, existing?.provider ?? null); if (!existing && normalizedModel === null) { return state; } diff --git a/apps/web/src/providerAuthGuidance.ts b/apps/web/src/providerAuthGuidance.ts new file mode 100644 index 000000000..0cdec33c3 --- /dev/null +++ b/apps/web/src/providerAuthGuidance.ts @@ -0,0 +1,18 @@ +import type { ProviderKind } from "@t3tools/contracts"; + +export interface ProviderAuthGuidance { + readonly summary: string; + readonly detail: string; +} + +export function getProviderAuthGuidance(provider: ProviderKind): ProviderAuthGuidance | null { + if (provider !== "claudeCode") { + return null; + } + + return { + summary: "Auth modes: Max/Pro sign-in or Claude-native API key mode.", + detail: + "Use `claude auth login` for Max/Pro, or configure API key mode outside T3 Code with `ANTHROPIC_API_KEY`, `apiKeyHelper`, or `forceLoginMethod`. T3 Code does not store Claude secrets.", + }; +} \ No newline at end of file diff --git a/apps/web/src/routes/_chat.settings.test.ts b/apps/web/src/routes/_chat.settings.test.ts new file mode 100644 index 000000000..c115c4093 --- /dev/null +++ b/apps/web/src/routes/_chat.settings.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { getProviderAuthGuidance } from "../providerAuthGuidance"; + +describe("getProviderAuthGuidance", () => { + it("documents Claude Code's native auth modes without app-managed secrets", () => { + expect(getProviderAuthGuidance("claudeCode")).toEqual({ + summary: "Auth modes: Max/Pro sign-in or Claude-native API key mode.", + detail: + "Use `claude auth login` for Max/Pro, or configure API key mode outside T3 Code with `ANTHROPIC_API_KEY`, `apiKeyHelper`, or `forceLoginMethod`. T3 Code does not store Claude secrets.", + }); + }); + + it("does not add extra auth guidance for Codex", () => { + expect(getProviderAuthGuidance("codex")).toBeNull(); + }); +}); \ No newline at end of file diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index cc4a39a27..e523e7a3b 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; -import { type ProviderKind } from "@t3tools/contracts"; +import { type ProviderKind, type ServerProviderStatus } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { ZapIcon } from "lucide-react"; @@ -13,6 +13,7 @@ import { } from "../appSettings"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; +import { getProviderAuthGuidance } from "../providerAuthGuidance"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; @@ -47,6 +48,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: string; example: string; }> = [ + { + provider: "claudeCode", + title: "Claude Code", + description: "Save additional Claude Code model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "anthropic/claude-sonnet-max", + }, { provider: "codex", title: "Codex", @@ -56,11 +64,95 @@ const MODEL_PROVIDER_SETTINGS: Array<{ }, ] as const; +const PROVIDER_INSTALL_SETTINGS: Array<{ + provider: ProviderKind; + title: string; + description: string; + binaryLabel: string; + binaryPlaceholder: string; + homeLabel: string; + homePlaceholder: string; + homeHint: string; + resetLabel: string; +}> = [ + { + provider: "claudeCode", + title: "Claude Code", + description: "These overrides apply to new Claude Code sessions and let you use a non-default CLI install.", + binaryLabel: "Claude binary path", + binaryPlaceholder: "claude", + homeLabel: "CLAUDE_CONFIG_DIR path", + homePlaceholder: "/Users/you/.claude", + homeHint: "Optional custom Claude Code config directory.", + resetLabel: "Reset Claude overrides", + }, + { + provider: "codex", + title: "Codex", + description: "These overrides apply to new Codex sessions and let you use a non-default Codex install.", + binaryLabel: "Codex binary path", + binaryPlaceholder: "codex", + homeLabel: "CODEX_HOME path", + homePlaceholder: "/Users/you/.codex", + homeHint: "Optional custom Codex home/config directory.", + resetLabel: "Reset Codex overrides", + }, +] as const; + +function getProviderBinaryPath(settings: ReturnType["settings"], provider: ProviderKind) { + return provider === "claudeCode" ? settings.claudeBinaryPath : settings.codexBinaryPath; +} + +function getProviderHomePath(settings: ReturnType["settings"], provider: ProviderKind) { + return provider === "claudeCode" ? settings.claudeHomePath : settings.codexHomePath; +} + +function patchProviderOverrides(provider: ProviderKind, paths: { binaryPath: string; homePath: string }) { + return provider === "claudeCode" + ? { claudeBinaryPath: paths.binaryPath, claudeHomePath: paths.homePath } + : { codexBinaryPath: paths.binaryPath, codexHomePath: paths.homePath }; +} + +function formatProviderAuthStatus(status: ServerProviderStatus): string { + switch (status.authStatus) { + case "authenticated": + return "Authenticated"; + case "unauthenticated": + return "Authentication required"; + default: + return "Auth status unknown"; + } +} + +function formatProviderStatus(status: ServerProviderStatus): string { + switch (status.status) { + case "ready": + return "Ready"; + case "warning": + return "Limited"; + default: + return "Unavailable"; + } +} + +function getProviderStatusClasses(status: ServerProviderStatus): string { + switch (status.status) { + case "ready": + return "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-300"; + case "warning": + return "border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300"; + default: + return "border-destructive/30 bg-destructive/5 text-destructive"; + } +} + function getCustomModelsForProvider( settings: ReturnType["settings"], provider: ProviderKind, ) { switch (provider) { + case "claudeCode": + return settings.customClaudeModels; case "codex": default: return settings.customCodexModels; @@ -72,6 +164,8 @@ function getDefaultCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "claudeCode": + return defaults.customClaudeModels; case "codex": default: return defaults.customCodexModels; @@ -80,6 +174,8 @@ function getDefaultCustomModelsForProvider( function patchCustomModels(provider: ProviderKind, models: string[]) { switch (provider) { + case "claudeCode": + return { customClaudeModels: models }; case "codex": default: return { customCodexModels: models }; @@ -96,15 +192,15 @@ function SettingsRouteView() { Record >({ codex: "", + claudeCode: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); - const codexBinaryPath = settings.codexBinaryPath; - const codexHomePath = settings.codexHomePath; const codexServiceTier = settings.codexServiceTier; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; + const providerStatuses = serverConfigQuery.data?.providers ?? []; const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -244,62 +340,145 @@ function SettingsRouteView() {
-

Codex App Server

+

Provider status

- These overrides apply to new sessions and let you use a non-default Codex install. + Current Claude Code and Codex availability reported by the desktop server.

-
- - - - -
-

- Binary source:{" "} - {codexBinaryPath || "PATH"} -

- -
+
+ {PROVIDER_INSTALL_SETTINGS.map((providerSettings) => { + const status = providerStatuses.find( + (entry) => entry.provider === providerSettings.provider, + ); + const authGuidance = getProviderAuthGuidance(providerSettings.provider); + const binaryPath = getProviderBinaryPath(settings, providerSettings.provider); + return ( +
+
+
+

{providerSettings.title}

+

+ {status?.message ?? "Waiting for provider health check."} +

+
+ + {status ? formatProviderStatus(status) : "Unknown"} + +
+
+

{status ? formatProviderAuthStatus(status) : "Auth status unknown"}

+ {authGuidance ?

{authGuidance.summary}

: null} +

+ Binary source:{" "} + {binaryPath || "PATH"} +

+
+
+ ); + })}
+ {PROVIDER_INSTALL_SETTINGS.map((providerSettings) => { + const binaryPath = getProviderBinaryPath(settings, providerSettings.provider); + const homePath = getProviderHomePath(settings, providerSettings.provider); + const defaultBinaryPath = getProviderBinaryPath(defaults, providerSettings.provider); + const defaultHomePath = getProviderHomePath(defaults, providerSettings.provider); + const authGuidance = getProviderAuthGuidance(providerSettings.provider); + + return ( +
+
+

{providerSettings.title} CLI

+

{providerSettings.description}

+
+ +
+ {authGuidance ? ( +
+

Authentication

+

{authGuidance.detail}

+
+ ) : null} + + + + + +
+

+ Binary source:{" "} + {binaryPath || "PATH"} +

+ +
+
+
+ ); + })} +

Models

diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3d1d269d4..98e098157 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -10,6 +10,7 @@ import { deriveTimelineEntries, deriveWorkLogEntries, findLatestProposedPlan, + formatElapsed, hasToolActivityForTurn, isLatestTurnSettled, } from "./session-logic"; @@ -37,6 +38,24 @@ function makeActivity(overrides: { }; } +describe("formatElapsed", () => { + it("hides elapsed labels below the configured visibility threshold", () => { + expect( + formatElapsed("2026-02-23T00:00:00.000Z", "2026-02-23T00:00:00.999Z", { + minimumVisibleMs: 1_000, + }), + ).toBeNull(); + }); + + it("keeps elapsed labels once the configured visibility threshold is reached", () => { + expect( + formatElapsed("2026-02-23T00:00:00.000Z", "2026-02-23T00:00:01.000Z", { + minimumVisibleMs: 1_000, + }), + ).toBe("1.0s"); + }); +}); + describe("derivePendingApprovals", () => { it("tracks open approvals and removes resolved ones", () => { const activities: OrchestrationThreadActivity[] = [ @@ -344,6 +363,90 @@ describe("deriveWorkLogEntries", () => { expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); + it("formats Claude rich web search lifecycle rows with concise labels", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "web-search-updated", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "web_search", + tone: "tool", + payload: { + itemType: "web_search", + detail: "weather nyc", + data: { + item: { + type: "server_tool_use", + toolName: "web_search", + summary: "weather nyc", + }, + }, + }, + }), + makeActivity({ + id: "web-search-completed", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "web_search complete", + tone: "tool", + payload: { + itemType: "web_search", + detail: "Weather in NYC - Example", + data: { + item: { + type: "web_search_tool_result", + toolName: "web_search", + summary: "Weather in NYC - Example", + }, + }, + }, + }), + ]; + + expect(deriveWorkLogEntries(activities, undefined)).toEqual([ + { + id: "web-search-updated", + createdAt: "2026-02-23T00:00:01.000Z", + label: "Searching the web", + detail: "weather nyc", + tone: "tool", + }, + { + id: "web-search-completed", + createdAt: "2026-02-23T00:00:02.000Z", + label: "Web search complete", + detail: "Weather in NYC - Example", + tone: "tool", + }, + ]); + }); + + it("keeps non-rich tool rows unchanged", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-complete", + createdAt: "2026-02-23T00:00:03.000Z", + summary: "Run tests complete", + kind: "tool.completed", + tone: "tool", + payload: { + itemType: "command_execution", + detail: "bun run test -- src/session-logic.test.ts", + }, + }), + ]; + + expect(deriveWorkLogEntries(activities, undefined)).toEqual([ + { + id: "command-complete", + createdAt: "2026-02-23T00:00:03.000Z", + label: "Run tests complete", + detail: "bun run test -- src/session-logic.test.ts", + tone: "tool", + }, + ]); + }); + it("omits task start and completion lifecycle entries", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -640,18 +743,18 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { + it("keeps Claude Code available while leaving Cursor as an unavailable placeholder", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, + { value: "claudeCode", label: "Claude Code", available: true }, { value: "cursor", label: "Cursor", available: false }, ]); expect(claude).toEqual({ value: "claudeCode", 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 e9351ca2b..c41a19183 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -18,7 +18,7 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, + { value: "claudeCode", label: "Claude Code", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; @@ -63,6 +63,38 @@ export interface LatestProposedPlanState { planMarkdown: string; } +function deriveRichContentWorkLogEntry( + activity: OrchestrationThreadActivity, + payload: Record | null, +): Pick | null { + if (!payload || payload.itemType !== "web_search") { + return null; + } + const data = payload.data && typeof payload.data === "object" + ? (payload.data as Record) + : null; + const item = data?.item && typeof data.item === "object" + ? (data.item as Record) + : null; + const detail = typeof payload.detail === "string" && payload.detail.length > 0 ? payload.detail : undefined; + + if (activity.kind === "tool.updated" && item?.type === "server_tool_use") { + return { + label: "Searching the web", + ...(detail ? { detail } : {}), + }; + } + + if (activity.kind === "tool.completed" && item?.type === "web_search_tool_result") { + return { + label: "Web search complete", + ...(detail ? { detail } : {}), + }; + } + + return null; +} + export type TimelineEntry = | { id: string; @@ -103,14 +135,30 @@ export function formatDuration(durationMs: number): string { return `${minutes}m ${seconds}s`; } -export function formatElapsed(startIso: string, endIso: string | undefined): string | null { +export interface FormatElapsedOptions { + minimumVisibleMs?: number; +} + +export function formatElapsed( + startIso: string, + endIso: string | undefined, + options: FormatElapsedOptions = {}, +): string | null { if (!endIso) return null; const startedAt = Date.parse(startIso); const endedAt = Date.parse(endIso); if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { return null; } - return formatDuration(endedAt - startedAt); + const elapsedMs = endedAt - startedAt; + if ( + options.minimumVisibleMs !== undefined && + Number.isFinite(options.minimumVisibleMs) && + elapsedMs < options.minimumVisibleMs + ) { + return null; + } + return formatDuration(elapsedMs); } type LatestTurnTiming = Pick; @@ -417,15 +465,18 @@ export function deriveWorkLogEntries( activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) : null; + const richContentEntry = deriveRichContentWorkLogEntry(activity, payload); const command = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const entry: WorkLogEntry = { id: activity.id, createdAt: activity.createdAt, - label: activity.summary, + label: richContentEntry?.label ?? activity.summary, tone: activity.tone === "approval" ? "info" : activity.tone, }; - if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { + if (richContentEntry?.detail) { + entry.detail = richContentEntry.detail; + } else if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { entry.detail = payload.detail; } if (command) { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 145d8301e..b0a6e3357 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -7,7 +7,13 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { markThreadUnread, syncServerReadModel, type AppState } from "./store"; +import { + inferProviderFromModel, + markThreadUnread, + resolveEffectiveProvider, + syncServerReadModel, + type AppState, +} from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { @@ -135,16 +141,69 @@ describe("store pure functions", () => { }); describe("store read model sync", () => { - it("falls back to the codex default for unsupported provider models without an active session", () => { + it("keeps Claude provider models for non-session Claude threads", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( makeReadModelThread({ - model: "claude-opus-4-6", + model: "claude-opus-4-5-20250929", }), ); const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); + expect(next.threads[0]?.model).toBe("opus"); + }); + + it("keeps Claude as the project default model when the read model defaults to Claude", () => { + const initialState: AppState = { + projects: [], + threads: [makeThread()], + threadsHydrated: true, + }; + const readModel = { + ...makeReadModel(makeReadModelThread({})), + projects: [ + { + id: ProjectId.makeUnsafe("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: "claude-sonnet-4-5-20250929", + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + deletedAt: null, + scripts: [], + }, + ], + } satisfies OrchestrationReadModel; + + const next = syncServerReadModel(initialState, readModel); + + expect(next.projects[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.claudeCode); + }); +}); + +describe("provider inference helpers", () => { + it("infers Claude Code from Claude model aliases", () => { + expect(inferProviderFromModel("claude-sonnet-4-5-20250929")).toBe("claudeCode"); + }); + + it("prefers the explicit provider choice before session or model fallbacks", () => { + expect( + resolveEffectiveProvider({ + explicitProvider: "claudeCode", + sessionProviderName: "codex", + model: "gpt-5.3-codex", + }), + ).toBe("claudeCode"); + }); + + it("uses the active session provider when there is no explicit selection", () => { + expect( + resolveEffectiveProvider({ + explicitProvider: null, + sessionProviderName: "claudeCode", + model: "gpt-5.3-codex", + }), + ).toBe("claudeCode"); }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 65c966537..761bcde04 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -9,7 +9,6 @@ import { import { getModelOptions, normalizeModelSlug, - resolveModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { create } from "zustand"; @@ -41,6 +40,13 @@ const initialState: AppState = { threadsHydrated: false, }; const persistedExpandedProjectCwds = new Set(); +const PROVIDER_KINDS = ["claudeCode", "codex"] as const satisfies readonly ProviderKind[]; +const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { + codex: new Set(getModelOptions("codex").map((option) => option.slug)), + claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), +}; + +type ModelOptionsByProvider = Record>; // ── Persist helpers ────────────────────────────────────────────────── @@ -106,13 +112,17 @@ function mapProjectsFromReadModel( const existing = previous.find((entry) => entry.id === project.id) ?? previous.find((entry) => entry.cwd === project.workspaceRoot); + const inferredProjectProvider = inferProviderFromModel(project.defaultModel) ?? "codex"; return { id: project.id, name: project.title, cwd: project.workspaceRoot, model: existing?.model ?? - resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex), + resolveModelSlugForProvider( + inferredProjectProvider, + project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER[inferredProjectProvider], + ), expanded: existing?.expanded ?? (persistedExpandedProjectCwds.size > 0 @@ -123,6 +133,43 @@ function mapProjectsFromReadModel( }); } +function normalizeProviderName(providerName: string | null | undefined): ProviderKind | null { + return providerName === "codex" || providerName === "claudeCode" ? providerName : null; +} + +export function inferProviderFromModel( + model: string | null | undefined, + modelOptionsByProvider?: ModelOptionsByProvider, +): ProviderKind | null { + const modelSlugsByProvider = modelOptionsByProvider + ? { + codex: new Set(modelOptionsByProvider.codex.map((option) => option.slug)), + claudeCode: new Set(modelOptionsByProvider.claudeCode.map((option) => option.slug)), + } + : BUILT_IN_MODEL_SLUGS_BY_PROVIDER; + for (const provider of PROVIDER_KINDS) { + const normalizedModel = normalizeModelSlug(model, provider); + if (normalizedModel && modelSlugsByProvider[provider].has(normalizedModel)) { + return provider; + } + } + return null; +} + +export function resolveEffectiveProvider(input: { + readonly explicitProvider?: ProviderKind | null; + readonly sessionProviderName?: string | null; + readonly model?: string | null | undefined; + readonly modelOptionsByProvider?: ModelOptionsByProvider; +}): ProviderKind { + return ( + input.explicitProvider ?? + normalizeProviderName(input.sessionProviderName) ?? + inferProviderFromModel(input.model, input.modelOptionsByProvider) ?? + "codex" + ); +} + function toLegacySessionStatus( status: OrchestrationSessionStatus, ): "connecting" | "ready" | "running" | "error" | "closed" { @@ -143,26 +190,16 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex") { - return providerName; - } - return "codex"; + return normalizeProviderName(providerName) ?? "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") { - return input.sessionProviderName; - } - const normalizedCodex = normalizeModelSlug(input.model, "codex"); - if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { - return "codex"; - } - return "codex"; + return ( + normalizeProviderName(input.sessionProviderName) ?? inferProviderFromModel(input.model) ?? "codex" + ); } function resolveWsHttpOrigin(): string { diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts new file mode 100644 index 000000000..10cabe62d --- /dev/null +++ b/packages/contracts/src/model.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { + DEFAULT_MODEL_BY_PROVIDER, + MODEL_OPTIONS_BY_PROVIDER, + MODEL_SLUG_ALIASES_BY_PROVIDER, + ProviderModelOptions, +} from "./model"; + +const decodeProviderModelOptions = Schema.decodeUnknownSync(ProviderModelOptions); + +describe("ProviderModelOptions", () => { + it("accepts claude code-scoped model options", () => { + const parsed = decodeProviderModelOptions({ + claudeCode: {}, + }); + + expect(parsed.claudeCode).toEqual({}); + }); +}); + +describe("claude code model catalog", () => { + it("defines built-in options and defaults", () => { + expect(MODEL_OPTIONS_BY_PROVIDER.claudeCode).toEqual([ + { slug: "sonnet", name: "Sonnet" }, + { slug: "opus", name: "Opus" }, + { slug: "haiku", name: "Haiku" }, + { slug: "sonnet[1m]", name: "Sonnet (1M context)" }, + ]); + expect(DEFAULT_MODEL_BY_PROVIDER.claudeCode).toBe("sonnet"); + }); + + it("maps known full model names back to built-in aliases", () => { + expect(MODEL_SLUG_ALIASES_BY_PROVIDER.claudeCode["claude-sonnet-4-5-20250929"]).toBe( + "sonnet", + ); + expect( + MODEL_SLUG_ALIASES_BY_PROVIDER.claudeCode[ + "anthropic.claude-sonnet-4-5-20250929-v1:0[1m]" + ], + ).toBe("sonnet[1m]"); + }); +}); \ No newline at end of file diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09d..6e109138a 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -10,8 +10,12 @@ export const CodexModelOptions = Schema.Struct({ }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const ClaudeCodeModelOptions = Schema.Struct({}); +export type ClaudeCodeModelOptions = typeof ClaudeCodeModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), + claudeCode: Schema.optional(ClaudeCodeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -28,6 +32,12 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + claudeCode: [ + { slug: "sonnet", name: "Sonnet" }, + { slug: "opus", name: "Opus" }, + { slug: "haiku", name: "Haiku" }, + { slug: "sonnet[1m]", name: "Sonnet (1M context)" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -36,6 +46,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + claudeCode: "sonnet", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -46,12 +57,23 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + claudeCode: { + "claude-sonnet-4-5-20250929": "sonnet", + "anthropic.claude-sonnet-4-5-20250929-v1:0": "sonnet", + "anthropic.claude-sonnet-4-5-20250929-v1:0[1m]": "sonnet[1m]", + "claude-opus-4-5-20250929": "opus", + "anthropic.claude-opus-4-5-20250929-v1:0": "opus", + "claude-haiku-4-5-20251001": "haiku", + "anthropic.claude-haiku-4-5-20251001-v1:0": "haiku", + }, } as const satisfies Record>; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, + claudeCode: [], } as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", + claudeCode: null, } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 25a641edb..25315eedd 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -140,6 +140,42 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( }), ); +it.effect("accepts claude code as an explicit provider in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-claude", + threadId: "thread-1", + message: { + messageId: "msg-claude", + role: "user", + text: "hello", + attachments: [], + }, + provider: "claudeCode", + model: "sonnet", + modelOptions: { + claudeCode: {}, + }, + providerOptions: { + claudeCode: { + binaryPath: "/usr/local/bin/claude", + homePath: "/tmp/.claude", + }, + }, + runtimeMode: "full-access", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + assert.strictEqual(parsed.provider, "claudeCode"); + assert.deepStrictEqual(parsed.modelOptions?.claudeCode, {}); + assert.deepStrictEqual(parsed.providerOptions?.claudeCode, { + binaryPath: "/usr/local/bin/claude", + homePath: "/tmp/.claude", + }); + }), +); + it.effect("decodes thread.created runtime mode for historical events", () => Effect.gen(function* () { const parsed = yield* decodeThreadCreatedPayload({ @@ -196,6 +232,7 @@ it.effect( createdAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.provider, undefined); + assert.strictEqual(parsed.providerOptions, undefined); assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); }), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index aa7bd827d..e06a8faa7 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", "claudeCode"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -65,6 +65,21 @@ export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown); export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; +const CodexProviderCommandOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + homePath: Schema.optional(TrimmedNonEmptyString), +}); + +const ClaudeCodeProviderCommandOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + homePath: Schema.optional(TrimmedNonEmptyString), +}); + +const ProviderCommandOptions = Schema.Struct({ + codex: Schema.optional(CodexProviderCommandOptions), + claudeCode: Schema.optional(ClaudeCodeProviderCommandOptions), +}); + export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; @@ -366,6 +381,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderCommandOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -388,6 +404,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderCommandOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -668,6 +685,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderCommandOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 997db09b7..0df55ba8c 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -34,6 +34,30 @@ describe("ProviderSessionStartInput", () => { expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); }); + it("accepts claude code-compatible payloads", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "claudeCode", + cwd: "/tmp/workspace", + model: "sonnet", + modelOptions: { + claudeCode: {}, + }, + runtimeMode: "full-access", + providerOptions: { + claudeCode: { + binaryPath: "/usr/local/bin/claude", + homePath: "/tmp/.claude", + }, + }, + }); + + expect(parsed.provider).toBe("claudeCode"); + expect(parsed.modelOptions?.claudeCode).toEqual({}); + expect(parsed.providerOptions?.claudeCode?.binaryPath).toBe("/usr/local/bin/claude"); + expect(parsed.providerOptions?.claudeCode?.homePath).toBe("/tmp/.claude"); + }); + it("rejects payloads without runtime mode", () => { expect(() => decodeProviderSessionStartInput({ @@ -61,4 +85,17 @@ describe("ProviderSendTurnInput", () => { expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh"); expect(parsed.modelOptions?.codex?.fastMode).toBe(true); }); + + it("accepts claude code model options", () => { + const parsed = decodeProviderSendTurnInput({ + threadId: "thread-1", + model: "sonnet", + modelOptions: { + claudeCode: {}, + }, + }); + + expect(parsed.model).toBe("sonnet"); + expect(parsed.modelOptions?.claudeCode).toEqual({}); + }); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9ca7068ad..9bb64edd8 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -53,8 +53,14 @@ const CodexProviderStartOptions = Schema.Struct({ homePath: Schema.optional(TrimmedNonEmptyStringSchema), }); +const ClaudeCodeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + homePath: Schema.optional(TrimmedNonEmptyStringSchema), +}); + const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), }); export const ProviderSessionStartInput = Schema.Struct({ diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 903bb5da7..b532098d5 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-code.stream-json", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8771a24c1..147a3cecc 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -14,6 +14,10 @@ describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); + expect(normalizeModelSlug("claude-sonnet-4-5-20250929", "claudeCode")).toBe("sonnet"); + expect(normalizeModelSlug("anthropic.claude-sonnet-4-5-20250929-v1:0[1m]", "claudeCode")).toBe( + "sonnet[1m]", + ); }); it("returns null for empty or missing values", () => { @@ -49,21 +53,36 @@ describe("resolveModelSlug", () => { for (const model of MODEL_OPTIONS_BY_PROVIDER.codex) { expect(resolveModelSlug(model.slug)).toBe(model.slug); } + + for (const model of MODEL_OPTIONS_BY_PROVIDER.claudeCode) { + expect(resolveModelSlug(model.slug, "claudeCode")).toBe(model.slug); + } }); + it("keeps codex defaults for backward compatibility", () => { expect(getDefaultModel()).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); expect(getModelOptions()).toEqual(MODEL_OPTIONS_BY_PROVIDER.codex); }); + + it("returns claude code defaults when requested", () => { + expect(getDefaultModel("claudeCode")).toBe(DEFAULT_MODEL_BY_PROVIDER.claudeCode); + expect(getModelOptions("claudeCode")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeCode); + }); }); describe("getReasoningEffortOptions", () => { it("returns codex reasoning options for codex", () => { expect(getReasoningEffortOptions("codex")).toEqual(["xhigh", "high", "medium", "low"]); }); + + it("returns no reasoning options for claude code", () => { + expect(getReasoningEffortOptions("claudeCode")).toEqual([]); + }); }); describe("getDefaultReasoningEffort", () => { it("returns provider-scoped defaults", () => { expect(getDefaultReasoningEffort("codex")).toBe("high"); + expect(getDefaultReasoningEffort("claudeCode")).toBeNull(); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb9..17a472746 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,8 +1,10 @@ import { CODEX_REASONING_EFFORT_OPTIONS, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, + REASONING_EFFORT_OPTIONS_BY_PROVIDER, type CodexReasoningEffort, type ModelSlug, type ProviderKind, @@ -12,6 +14,7 @@ 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)), + claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)), }; export function getModelOptions(provider: ProviderKind = "codex") { @@ -64,7 +67,7 @@ export function resolveModelSlugForProvider( export function getReasoningEffortOptions( provider: ProviderKind = "codex", ): ReadonlyArray { - return provider === "codex" ? CODEX_REASONING_EFFORT_OPTIONS : []; + return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; } export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; @@ -72,7 +75,7 @@ export function getDefaultReasoningEffort(provider: ProviderKind): CodexReasonin export function getDefaultReasoningEffort( provider: ProviderKind = "codex", ): CodexReasoningEffort | null { - return provider === "codex" ? "high" : null; + return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; } export { CODEX_REASONING_EFFORT_OPTIONS };