diff --git a/bun.lock b/bun.lock index d55babc1b6..84098662f1 100644 --- a/bun.lock +++ b/bun.lock @@ -274,6 +274,7 @@ "@ai-sdk/xai": "3.0.82", "@altimateai/altimate-core": "0.9.0", "@altimateai/drivers": "workspace:*", + "@anthropic-ai/claude-agent-sdk": "0.3.301", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", @@ -665,6 +666,24 @@ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.301", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.301", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.301", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.301", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.301", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.301", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.301", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.301", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.301" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-mAe4zIFRL9BejcFHQVc+D0gxrMR/7mDt5GgdekfWB2DNWZebHoeGhgP2R3rZ/BcEZnW10nSGV2IwWyywwZw83Q=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.301", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Hva/QZr94pm9mN6Trz59XGLgRPn8zLPrghRK8/MsvLCTrCzD1CNd9Pe1pUV1woTX29qPOGP3xRU3Mgm2LhIL7A=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.301", "", { "os": "darwin", "cpu": "x64" }, "sha512-k6jNdu9eEhkPLTPTwHTn3rbIpKHm0mcndkS4wAZtQehCIFc69tmZZ4yeEtQ4eZ2jR0+6ttLtRy+5Q82Q6Ohm0Q=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.301", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uqjy+YD6ImvelrFs7WHRMboT3BqknGLrDSdiejDqzFk62TZZOqIH9OFHyiyvHexoO31CpDEcn+d7LB+SgpNurA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.301", "", { "os": "linux", "cpu": "arm64" }, "sha512-C1+q1cYZY2bYxDYCfHHaq9ToGMTO/6FdbB0JShyPCyAt0lrRC+myNgsvPq/0ZsrtPWbcbrpX6QgbsFn2QzEWcw=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.301", "", { "os": "linux", "cpu": "x64" }, "sha512-V0zrxg59pBSp6PXPD70m/nRwUApKM/q5SJdeHq2ZAn7mcAr/GIBRoBEAIrA2kOAK9EkIyjKNKkrwuOVeyId/lA=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.301", "", { "os": "linux", "cpu": "x64" }, "sha512-qmHolcb+CgzYc4rfZrJd5iEIzOPRPz2edJBH+8V4W4R+bswsNaqf59NBkrDt3GdfmGr42touHYJ/4dV+g/b5eQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.301", "", { "os": "win32", "cpu": "arm64" }, "sha512-V/iwQ3/wcxeQaycw2Daym9CzxssjYXIWtQJEFoU+Zrck3++2lD24KxFK0pwB3f258/uwdKoAM0x5p7+FEMRzwQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.301", "", { "os": "win32", "cpu": "x64" }, "sha512-FJe1o2nt+K7mQ7KG5VdEoCEo3HtUmEeITCn6ZswKGEHmgmZDogDnExQ649+IYuRXpW5dAQlYqdWaEA6cvtmoXA=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 7dac03ff9a..84402a9e41 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -155,6 +155,47 @@ identity. Available models: `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001` +## Claude Agent (Claude subscription) + +Claude Agent runs Claude models through your own Claude Code login, so a Claude Pro or Max subscription covers usage. Altimate Code starts your installed `claude` program through Anthropic's Claude Agent SDK. It never reads or stores your Claude Code token. + +1. Install [Claude Code](https://code.claude.com/docs/en/setup) (2.1.248 or later) and run `claude auth login`. +2. In Altimate Code, run `/connect` and pick **Claude Agent**. The dialog checks your Claude Code login and connects once it finds one. +3. Pick a model with `/models`. The list is what your Claude Code reports, such as `claude-agent/opus`, `claude-agent/sonnet` and `claude-agent/haiku`, and each model offers Claude Code's effort levels as variants. + +Any Claude Code login works: a subscription, an API key or a cloud account. A subscription shows $0 cost. An API key or cloud login is priced from that provider's catalog. + +How it behaves: + +- Claude uses Altimate Code's tools, permissions, agents, skills and `AGENTS.md` instructions. Claude Code's own tools, settings files, `CLAUDE.md`, hooks, MCP servers, memory and auto compaction are turned off. +- Chats use Claude Code's system prompt plus the selected agent's prompt as its role. Temperature, top-p and top-k are not supported. +- You can switch between Claude Agent and other models in the same session. Claude is sent the part of the conversation it has not seen. +- Each session keeps one `claude` process, which stops after 5 minutes idle and resumes the saved Claude session on the next message. +- `claude` must be installed on the same machine. Set `OPENCODE_CLAUDE_PATH` to run a specific `claude` binary instead of the one on `PATH`. + +Connection settings in config map to Claude Code variables: + +```json +{ + "provider": { + "claude-agent": { + "options": { + "baseURL": "https://gateway.example.com", + "headers": { "x-team": "data" } + } + } + } +} +``` + +| Option | Claude Code variable | +|--------|---------------------| +| `baseURL` | `ANTHROPIC_BASE_URL` | +| `apiKey` | `ANTHROPIC_API_KEY` | +| `authToken` | `ANTHROPIC_AUTH_TOKEN` | +| `headers` | `ANTHROPIC_CUSTOM_HEADERS` | +| `body` | `CLAUDE_CODE_EXTRA_BODY` | + ## OpenAI ```json diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3599641b6c..2d6190f55f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -81,6 +81,7 @@ "@ai-sdk/xai": "3.0.82", "@altimateai/altimate-core": "0.9.0", "@altimateai/drivers": "workspace:*", + "@anthropic-ai/claude-agent-sdk": "0.3.301", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", diff --git a/packages/opencode/src/altimate/provider/claude-agent/README.md b/packages/opencode/src/altimate/provider/claude-agent/README.md new file mode 100644 index 0000000000..f4f5119e8c --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/README.md @@ -0,0 +1,83 @@ +# Claude Agent provider + + + + + + + + + + +## Purpose + +Runs Claude models through the user's own Claude Code login (`claude auth login`), so a Claude Pro/Max +subscription covers usage. The CLI starts the user's installed `claude` through +`@anthropic-ai/claude-agent-sdk`, turns off Claude Code's own tools and settings, and gives Claude the +CLI's tools over an in-process MCP server. It never reads Claude Code's token. + +Ported from upstream opencode PR 58782, "feat(core): add Claude Agent provider" (merge commit +`f29e281322b`). Upstream builds it in +`packages/core/src/plugin/provider/claude-agent/` on core's v2 plugin context. The shipped CLI does not +run that runtime, so the upstream modules that only depend on the SDK live here, and the plugin-context +wiring is replaced by the call sites below. + +## Layout + +Copied from upstream with import paths adjusted (compare against the upstream folder in a bridge merge): + +- `claude/` — `claude` process lifecycle: probe, per-session processes, status, forks, SDK loading. +- `sync/` — matches the CLI's history with Claude's saved session (catch-up, checkpoints, branches). +- `tools/` — the MCP tool server, PreToolUse/PostToolUse hooks, held tool calls and results. +- `language-model/` — the `LanguageModelV3` the AI SDK calls, and Claude stream conversion. + +Altimate-only integration (replaces upstream `index.ts`, `provider.ts`, `aisdk.ts`, `integration.ts`, +`session.ts`, `instructions.ts`): + +- `id.ts` — provider ID `claude-agent` and package name `@opencode/claude-agent`. +- `index.ts` — `ClaudeAgent`: session registry per instance, AI SDK factory, login check, request routing. +- `catalog.ts` — saved model list from Claude Code, pricing by login type, `Provider.Model` entries. +- `storage.ts` — JSON files under `/claude-agent/` (model list, per-session Claude records). +- `plugin.ts` — internal plugin: `/connect` login method, session-idle release, config connection. + +## Call sites (all inside `altimate_change` markers) + +- `provider/provider.ts` — bundled SDK factory, provider database entry, `haiku` as small model. +- `provider/transform.ts` — `variants()` returns the effort levels Claude Code reported. +- `session/llm.ts` — request kind and session headers, agent role and instructions as options, no default output cap + (a plugin's explicit `maxOutputTokens` still applies). +- `session/message-v2.ts` — stopped Claude Agent tool calls carry `toolAborted` (see `tools/aborted.ts`). +- `plugin/index.ts` — registers `ClaudeAgentPlugin`. +- `packages/tui/src/component/dialog-provider.tsx` — listed under Popular with a subscription hint. + +## Differences from upstream + +- **Request kinds:** titles, summaries and compaction run as standalone side calls with the CLI's prompt. + Upstream copies the chat's Claude session for summaries and compaction to reuse its prompt cache; the + shipped runtime has no hook to snapshot a session for them. Forked sessions start a fresh Claude session. +- **Tool calls:** `convert-claude-stream.ts` emits `tool-input-start/delta/end` before each `tool-call`, + because the shipped session processor only records a call whose part `tool-input-start` opened. +- **Instructions:** taken from `session/llm.ts` input (`input.system` and the user's system text) rather than + keyed instruction metadata. The per-turn environment block stays out, as upstream leaves out + `core/date` and `core/environment`. +- **Tool errors and stops:** upstream's `context` hook flags tool errors and stops in provider metadata. The + shipped runtime already sends failed tools as `error-text` results, which `tools/result.ts` marks as errors. + Stopped calls get `toolAborted` from history conversion, so the waiting Claude turn is interrupted. +- **Generate calls:** upstream only streams. `doGenerate` collects the streamed reply, because the shipped CLI + calls `generateObject`/`generateText` (agent creation, learning reflection). A JSON request is asked for in + the prompt, since Claude Code has no structured-output mode, and a markdown fence around it is removed. +- **Per-project state:** each directory gets its own session registry, connection settings and login status. +- **MCP server:** built on `@modelcontextprotocol/sdk` 1.x (already bundled) instead of `@modelcontextprotocol/server` 2.x. +- **Connection settings:** `headerTimeout` and `chunkTimeout` are consumed by the provider loader before + the factory runs, so they do not reach Claude Code. +- **Model list refresh:** a new list from a chat's `claude` start is saved and shown after the next + instance reload; `/connect` reloads immediately. +- **Branding:** model-visible text says "Altimate Code"; internal tool names keep `mcp__opencode__*`. + +## Tests + +`packages/opencode/test/altimate/provider/claude-agent/` — upstream's tests (except `plugin.test.ts`, +which needs core's plugin host) replaying recorded real Claude sessions through the real SDK with a fake +`claude`, plus `integration.test.ts` for the Altimate-only modules. + + diff --git a/packages/opencode/src/altimate/provider/claude-agent/catalog.ts b/packages/opencode/src/altimate/provider/claude-agent/catalog.ts new file mode 100644 index 0000000000..335cfbc201 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/catalog.ts @@ -0,0 +1,145 @@ +export * as ClaudeAgentCatalog from "./catalog" + +import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk" +import { Option, Schema } from "effect" +import type { Provider } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" +import type { ClaudeProbe } from "./claude/probe" +import { ClaudeAgentID } from "./id" +import { ClaudeAgentStorage } from "./storage" + +// Ported from upstream `claude-agent/provider.ts`: the model list kept between starts, so the CLI +// can list Claude models without running `claude`. +const CatalogModel = Schema.Struct({ + id: Schema.String, + name: Schema.String, + efforts: Schema.Array(Schema.String), + contextWindow: Schema.optionalKey(Schema.Number), + resolvedModel: Schema.optionalKey(Schema.String), +}) +type CatalogModel = typeof CatalogModel.Type +const Catalog = Schema.Struct({ + priceSource: Schema.optionalKey(Schema.String), + models: Schema.Array(CatalogModel), +}) +export type Catalog = typeof Catalog.Type +const KEY = "models" + +// Before the first `claude` start reports its models. Claude Code resolves these aliases to its +// current models, so they stay valid as Anthropic ships new ones. +const FALLBACK: readonly CatalogModel[] = [ + { id: "opus", name: "Opus", efforts: [] }, + { id: "sonnet", name: "Sonnet", efforts: [] }, + { id: "haiku", name: "Haiku", efforts: [] }, +] + +const DEFAULT_CONTEXT = 200_000 +const DEFAULT_OUTPUT = 32_000 + +// Mantle is Bedrock serving Anthropic's API, and Bedrock's list drops most `anthropic.…` IDs, +// so it uses Anthropic's prices. +const PRICE_SOURCES: Partial, string>> = { + firstParty: "anthropic", + anthropicAws: "anthropic", + anthropicGoogleCloud: "anthropic", + mantle: "anthropic", + bedrock: "amazon-bedrock", + vertex: "google-vertex", + foundry: "azure", +} + +export async function load(): Promise { + const saved = Schema.decodeUnknownOption(Catalog)(await ClaudeAgentStorage.read(KEY)) + return Option.getOrElse(saved, (): Catalog => ({ models: [] })) +} + +// Saves what a probe or a chat's startup reported. Returns whether the list changed, so the +// caller knows the provider list needs a reload to show it. +export async function update(probe: ClaudeProbe.Result, baseURL: string | undefined) { + const current = await load() + const source = priceSource(probe.account, baseURL) + const next: Catalog = { + ...(source ? { priceSource: source } : {}), + models: fromClaude(probe.models, current.models), + } + if (JSON.stringify(next) === JSON.stringify(current)) return false + await ClaudeAgentStorage.write(KEY, next) + return true +} + +export function provider( + catalog: Catalog, + database: Readonly> }>>, +): Provider.Info { + const prices = catalog.priceSource ? database[catalog.priceSource]?.models : undefined + const models = catalog.models.length > 0 ? catalog.models : FALLBACK + return { + id: ProviderID.make(ClaudeAgentID.ID), + name: "Claude Agent", + source: "custom", + env: [], + options: {}, + models: Object.fromEntries(models.map((model) => [model.id, toModel(model, prices)])), + } +} + +// Context windows only come from the full probe, so a chat's startup report keeps the saved ones. +function fromClaude(models: readonly ClaudeProbe.Model[], saved: readonly CatalogModel[]): CatalogModel[] { + const known = new Map() + for (const model of saved) known.set(model.id, model.contextWindow) + const result: CatalogModel[] = [] + for (const model of models) { + // "default" follows Claude Code's own default, which can change under a running session. + if (model.value === "default") continue + const contextWindow = model.contextWindow ?? known.get(model.value) + result.push({ + id: model.value, + name: model.displayName, + efforts: model.supportedEffortLevels ?? [], + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(model.resolvedModel ? { resolvedModel: model.resolvedModel } : {}), + }) + } + return result +} + +// A subscription covers usage, and a custom base URL or an enterprise gateway can bill anything, +// so they show no cost. +function priceSource(account: AccountInfo, baseURL: string | undefined) { + if (baseURL) return + if (account.apiProvider === undefined) return + if (account.apiProvider === "firstParty" && account.apiKeySource === undefined) return + return PRICE_SOURCES[account.apiProvider] +} + +function toModel(model: CatalogModel, prices: Readonly> | undefined): Provider.Model { + // The 1M context version's price tiers are on the base model. + const id = (model.resolvedModel ?? model.id).replace(/\[1m\]$/, "") + // Mantle names models `anthropic.claude-…`, and Vertex lists current models as `claude-…@default`. + const priced = prices?.[id] ?? prices?.[id.replace(/^anthropic\./, "")] ?? prices?.[`${id}@default`] + return { + id: ModelID.make(model.id), + providerID: ProviderID.make(ClaudeAgentID.ID), + api: { id: model.id, url: "", npm: ClaudeAgentID.PACKAGE }, + name: model.name, + family: model.id.includes("haiku") ? "claude-haiku" : undefined, + capabilities: { + // Claude Code sets sampling itself; temperature, top-p and top-k are not passed through. + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: priced?.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, + // The SDK reports context windows before a prompt, but not output limits. + limit: { context: model.contextWindow ?? DEFAULT_CONTEXT, output: priced?.limit.output ?? DEFAULT_OUTPUT }, + status: "active", + options: {}, + headers: {}, + release_date: "", + variants: Object.fromEntries(model.efforts.map((effort) => [effort, { effort }])), + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/claude/fork.ts b/packages/opencode/src/altimate/provider/claude-agent/claude/fork.ts new file mode 100644 index 0000000000..9aadad5158 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/claude/fork.ts @@ -0,0 +1,34 @@ +export * as ClaudeFork from "./fork" + +import { ClaudeSDK } from "./sdk" + +export interface Result { + readonly sessionID: string + readonly messages: ReadonlyMap +} + +// The SDK gives forked messages fresh UUIDs. Verify the copied prefix before mapping checkpoints onto them. +export async function create(sessionID: string, messageID: string): Promise { + const { deleteSession, forkSession, getSessionMessages } = await ClaudeSDK.load() + const source = await getSessionMessages(sessionID) + const end = source.findIndex((message) => message.uuid === messageID) + if (end < 0) return + const fork = await forkSession(sessionID, { upToMessageId: messageID }) + try { + const copied = await getSessionMessages(fork.sessionId) + if (copied.length !== end + 1) throw new Error("Claude fork did not preserve the requested history boundary") + const messages = new Map() + for (let i = 0; i < copied.length; i++) { + const before = source[i]! + const after = copied[i]! + if (before.type !== after.type || JSON.stringify(before.message) !== JSON.stringify(after.message)) { + throw new Error("Claude fork changed the copied conversation") + } + messages.set(before.uuid, after.uuid) + } + return { sessionID: fork.sessionId, messages } + } catch (error) { + await deleteSession(fork.sessionId) + throw error + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/claude/io.ts b/packages/opencode/src/altimate/provider/claude-agent/claude/io.ts new file mode 100644 index 0000000000..f16dbc4b0d --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/claude/io.ts @@ -0,0 +1,32 @@ +export * as ClaudeIO from "./io" + +import type { Options } from "@anthropic-ai/claude-agent-sdk" +import { which } from "@/util/which" +import { ClaudeFork } from "./fork" +import { ClaudeSDK } from "./sdk" + +// Everything that reaches outside opencode. Tests swap it for a fake `claude`. +export interface Interface { + // Undefined uses the SDK's own spawn + readonly spawn?: Options["spawnClaudeCodeProcess"] + readonly which: () => string | undefined + // Before our isolation settings + readonly env: () => Record + readonly sessionExists: (sessionID: string) => Promise + readonly fork: (sessionID: string, messageID: string) => Promise + readonly remove: (sessionID: string) => Promise +} + +export const real: Interface = { + which: () => process.env.OPENCODE_CLAUDE_PATH || which("claude") || undefined, + env: () => process.env, + sessionExists: async (sessionID) => { + const { getSessionInfo } = await ClaudeSDK.load() + return (await getSessionInfo(sessionID)) !== undefined + }, + fork: ClaudeFork.create, + remove: async (sessionID) => { + const { deleteSession } = await ClaudeSDK.load() + await deleteSession(sessionID) + }, +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/claude/probe.ts b/packages/opencode/src/altimate/provider/claude-agent/claude/probe.ts new file mode 100644 index 0000000000..642d8e03dd --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/claude/probe.ts @@ -0,0 +1,117 @@ +export * as ClaudeProbe from "./probe" + +import type { AccountInfo, ModelInfo, Options, Query } from "@anthropic-ai/claude-agent-sdk" +import { Effect, Schema } from "effect" +import { ClaudeSDK } from "./sdk" + +export interface Model extends ModelInfo { + contextWindow?: number +} + +export interface Result { + readonly version: string | undefined + readonly account: AccountInfo + readonly models: readonly Model[] +} + +export class ProbeError extends Schema.TaggedErrorClass()("ClaudeProbe.ProbeError", { + cause: Schema.Defect, +}) {} + +// Older binaries silently ignore verbatimPrompts and can interpret user text as Claude commands. +const MINIMUM_VERSION = "2.1.248" +// Older binaries do not support summary-only context usage. Their models keep the conservative fallback. +const CONTEXT_DISCOVERY_VERSION = "2.1.257" + +export function versionError(version: string | undefined) { + if (version && atLeast(version, MINIMUM_VERSION)) return + const message = version + ? `Claude Code ${version} is too old; ${MINIMUM_VERSION} or later is required.` + : "Claude Code did not report its version, so compatibility could not be checked." + return new Error(`${message} Run \`claude update\`.`) +} + +// Compares release numbers like 2.1.248. A pre-release or build suffix is ignored. +function atLeast(version: string, minimum: string) { + const parse = (value: string) => value.split(/[-+]/)[0].split(".").map(Number) + const current = parse(version) + const required = parse(minimum) + const index = required.findIndex((part, position) => (current[position] ?? 0) !== part) + return index === -1 || (current[index] ?? 0) > required[index] +} + +// The input never sends a message, so no model call is made and no tokens are used. +export const run = Effect.fn("ClaudeProbe.run")(function* (options: Options) { + const stop = Promise.withResolvers() + return yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: async () => { + const { query } = await ClaudeSDK.load() + return query({ prompt: waitFor(stop.promise), options: { ...options, persistSession: false } }) + }, + catch: (cause) => new ProbeError({ cause }), + }), + (session) => + Effect.gen(function* () { + const init = yield* Effect.tryPromise({ + try: () => session.initializationResult(), + catch: (cause) => new ProbeError({ cause }), + }).pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => Effect.fail(new ProbeError({ cause: new Error("claude did not start within 30 seconds") })), + }), + ) + // No prompt is sent here, so an old Claude Code can still list its models. + // The version is checked when a chat starts, and that chat's error tells the user to update. + const version = init.claude_code_version + const models: Model[] = init.models.map((model) => ({ ...model })) + if (version && atLeast(version, CONTEXT_DISCOVERY_VERSION)) yield* discoverContext(session, models) + return { version, account: init.account, models } satisfies Result + }), + (session) => + Effect.sync(() => { + stop.resolve() + session.close() + }), + ) +}) + +// Sets each model's context window in place, so models discovered before a timeout keep theirs. +const discoverContext = Effect.fn("ClaudeProbe.discoverContext")( + function* (session: Query, models: readonly Model[]) { + for (const model of models) { + if (model.value === "default") continue + yield* Effect.tryPromise(async () => { + await session.setModel(model.value) + const usage = await session.getContextUsage({ detail: "summary" }) + // rawMaxTokens is capacity; maxTokens can reflect Claude's own compaction window. + if (!Number.isSafeInteger(usage.rawMaxTokens) || usage.rawMaxTokens <= 0) { + throw new Error("Claude Code did not report a valid context window") + } + model.contextWindow = usage.rawMaxTokens + }).pipe( + Effect.catch((error) => + Effect.logWarning("Claude Code context discovery failed", { + model: model.value, + cause: error.cause, + }), + ), + ) + } + }, + Effect.timeoutOrElse({ + duration: "30 seconds", + // A stuck control must not become a login failure. Closing this query cancels the pending request. + orElse: () => + Effect.logWarning("Claude Code context discovery timed out; undiscovered models use conservative limits"), + }), +) + +export function isLoggedIn(account: AccountInfo) { + return account.tokenSource !== "none" || account.apiKeySource !== undefined +} + +async function* waitFor(done: Promise): AsyncGenerator { + await done +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/claude/query-options.ts b/packages/opencode/src/altimate/provider/claude-agent/claude/query-options.ts new file mode 100644 index 0000000000..8de5da3491 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/claude/query-options.ts @@ -0,0 +1,109 @@ +export * as ClaudeQueryOptions from "./query-options" + +import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider" +import type { Options } from "@anthropic-ai/claude-agent-sdk" +import { ToolHooks } from "../tools/hooks" +import type { PendingTools } from "../tools/pending" +import { ToolServer } from "../tools/server" +import type { ClaudeIO } from "./io" + +// Claude Code features that duplicate opencode's. opencode's version wins (specs/v2/claude-agent-provider.md, "Turned Off"). +const ISOLATION_ENV = { + CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1", + CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", + DISABLE_AUTO_COMPACT: "1", + CLAUDE_CODE_ENABLE_AWAY_SUMMARY: "0", + ENABLE_TOOL_SEARCH: "false", + CLAUDE_CODE_DISABLE_ADVISOR_TOOL: "1", + // Raised so opencode's own truncation applies instead + MAX_MCP_OUTPUT_TOKENS: "200000", + // Skips OAuth, so the subscription would not be used + CLAUDE_CODE_SIMPLE: undefined, + // A shell-level effort override would take precedence over OpenCode's live effort settings. + CLAUDE_CODE_EFFORT_LEVEL: undefined, + // Caps every OpenCode tool call, which would fail subagents and permission prompts that run longer + MCP_TOOL_TIMEOUT: undefined, +} + +export function connectionEnv(connection: { + readonly settings?: Readonly> + readonly headers?: Readonly> + readonly body?: Readonly> +}): Record { + const env: Record = {} + const settings = connection.settings ?? {} + if (typeof settings.baseURL === "string") env.ANTHROPIC_BASE_URL = settings.baseURL + if (typeof settings.apiKey === "string") env.ANTHROPIC_API_KEY = settings.apiKey + if (typeof settings.authToken === "string") env.ANTHROPIC_AUTH_TOKEN = settings.authToken + if (typeof settings.headerTimeout === "number") env.API_TIMEOUT_MS = String(settings.headerTimeout) + if (typeof settings.chunkTimeout === "number") env.CLAUDE_STREAM_IDLE_TIMEOUT_MS = String(settings.chunkTimeout) + const headers: string[] = [] + for (const [name, value] of Object.entries(connection.headers ?? {})) { + headers.push(`${name}: ${value}`) + } + if (headers.length > 0) env.ANTHROPIC_CUSTOM_HEADERS = headers.join("\n") + if (connection.body !== undefined && Object.keys(connection.body).length > 0) { + env.CLAUDE_CODE_EXTRA_BODY = JSON.stringify(connection.body) + } + return env +} + +export function base(input: { + executable: string + io: ClaudeIO.Interface + connection?: Readonly> +}): Options { + return { + pathToClaudeCodeExecutable: input.executable, + spawnClaudeCodeProcess: input.io.spawn, + env: { ...input.io.env(), ...input.connection, ...ISOLATION_ENV }, + settingSources: [], + settings: { disableAllHooks: true }, + // Without summaries, OpenCode has no reasoning text to display + thinking: { type: "adaptive", display: "summarized" }, + strictMcpConfig: true, + skills: [], + tools: [], + permissionMode: "dontAsk", + allowedTools: ["mcp__opencode__*"], + verbatimPrompts: true, + } +} + +export async function session(input: { + readonly executable: string + readonly io: ClaudeIO.Interface + readonly connection?: Readonly> + readonly directory: string + readonly model: string + readonly effort?: Options["effort"] + readonly maxOutputTokens?: number + readonly system: NonNullable + readonly tools: readonly LanguageModelV3FunctionTool[] + readonly pending: PendingTools.Interface + // Side calls run without saving a transcript + readonly persist: boolean + readonly resume?: string +}): Promise { + const options: Options = { + ...base(input), + cwd: input.directory, + model: input.model, + effort: input.effort, + systemPrompt: input.system, + includePartialMessages: true, + persistSession: input.persist, + hooks: ToolHooks.make(input.pending), + mcpServers: {}, + } + if (input.maxOutputTokens !== undefined) { + options.env = { ...options.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(input.maxOutputTokens) } + } + if (input.tools.length > 0) options.mcpServers = { opencode: await ToolServer.make(input.tools, input.pending) } + if (input.resume !== undefined) { + options.resume = input.resume + return options + } + options.sessionId = crypto.randomUUID() + return options +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/claude/sdk.ts b/packages/opencode/src/altimate/provider/claude-agent/claude/sdk.ts new file mode 100644 index 0000000000..03de53950b --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/claude/sdk.ts @@ -0,0 +1,17 @@ +export * as ClaudeSDK from "./sdk" + +import type * as SDK from "@anthropic-ai/claude-agent-sdk" + +// Loaded on first use, so opencode starts without loading the SDK. +let sdk: Promise | undefined + +export function load() { + sdk ??= import("@anthropic-ai/claude-agent-sdk").catch((cause) => { + // Let the next request try again, for example after the SDK is installed. + sdk = undefined + throw new Error("Claude Agent SDK isn't installed. Install @anthropic-ai/claude-agent-sdk to use Claude Agent.", { + cause, + }) + }) + return sdk +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/claude/session.ts b/packages/opencode/src/altimate/provider/claude-agent/claude/session.ts new file mode 100644 index 0000000000..818e932887 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/claude/session.ts @@ -0,0 +1,163 @@ +export * as ClaudeSession from "./session" + +import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider" +import type { Options, SDKMessage, SDKResultMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk" +import { ToolNames } from "../tools/names" +import { ClaudeProbe } from "./probe" +import { ClaudeSDK } from "./sdk" + +interface Turn { + readonly drained: PromiseWithResolvers + readonly messages: SDKMessage[] + wake?: () => void + finished: boolean +} + +export type Interface = Awaited> + +export async function start(options: Options) { + const { query } = await ClaudeSDK.load() + let input!: ReadableStreamDefaultController + const prompt = new ReadableStream({ + start(controller) { + input = controller + }, + }) + // Bun's ReadableStream is async-iterable; the DOM lib types used here do not declare it. + const session = query({ prompt: prompt as unknown as AsyncIterable, options }) + let turn: Turn | undefined + let initialized = false + let ended = false + let failure: unknown + let stopping: Promise | undefined + let persisted = false + const ready = session.initializationResult().then((init): ClaudeProbe.Result => { + const error = ClaudeProbe.versionError(init.claude_code_version) + if (error) throw error + initialized = true + return { version: init.claude_code_version!, account: init.account, models: init.models } + }) + // A cancel during startup can stop the session before anything awaits ready. + ready.catch(() => {}) + + function finishTurn() { + if (!turn) return + turn.finished = true + turn.drained.resolve() + } + + // The only reader of the SDK iterator, including while a turn is being cancelled. + async function readAll() { + try { + for await (const message of session) { + if (turn) { + turn.messages.push(message) + turn.wake?.() + } + if (message.type === "assistant" || message.type === "result") persisted = true + if (message.type === "result") finishTurn() + } + } catch (error) { + failure = error + } finally { + ended = true + finishTurn() + turn?.wake?.() + } + } + const pump = readAll() + + async function interrupt() { + const active = turn + if (!active || active.finished || ended) return + await session.interrupt() + // interrupt() resolves before the result and transcript flush, so wait for the drain before another turn starts. + await active.drained.promise + } + + async function shutdown() { + try { + if (initialized && !ended) { + await session.interrupt() + if (turn && !turn.finished) await turn.drained.promise + } + input.close() + // An uninitialized process has no conversation to flush. + if (!initialized) session.close() + await pump + } finally { + session.close() + } + } + + function stop() { + if (!stopping) stopping = shutdown() + return stopping + } + + function begin(content: SDKUserMessage["message"]["content"], uuid = crypto.randomUUID()) { + if (ended || stopping) throw failure ?? new Error("Claude Code session has stopped") + if (turn && !turn.finished) throw new Error("Claude Code is still finishing the previous turn") + turn = { drained: Promise.withResolvers(), messages: [], finished: false } + input.enqueue({ type: "user", uuid, message: { role: "user", content }, parent_tool_use_id: null }) + } + + async function readStep( + emit: (message: SDKMessage) => void, + tools: readonly LanguageModelV3FunctionTool[], + ): Promise { + const active = turn + if (!active) throw new Error("No Claude turn to read") + let calledTools = false + while (true) { + const message = active.messages.shift() + if (!message) { + if (ended) throw failure ?? new Error("Claude Code stopped before finishing its reply") + await new Promise((resolve) => { + active.wake = resolve + }) + continue + } + emit(message) + if (message.type === "assistant" && message.parent_tool_use_id === null) { + for (const part of message.message.content) { + // Calls to tools we did not offer get Claude Code's own error, and the turn continues without OpenCode. + if (part.type === "tool_use" && ToolNames.original(part.name, tools)) calledTools = true + } + } + const replyEnded = + message.type === "stream_event" && message.parent_tool_use_id === null && message.event.type === "message_stop" + if (calledTools && replyEnded) return "tools" + if (message.type === "result") return message + } + } + + return { + id: options.resume ?? options.sessionId!, + ready, + get ended() { + return ended + }, + get persisted() { + return persisted + }, + get running() { + return !!turn && !turn.finished + }, + begin, + readStep, + addUser(content: SDKUserMessage["message"]["content"], uuid: NonNullable) { + input.enqueue({ + type: "user", + uuid, + priority: "next", + message: { role: "user", content }, + parent_tool_use_id: null, + }) + }, + interrupt, + stop, + setModel: (model: string) => session.setModel(model), + setEffort: (effort: Options["effort"]) => session.applyFlagSettings({ effortLevel: effort ?? null }), + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/claude/sessions.ts b/packages/opencode/src/altimate/provider/claude-agent/claude/sessions.ts new file mode 100644 index 0000000000..e4e4babca7 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/claude/sessions.ts @@ -0,0 +1,467 @@ +export * as ClaudeSessions from "./sessions" + +import type { Options, SDKMessage } from "@anthropic-ai/claude-agent-sdk" +import type { + LanguageModelV3FunctionTool, + LanguageModelV3Prompt, + LanguageModelV3TextPart, + LanguageModelV3ToolCallPart, +} from "@ai-sdk/provider" +import { Effect, Fiber, Semaphore } from "effect" +import { ClaudeIO } from "./io" +import type { ClaudeStatus } from "./status" +import { ClaudeQueryOptions } from "./query-options" +import { ClaudeSession } from "./session" +import { SessionRecord } from "../sync/record" +import { Checkpoints } from "../sync/checkpoints" +import { SyncDecision } from "../sync/decide" +import { CatchUp } from "../sync/catch-up" +import { ConvertToClaudeMessage } from "../language-model/convert-to-claude-message" +import { PendingTools } from "../tools/pending" +import { ToolNames } from "../tools/names" +import { ToolResult } from "../tools/result" + +export const HANDOVER_MS = 10_000 +export const IDLE_MS = 5 * 60_000 + +export interface Request { + // Only primary calls have one. Side calls run in a throwaway process. + readonly sessionID?: string + // Native OpenCode fork lineage, or the session supplying a compaction/generate snapshot. + readonly sourceID?: string + readonly compaction?: boolean + readonly model: string + readonly effort?: Options["effort"] + readonly maxOutputTokens?: number + readonly connection?: Readonly> + readonly system: NonNullable + readonly prompt: LanguageModelV3Prompt + readonly tools: readonly LanguageModelV3FunctionTool[] + readonly emit: (message: SDKMessage) => void +} + +interface LiveProcess { + readonly session: ClaudeSession.Interface + readonly pending: PendingTools.Interface + model: string + effort?: Options["effort"] + // Settings that need a new process to change. Model and effort change on the running one. + readonly launch: string + waiting?: string[] +} + +interface ToolContinuation { + readonly end: number + readonly results: Array<{ id: string; result: ToolResult.Result }> +} + +interface Entry { + readonly lock: Semaphore.Semaphore + process?: LiveProcess + record?: SessionRecord.Record + idle?: Fiber.Fiber + active?: Fiber.Fiber +} + +export interface Interface { + readonly run: (request: Request) => Effect.Effect + // OpenCode stopped running the session, so a turn still waiting for tool results will not get them. + readonly idle: (sessionID: string) => Effect.Effect +} + +const launch = (request: Request) => + JSON.stringify([request.maxOutputTokens, request.connection, request.system, request.tools]) + +const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error))) +const attempt = (run: () => Promise) => Effect.tryPromise({ try: run, catch: toError }) +const attemptSync = (run: () => A) => Effect.try({ try: run, catch: toError }) + +export function registry() { + const entries = new Map() + // Side calls share startup/draining with chat, but are tracked only until their request ends. + const temporary = new Set() + let owners = 0 + let handover: Fiber.Fiber | undefined + + const interrupt = Effect.fn("ClaudeSessions.interrupt")(function* (entry: Entry) { + const process = entry.process + if (!process) return + yield* attempt(() => process.session.interrupt()) + process.pending.clear() + process.waiting = undefined + }, Effect.uninterruptible) + + const stop = Effect.fn("ClaudeSessions.stop")(function* (entry: Entry) { + const process = entry.process + if (!process) return + yield* attempt(() => process.session.stop()).pipe( + Effect.ensuring( + Effect.sync(() => { + process.pending.clear() + entry.process = undefined + }), + ), + ) + }, Effect.uninterruptible) + + const armIdle = Effect.fn("ClaudeSessions.armIdle")(function* (entry: Entry) { + if (entry.idle) yield* Fiber.interrupt(entry.idle) + entry.idle = yield* Effect.sleep(IDLE_MS).pipe( + Effect.andThen(entry.lock.withPermit(stop(entry))), + Effect.catch((error) => Effect.logWarning(error)), + Effect.forkDetach, + ) + }) + + const closeEntries = Effect.fn("ClaudeSessions.closeEntries")(function* () { + for (const entry of [...entries.values(), ...temporary]) { + if (entry.active) yield* Fiber.interrupt(entry.active) + // The active turn's finalizer can install a new idle timer. + if (entry.idle) yield* Fiber.interrupt(entry.idle) + yield* entry.lock.withPermit(stop(entry)).pipe(Effect.catch((error) => Effect.logWarning(error))) + } + entries.clear() + }) + + const cancelHandover = Effect.fn("ClaudeSessions.cancelHandover")(function* () { + if (!handover) return + yield* Fiber.interrupt(handover) + handover = undefined + }) + + const close = Effect.fn("ClaudeSessions.close")(function* () { + yield* cancelHandover() + yield* closeEntries() + }) + + const attach = Effect.fn("ClaudeSessions.attach")(function* (input: { + readonly io: ClaudeIO.Interface + readonly status: ClaudeStatus.Interface + readonly storage: SessionRecord.Storage + readonly directory: string + }) { + const records = SessionRecord.make(input.storage) + owners++ + yield* cancelHandover() + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + owners-- + if (owners !== 0) return + if (entries.size === 0 && temporary.size === 0) return + handover = yield* Effect.sleep(HANDOVER_MS).pipe(Effect.andThen(closeEntries()), Effect.forkDetach) + }), + ) + + const open = Effect.fn("ClaudeSessions.open")(function* (entry: Entry, request: Request) { + const starting = !entry.process + // A cancel can only land while waiting for ready, so a started process is always recorded and stopped. + const opened = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + if (!entry.process) { + const executable = input.io.which() + if (!executable) { + yield* input.status.report({ type: "missing" }) + return yield* Effect.fail(new Error("Install Claude Code and run `claude auth login` first")) + } + const pending = PendingTools.make() + const session = yield* attempt(async () => + ClaudeSession.start( + await ClaudeQueryOptions.session({ + executable, + io: input.io, + connection: request.connection, + directory: input.directory, + model: request.model, + effort: request.effort, + maxOutputTokens: request.maxOutputTokens, + system: request.system, + tools: request.tools, + pending, + persist: request.sessionID !== undefined, + resume: entry.record?.claudeSessionID, + }), + ), + ) + entry.process = { session, pending, model: request.model, effort: request.effort, launch: launch(request) } + } + const process = entry.process + const probe = yield* restore( + attempt(() => process.session.ready).pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => Effect.fail(new Error("claude did not start within 30 seconds")), + }), + ), + ).pipe(Effect.onError(() => stop(entry).pipe(Effect.ignore))) + return { process, probe } + }), + ) + const process = opened.process + // A new process reports its version, login and models, which keeps the model list current without a separate check. + if (starting) yield* input.status.report({ type: "ready", probe: opened.probe }) + // Apply controls before sending input or releasing parked tool results. + if (process.model !== request.model) { + yield* attempt(() => process.session.setModel(request.model)) + process.model = request.model + } + if (process.effort !== request.effort) { + yield* attempt(() => process.session.setEffort(request.effort)) + process.effort = request.effort + } + return process + }) + + const turn = Effect.fn("ClaudeSessions.turn")(function* ( + entry: Entry, + request: Request, + start: number, + continuation?: ToolContinuation, + ) { + // Validate before opening a process: a rejected rebuild must not pair an empty process with the old record. + const content = yield* attemptSync(() => { + if (request.sessionID || entry.record) return CatchUp.content(request.prompt, continuation?.end ?? start) + return ConvertToClaudeMessage.content(request.prompt) + }) + const live = yield* open(entry, request) + const session = live.session + + const addsUserMessage = !continuation || content.length > 0 + const userID = addsUserMessage ? crypto.randomUUID() : undefined + + const reply: Array = [] + const toolMessages = new Map() + const seen = new Set() + let messageID: string | undefined + const waiting: string[] = [] + + const emit = (message: SDKMessage) => { + if (message.type === "system" && message.subtype === "init" && request.tools.length > 0) { + const connected = message.mcp_servers.some( + (server) => server.name === "opencode" && server.status === "connected", + ) + if (!connected) throw new Error("The Altimate Code tool server did not connect to Claude Code") + } + if ( + message.type === "assistant" && + message.parent_tool_use_id === null && + !message.error && + !seen.has(message.uuid) + ) { + seen.add(message.uuid) + messageID = message.uuid + for (const part of message.message.content) { + if (part.type === "text") { + reply.push({ type: "text", text: part.text }) + continue + } + if (part.type !== "tool_use") continue + const name = ToolNames.original(part.name, request.tools) + if (!name) continue + live.pending.expect(part.id, part.name) + waiting.push(part.id) + reply.push({ type: "tool-call", toolCallId: part.id, toolName: name, input: part.input }) + } + } + if (message.type === "user" && message.uuid && Array.isArray(message.message.content)) { + for (const part of message.message.content) { + if (part.type === "tool_result") toolMessages.set(part.tool_use_id, message.uuid) + } + } + request.emit(message) + } + + let completed = false + let interrupted = false + let started = false + let parked = false + const step = Effect.gen(function* () { + if (!continuation) session.begin(content, userID) + if (continuation) { + if (userID !== undefined) session.addUser(content, userID) + for (const item of continuation.results) { + live.pending.resolve(item.id, item.result) + } + } + started = true + const outcome = yield* attempt(() => session.readStep(emit, request.tools)) + parked = outcome === "tools" + completed = true + live.waiting = parked ? waiting : undefined + }) + + // A tool boundary ends only the OpenCode step; the Claude turn waits for the results. Anything else interrupts and drains it. + const interruptUnlessParked = Effect.gen(function* () { + if (parked) return + yield* interrupt(entry).pipe(Effect.orDie) + }) + + const saveRecord = Effect.gen(function* () { + if (!request.sessionID || !session.persisted || !started) return + const history = + completed || interrupted + ? Checkpoints.build({ + prompt: request.prompt, + previous: entry.record, + userID, + reply: completed ? reply : [], + messageID: completed ? messageID : undefined, + toolMessages, + }) + : undefined + entry.record = { claudeSessionID: session.id, history, interrupted: interrupted && !completed } + yield* records.set(request.sessionID, entry.record) + }) + + yield* step.pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + interrupted = true + }), + ), + Effect.ensuring(interruptUnlessParked), + Effect.ensuring(saveRecord), + ) + }) + + const release = Effect.fn("ClaudeSessions.release")(function* (entry: Entry, request: Request) { + entry.active = undefined + if (!request.sessionID) { + yield* stop(entry).pipe(Effect.catch((error) => Effect.logWarning(error))) + const sessionID = entry.record?.claudeSessionID + if (sessionID) + yield* attempt(() => input.io.remove(sessionID)).pipe(Effect.catch((error) => Effect.logWarning(error))) + temporary.delete(entry) + return + } + // A turn waiting for tool results is still busy. Subagents, permission prompts and questions can take longer + // than IDLE_MS, so idle() starts the timer once OpenCode stops running the session. + if (entry.process?.waiting) return + yield* armIdle(entry) + }) + + const branch = Effect.fn("ClaudeSessions.branch")(function* ( + entry: Entry, + request: Request, + source?: SessionRecord.Record, + ) { + const boundary = SyncDecision.boundary(request.prompt, source) + if (!source || !boundary) return 0 + // Validate catch-up input before creating a forked transcript on disk. + yield* attemptSync(() => CatchUp.content(request.prompt, boundary.start)) + const fork = yield* attempt(() => input.io.fork(source.claudeSessionID, boundary.messageID)) + if (!fork) return 0 + const history = boundary.history.map((checkpoint) => ({ + hash: checkpoint.hash, + claudeMessageID: checkpoint.claudeMessageID ? fork.messages.get(checkpoint.claudeMessageID) : undefined, + })) + entry.record = { claudeSessionID: fork.sessionID, history } + return boundary.start + }, Effect.uninterruptible) + + const snapshot = Effect.fn("ClaudeSessions.snapshot")(function* (entry: Entry, request: Request) { + const sourceID = request.sourceID + if (!sourceID) return 0 + const source = entries.get(sourceID) + const copy = Effect.gen(function* () { + // Compaction ends the old primary context. Generate only takes a read-only snapshot. + if (request.compaction && source) yield* stop(source) + const record = source?.record ?? (yield* records.get(sourceID)) + return yield* branch(entry, request, record) + }) + // Hold the source only while copying its settled prefix, never during side-call inference. + if (!source) return yield* copy + return yield* source.lock.withPermit(copy) + }) + + const prepare = Effect.fn("ClaudeSessions.prepare")(function* (entry: Entry, request: Request) { + // Retire an exited or incompatible process first: once we continue its parked turn, open() must not replace it. + if (entry.process) { + const needsRestart = entry.process.session.ended || entry.process.launch !== launch(request) + if (needsRestart) yield* stop(entry) + } + + if (!request.sessionID) { + const start = yield* snapshot(entry, request) + return { start, continuation: undefined } + } + + if (!entry.record) entry.record = yield* records.get(request.sessionID) + const decision = SyncDecision.decide(request.prompt, entry.record) + const start = yield* Effect.gen(function* () { + if (decision.kind === "fresh" || decision.kind === "changed") { + yield* stop(entry) + const previous = entry.record + entry.record = undefined + if (decision.kind === "changed") return yield* branch(entry, request, previous) + if (!previous) return yield* snapshot(entry, request) + return decision.start + } + if (entry.process || !entry.record) return decision.start + const sessionID = entry.record.claudeSessionID + const exists = yield* attempt(() => input.io.sessionExists(sessionID)) + if (exists) return decision.start + entry.record = undefined + return 0 + }) + + const live = entry.process + if (!live || !live.session.running || !live.waiting) return { start, continuation: undefined } + const collected = yield* attemptSync(() => ToolResult.collect(request.prompt, start)) + const continuation: ToolContinuation = { end: collected.end, results: [] } + for (const id of live.waiting) { + const result = collected.results.get(id) + if (!result) { + yield* stop(entry) + entry.record = undefined + return { start: 0, continuation: undefined } + } + continuation.results.push({ id, result }) + } + if (collected.aborted) { + // A stopped OpenCode turn needs a fresh Claude turn; addUser would only steer the old one. + yield* interrupt(entry) + return { start, continuation: undefined } + } + return { start, continuation } + }) + + const entryFor = (request: Request) => { + if (request.sessionID) { + const existing = entries.get(request.sessionID) + if (existing) return existing + const entry: Entry = { lock: Semaphore.makeUnsafe(1) } + entries.set(request.sessionID, entry) + return entry + } + const entry: Entry = { lock: Semaphore.makeUnsafe(1) } + temporary.add(entry) + return entry + } + + const run = Effect.fn("ClaudeSessions.run")(function* (request: Request) { + const entry = entryFor(request) + yield* entry.lock.withPermit( + Effect.gen(function* () { + if (entry.idle) { + yield* Fiber.interrupt(entry.idle) + entry.idle = undefined + } + entry.active = yield* Effect.withFiber(Effect.succeed) + const prepared = yield* prepare(entry, request) + yield* turn(entry, request, prepared.start, prepared.continuation) + }).pipe(Effect.ensuring(release(entry, request))), + ) + }) + + const idle = Effect.fn("ClaudeSessions.idle")(function* (sessionID: string) { + const entry = entries.get(sessionID) + // Other processes already have a timer, and a running request starts one when it ends. + if (!entry?.process?.waiting || entry.active) return + yield* armIdle(entry) + }) + + return { run, idle } satisfies Interface + }) + + return { attach, close } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/claude/status.ts b/packages/opencode/src/altimate/provider/claude-agent/claude/status.ts new file mode 100644 index 0000000000..be5c7387cb --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/claude/status.ts @@ -0,0 +1,50 @@ +export * as ClaudeStatus from "./status" + +import { Effect, Semaphore, SubscriptionRef } from "effect" +import type { ClaudeIO } from "./io" +import { ClaudeProbe } from "./probe" +import { ClaudeQueryOptions } from "./query-options" + +export type State = + | { readonly type: "checking" } + | { readonly type: "missing" } + | { readonly type: "failed"; readonly message: string } + | { readonly type: "ready"; readonly probe: ClaudeProbe.Result } + +export interface Interface { + readonly state: SubscriptionRef.SubscriptionRef + readonly refresh: () => Effect.Effect + // What a chat's own process found when it started + readonly report: (next: State) => Effect.Effect +} + +export const make = Effect.fn("ClaudeStatus.make")(function* ( + io: ClaudeIO.Interface, + connection: () => Effect.Effect>, +) { + const state = yield* SubscriptionRef.make({ type: "checking" }) + const lock = yield* Semaphore.make(1) + + // Serialized so a reconnect checks again after startup, rather than racing it with an older account snapshot. + const refresh = Effect.fn("ClaudeStatus.refresh")(function* () { + const executable = io.which() + if (!executable) return yield* SubscriptionRef.set(state, { type: "missing" }) + const env = yield* connection() + const next = yield* ClaudeProbe.run(ClaudeQueryOptions.base({ executable, io, connection: env })).pipe( + Effect.map((probe): State => ({ type: "ready", probe })), + Effect.catch((error) => + Effect.logWarning("Claude Code login check failed", { cause: error.cause }).pipe( + Effect.as({ + type: "failed", + message: error.cause instanceof Error ? error.cause.message : String(error.cause), + }), + ), + ), + ) + yield* SubscriptionRef.set(state, next) + }, lock.withPermit) + + const report = (next: State) => SubscriptionRef.set(state, next) + + return { state, refresh, report } satisfies Interface +}) diff --git a/packages/opencode/src/altimate/provider/claude-agent/id.ts b/packages/opencode/src/altimate/provider/claude-agent/id.ts new file mode 100644 index 0000000000..bfe217e776 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/id.ts @@ -0,0 +1,6 @@ +export * as ClaudeAgentID from "./id" + +// The provider ID upstream registers (`Provider.ID.claudeAgent`), kept so configs and sessions carry over. +export const ID = "claude-agent" +// The AI SDK package name the shipped provider loader resolves to the bundled Claude Agent factory. +export const PACKAGE = "@opencode/claude-agent" diff --git a/packages/opencode/src/altimate/provider/claude-agent/index.ts b/packages/opencode/src/altimate/provider/claude-agent/index.ts new file mode 100644 index 0000000000..b96e598e5c --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/index.ts @@ -0,0 +1,181 @@ +export * as ClaudeAgent from "./index" + +import { Effect, Exit, Scope, SubscriptionRef } from "effect" +import { Instance } from "@/project/instance" +import { Log } from "@/util/log" +import { ClaudeIO } from "./claude/io" +import { ClaudeProbe } from "./claude/probe" +import { ClaudeQueryOptions } from "./claude/query-options" +import { ClaudeSessions } from "./claude/sessions" +import { ClaudeStatus } from "./claude/status" +import { ClaudeAgentCatalog } from "./catalog" +import { ClaudeAgentID } from "./id" +import { ClaudeAgentLanguageModel } from "./language-model/claude-agent-language-model" +import { RequestKind } from "./language-model/request-kind" +import { ClaudeSystemPrompt } from "./language-model/system-prompt" +import { ClaudeAgentStorage } from "./storage" + +// Shipped-runtime integration of upstream's Claude Agent provider (opencode PR 58782). +// Upstream registers it through core's v2 plugin context (`ctx.provider`, `ctx.aisdk.hook`, +// `ctx.session.hook`); the shipped CLI resolves providers in `provider/provider.ts` and builds +// requests in `session/llm.ts`, so this module exposes the same pieces for those call sites. + +const log = Log.create({ service: "claude-agent" }) + +export const ID = ClaudeAgentID.ID +export const PACKAGE = ClaudeAgentID.PACKAGE +// What `/connect` saves. It only records that the user chose the Claude Code login; the login +// itself stays with `claude`, and this value is never sent anywhere. +export const AUTH_KEY = "claude-code-login" + +export function matches(model: { readonly api: { readonly npm: string } }) { + return model.api.npm === PACKAGE +} + +const io = ClaudeIO.real +// One registry per directory, outside any instance, so an instance reload of the same directory adopts its +// running `claude` processes, and disposing one project never waits on another project's owner count. +const registries = new Map>() + +function registryFor(directory: string) { + let registry = registries.get(directory) + if (!registry) { + registry = ClaudeSessions.registry() + registries.set(directory, registry) + } + return registry +} + +// A chat's own process reports the version, login and models it found, which keeps the saved model +// list current without a separate check. +async function saveCatalog(state: ClaudeStatus.State, connection: Readonly>) { + if (state.type !== "ready") return + const baseURL = connection.ANTHROPIC_BASE_URL ?? io.env().ANTHROPIC_BASE_URL + await ClaudeAgentCatalog.update(state.probe, baseURL).catch((error) => + log.warn("failed to save Claude Agent models", { error }), + ) +} + +// Per instance: this project's connection settings, login status and registry attachment. Disposing the +// instance closes the attachment, and its processes get the registry's handover window. +const instance = Instance.state( + async () => { + const settings = { connection: {} as Record } + const inner = await Effect.runPromise(ClaudeStatus.make(io, () => Effect.sync(() => settings.connection))) + const status: ClaudeStatus.Interface = { + ...inner, + report: (next) => + inner.report(next).pipe(Effect.andThen(Effect.promise(() => saveCatalog(next, settings.connection)))), + } + const scope = Scope.makeUnsafe() + const sessions = await Effect.runPromise( + registryFor(Instance.directory) + .attach({ io, status, storage: ClaudeAgentStorage.sessions, directory: Instance.directory }) + .pipe(Scope.provide(scope)), + ) + return { settings, status, sessions, scope } + }, + async (entry) => { + await Effect.runPromise(Scope.close(entry.scope, Exit.void)) + }, +) + +// The connection settings from this project's provider config (`settings.baseURL`, `headers`, ...), as +// Claude Code environment variables. Used by the login check, which runs before any chat. Call within the instance. +export async function configure(options: Readonly> | undefined) { + const { settings } = await instance() + settings.connection = ClaudeQueryOptions.connectionEnv({ + settings: options, + headers: options?.headers as Record | undefined, + body: options?.body as Record | undefined, + }) +} + +// Runs `claude` without sending a prompt (no tokens are used) to read its version, login and models. +// Call within the instance. +export async function check(): Promise { + const { status, settings } = await instance() + await Effect.runPromise(status.refresh()) + const state = await Effect.runPromise(SubscriptionRef.get(status.state)) + await saveCatalog(state, settings.connection) + return state +} + +export function problem(state: ClaudeStatus.State): string | undefined { + if (state.type === "missing") return "Claude Code is not installed. Install it, then run `claude auth login`." + if (state.type === "failed") return state.message + if (state.type === "ready" && !ClaudeProbe.isLoggedIn(state.probe.account)) + return "Claude Code is not logged in. Run `claude auth login` in a terminal." + if (state.type === "checking") return "Claude Code login check did not finish." + return undefined +} + +// The session stopped running, so a Claude turn still waiting for tool results will not get them. +// Call within the instance. +export async function idle(sessionID: string) { + const { sessions } = await instance() + await Effect.runPromise(sessions.idle(sessionID)) +} + +// The provider's merged options as Claude Code environment variables. The loader fills `apiKey` +// from the saved /connect marker, which is not an Anthropic key; a configured apiKey still maps. +export function connectionEnv(options: Readonly>) { + const { apiKey, ...settings } = options + return ClaudeQueryOptions.connectionEnv({ + settings: apiKey === AUTH_KEY ? settings : options, + headers: options.headers, + body: options.body, + }) +} + +// The AI SDK provider factory for `PACKAGE`, called by the shipped provider loader with the +// provider's merged options. Mirrors upstream's `ClaudeAISDK.register` "sdk" hook. +export function createSDK(options: Record) { + // Resolved here, inside the instance, because the model's stream runs from the AI SDK's callbacks. + const current = instance() + // A failed attach surfaces from the chat that awaits it, not as an unhandled rejection. + current.catch(() => {}) + const env = connectionEnv(options) + const dependencies: ClaudeAgentLanguageModel.Dependencies = { + run: async (request, signal) => { + const { sessions } = await current + await Effect.runPromise(sessions.run(request), { signal }) + }, + } + return { + languageModel: (modelID: string) => new ClaudeAgentLanguageModel.Model(modelID, dependencies, env), + } +} + +// Upstream's request kinds. Titles, summaries and compaction run as standalone side calls here: +// the shipped runtime has no hook to snapshot a chat's Claude session for them. +function kind(input: { readonly agent: string; readonly small?: boolean }) { + if (input.small) return "utility" + if (input.agent === "title" || input.agent === "summary" || input.agent === "compaction") return "utility" + return "primary" +} + +// What `session/llm.ts` adds to a Claude Agent request, standing in for upstream's +// `model.request`, `context` and instruction hooks. +export function request(input: { + readonly sessionID: string + readonly agent: string + readonly agentPrompt?: string + readonly small?: boolean + // Instruction sources after the agent/provider prompt: AGENTS.md, skills, workspace rules, ... + // The per-turn environment block is left out: Claude Code writes its own date and environment, + // and forwarding them made Claude subscriptions reject chats as a third-party app. + readonly instructions: readonly (string | undefined)[] +}) { + const requestKind = kind(input) + return { + headers: { + [RequestKind.HEADER]: requestKind, + ...(requestKind === "primary" ? { [RequestKind.SESSION_HEADER]: input.sessionID } : {}), + }, + options: { + [ClaudeSystemPrompt.AGENT_OPTION]: input.agentPrompt ?? "", + [ClaudeSystemPrompt.INSTRUCTIONS_OPTION]: input.instructions.filter((text) => text?.trim()).join("\n\n"), + }, + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/language-model/claude-agent-error.ts b/packages/opencode/src/altimate/provider/claude-agent/language-model/claude-agent-error.ts new file mode 100644 index 0000000000..96787ce55e --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/language-model/claude-agent-error.ts @@ -0,0 +1,39 @@ +export * as ClaudeAgentError from "./claude-agent-error" + +import type { SDKAssistantMessageError, SDKMessage, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk" + +// Keep request-local diagnostics until the terminal result. A usage warning alone +// is not a failed request, and Claude Code owns any retries before this result. +export function make() { + let code: SDKAssistantMessageError | undefined + let rejected: SDKRateLimitInfo | undefined + return (message: SDKMessage): Error | undefined => { + if (message.type === "rate_limit_event") { + rejected = message.rate_limit_info.status === "rejected" ? message.rate_limit_info : undefined + } + if (message.type === "assistant" && message.parent_tool_use_id === null && message.error) code = message.error + if (message.type !== "result") return + + const status = message.subtype === "success" ? (message.api_error_status ?? undefined) : undefined + const httpError = status !== undefined && status >= 400 + if (message.subtype === "success" && !message.is_error && !httpError) return + const result = message.subtype === "success" ? message.result : message.errors.join("\n") + const failure = + status !== undefined && status >= 500 + ? (status === 529 ? "Anthropic is overloaded (529)." : `Anthropic returned HTTP ${status}.`) + + " Claude Code exhausted its retries. Try again shortly." + : result + const limited = code === "rate_limit" || (rejected && status === 429) + const window = rejected?.rateLimitType?.replaceAll("_", " ") + const limit = + limited && !failure ? `Claude Code subscription limit reached${window ? ` (${window})` : ""}.` : failure + const reset = limited && rejected?.resetsAt ? ` Resets at ${new Date(rejected.resetsAt * 1000).toISOString()}.` : "" + const billing = code === "billing_error" ? " Check your Claude plan and extra-usage settings." : "" + const auth = code === "authentication_failed" ? " Run `claude auth login` in a terminal." : "" + const text = limit + reset + billing + auth + + if (text) return new Error(text) + if (status) return new Error(`Claude Code could not finish its reply (HTTP ${status})`) + return new Error("Claude Code could not finish its reply") + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/language-model/claude-agent-language-model.ts b/packages/opencode/src/altimate/provider/claude-agent/language-model/claude-agent-language-model.ts new file mode 100644 index 0000000000..cb51cd780a --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/language-model/claude-agent-language-model.ts @@ -0,0 +1,188 @@ +export * as ClaudeAgentLanguageModel from "./claude-agent-language-model" + +import type { + JSONSchema7, + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3Content, + LanguageModelV3FinishReason, + LanguageModelV3FunctionTool, + LanguageModelV3GenerateResult, + LanguageModelV3Prompt, + LanguageModelV3StreamPart, + LanguageModelV3StreamResult, + LanguageModelV3Usage, +} from "@ai-sdk/provider" +import type { ClaudeSessions } from "../claude/sessions" +import type { EffortLevel } from "@anthropic-ai/claude-agent-sdk" +import { ClaudeAgentID } from "../id" +import { ConvertClaudeStream } from "./convert-claude-stream" +import { RequestKind } from "./request-kind" +import { ClaudeSystemPrompt } from "./system-prompt" + +export interface Dependencies { + readonly run: (request: ClaudeSessions.Request, signal: AbortSignal) => Promise +} + +export class Model implements LanguageModelV3 { + readonly specificationVersion = "v3" + readonly provider = ClaudeAgentID.ID + readonly supportedUrls = {} + + constructor( + readonly modelId: string, + readonly dependencies: Dependencies, + readonly connection: Readonly> = {}, + ) {} + + // altimate_change start — upstream only streams. The shipped CLI also calls generateObject/generateText + // (agent creation, learning reflection), so a generate call collects the streamed reply. Claude Code has no + // structured-output mode, so a JSON request is asked for in the prompt and any markdown fence is removed. + async doGenerate(options: LanguageModelV3CallOptions): Promise { + const json = options.responseFormat?.type === "json" ? options.responseFormat : undefined + const prompt: LanguageModelV3Prompt = json + ? [{ role: "system", content: jsonInstruction(json.schema) }, ...options.prompt] + : options.prompt + const { stream } = await this.doStream({ ...options, prompt }) + const content: LanguageModelV3Content[] = [] + const open = new Map() + let finishReason: LanguageModelV3FinishReason = { unified: "other", raw: undefined } + let usage: LanguageModelV3Usage | undefined + let response: { id?: string; modelId?: string } = {} + for await (const part of stream as unknown as AsyncIterable) { + switch (part.type) { + case "text-start": + case "reasoning-start": + open.set(part.id, { type: part.type === "text-start" ? "text" : "reasoning", text: "" }) + break + case "text-delta": + case "reasoning-delta": { + const block = open.get(part.id) + if (block) block.text += part.delta + break + } + case "text-end": + case "reasoning-end": { + const block = open.get(part.id) + open.delete(part.id) + if (block) content.push({ type: block.type, text: block.text }) + break + } + case "tool-call": + content.push({ type: "tool-call", toolCallId: part.toolCallId, toolName: part.toolName, input: part.input }) + break + case "response-metadata": + response = { id: part.id, modelId: part.modelId } + break + case "finish": + finishReason = part.finishReason + usage = part.usage + break + case "error": + throw part.error + } + } + if (json) { + for (const part of content) if (part.type === "text") part.text = stripFence(part.text) + } + return { + content, + finishReason, + usage: usage ?? { inputTokens: {}, outputTokens: {} }, + warnings: [], + response, + } as LanguageModelV3GenerateResult + } + // altimate_change end + + async doStream(options: LanguageModelV3CallOptions): Promise { + const abort = new AbortController() + const signal = options.abortSignal ? AbortSignal.any([abort.signal, options.abortSignal]) : abort.signal + const tools = + options.toolChoice?.type === "none" + ? [] + : (options.tools ?? []).filter((tool): tool is LanguageModelV3FunctionTool => tool.type === "function") + const headers = options.headers ?? {} + const kind = headers[RequestKind.HEADER] + const session = headers[RequestKind.SESSION_HEADER] + const settings = options.providerOptions?.[ClaudeAgentID.ID] + const role = settings?.[ClaudeSystemPrompt.AGENT_OPTION] + const agent = typeof role === "string" ? role : undefined + const loaded = settings?.[ClaudeSystemPrompt.INSTRUCTIONS_OPTION] + const instructions = typeof loaded === "string" ? loaded : undefined + const effort = parseEffort(settings?.effort) + const primary = kind === "primary" + const branch = kind === "generate" || kind === "compaction" + const sessionID = primary ? session : undefined + // Conversation branches copy their source session and keep the Claude prompt. OpenCode supplies their task as user input. + const sourceID = primary ? headers[RequestKind.FORK_HEADER] : branch ? session : undefined + // Branches offer the chat's tools and system prompt so Claude reuses the chat's prompt cache. + const offered = primary || branch ? tools : [] + const convert = ConvertClaudeStream.make(offered) + // Titles are standalone tasks, so OpenCode's system prompt replaces Claude Code's. + const system = + primary || branch ? ClaudeSystemPrompt.make(agent, instructions) : ClaudeSystemPrompt.utility(options.prompt) + let cancelled = false + let task: Promise + const stream = new ReadableStream({ + start: (controller) => { + controller.enqueue({ type: "stream-start", warnings: [] }) + task = this.dependencies + .run( + { + sessionID, + sourceID, + compaction: kind === "compaction", + model: this.modelId, + effort, + maxOutputTokens: options.maxOutputTokens, + connection: this.connection, + system, + prompt: options.prompt, + tools: offered, + emit: (message) => { + if (cancelled) return + for (const part of convert(message)) controller.enqueue(part) + }, + }, + signal, + ) + .then( + () => { + if (!cancelled) controller.close() + }, + (error) => { + if (!cancelled) controller.error(error) + }, + ) + }, + cancel: () => { + cancelled = true + abort.abort() + return task + }, + }) + return { stream } + } +} + +const EFFORTS: readonly EffortLevel[] = ["low", "medium", "high", "xhigh", "max"] + +function parseEffort(value: unknown): EffortLevel | undefined { + if (value === undefined) return undefined + const effort = EFFORTS.find((effort) => value === effort) + if (effort !== undefined) return effort + throw new Error("Claude Agent effort must be low, medium, high, xhigh or max") +} + +// altimate_change start — JSON output for generate calls (see doGenerate) +function jsonInstruction(schema: JSONSchema7 | undefined) { + const shape = schema ? ` that matches this JSON schema:\n${JSON.stringify(schema)}` : "" + return `Respond with only a JSON value${shape}\nDo not wrap it in a markdown code fence or add any other text.` +} + +function stripFence(text: string) { + const match = text.trim().match(/^```(?:json)?\s*\n([\s\S]*?)\n?```$/) + return match ? match[1]! : text +} +// altimate_change end diff --git a/packages/opencode/src/altimate/provider/claude-agent/language-model/convert-claude-stream.ts b/packages/opencode/src/altimate/provider/claude-agent/language-model/convert-claude-stream.ts new file mode 100644 index 0000000000..b13275f146 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/language-model/convert-claude-stream.ts @@ -0,0 +1,141 @@ +export * as ConvertClaudeStream from "./convert-claude-stream" + +import type { LanguageModelV3FunctionTool, LanguageModelV3StreamPart } from "@ai-sdk/provider" +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk" +import { ClaudeAgentID } from "../id" +import { ClaudeUsage } from "./usage" +import { ToolNames } from "../tools/names" +import { ClaudeAgentError } from "./claude-agent-error" + +export function make(tools: readonly LanguageModelV3FunctionTool[]) { + const usage = ClaudeUsage.make() + const failure = ClaudeAgentError.make() + const streamed = new Set() + const blocks = new Map() + let messageID = "" + let calledTools = false + let metadata: Record | undefined + + return (message: SDKMessage): LanguageModelV3StreamPart[] => { + const parts: LanguageModelV3StreamPart[] = [] + const error = failure(message) + if (error) return [{ type: "error", error }] + if (message.type === "stream_event" && message.parent_tool_use_id === null) { + const event = message.event + switch (event.type) { + case "message_start": + messageID = event.message.id + metadata = undefined + streamed.add(messageID) + usage.update(messageID, event.message.usage) + parts.push({ type: "response-metadata", id: messageID, modelId: event.message.model }) + break + case "content_block_start": { + const block = event.content_block + const id = `${messageID}:${event.index}` + if (block.type === "text") { + blocks.set(event.index, "text") + parts.push({ type: "text-start", id }) + if (block.text) parts.push({ type: "text-delta", id, delta: block.text }) + break + } + if (block.type === "thinking") { + blocks.set(event.index, "reasoning") + parts.push({ type: "reasoning-start", id }) + if (block.thinking) parts.push({ type: "reasoning-delta", id, delta: block.thinking }) + } + break + } + case "content_block_delta": { + const id = `${messageID}:${event.index}` + const delta = event.delta + if (delta.type === "text_delta") { + parts.push({ type: "text-delta", id, delta: delta.text }) + break + } + if (delta.type === "thinking_delta" && delta.thinking) { + parts.push({ type: "reasoning-delta", id, delta: delta.thinking }) + } + break + } + case "content_block_stop": { + const kind = blocks.get(event.index) + if (kind) parts.push({ type: `${kind}-end`, id: `${messageID}:${event.index}`, providerMetadata: metadata }) + blocks.delete(event.index) + break + } + case "message_delta": + usage.update(messageID, event.usage) + break + case "message_stop": + // Must match ClaudeSession.readStep's tool boundary, or OpenCode's step ends without a finish event. + if (calledTools) { + parts.push({ + type: "finish", + finishReason: { unified: "tool-calls", raw: "tool_use" }, + usage: usage.snapshot(), + }) + } + break + } + } + if (message.type === "assistant" && message.parent_tool_use_id === null) { + // Synthetic API errors are diagnostics, not assistant answers. + if (message.error) return parts + // Stream events carry API message IDs; only the complete assistant message carries the transcript UUID. + metadata = { [ClaudeAgentID.ID]: { sessionID: message.session_id, messageID: message.uuid } } + usage.update(message.message.id, message.message.usage) + for (const block of message.message.content) { + if (block.type !== "tool_use") continue + const toolName = ToolNames.original(block.name, tools) + if (!toolName) continue + calledTools = true + // altimate_change start — the shipped session processor opens a tool part on tool-input-start + // and only fills it on tool-call, so a bare tool-call (all upstream core needs) is dropped and + // the step loops. Claude's complete message carries the whole input, so it is one delta. + const input = JSON.stringify(block.input) + parts.push({ type: "tool-input-start", id: block.id, toolName, providerMetadata: metadata }) + parts.push({ type: "tool-input-delta", id: block.id, delta: input }) + parts.push({ type: "tool-input-end", id: block.id }) + // altimate_change end + parts.push({ + type: "tool-call", + toolCallId: block.id, + toolName, + input, + providerMetadata: metadata, + }) + } + // Complete assistant messages repeat the streamed content. Only use them when nothing was streamed. + if (!streamed.has(message.message.id)) { + for (let i = 0; i < message.message.content.length; i++) { + const block = message.message.content[i]! + const id = `${message.uuid}:${i}` + if (block.type === "text") { + parts.push({ type: "text-start", id }) + parts.push({ type: "text-delta", id, delta: block.text }) + parts.push({ type: "text-end", id, providerMetadata: metadata }) + continue + } + if (block.type === "thinking") { + parts.push({ type: "reasoning-start", id }) + parts.push({ type: "reasoning-delta", id, delta: block.thinking }) + parts.push({ type: "reasoning-end", id }) + } + } + } + } + if (message.type === "result") { + if (message.num_turns === 1) usage.result(message.usage) + parts.push({ + type: "finish", + finishReason: { + unified: message.stop_reason === "max_tokens" ? "length" : "stop", + raw: message.stop_reason ?? undefined, + }, + usage: usage.snapshot(), + }) + } + return parts + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/language-model/convert-to-claude-message.ts b/packages/opencode/src/altimate/provider/claude-agent/language-model/convert-to-claude-message.ts new file mode 100644 index 0000000000..42eab12768 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/language-model/convert-to-claude-message.ts @@ -0,0 +1,55 @@ +export * as ConvertToClaudeMessage from "./convert-to-claude-message" + +import type { LanguageModelV3FilePart, LanguageModelV3Prompt } from "@ai-sdk/provider" +import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk" + +export type ContentBlock = Exclude[number] + +// Claude can't be given earlier turns, so their roles become text tags. Attachments stay real content blocks. +export function content(prompt: LanguageModelV3Prompt): ContentBlock[] { + const blocks: ContentBlock[] = [] + const history = prompt.some((message) => message.role === "assistant" || message.role === "tool") + for (const message of prompt) { + if (message.role === "system") continue + if (history) blocks.push({ type: "text", text: `<${message.role}>` }) + for (const part of message.content) { + if (part.type === "text") { + blocks.push({ type: "text", text: part.text }) + continue + } + if (part.type === "file") { + blocks.push(file(part)) + continue + } + if (part.type === "tool-call") { + const call = JSON.stringify({ id: part.toolCallId, name: part.toolName, input: part.input }) + blocks.push({ type: "text", text: `\n${call}\n` }) + continue + } + if (part.type === "tool-result") { + const result = JSON.stringify({ id: part.toolCallId, name: part.toolName, output: part.output }) + blocks.push({ type: "text", text: `\n${result}\n` }) + } + } + if (history) blocks.push({ type: "text", text: `` }) + } + return blocks +} + +function file(part: LanguageModelV3FilePart): ContentBlock { + const type = part.mediaType + if (type === "application/pdf") { + if (part.data instanceof URL) return { type: "document", source: { type: "url", url: part.data.href } } + return { type: "document", source: { type: "base64", media_type: type, data: base64(part.data) } } + } + if (type === "image/png" || type === "image/jpeg" || type === "image/gif" || type === "image/webp") { + if (part.data instanceof URL) return { type: "image", source: { type: "url", url: part.data.href } } + return { type: "image", source: { type: "base64", media_type: type, data: base64(part.data) } } + } + throw new Error(`Claude Code does not support ${type} attachments`) +} + +function base64(data: string | Uint8Array) { + if (typeof data === "string") return data + return Buffer.from(data).toString("base64") +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/language-model/request-kind.ts b/packages/opencode/src/altimate/provider/claude-agent/language-model/request-kind.ts new file mode 100644 index 0000000000..516c8be594 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/language-model/request-kind.ts @@ -0,0 +1,5 @@ +export * as RequestKind from "./request-kind" + +export const HEADER = "x-opencode-claude-agent-kind" +export const SESSION_HEADER = "x-opencode-session-id" +export const FORK_HEADER = "x-opencode-claude-agent-fork-source" diff --git a/packages/opencode/src/altimate/provider/claude-agent/language-model/system-prompt.ts b/packages/opencode/src/altimate/provider/claude-agent/language-model/system-prompt.ts new file mode 100644 index 0000000000..c965feb173 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/language-model/system-prompt.ts @@ -0,0 +1,28 @@ +export * as ClaudeSystemPrompt from "./system-prompt" + +import type { Options } from "@anthropic-ai/claude-agent-sdk" +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" + +export const AGENT_OPTION = "claudeCodeAgent" +export const INSTRUCTIONS_OPTION = "claudeCodeInstructions" + +const primary = { + type: "preset", + preset: "claude_code", + // Agent changes must take effect when a process resumes the same native session. + snapshot: false, + append: + "You are running in Altimate Code through the Claude Code harness. Mention this only when asked.", +} satisfies Options["systemPrompt"] + +export function make(role?: string, instructions?: string) { + const blocks = [primary.append] + if (role) blocks.push("# Agent role (from Altimate Code)\n" + role) + if (instructions) blocks.push(instructions) + return { ...primary, append: blocks.join("\n\n") } +} + +export function utility(prompt: LanguageModelV3Prompt) { + const blocks = prompt.filter((message) => message.role === "system").map((message) => message.content) + return blocks.join("\n\n") || "You are a text generation helper. Return only the requested output." +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/language-model/usage.ts b/packages/opencode/src/altimate/provider/claude-agent/language-model/usage.ts new file mode 100644 index 0000000000..96dd25fad2 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/language-model/usage.ts @@ -0,0 +1,58 @@ +export * as ClaudeUsage from "./usage" + +import type { LanguageModelV3Usage } from "@ai-sdk/provider" + +interface Usage { + readonly input_tokens?: number | null + readonly cache_read_input_tokens?: number | null + readonly cache_creation_input_tokens?: number | null + readonly output_tokens?: number + readonly output_tokens_details?: { readonly thinking_tokens?: number } | null +} + +export function make() { + const messages = new Map() + let last: string | undefined + + function update(id: string, usage: Usage) { + const saved = messages.get(id) + const counts = saved ?? { input: 0, read: 0, write: 0, output: 0, reasoning: 0 } + if (!saved) { + messages.set(id, counts) + last = id + } + counts.input = Math.max(counts.input, usage.input_tokens ?? 0) + counts.read = Math.max(counts.read, usage.cache_read_input_tokens ?? 0) + counts.write = Math.max(counts.write, usage.cache_creation_input_tokens ?? 0) + counts.output = Math.max(counts.output, usage.output_tokens ?? 0) + counts.reasoning = Math.max(counts.reasoning, usage.output_tokens_details?.thinking_tokens ?? 0) + } + + return { + update, + result(usage: Usage) { + // Result usage sums the whole turn; only apply it when there was a single model call. + if (messages.size <= 1) update(last ?? "result", usage) + }, + snapshot(): LanguageModelV3Usage { + let output = 0 + let reasoning = 0 + for (const counts of messages.values()) { + output += counts.output + reasoning += counts.reasoning + } + // Context occupancy is the last API call's input, not the sum of repeated contexts. + const lastCall = last === undefined ? undefined : messages.get(last) + const inputTotal = lastCall ? lastCall.input + lastCall.read + lastCall.write : undefined + return { + inputTokens: { + total: inputTotal, + noCache: lastCall?.input, + cacheRead: lastCall?.read, + cacheWrite: lastCall?.write, + }, + outputTokens: { total: output, reasoning, text: output - reasoning }, + } + }, + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/plugin.ts b/packages/opencode/src/altimate/provider/claude-agent/plugin.ts new file mode 100644 index 0000000000..ddc2312c5f --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/plugin.ts @@ -0,0 +1,59 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { Log } from "@/util/log" +import { ClaudeAgent } from "./index" + +const log = Log.create({ service: "claude-agent.plugin" }) + +const SETUP_URL = "https://code.claude.com/docs/en/setup" +// While /connect is open, check again on this interval so `claude auth login` in another +// terminal is picked up without restarting the dialog. +const RECHECK_MS = 3_000 +const RECHECK_LIMIT_MS = 5 * 60_000 + +// Ported from upstream's `ClaudeIntegration` and the plugin's session-idle subscription. +// The login method only records that the user chose Claude Code. The login itself belongs to +// `claude`, and the CLI never reads or stores its token. +export async function ClaudeAgentPlugin(_input: PluginInput): Promise { + return { + config: async (config) => { + await ClaudeAgent.configure(config.provider?.[ClaudeAgent.ID]?.options) + }, + event: async ({ event }) => { + if (event.type !== "session.idle") return + await ClaudeAgent.idle(event.properties.sessionID).catch((error) => + log.warn("failed to release an idle Claude session", { error }), + ) + }, + auth: { + provider: ClaudeAgent.ID, + methods: [ + { + type: "oauth", + label: "Claude Code login (claude auth login)", + // Returns at once so the dialog shows its waiting screen; the login check (which starts + // `claude` and reads every model's context window) runs in the callback. + async authorize() { + return { + url: SETUP_URL, + method: "auto" as const, + instructions: + "Checking your Claude Code login. If you have not logged in yet, run `claude auth login` in a terminal; this connects as soon as it finds the login.", + async callback() { + const deadline = Date.now() + RECHECK_LIMIT_MS + while (true) { + const reason = ClaudeAgent.problem(await ClaudeAgent.check()) + if (!reason) return { type: "success" as const, key: ClaudeAgent.AUTH_KEY } + if (Date.now() >= deadline) { + log.warn("Claude Code login was not found", { reason }) + return { type: "failed" as const } + } + await Bun.sleep(RECHECK_MS) + } + }, + } + }, + }, + ], + }, + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/storage.ts b/packages/opencode/src/altimate/provider/claude-agent/storage.ts new file mode 100644 index 0000000000..86ecd21d01 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/storage.ts @@ -0,0 +1,39 @@ +export * as ClaudeAgentStorage from "./storage" + +import path from "path" +import { Effect } from "effect" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { Log } from "@/util/log" + +const log = Log.create({ service: "claude-agent.storage" }) + +// Upstream keeps this state in the plugin's own storage; the shipped CLI has no per-plugin store, +// so each key is one JSON file under the data directory. +function root() { + return path.join(Global.Path.data, "claude-agent") +} + +function file(key: string) { + // Keys are plugin-chosen ("models", "sessions/"); never let one leave the root. + const target = path.resolve(root(), `${key}.json`) + if (!target.startsWith(root() + path.sep)) throw new Error(`Invalid Claude Agent storage key: ${key}`) + return target +} + +export async function read(key: string): Promise { + return Filesystem.readJson(file(key)).catch(() => undefined) +} + +export async function write(key: string, value: unknown) { + await Filesystem.writeJson(file(key), value) +} + +// The shape ClaudeSessions expects. A failed write must not fail a chat, so it is logged and dropped. +export const sessions = { + get: (key: string) => Effect.promise(() => read(key)), + set: (key: string, value: unknown) => + Effect.promise(() => + write(key, value).catch((error) => log.warn("failed to save Claude session record", { key, error })), + ), +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/sync/catch-up.ts b/packages/opencode/src/altimate/provider/claude-agent/sync/catch-up.ts new file mode 100644 index 0000000000..290d0df695 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/sync/catch-up.ts @@ -0,0 +1,21 @@ +export * as CatchUp from "./catch-up" + +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" +import { ConvertToClaudeMessage } from "../language-model/convert-to-claude-message" +import { Fingerprint } from "./fingerprint" + +export function content(prompt: LanguageModelV3Prompt, start = 0): ConvertToClaudeMessage.ContentBlock[] { + const missed = prompt.slice(start) + const end = Fingerprint.groups(missed).findLast((group) => group.role !== "user")?.end ?? 0 + const blocks: ConvertToClaudeMessage.ContentBlock[] = [] + if (end > 0) { + blocks.push({ + type: "text", + text: "\nThe following is conversation history from Altimate Code that you have not seen. Treat it as prior conversation, not a new request. Continue with the user messages after this checkpoint.", + }) + blocks.push(...ConvertToClaudeMessage.content(missed.slice(0, end))) + blocks.push({ type: "text", text: "" }) + } + blocks.push(...ConvertToClaudeMessage.content(missed.slice(end))) + return blocks +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/sync/checkpoints.ts b/packages/opencode/src/altimate/provider/claude-agent/sync/checkpoints.ts new file mode 100644 index 0000000000..6e57dd9ec6 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/sync/checkpoints.ts @@ -0,0 +1,41 @@ +export * as Checkpoints from "./checkpoints" + +import type { LanguageModelV3Prompt, LanguageModelV3TextPart, LanguageModelV3ToolCallPart } from "@ai-sdk/provider" +import { Fingerprint } from "./fingerprint" +import type { SessionRecord } from "./record" + +export function build(input: { + prompt: LanguageModelV3Prompt + previous?: SessionRecord.Record + userID?: string + reply: Array + messageID?: string + toolMessages: Map +}) { + const history: SessionRecord.Checkpoint[] = [] + const groups = Fingerprint.groups(input.prompt) + for (let i = 0; i < groups.length; i++) { + const group = groups[i]! + const previous = input.previous?.history?.[i] + let claudeMessageID: string | undefined + if (previous && previous.hash === group.hash) claudeMessageID = previous.claudeMessageID + if (group.role === "tool") { + for (const message of input.prompt.slice(group.start, group.end)) { + if (message.role !== "tool") continue + for (const part of message.content) { + if (part.type !== "tool-result") continue + const toolMessage = input.toolMessages.get(part.toolCallId) + if (toolMessage !== undefined) claudeMessageID = toolMessage + } + } + } + const isLast = i === groups.length - 1 + if (isLast && input.userID) claudeMessageID = input.userID + history.push({ hash: group.hash, claudeMessageID }) + } + if (input.reply.length > 0) { + const replyGroup = Fingerprint.groups([{ role: "assistant", content: input.reply }])[0]! + history.push({ hash: replyGroup.hash, claudeMessageID: input.messageID }) + } + return history +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/sync/decide.ts b/packages/opencode/src/altimate/provider/claude-agent/sync/decide.ts new file mode 100644 index 0000000000..30c9258a1d --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/sync/decide.ts @@ -0,0 +1,58 @@ +export * as SyncDecision from "./decide" + +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" +import { Fingerprint } from "./fingerprint" +import type { SessionRecord } from "./record" + +export function decide(prompt: LanguageModelV3Prompt, record?: SessionRecord.Record) { + const saved = record?.history ?? [] + const groups = Fingerprint.groups(prompt, record?.interrupted ? saved : undefined) + if (saved.length === 0) return { kind: "fresh", start: 0 } as const + + let matched = 0 + while (matched < saved.length && matched < groups.length) { + if (saved[matched]!.hash !== groups[matched]!.hash) break + matched++ + } + if (matched === 0) return { kind: "fresh", start: 0 } as const + // Changed histories need an isolated branch, or a rebuild when no safe checkpoint survives. + if (matched < saved.length) return { kind: "changed", start: 0 } as const + + // Claude's drained transcript owns the interrupted reply, not OpenCode's partial copy. + while (record?.interrupted && groups[matched]?.role === "assistant") { + const reply = prompt[groups[matched]!.start]! + if (reply.role !== "assistant" || reply.content.some((part) => part.type === "tool-call")) break + matched++ + } + const start = groups[matched]?.start ?? prompt.length + if (record?.interrupted && start === prompt.length) return { kind: "changed", start: 0 } as const + const missed = groups.slice(matched) + const kind = missed.some((group) => group.role !== "user") ? "catch-up" : "continue" + return { kind, start } as const +} + +// A branch must not end between a native tool call and its results. +export function boundary(prompt: LanguageModelV3Prompt, record?: SessionRecord.Record) { + const groups = Fingerprint.groups(prompt) + const history = record?.history ?? [] + const pending = new Set() + let found: { messageID: string; start: number; history: readonly SessionRecord.Checkpoint[] } | undefined + for (let i = 0; i < groups.length; i++) { + const group = groups[i]! + // Undo can resubmit identical input; keep the current user group for the new turn. + if (i === groups.length - 1 && group.role === "user") break + const checkpoint = history[i] + if (!checkpoint || checkpoint.hash !== group.hash) break + for (const message of prompt.slice(group.start, group.end)) { + if (message.role === "system") continue + for (const part of message.content) { + if (part.type === "tool-call") pending.add(part.toolCallId) + if (part.type === "tool-result") pending.delete(part.toolCallId) + } + } + if (checkpoint.claudeMessageID && pending.size === 0) { + found = { messageID: checkpoint.claudeMessageID, start: group.end, history: history.slice(0, i + 1) } + } + } + return found +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/sync/fingerprint.ts b/packages/opencode/src/altimate/provider/claude-agent/sync/fingerprint.ts new file mode 100644 index 0000000000..30730f0f95 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/sync/fingerprint.ts @@ -0,0 +1,117 @@ +export * as Fingerprint from "./fingerprint" + +import { createHash } from "node:crypto" +import type { LanguageModelV3Message, LanguageModelV3Prompt } from "@ai-sdk/provider" + +export interface Group { + readonly hash: string + readonly role: "user" | "assistant" | "tool" + readonly start: number + readonly end: number +} + +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]` + if (value !== null && typeof value === "object") { + const object = value as Record + const fields: string[] = [] + for (const key of Object.keys(object).sort()) { + if (object[key] !== undefined) fields.push(`${JSON.stringify(key)}:${canonical(object[key])}`) + } + return `{${fields.join(",")}}` + } + return JSON.stringify(value) ?? "null" +} + +function hash(value: unknown) { + return createHash("sha256").update(canonical(value)).digest("hex") +} + +// opencode sends thinking and provider options differently depending on the target model, so they don't count. +function visible(message: Exclude) { + const parts: unknown[] = [] + let text = "" + for (const part of message.content) { + if (part.type === "reasoning") continue + if (part.type === "text") { + text += part.text + continue + } + if (text) parts.push({ type: "text", text }) + text = "" + if (part.type === "file") { + const data = + part.data instanceof URL + ? part.data.href + : typeof part.data === "string" + ? part.data + : Buffer.from(part.data).toString("base64") + parts.push({ type: part.type, mediaType: part.mediaType, data }) + continue + } + if (part.type === "tool-call") { + parts.push({ type: part.type, id: part.toolCallId, name: part.toolName, input: part.input }) + continue + } + if (part.type === "tool-result") { + const output = { ...part.output } + if ("providerOptions" in output) delete output.providerOptions + if (output.type === "content") { + output.value = output.value.map((part) => { + const item = { ...part } + delete item.providerOptions + return item + }) + } + parts.push({ type: part.type, id: part.toolCallId, name: part.toolName, output }) + } + } + if (text) parts.push({ type: "text", text }) + return parts +} + +// OpenCode lowers tool attachments into this separate user message. +function isToolMedia(message: LanguageModelV3Message) { + return ( + message.role === "user" && + message.content.length > 1 && + message.content[0]?.type === "text" && + message.content[0].text === "Attached media from tool result:" && + message.content.slice(1).every((part) => part.type === "file") + ) +} + +export function groups(prompt: LanguageModelV3Prompt, prefix: readonly { readonly hash: string }[] = []): Group[] { + const groups: Group[] = [] + let i = 0 + while (i < prompt.length) { + const message = prompt[i]! + const start = i + i++ + if (message.role === "system") continue + const parts = visible(message) + if (parts.length === 0) continue + const members = [hash(parts)] + if (message.role === "user") { + while (i < prompt.length) { + // A submitted input remains a boundary even when a later user message arrives without a visible reply. + const checkpoint = prefix[groups.length] + if (checkpoint && hash({ role: message.role, members: members.toSorted() }) === checkpoint.hash) break + const next = prompt[i]! + if (next.role !== "user") break + members.push(hash(visible(next))) + i++ + } + members.sort() + } + if (message.role === "tool") { + const next = prompt[i] + if (next && next.role === "user" && isToolMedia(next)) { + members.push(hash(visible(next))) + i++ + } + } + groups.push({ hash: hash({ role: message.role, members }), role: message.role, start, end: i }) + } + return groups +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/sync/record.ts b/packages/opencode/src/altimate/provider/claude-agent/sync/record.ts new file mode 100644 index 0000000000..b1d971e88e --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/sync/record.ts @@ -0,0 +1,28 @@ +export * as SessionRecord from "./record" + +import { Effect, Option, Schema } from "effect" + +const Checkpoint = Schema.Struct({ hash: Schema.String, claudeMessageID: Schema.optional(Schema.String) }) +export type Checkpoint = typeof Checkpoint.Type +const Record = Schema.Struct({ + claudeSessionID: Schema.String, + history: Schema.optional(Schema.Array(Checkpoint)), + // After cancellation, Claude owns the partial reply; history ends at the input it received. + interrupted: Schema.optional(Schema.Boolean), +}) +export type Record = typeof Record.Type + +export interface Storage { + readonly get: (key: string) => Effect.Effect + readonly set: (key: string, value: Record) => Effect.Effect +} + +export function make(storage: Storage) { + return { + get: Effect.fn("SessionRecord.get")(function* (sessionID: string) { + const saved = Schema.decodeUnknownOption(Record)(yield* storage.get(`sessions/${sessionID}`)) + return Option.getOrUndefined(saved) + }), + set: (sessionID: string, record: Record) => storage.set(`sessions/${sessionID}`, record), + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/tools/aborted.ts b/packages/opencode/src/altimate/provider/claude-agent/tools/aborted.ts new file mode 100644 index 0000000000..1560dfafb5 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/tools/aborted.ts @@ -0,0 +1,13 @@ +export * as ToolAborted from "./aborted" + +import { ClaudeAgentID } from "../id" + +// Upstream's session `context` hook flags a stopped tool in the result's provider metadata, so +// `ToolResult.collect` interrupts the waiting Claude turn instead of handing it the cancelled result. +// The shipped history conversion sends a tool part's call metadata as the result's provider options, +// so stopped Claude Agent calls carry the same flag there. Other providers' metadata is left as is. +export function mark | undefined>(metadata: T): T { + const own = metadata?.[ClaudeAgentID.ID] + if (!own || typeof own !== "object") return metadata + return { ...metadata, [ClaudeAgentID.ID]: { ...own, toolAborted: true } } as T +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/tools/hooks.ts b/packages/opencode/src/altimate/provider/claude-agent/tools/hooks.ts new file mode 100644 index 0000000000..7b85869135 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/tools/hooks.ts @@ -0,0 +1,23 @@ +export * as ToolHooks from "./hooks" + +import type { HookCallback, Options } from "@anthropic-ai/claude-agent-sdk" +import type { PendingTools } from "./pending" + +export function make(pending: PendingTools.Interface): Options["hooks"] { + const before: HookCallback = async (input) => { + if (input.hook_event_name === "PreToolUse") pending.before(input.tool_use_id, input.tool_name) + return {} + } + + const after: HookCallback = async (input) => { + if (input.hook_event_name !== "PostToolUse") return {} + const replacement = pending.after(input.tool_use_id) + if (!replacement) return {} + return { hookSpecificOutput: { hookEventName: "PostToolUse", updatedMCPToolOutput: replacement } } + } + + return { + PreToolUse: [{ hooks: [before] }], + PostToolUse: [{ hooks: [after] }], + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/tools/names.ts b/packages/opencode/src/altimate/provider/claude-agent/tools/names.ts new file mode 100644 index 0000000000..0c5f6b66d9 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/tools/names.ts @@ -0,0 +1,30 @@ +export * as ToolNames from "./names" + +import { createHash } from "node:crypto" +import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider" + +export const PREFIX = "mcp__opencode__" + +export function short(name: string) { + const limit = 64 - PREFIX.length + if (name.length <= limit && /^[a-zA-Z0-9_-]+$/.test(name)) return name + const hash = createHash("sha256").update(name).digest("hex").slice(0, 10) + return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, limit - 11) + "_" + hash +} + +// Undefined for a tool we did not offer. Claude Code answers those calls itself, without our hooks or server. +export function original(name: string, tools: readonly LanguageModelV3FunctionTool[]) { + return tools.find((tool) => PREFIX + short(tool.name) === name)?.name +} + +// Claude Code cuts each MCP tool description at this many characters. +const DESCRIPTION_LIMIT = 2048 + +export function description(tool: LanguageModelV3FunctionTool) { + const text = tool.description ?? "" + if (tool.name !== "subagent" || text.length <= DESCRIPTION_LIMIT) return text.slice(0, DESCRIPTION_LIMIT) + // The subagent list comes last in a long description, so move it first to keep it after the cut. + const agents = text.indexOf("Available subagents:") + if (agents === -1) return text.slice(0, DESCRIPTION_LIMIT) + return (text.slice(agents) + "\n\n" + text.slice(0, agents)).slice(0, DESCRIPTION_LIMIT) +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/tools/pending.ts b/packages/opencode/src/altimate/provider/claude-agent/tools/pending.ts new file mode 100644 index 0000000000..78c863a0ba --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/tools/pending.ts @@ -0,0 +1,72 @@ +export * as PendingTools from "./pending" + +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" +import type { ToolResult } from "./result" + +interface Call { + readonly name: string + readonly result: PromiseWithResolvers +} + +export interface Interface { + readonly expect: (id: string, name: string) => void + readonly before: (id: string, name: string) => void + readonly call: (name: string) => Promise + readonly resolve: (id: string, result: ToolResult.Result) => void + readonly after: (id: string) => ToolResult.Result["replacement"] + readonly clear: () => void +} + +export function make(): Interface { + const calls = new Map() + const replacements = new Map() + let next: string | undefined + + function expect(id: string, name: string) { + const saved = calls.get(id) + const call = saved ?? { name, result: Promise.withResolvers() } + if (!saved) calls.set(id, call) + if (call.name !== name) throw new Error(`Mismatched Claude tool call ${id}`) + return call + } + + return { + expect, + before(id: string, name: string) { + expect(id, name) + next = id + }, + async call(name: string) { + const id = next + next = undefined + if (!id) throw new Error("Claude invoked an Altimate Code tool without its PreToolUse ID") + const call = expect(id, name) + const result = await call.result.promise + if (result.replacement) replacements.set(id, result.replacement) + calls.delete(id) + return result.mcp + }, + resolve(id: string, result: ToolResult.Result) { + const call = calls.get(id) + if (!call) throw new Error(`No waiting Claude tool call ${id}`) + call.result.resolve(result) + }, + after(id: string) { + const replacement = replacements.get(id) + replacements.delete(id) + return replacement + }, + // Call only after interrupt-and-drain; resolving these handlers earlier can start another model call. + clear() { + for (const call of calls.values()) { + call.result.resolve({ + mcp: { isError: true, content: [{ type: "text", text: "Cancelled by user." }] }, + replacement: undefined, + }) + } + calls.clear() + replacements.clear() + next = undefined + }, + } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/tools/result.ts b/packages/opencode/src/altimate/provider/claude-agent/tools/result.ts new file mode 100644 index 0000000000..091d210c06 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/tools/result.ts @@ -0,0 +1,110 @@ +export * as ToolResult from "./result" + +import type { + LanguageModelV3FilePart, + LanguageModelV3Prompt, + LanguageModelV3ToolResultOutput, + LanguageModelV3ToolResultPart, +} from "@ai-sdk/provider" +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" +import { ClaudeAgentID } from "../id" +import { ConvertToClaudeMessage } from "../language-model/convert-to-claude-message" +import { Fingerprint } from "../sync/fingerprint" + +export interface Result { + readonly mcp: CallToolResult + readonly replacement: ConvertToClaudeMessage.ContentBlock[] | undefined +} + +export function convert( + output: LanguageModelV3ToolResultOutput, + files: readonly LanguageModelV3FilePart[] = [], + failed = false, +): Result { + const parts: Array<{ type: "text"; text: string } | LanguageModelV3FilePart> = [] + switch (output.type) { + case "text": + case "error-text": + parts.push({ type: "text", text: output.value }) + break + case "json": + case "error-json": + parts.push({ type: "text", text: JSON.stringify(output.value) }) + break + case "execution-denied": + parts.push({ type: "text", text: output.reason ?? "Tool execution was denied by the user." }) + break + case "content": + for (const part of output.value) { + if (part.type === "text") { + parts.push({ type: "text", text: part.text }) + continue + } + if (part.type === "image-data" || part.type === "file-data") { + parts.push({ type: "file", mediaType: part.mediaType, data: part.data }) + continue + } + if (part.type === "image-url") { + // Only picks an image block. URL images are sent without a media type. + parts.push({ type: "file", mediaType: "image/png", data: new URL(part.url) }) + continue + } + throw new Error(`Claude Code does not support ${part.type} tool output`) + } + break + } + parts.push(...files) + const content = ConvertToClaudeMessage.content([{ role: "user", content: parts }]) + const isError = + failed || output.type === "error-text" || output.type === "error-json" || output.type === "execution-denied" + const mcp: CallToolResult = { content: [], isError } + let replace = false + for (const block of content) { + if (block.type === "text") { + mcp.content.push({ type: "text", text: block.text }) + continue + } + if (block.type === "image" && block.source.type === "base64") { + mcp.content.push({ type: "image", mimeType: block.source.media_type, data: block.source.data }) + continue + } + replace = true + mcp.content.push({ type: "text", text: "Attached document or image follows." }) + } + // The PostToolUse hook replaces tool_result.content, so it needs content blocks rather than an MCP envelope. + return { mcp, replacement: replace ? content : undefined } +} + +export function collect(prompt: LanguageModelV3Prompt, start: number) { + const results = new Map() + let end = start + let aborted = false + for (const group of Fingerprint.groups(prompt.slice(start))) { + if (group.role !== "tool") break + const messages = prompt.slice(start + group.start, start + group.end) + const tools: LanguageModelV3ToolResultPart[] = [] + const files: LanguageModelV3FilePart[] = [] + for (const message of messages) { + if (message.role === "tool") { + for (const part of message.content) { + if (part.type === "tool-result") tools.push(part) + } + } + if (message.role === "user") { + for (const part of message.content) { + if (part.type === "file") files.push(part) + } + } + } + // OpenCode puts the group's media in a following user message; attach it to the group's last result. + for (let i = 0; i < tools.length; i++) { + const tool = tools[i]! + const flags = tool.providerOptions?.[ClaudeAgentID.ID] + if (flags?.toolAborted === true) aborted = true + const isLast = i === tools.length - 1 + results.set(tool.toolCallId, convert(tool.output, isLast ? files : [], flags?.toolError === true)) + } + end = start + group.end + } + return { results, end, aborted } +} diff --git a/packages/opencode/src/altimate/provider/claude-agent/tools/server.ts b/packages/opencode/src/altimate/provider/claude-agent/tools/server.ts new file mode 100644 index 0000000000..090cb86aa2 --- /dev/null +++ b/packages/opencode/src/altimate/provider/claude-agent/tools/server.ts @@ -0,0 +1,38 @@ +export * as ToolServer from "./server" + +import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider" +import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk" +import type { Tool } from "@modelcontextprotocol/sdk/types.js" +import { ToolNames } from "./names" +import type { PendingTools } from "./pending" + +// Upstream builds this on `@modelcontextprotocol/server` 2.x. The shipped CLI already bundles +// `@modelcontextprotocol/sdk` 1.x, which the Claude Agent SDK also peers on, so the same low-level +// server is built from that package instead of adding a second MCP SDK. +export async function make( + tools: readonly LanguageModelV3FunctionTool[], + pending: PendingTools.Interface, +): Promise { + // Loaded on first use, so the CLI starts without loading the MCP server. + const { Server } = await import("@modelcontextprotocol/sdk/server/index.js") + const { CallToolRequestSchema, ListToolsRequestSchema } = await import("@modelcontextprotocol/sdk/types.js") + const server = new Server({ name: "opencode", version: "1.0.0" }, { capabilities: { tools: {} } }) + const definitions = tools.map( + (tool): Tool => ({ + name: ToolNames.short(tool.name), + description: ToolNames.description(tool), + inputSchema: tool.inputSchema as Tool["inputSchema"], + // Claude serializes non-read-only tools, so each handler takes the ID from the preceding hook. + annotations: { readOnlyHint: false }, + _meta: { "anthropic/alwaysLoad": true, "anthropic/maxResultSizeChars": 500_000 }, + }), + ) + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: definitions })) + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const name = ToolNames.PREFIX + request.params.name + if (!ToolNames.original(name, tools)) throw new Error(`Claude Code called an unregistered tool: ${name}`) + return pending.call(name) + }) + // The SDK's public instance type names the higher-level McpServer; the low-level Server is what it drives. + return { type: "sdk", name: "opencode", instance: server as unknown as McpSdkServerConfigWithInstance["instance"] } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index fe1cb9abc6..e12b2444f2 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -33,6 +33,9 @@ import { AltimateAuthPlugin } from "../altimate/plugin/altimate" // altimate_change start — onboarding activation-funnel telemetry plugin import { OnboardingTelemetryPlugin } from "../altimate/plugin/onboarding-telemetry" // altimate_change end +// altimate_change start — Claude Agent provider: /connect login check and idle-session release +import { ClaudeAgentPlugin } from "../altimate/provider/claude-agent/plugin" +// altimate_change end // altimate_change start — wire plugin experimental_workspace.register into the // control-plane adapter registry consumed by control-plane/workspace.ts import { registerAdapter } from "../control-plane/adapters" @@ -64,6 +67,7 @@ export namespace Plugin { DatabricksAuthPlugin, AltimateAuthPlugin, OnboardingTelemetryPlugin, + ClaudeAgentPlugin, ] // altimate_change end diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2a16d829e4..95719c635e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -35,6 +35,10 @@ import { AltimateApi } from "../altimate/api/client" // altimate_change start — managed Altimate Base provider and credential boundary import { FreeTier } from "../altimate/free/client" // altimate_change end +// altimate_change start — Claude Agent provider (Claude Code login through the Claude Agent SDK) +import { ClaudeAgent } from "../altimate/provider/claude-agent" +import { ClaudeAgentCatalog } from "../altimate/provider/claude-agent/catalog" +// altimate_change end // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -398,6 +402,9 @@ export namespace Provider { "@gitlab/gitlab-ai-provider": createGitLab, // @ts-ignore (TODO: kill this code so we dont have to maintain it) "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, + // altimate_change start — Claude Agent models run through the user's local `claude` + [ClaudeAgent.PACKAGE]: ClaudeAgent.createSDK, + // altimate_change end } // altimate_change start — loaders may need the resolved model to choose SDK APIs @@ -1782,6 +1789,10 @@ export namespace Provider { } // altimate_change end + // altimate_change start — Claude Agent: models Claude Code last reported, priced from the login's provider + database[ClaudeAgent.ID] = ClaudeAgentCatalog.provider(await ClaudeAgentCatalog.load(), database) + // altimate_change end + function mergeProvider(providerID: ProviderID, provider: Partial) { const existing = providers[providerID] if (existing) { @@ -2404,6 +2415,9 @@ export namespace Provider { // prioritize free models for github copilot priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] } + // altimate_change start — Claude Agent lists Claude Code's model aliases + if (providerID === ClaudeAgent.ID) priority = ["haiku"] + // altimate_change end for (const item of priority) { if (providerID === ProviderID.amazonBedrock) { const crossRegionPrefixes = ["global.", "us.", "eu."] diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d0d7ffdfb1..bb4da30450 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -6,6 +6,9 @@ import type { Provider } from "./provider" import type { ModelsDev } from "./models" import { iife } from "@/util/iife" import { Flag } from "@/flag/flag" +// altimate_change start — Claude Agent provider package ID +import { ClaudeAgentID } from "@/altimate/provider/claude-agent/id" +// altimate_change end type Modality = NonNullable["input"][number] @@ -780,6 +783,9 @@ export namespace ProviderTransform { // altimate_change end export function variants(model: Provider.Model): Record> { + // altimate_change start — Claude Agent variants are the effort levels Claude Code reported + if (model.api.npm === ClaudeAgentID.PACKAGE) return { ...model.variants } + // altimate_change end if (!model.capabilities.reasoning) return {} const id = model.id.toLowerCase() diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ff59ada069..7316c82c56 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -52,6 +52,9 @@ import { LLMRequestEvent } from "@/altimate/observability/tracing" import { applyBudgetEffort, budgetMessages } from "@/altimate/session/budget-effort" import { activeRun, effortOverride, hasActiveRuns, toolsDisabled } from "@/altimate/session/run-budget-effort" import { analystEffortOptions } from "@/altimate/session/analyst-effort" +// altimate_change start — Claude Agent provider request routing +import { ClaudeAgent } from "@/altimate/provider/claude-agent" +// altimate_change end import { Session } from "@/session" import { SessionID } from "@/session/schema" // altimate_change end @@ -211,6 +214,19 @@ export namespace LLM { if (isCodex) { options.instructions = SystemPrompt.instructions() } + // altimate_change start — Claude Agent: Claude Code's own prompt replaces the provider prompt, so the + // agent prompt and instruction sources travel as options; the environment block is left out + const claudeAgent = ClaudeAgent.matches(input.model) + ? ClaudeAgent.request({ + sessionID: input.sessionID, + agent: input.agent.name, + agentPrompt: input.agent.prompt, + small: input.small, + instructions: [...input.system, input.user.system], + }) + : undefined + if (claudeAgent) Object.assign(options, claudeAgent.options) + // altimate_change end // altimate_change start — pass maxOutputTokens INTO chat.params hook so plugins // (codex, github-copilot, third-party) can override it. Upstream PRs #21220 + #21225 @@ -233,8 +249,9 @@ export namespace LLM { : undefined, topP: input.agent.topP ?? ProviderTransform.topP(input.model), topK: ProviderTransform.topK(input.model), + // Claude Agent: without an explicit cap from a plugin, Claude Code picks its own output budget maxOutputTokens: - isCodex || provider.id.includes("github-copilot") + isCodex || provider.id.includes("github-copilot") || claudeAgent ? undefined : ProviderTransform.maxOutputTokens(input.model), options, @@ -274,6 +291,9 @@ export namespace LLM { headers, ) // altimate_change end + // altimate_change start — Claude Agent: request kind and session, read by its language model + if (claudeAgent) Object.assign(requestHeaders, claudeAgent.headers) + // altimate_change end // altimate_change start — finalization must have no executable catalog, even for unsolicited tool calls const runBudget = await sessionRunBudget(input.sessionID) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 7fc1f336a1..2c8fd37833 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -19,6 +19,9 @@ import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" import { createHash } from "node:crypto" +// altimate_change start — Claude Agent: stopped-tool flag for its waiting turn +import { ToolAborted } from "@/altimate/provider/claude-agent/tools/aborted" +// altimate_change end /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -937,6 +940,10 @@ export namespace MessageV2 { if (part.state.status === "error") { // altimate_change start — upstream_fix: preserve partial output from aborted tools. const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined + // altimate_change start — Claude Agent: a stopped call interrupts its waiting Claude turn + const callMetadata = + part.state.metadata?.interrupted === true ? ToolAborted.mark(part.metadata) : part.metadata + // altimate_change end if (typeof output === "string") { assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, @@ -944,7 +951,7 @@ export namespace MessageV2 { toolCallId: replayCallID, input: part.state.input, output, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + ...(differentModel ? {} : { callProviderMetadata: callMetadata }), }) } else { assistantMessage.parts.push({ @@ -953,7 +960,7 @@ export namespace MessageV2 { toolCallId: replayCallID, input: part.state.input, errorText: part.state.error, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + ...(differentModel ? {} : { callProviderMetadata: callMetadata }), }) } // altimate_change end @@ -969,7 +976,9 @@ export namespace MessageV2 { // altimate_change end input: part.state.input, errorText: "[Tool execution was interrupted]", - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + // altimate_change start — Claude Agent: a stopped call interrupts its waiting Claude turn + ...(differentModel ? {} : { callProviderMetadata: ToolAborted.mark(part.metadata) }), + // altimate_change end }) } if (part.type === "reasoning") { diff --git a/packages/opencode/test/altimate/provider/claude-agent/claude/branches.test.ts b/packages/opencode/test/altimate/provider/claude-agent/claude/branches.test.ts new file mode 100644 index 0000000000..9fff5aea82 --- /dev/null +++ b/packages/opencode/test/altimate/provider/claude-agent/claude/branches.test.ts @@ -0,0 +1,286 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber } from "effect" +import { SessionRecord } from "@/altimate/provider/claude-agent/sync/record" +import { RequestKind } from "@/altimate/provider/claude-agent/language-model/request-kind" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { TestSessions } from "../lib/sessions" +import { TestTools } from "../lib/tools" + +const first = TestSessions.prompt("Remember the marker COPPER-OTTER. Reply only with that marker.") + +describe("Claude history branches", () => { + it.effect("a failed compaction deletes its temporary branch and keeps the unmodified primary history resumable", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "billing-error" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const before = yield* SessionRecord.make(test.storage).get("session-a") + const reply = yield* TestSessions.reply(test.model, TestSessions.followup("Summarize"), "session-a", "compaction") + const error = reply.find((part) => part.type === "error") + expect(String(error?.error)).toContain("extra usage") + expect(claude.removed).toEqual([claude.forks[0]!.sessionID]) + expect(yield* SessionRecord.make(test.storage).get("session-a")).toEqual(before) + expect(TestSessions.text(yield* TestSessions.reply(test.model, TestSessions.followup())).trim()).toBe( + "COPPER-OTTER", + ) + expect(claude.runs[2]!.args).toContain(`--resume=${before!.claudeSessionID}`) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("an unsupported attachment in changed history fails with its own message before forking", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const edited = TestSessions.followup() + edited[2] = { role: "assistant", content: [{ type: "text", text: "An edited reply" }] } + edited[3] = { role: "user", content: [{ type: "file", mediaType: "image/svg+xml", data: "PHN2Zy8+" }] } + const error = yield* TestSessions.reply(test.model, edited).pipe(Effect.flip) + expect(String(error.cause)).toContain("Claude Code does not support image/svg+xml attachments") + expect(claude.forks).toHaveLength(0) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("a fork finds its source after reload and rebuilds safely if that transcript has disappeared", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectInterrupt(), + ]) + const saved = TestSessions.storage() + const old = yield* TestSessions.start(claude, saved) + yield* TestSessions.reply(old.model, first) + yield* TestSessions.reply(old.model, TestSessions.followup()) + const source = (yield* SessionRecord.make(saved).get("session-a"))! + yield* old.registry.close() + const next = yield* TestSessions.start(claude, saved) + const fork = (sessionID: string) => + Effect.promise(async () => { + const result = await next.model.doStream({ + prompt: TestSessions.followup("A forked question"), + headers: { + [RequestKind.HEADER]: "primary", + [RequestKind.SESSION_HEADER]: sessionID, + [RequestKind.FORK_HEADER]: "session-a", + }, + }) + return TestSessions.collect(result.stream) + }) + yield* fork("child-a") + expect(claude.forks[0]!.source).toBe(source.claudeSessionID) + expect(claude.runs[1]!.args).toContain(`--resume=${claude.forks[0]!.sessionID}`) + claude.transcripts.delete(source.claudeSessionID) + yield* fork("child-b") + expect(claude.forks).toHaveLength(1) + expect(claude.runs[2]!.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + const rebuilt = JSON.stringify( + claude.runs[2]!.received.find((message) => message.type === "user").message.content, + ) + expect(rebuilt).toContain("COPPER-OTTER") + expect(rebuilt).toContain("A forked question") + yield* next.registry.close() + claude.check() + }), + ) + + it.effect("undo resends an unchanged question, then branches again using remapped checkpoints", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const records = SessionRecord.make(test.storage) + yield* TestSessions.reply(test.model, first) + yield* TestSessions.reply(test.model, TestSessions.followup()) + const original = (yield* records.get("session-a"))! + yield* TestSessions.reply(test.model, TestSessions.followup()) + const branch = (yield* records.get("session-a"))! + expect(claude.forks[0]!.boundary).toBe(original.history![1]!.claudeMessageID!) + expect(claude.runs[1]!.received.find((message) => message.type === "user").message.content).toEqual([ + { type: "text", text: "What was the marker? Reply only with it." }, + ]) + expect(branch.claudeSessionID).not.toBe(original.claudeSessionID) + expect(branch.history![1]!.claudeMessageID).not.toBe(original.history![1]!.claudeMessageID) + const reply = yield* TestSessions.reply(test.model, TestSessions.followup("Another replacement")) + expect(claude.forks).toHaveLength(2) + expect(claude.forks[1]).toMatchObject({ + source: branch.claudeSessionID, + boundary: branch.history![1]!.claudeMessageID, + }) + expect(claude.runs[2]!.received.find((message) => message.type === "user").message.content).toEqual([ + { type: "text", text: "Another replacement" }, + ]) + expect(reply.find((part) => part.type === "text-end")?.providerMetadata?.["claude-agent"]?.sessionID).toBe( + claude.forks[1]!.sessionID, + ) + expect(claude.removed).toHaveLength(0) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("an edited earlier reply forks before it and sends the edited history", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const prompt = TestSessions.followup() + prompt[2] = { role: "assistant", content: [{ type: "text", text: "REVISED-MARKER" }] } + yield* TestSessions.reply(test.model, prompt) + expect(claude.forks).toHaveLength(1) + expect(claude.runs[1]!.args).toContain(`--resume=${claude.forks[0]!.sessionID}`) + const user = JSON.stringify(claude.runs[1]!.received.find((message) => message.type === "user").message.content) + expect(user).toContain("REVISED-MARKER") + expect(user).not.toContain("COPPER-OTTER") + yield* test.registry.close() + claude.check() + }), + ) + + it.effect( + "cancelling one generate branch leaves another and the primary session usable, and deletes both temporary branches", + () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel" }), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "utility" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectInterrupt({ recording: "text-cancel" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const before = yield* SessionRecord.make(test.storage).get("session-a") + const stream = yield* TestSessions.stream( + test.model, + TestSessions.followup("Write a long answer"), + "session-a", + "generate", + ) + const reader = stream.stream.getReader() + yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => {}))) + yield* Effect.promise(async () => { + for (;;) { + const part = await reader.read() + if (part.done) throw new Error("Generate ended before cancellation") + if (part.value.type === "text-delta") break + } + }) + const other = yield* TestSessions.reply( + test.model, + TestSessions.followup("Reply only LILAC-HERON"), + "session-a", + "generate", + ) + expect(TestSessions.text(other).trim()).toBe("LILAC-HERON") + expect(claude.forks).toHaveLength(2) + yield* Effect.promise(() => reader.cancel()) + expect(new Set(claude.removed)).toEqual(new Set(claude.forks.map((fork) => fork.sessionID))) + expect(yield* SessionRecord.make(test.storage).get("session-a")).toEqual(before) + expect(claude.runs[0]!.received.some((message) => message.request?.subtype === "interrupt")).toBe(false) + expect(TestSessions.text(yield* TestSessions.reply(test.model, TestSessions.followup())).trim()).toBe( + "COPPER-OTTER", + ) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("compaction drains a parked tool turn and the next primary request starts from the summary alone", () => + Effect.gen(function* () { + const cancel = FakeClaude.replay("tools-waiting-cancel") + const nextUser = cancel.findIndex((step, i) => i > 1 && step.type === "exchange" && step.input.type === "user") + const steps = cancel.slice(0, nextUser) + const terminal = steps.findIndex( + (step) => step.type === "exchange" && step.replies.some((message) => message.type === "result"), + ) + const ending = steps[terminal]! + if (ending.type !== "exchange") throw new Error("Missing recorded terminal result") + const drain = Promise.withResolvers() + steps[terminal] = { ...ending, beforeResult: drain.promise } + const claude = FakeClaude.make([ + ...steps, + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "utility" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* Effect.addFinalizer(() => Effect.sync(() => drain.resolve())) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(test.model, prompt), () => ({ + type: "execution-denied", + reason: "Compacted", + })) + const summary = yield* TestSessions.reply(test.model, prompt, "session-a", "compaction").pipe(Effect.forkChild) + yield* Effect.promise(() => claude.started(steps[terminal]!)) + expect(claude.forks).toHaveLength(0) + expect(claude.runs).toHaveLength(1) + drain.resolve() + yield* Fiber.join(summary) + expect(claude.forks).toHaveLength(1) + expect(claude.removed).toEqual([claude.forks[0]!.sessionID]) + const compacted = TestSessions.prompt( + "Only SUMMARY-HERON remains.", + ) + yield* TestSessions.reply(test.model, compacted) + expect(claude.runs[2]!.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + const input = JSON.stringify(claude.runs[2]!.received.find((message) => message.type === "user").message.content) + expect(input).toContain("SUMMARY-HERON") + expect(input).not.toContain("lookup") + yield* test.registry.close() + claude.check() + }), + ) +}) diff --git a/packages/opencode/test/altimate/provider/claude-agent/claude/probe.test.ts b/packages/opencode/test/altimate/provider/claude-agent/claude/probe.test.ts new file mode 100644 index 0000000000..b2b1422d14 --- /dev/null +++ b/packages/opencode/test/altimate/provider/claude-agent/claude/probe.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Fiber } from "effect" +import { TestClock } from "effect/testing" +import { ClaudeProbe } from "@/altimate/provider/claude-agent/claude/probe" +import { ClaudeQueryOptions } from "@/altimate/provider/claude-agent/claude/query-options" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { FakeIO } from "../lib/fake-io" + +describe("login check", () => { + it.effect("discovers each model's capacity without sending a prompt", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("startup") + const context = steps.find( + (step) => step.type === "exchange" && step.input.request?.subtype === "get_context_usage", + ) + if (context?.type !== "exchange") throw new Error("Missing context recording") + // Claude's auto-compaction window is not the model's capacity. + context.replies[0].response.response.maxTokens = 200_000 + const claude = FakeClaude.make(steps) + const result = yield* ClaudeProbe.run( + ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io: FakeIO.make({ claude }) }), + ) + expect(result.models.find((model) => model.value === "opus")?.contextWindow).toBe(1_000_000) + expect(result.models.find((model) => model.value === "claude-opus-4-6")?.contextWindow).toBe(200_000) + expect(claude.runs[0]!.received.some((message) => message.type === "user")).toBe(false) + claude.check() + }), + ) + + it.effect("keeps the login and other limits when one model reports an invalid capacity", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("startup") + const context = steps.find( + (step) => step.type === "exchange" && step.input.request?.subtype === "get_context_usage", + ) + if (context?.type !== "exchange") throw new Error("Missing context recording") + context.replies[0].response.response.rawMaxTokens = 0 + const claude = FakeClaude.make(steps) + const result = yield* ClaudeProbe.run( + ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io: FakeIO.make({ claude }) }), + ) + expect(ClaudeProbe.isLoggedIn(result.account)).toBe(true) + expect(result.models.find((model) => model.value === "opus")?.contextWindow).toBeUndefined() + expect(result.models.find((model) => model.value === "sonnet")?.contextWindow).toBe(1_000_000) + claude.check() + }), + ) + + it.effect("closes stuck context discovery without turning it into a login failure", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("startup").slice(0, 3) + const context = { ...steps[2]!, hold: new Promise(() => {}) } + steps[2] = context + const claude = FakeClaude.make(steps) + const check = yield* ClaudeProbe.run( + ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io: FakeIO.make({ claude }) }), + ).pipe(Effect.forkScoped) + yield* Effect.promise(() => claude.started(context)) + yield* TestClock.adjust("30 seconds") + const result = yield* Fiber.join(check) + expect(ClaudeProbe.isLoggedIn(result.account)).toBe(true) + expect(result.models.find((model) => model.value === "opus")?.contextWindow).toBeUndefined() + yield* Effect.promise(() => claude.runs[0]!.closed) + expect(() => claude.check()).toThrow("1 script steps did not finish") + }), + ) + + it.effect("times out a stuck startup and closes the process", () => + Effect.gen(function* () { + const startup = FakeClaude.expectStartup({ hold: new Promise(() => {}) }) + const claude = FakeClaude.make([startup]) + const io = FakeIO.make({ claude }) + const check = yield* ClaudeProbe.run(ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io })).pipe( + Effect.flip, + Effect.forkScoped, + ) + yield* Effect.promise(() => claude.started(startup)) + yield* TestClock.adjust("30 seconds") + const error = yield* Fiber.join(check) + expect(String(error.cause)).toContain("did not start within 30 seconds") + yield* Effect.promise(() => claude.runs[0]!.closed) + }), + ) +}) + +describe("compatibility and login", () => { + test("only 2.1.248 or later is compatible", () => { + expect(ClaudeProbe.versionError("2.1.248")).toBeUndefined() + expect(ClaudeProbe.versionError("2.1.247")?.message).toContain("claude update") + expect(ClaudeProbe.versionError(undefined)?.message).toContain("claude update") + }) + + test("only a missing credential counts as logged out", () => { + expect(ClaudeProbe.isLoggedIn({ tokenSource: "none", apiProvider: "firstParty" })).toBe(false) + expect(ClaudeProbe.isLoggedIn({ tokenSource: "none", apiKeySource: "ANTHROPIC_API_KEY" })).toBe(true) + expect(ClaudeProbe.isLoggedIn({ subscriptionType: "Claude Team", apiProvider: "firstParty" })).toBe(true) + }) +}) diff --git a/packages/opencode/test/altimate/provider/claude-agent/claude/recorder.test.ts b/packages/opencode/test/altimate/provider/claude-agent/claude/recorder.test.ts new file mode 100644 index 0000000000..b487a740aa --- /dev/null +++ b/packages/opencode/test/altimate/provider/claude-agent/claude/recorder.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import path from "node:path" +import { Recorder } from "../lib/recorder" + +describe("recording privacy", () => { + test("checked-in recordings contain no thinking blocks or deltas", () => { + const directory = path.join(import.meta.dir, "../fixtures/recordings") + for (const name of readdirSync(directory)) { + if (!name.endsWith(".jsonl")) continue + const lines = readFileSync(path.join(directory, name), "utf8").trim().split("\n") + for (const line of lines) { + // Compare booleans so failures cannot print the offending content. + const unchanged = JSON.stringify(Recorder.parse(line)) === JSON.stringify(JSON.parse(line)) + expect(unchanged, name).toBe(true) + } + } + }) +}) diff --git a/packages/opencode/test/altimate/provider/claude-agent/claude/session.test.ts b/packages/opencode/test/altimate/provider/claude-agent/claude/session.test.ts new file mode 100644 index 0000000000..c60924516a --- /dev/null +++ b/packages/opencode/test/altimate/provider/claude-agent/claude/session.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { ClaudeQueryOptions } from "@/altimate/provider/claude-agent/claude/query-options" +import { ClaudeSession } from "@/altimate/provider/claude-agent/claude/session" +import { FakeClaude } from "../lib/fake-claude" +import { FakeIO } from "../lib/fake-io" + +describe("Claude session", () => { + test.each([0, 1])("exit code %i before a result reports failure and allows cleanup", async (code) => { + const turn = FakeClaude.expectTurn({ hold: new Promise(() => {}) }) + const claude = FakeClaude.make([FakeClaude.expectStartup(), turn]) + const session = await ClaudeSession.start({ + ...ClaudeQueryOptions.base({ executable: FakeIO.EXECUTABLE, io: FakeIO.make({ claude }) }), + sessionId: crypto.randomUUID(), + }) + try { + await session.ready + session.begin([{ type: "text", text: "Reply with a marker." }]) + await claude.started(turn) + await claude.runs[0]!.exit(code) + const message = code === 0 ? "Claude Code stopped before finishing its reply" : "code 1" + await expect(session.readStep(() => {}, [])).rejects.toThrow(message) + await session.interrupt() + expect(claude.runs[0]!.received.some((message) => message.request?.subtype === "interrupt")).toBe(false) + } finally { + await session.stop() + } + await claude.runs[0]!.closed + expect(() => claude.check()).toThrow("1 script steps did not finish") + }) +}) diff --git a/packages/opencode/test/altimate/provider/claude-agent/claude/sessions.test.ts b/packages/opencode/test/altimate/provider/claude-agent/claude/sessions.test.ts new file mode 100644 index 0000000000..ad4c85ce57 --- /dev/null +++ b/packages/opencode/test/altimate/provider/claude-agent/claude/sessions.test.ts @@ -0,0 +1,430 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber } from "effect" +import { TestClock } from "effect/testing" +import { ClaudeSessions } from "@/altimate/provider/claude-agent/claude/sessions" +import { ClaudeAgentLanguageModel } from "@/altimate/provider/claude-agent/language-model/claude-agent-language-model" +import { RequestKind } from "@/altimate/provider/claude-agent/language-model/request-kind" +import { ClaudeSystemPrompt } from "@/altimate/provider/claude-agent/language-model/system-prompt" +import { SessionRecord } from "@/altimate/provider/claude-agent/sync/record" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { TestSessions } from "../lib/sessions" +import { TestTools } from "../lib/tools" +import { Recorder } from "../lib/recorder" + +const first = TestSessions.prompt("Remember the marker COPPER-OTTER. Reply only with that marker.") + +describe("Claude sessions", () => { + it.effect("streams consecutive turns once, on one process, with per-turn usage", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const one = yield* TestSessions.reply(test.model, first) + const two = yield* TestSessions.reply(test.model, TestSessions.followup()) + expect(TestSessions.text(one)).toBe("COPPER-OTTER") + expect(TestSessions.text(two)).toBe("COPPER-OTTER") + const finish = two.find((part) => part.type === "finish")! + const usage = Recorder.result("text", 1).usage + expect(finish.usage.inputTokens).toEqual({ + total: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens, + noCache: usage.input_tokens, + cacheRead: usage.cache_read_input_tokens, + cacheWrite: usage.cache_creation_input_tokens, + }) + expect(finish.usage.outputTokens.total).toBe(usage.output_tokens) + expect(claude.runs).toHaveLength(1) + const users = claude.runs[0]!.received.filter((message) => message.type === "user") + expect(users).toHaveLength(2) + expect(users[1].message.content).toEqual([{ type: "text", text: "What was the marker? Reply only with it." }]) + expect(users[0].client_composed).toBe(true) + yield* test.registry.close() + yield* Effect.promise(() => claude.runs[0]!.closed) + claude.check() + }), + ) + + it.effect("a missing Claude transcript is rebuilt from history, even after a rejected attachment", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const before = (yield* SessionRecord.make(test.storage).get("session-a"))! + yield* test.registry.close() + const registry = ClaudeSessions.registry() + yield* Effect.addFinalizer(() => registry.close()) + const sessions = yield* registry.attach({ + ...test.input, + io: { ...test.input.io, sessionExists: async () => false }, + }) + const model = yield* TestSessions.model(sessions) + const prompt = TestSessions.followup("Inspect this image") + prompt.push({ role: "user", content: [{ type: "file", mediaType: "image/svg+xml", data: "PHN2Zy8+" }] }) + const error = yield* TestSessions.reply(model, prompt).pipe(Effect.flip) + expect(String(error.cause)).toContain("Claude Code does not support image/svg+xml attachments") + yield* TestSessions.reply(model, TestSessions.followup()) + expect(claude.runs).toHaveLength(2) + expect(claude.runs[1]!.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + const user = claude.runs[1]!.received.find((message) => message.type === "user") + expect(JSON.stringify(user.message.content)).toContain("") + expect(JSON.stringify(user.message.content)).toContain("COPPER-OTTER") + expect(JSON.stringify(user.message.content)).not.toContain("image/svg+xml") + const after = (yield* SessionRecord.make(test.storage).get("session-a"))! + expect(after.claudeSessionID).not.toBe(before.claudeSessionID) + yield* registry.close() + claude.check() + }), + ) + + it.effect("cancellation drains past the interrupt receipt before accepting another turn", () => + Effect.gen(function* () { + const drain = Promise.withResolvers() + const interrupt = FakeClaude.expectInterrupt({ recording: "text-cancel", beforeResult: drain.promise }) + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel" }), + interrupt, + FakeClaude.expectTurn({ recording: "text-cancel", index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const result = yield* TestSessions.stream(test.model, first) + const reader = result.stream.getReader() + // Wait for real partial text + const partial = yield* Effect.promise(async () => { + for (;;) { + const part = await reader.read() + if (part.done) throw new Error("Reply ended before any text") + if (part.value.type === "text-delta") return part.value.delta + } + }) + const cancel = yield* Effect.promise(() => reader.cancel()).pipe(Effect.forkScoped({ startImmediately: true })) + yield* Effect.promise(() => claude.started(interrupt)) + const prompt = [ + ...first, + { role: "assistant" as const, content: [{ type: "text" as const, text: partial }] }, + { role: "user" as const, content: [{ type: "text" as const, text: "Continue" }] }, + ] + const next = yield* TestSessions.reply(test.model, prompt).pipe(Effect.forkScoped({ startImmediately: true })) + expect(claude.runs[0]!.received.filter((message) => message.type === "user")).toHaveLength(1) + drain.resolve() + yield* Fiber.join(cancel) + expect(TestSessions.text(yield* Fiber.join(next))).toBe("COPPER-OTTER") + // Claude's transcript already holds the partial reply, so only the new input is sent on the same process. + expect(claude.runs).toHaveLength(1) + const users = claude.runs[0]!.received.filter((message) => message.type === "user") + expect(users[1].message.content).toEqual([{ type: "text", text: "Continue" }]) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("cancelled history resumes after a registry restart without replaying the partial reply", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel" }), + FakeClaude.expectInterrupt({ recording: "text-cancel" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel", index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const result = yield* TestSessions.stream(test.model, first) + const reader = result.stream.getReader() + yield* Effect.promise(async () => { + for (;;) { + const part = await reader.read() + if (part.done) throw new Error("Reply ended before any text") + if (part.value.type === "text-delta") break + } + await reader.cancel() + }) + const saved = (yield* SessionRecord.make(test.storage).get("session-a"))! + expect(saved.interrupted).toBe(true) + yield* test.registry.close() + const registry = ClaudeSessions.registry() + yield* Effect.addFinalizer(() => registry.close()) + const sessions = yield* registry.attach(test.input) + const model = yield* TestSessions.model(sessions) + // OpenCode may save no assistant text when cancellation happens early. + const prompt = [...first, { role: "user" as const, content: [{ type: "text" as const, text: "Continue" }] }] + expect(TestSessions.text(yield* TestSessions.reply(model, prompt))).toBe("COPPER-OTTER") + expect(claude.runs[1]!.args).toContain(`--resume=${saved.claudeSessionID}`) + const user = claude.runs[1]!.received.find((message) => message.type === "user") + expect(user.message.content).toEqual([{ type: "text", text: "Continue" }]) + const after = (yield* SessionRecord.make(test.storage).get("session-a"))! + expect(after.interrupted).toBe(false) + yield* registry.close() + claude.check() + }), + ) + + it.effect("chat keeps the Claude Code preset when OpenCode's built-in prompt changes", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const prompt = TestSessions.followup() + prompt[0] = { role: "system", content: "A changed OpenCode built-in agent prompt" } + yield* TestSessions.reply(test.model, prompt) + expect(claude.runs).toHaveLength(1) + const run = claude.runs[0]! + const init = run.received.find((message) => message.request?.subtype === "initialize").request + expect(init.systemPrompt).toBeUndefined() + expect(JSON.stringify(run.received)).not.toContain("A changed OpenCode built-in agent prompt") + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("a turn arriving during idle shutdown waits for the old process, then resumes its session", () => + Effect.gen(function* () { + const release = Promise.withResolvers() + const stop = FakeClaude.expectInterrupt({ hold: release.promise }) + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + stop, + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-resume" }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + const id = claude.runs[0]!.args.find((arg) => arg.startsWith("--session-id="))!.split("=")[1] + const timeout = yield* TestClock.adjust(ClaudeSessions.IDLE_MS).pipe( + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.promise(() => claude.started(stop)) + const next = yield* TestSessions.reply(test.model, TestSessions.followup()).pipe( + Effect.forkScoped({ startImmediately: true }), + ) + expect(claude.runs).toHaveLength(1) + release.resolve() + yield* Fiber.join(timeout) + expect(TestSessions.text(yield* Fiber.join(next))).toBe("COPPER-OTTER") + yield* Effect.promise(() => claude.runs[0]!.closed) + expect(claude.runs).toHaveLength(2) + expect(claude.runs[1]!.args).toContain(`--resume=${id}`) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("cancelling during startup closes it and does not save a nonexistent session", () => + Effect.gen(function* () { + const startup = FakeClaude.expectStartup({ hold: new Promise(() => {}) }) + const claude = FakeClaude.make([ + startup, + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const stream = yield* TestSessions.stream(test.model, first) + yield* Effect.promise(() => claude.started(startup)) + yield* Effect.promise(() => stream.stream.cancel()) + yield* Effect.promise(() => claude.runs[0]!.closed) + expect(test.storage.data.size).toBe(0) + expect(TestSessions.text(yield* TestSessions.reply(test.model, first))).toBe("COPPER-OTTER") + expect(claude.runs[1]!.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + yield* test.registry.close() + expect(() => claude.check()).toThrow("1 script steps did not finish") + }), + ) + + it.effect("chat checks compatibility before sending a prompt", () => + Effect.gen(function* () { + const startup = FakeClaude.expectStartup() + startup.reply.claude_code_version = "2.1.247" + const claude = FakeClaude.make([startup]) + const test = yield* TestSessions.start(claude) + const error = yield* TestSessions.reply(test.model, first).pipe(Effect.flip) + expect(String(error.cause)).toContain("claude update") + expect(claude.runs[0]!.received.some((message) => message.type === "user")).toBe(false) + yield* Effect.promise(() => claude.runs[0]!.closed) + claude.check() + }), + ) + + it.effect("changes model and effort live, and resumes when the output limit changes or is cleared", () => + Effect.gen(function* () { + const controls = FakeClaude.replay("controls").filter( + (step) => + step.type === "exchange" && ["set_model", "apply_flag_settings"].includes(step.input.request?.subtype), + ) + const recorded = (index: number) => FakeClaude.expectTurn({ recording: "controls", index }) + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + recorded(0), + controls[0]!, + recorded(1), + controls[1]!, + recorded(2), + controls[2]!, + controls[3]!, + recorded(3), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + recorded(4), + FakeClaude.expectInterrupt(), + FakeClaude.expectStartup(), + recorded(4), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const prompt = TestSessions.prompt("Remember CONTROL-OTTER. Reply only CONTROL-OTTER.") + const settings = [ + { model: "haiku", effort: "low", maxOutputTokens: 2048 }, + { model: "haiku", effort: "high", maxOutputTokens: 2048 }, + { model: "haiku", maxOutputTokens: 2048 }, + { model: "sonnet", effort: "low", maxOutputTokens: 2048 }, + { model: "sonnet", effort: "low", maxOutputTokens: 4096 }, + { model: "sonnet", effort: "low" }, + ] + for (const setting of settings) { + const model = new ClaudeAgentLanguageModel.Model(setting.model, test.model.dependencies) + const result = yield* Effect.promise(() => + model.doStream({ + prompt, + maxOutputTokens: setting.maxOutputTokens, + providerOptions: { "claude-agent": setting.effort ? { effort: setting.effort } : {} }, + headers: { [RequestKind.HEADER]: "primary", [RequestKind.SESSION_HEADER]: "session-a" }, + }), + ) + expect(TestSessions.text(yield* Effect.promise(() => TestSessions.collect(result.stream)))).toBe( + "CONTROL-OTTER", + ) + prompt.push( + { role: "assistant", content: [{ type: "text", text: "CONTROL-OTTER" }] }, + { role: "user", content: [{ type: "text", text: "What was the marker? Reply only with it." }] }, + ) + } + expect(claude.runs).toHaveLength(3) + const id = claude.runs[0]!.args.find((arg) => arg.startsWith("--session-id="))!.split("=")[1] + const requests = claude.runs[0]!.received.map((message) => message.request) + expect(requests).toContainEqual({ subtype: "set_model", model: "sonnet" }) + expect(requests).toContainEqual({ subtype: "apply_flag_settings", settings: { effortLevel: "high" } }) + expect(requests).toContainEqual({ subtype: "apply_flag_settings", settings: { effortLevel: null } }) + expect(claude.runs[0]!.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe("2048") + expect(claude.runs[1]!.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe("4096") + expect(claude.runs[2]!.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBeUndefined() + for (const run of claude.runs.slice(1)) { + expect(run.args).toContain("--model=sonnet") + expect(run.args).toContain(`--resume=${id}`) + } + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("titles use their own task prompt in a throwaway session that leaves the chat alone", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "utility" }), + FakeClaude.expectInterrupt(), + FakeClaude.expectTurn({ index: 1 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + yield* TestSessions.reply(test.model, first) + yield* TestSessions.reply(test.model, TestSessions.followup(), "session-a", "title") + expect(TestSessions.text(yield* TestSessions.reply(test.model, TestSessions.followup()))).toBe("COPPER-OTTER") + expect(claude.runs).toHaveLength(2) + const title = claude.runs[1]! + const init = title.received.find((message) => message.request?.subtype === "initialize").request + expect(init.systemPrompt).toEqual(["You are a concise test assistant."]) + expect(title.args).toContain("--no-session-persistence") + expect(test.storage.data.size).toBe(1) + yield* test.registry.close() + claude.check() + }), + ) + + // Claude's prompt cache only matches when tools and system prompt are identical, so branches must not drop them. + it.effect("conversation branches send the chat's tools and system prompt, and a tool call ends the branch", () => + Effect.gen(function* () { + // The branch replays a recorded tool call that is stopped while waiting for its result. + const waiting = FakeClaude.replay("tools-waiting-cancel") + const resumed = waiting.findIndex( + (step, index) => index > 1 && step.type === "exchange" && step.input.type === "user", + ) + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn(), + ...waiting.slice(0, resumed), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + // OpenCode's session hooks set these on chats and branches alike. + const settings = { + "claude-agent": { + [ClaudeSystemPrompt.AGENT_OPTION]: "AGENT-ROLE", + [ClaudeSystemPrompt.INSTRUCTIONS_OPTION]: "OPENCODE-INSTRUCTIONS", + }, + } + yield* TestTools.step(test.model, first, TestTools.tools, "primary", settings) + const parts = yield* TestTools.step(test.model, TestSessions.followup(), TestTools.tools, "generate", settings) + const chat = claude.runs[0]!.received.find((message) => message.request?.subtype === "initialize").request + const branch = claude.runs[1]!.received.find((message) => message.request?.subtype === "initialize").request + expect(chat.sdkMcpServers).toEqual(["opencode"]) + expect(branch.sdkMcpServers).toEqual(chat.sdkMcpServers) + expect(chat.appendSystemPrompt).toContain("AGENT-ROLE") + expect(chat.appendSystemPrompt).toContain("OPENCODE-INSTRUCTIONS") + expect(branch.appendSystemPrompt).toBe(chat.appendSystemPrompt) + // The reply ends at the tool call, and the throwaway process and fork are cleaned up. + expect(parts.filter((part) => part.type === "tool-call")).toHaveLength(2) + yield* Effect.promise(() => claude.runs[1]!.closed) + expect(claude.removed).toEqual([claude.forks[0]!.sessionID]) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("closing the registry interrupts and drains a running side call", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "text-cancel" }), + FakeClaude.expectInterrupt({ recording: "text-cancel" }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const stream = yield* TestSessions.stream(test.model, first, "session-a", "generate") + const reader = stream.stream.getReader() + yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => {}))) + yield* Effect.promise(async () => { + for (;;) { + const part = await reader.read() + if (part.done) throw new Error("Reply ended before shutdown could be tested") + if (part.value.type === "text-delta") break + } + }) + yield* test.registry.close() + yield* Effect.promise(() => claude.runs[0]!.closed) + expect(test.storage.data.size).toBe(0) + claude.check() + }), + ) +}) diff --git a/packages/opencode/test/altimate/provider/claude-agent/claude/status.test.ts b/packages/opencode/test/altimate/provider/claude-agent/claude/status.test.ts new file mode 100644 index 0000000000..cf4354cf06 --- /dev/null +++ b/packages/opencode/test/altimate/provider/claude-agent/claude/status.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber, SubscriptionRef } from "effect" +import { ClaudeProbe } from "@/altimate/provider/claude-agent/claude/probe" +import { ClaudeStatus } from "@/altimate/provider/claude-agent/claude/status" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { FakeIO } from "../lib/fake-io" + +describe("login status", () => { + it.effect("finds claude when it is installed after the plugin starts", () => + Effect.gen(function* () { + const claude = FakeClaude.make([FakeClaude.expectStartup()]) + let executable: string | undefined + const io = { ...FakeIO.make({ claude }), which: () => executable } + const status = yield* ClaudeStatus.make(io, () => Effect.succeed({})) + + yield* status.refresh() + expect(yield* SubscriptionRef.get(status.state)).toEqual({ type: "missing" }) + + executable = FakeIO.EXECUTABLE + yield* status.refresh() + expect((yield* SubscriptionRef.get(status.state)).type).toBe("ready") + claude.check() + }), + ) + + it.effect("a reconnect waits for the startup check, then publishes the new login", () => + Effect.gen(function* () { + const hold = Promise.withResolvers() + const atStart = FakeClaude.expectStartup({ recording: "startup-logged-out", hold: hold.promise }) + const onConnect = FakeClaude.expectStartup() + const claude = FakeClaude.make([atStart, onConnect]) + const status = yield* ClaudeStatus.make(FakeIO.make({ claude }), () => Effect.succeed({})) + + const startup = yield* status.refresh().pipe(Effect.forkScoped) + yield* Effect.promise(() => claude.started(atStart)) + const reconnect = yield* status.refresh().pipe(Effect.forkScoped({ startImmediately: true })) + expect(claude.runs).toHaveLength(1) + + hold.resolve() + yield* Fiber.join(startup) + yield* Fiber.join(reconnect) + const state = yield* SubscriptionRef.get(status.state) + expect(state.type).toBe("ready") + if (state.type !== "ready") throw new Error("login check did not finish") + expect(ClaudeProbe.isLoggedIn(state.probe.account)).toBe(true) + claude.check() + }), + ) + + it.effect("reports a failed startup and recovers on the next check", () => + Effect.gen(function* () { + const claude = FakeClaude.make([FakeClaude.expectStartup({ exitCode: 1 }), FakeClaude.expectStartup()]) + const status = yield* ClaudeStatus.make(FakeIO.make({ claude }), () => Effect.succeed({})) + + yield* status.refresh() + const failed = yield* SubscriptionRef.get(status.state) + expect(failed.type).toBe("failed") + if (failed.type !== "failed") throw new Error("startup failure was not reported") + expect(failed.message).toContain("code 1") + + yield* status.refresh() + expect((yield* SubscriptionRef.get(status.state)).type).toBe("ready") + claude.check() + }), + ) +}) diff --git a/packages/opencode/test/altimate/provider/claude-agent/claude/sync.test.ts b/packages/opencode/test/altimate/provider/claude-agent/claude/sync.test.ts new file mode 100644 index 0000000000..8c2dd74bc1 --- /dev/null +++ b/packages/opencode/test/altimate/provider/claude-agent/claude/sync.test.ts @@ -0,0 +1,70 @@ +import { describe, expect } from "bun:test" +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" +import { Effect } from "effect" +import { SessionRecord } from "@/altimate/provider/claude-agent/sync/record" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { TestSessions } from "../lib/sessions" + +describe("recorded history sync", () => { + it.effect("catches up once, saves transcript boundaries, then continues without replaying history", () => + Effect.gen(function* () { + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + FakeClaude.expectTurn({ recording: "sync" }), + FakeClaude.expectTurn({ recording: "sync", index: 1 }), + FakeClaude.expectTurn({ recording: "sync", index: 2 }), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const history: LanguageModelV3Prompt = [ + { role: "user", content: [{ type: "text", text: "Give me a marker." }] }, + { role: "assistant", content: [{ type: "text", text: "My marker is LILAC-HERON." }] }, + ] + const ask = Effect.fn(function* (text: string) { + history.push({ role: "user", content: [{ type: "text", text }] }) + const parts = yield* TestSessions.reply(test.model, history) + const reply = TestSessions.text(parts) + history.push({ role: "assistant", content: [{ type: "text", text: reply }] }) + return reply + }) + yield* ask("Remember COPPER-OTTER too. Reply only with COPPER-OTTER.") + history.push( + { role: "user", content: [{ type: "text", text: "Read the marker file." }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "marker-read", toolName: "read", input: { path: "marker.txt" } }], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "marker-read", + toolName: "read", + output: { type: "text", value: "LEMON-IBIS" }, + }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "I read the file successfully." }] }, + ) + yield* ask("List all three markers from our conversation, including the file. Output only the markers.") + yield* ask("Repeat those three markers.") + expect(claude.runs).toHaveLength(1) + const users = claude.runs[0]!.received.filter((message) => message.type === "user") + expect(JSON.stringify(users[0].message.content)).toContain("LILAC-HERON") + expect(JSON.stringify(users[1].message.content)).toContain("LEMON-IBIS") + expect(JSON.stringify(users[1].message.content)).not.toContain("LILAC-HERON") + expect(users[2].message.content).toEqual([{ type: "text", text: "Repeat those three markers." }]) + const record = (yield* SessionRecord.make(test.storage).get("session-a"))! + expect(record.history).toHaveLength(12) + expect(record.history![2]!.claudeMessageID).toBe(users[0].uuid) + expect(record.history![8]!.claudeMessageID).toBe(users[1].uuid) + expect(record.history![10]!.claudeMessageID).toBe(users[2].uuid) + // Catch-up's interior messages don't claim independent transcript boundaries. + expect(record.history![5]!.claudeMessageID).toBeUndefined() + yield* test.registry.close() + claude.check() + }), + ) +}) diff --git a/packages/opencode/test/altimate/provider/claude-agent/claude/tools.test.ts b/packages/opencode/test/altimate/provider/claude-agent/claude/tools.test.ts new file mode 100644 index 0000000000..32cfce8180 --- /dev/null +++ b/packages/opencode/test/altimate/provider/claude-agent/claude/tools.test.ts @@ -0,0 +1,364 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Fiber, Scope } from "effect" +import { TestClock } from "effect/testing" +import { ClaudeSessions } from "@/altimate/provider/claude-agent/claude/sessions" +import { it } from "../../../../lib/effect" +import { FakeClaude } from "../lib/fake-claude" +import { TestSessions } from "../lib/sessions" +import { TestTools } from "../lib/tools" +import { Recorder } from "../lib/recorder" + +describe("Claude tool steps", () => { + it.effect("two tool steps keep one turn alive past the idle timeout and match every result to its call", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("tools")) + const test = yield* TestSessions.start(claude) + const prompt = TestTools.prompt() + const steps = [] + for (let i = 0; i < 3; i++) { + // Subagents and unanswered permission prompts can keep OpenCode's tools running this long. + yield* TestClock.adjust(ClaudeSessions.IDLE_MS * 2) + const parts = yield* TestTools.step(test.model, prompt) + steps.push(parts) + TestTools.append(prompt, parts, TestTools.output) + } + expect(steps[0]!.filter((part) => part.type === "tool-call")).toHaveLength(2) + expect(steps[1]!.find((part) => part.type === "tool-call")?.toolName).toBe("confirm") + expect(TestSessions.text(steps[2]!)).toBe("COPPER-OTTER") + expect(claude.runs).toHaveLength(1) + const received = claude.runs[0]!.received + expect(received.filter((message) => message.type === "user")).toHaveLength(1) + expect(received.some((message) => message.request?.subtype === "interrupt")).toBe(false) + const outputs = received + .filter((message) => message.response?.response?.mcp_response) + .map((message) => message.response.response.mcp_response.result) + expect(outputs.map((result) => result.content[0].text)).toEqual(["LILAC-HERON", "LEMON-IBIS", "COPPER-OTTER"]) + const usage = Recorder.load("tools").filter( + (line) => + line.from === "claude" && line.message.type === "stream_event" && line.message.event.type === "message_delta", + ) + for (let i = 0; i < steps.length; i++) { + const recorded = usage[i]! + if (recorded.from !== "claude") throw new Error("Expected Claude usage") + expect(steps[i]!.find((part) => part.type === "finish")?.usage.outputTokens.total).toBe( + recorded.message.event.usage.output_tokens, + ) + } + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("plugin reload hands over the process with its pending handlers; without a reload it shuts down", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("tools")) + const base = yield* TestSessions.start(FakeClaude.make([])) + const input = { ...base.input, io: { ...base.input.io, spawn: claude.spawn } } + const registry = ClaudeSessions.registry() + yield* Effect.addFinalizer(() => registry.close()) + const scope = yield* Scope.make() + const old = yield* registry.attach(input).pipe(Effect.provideService(Scope.Scope, scope)) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(yield* TestSessions.model(old), prompt), TestTools.output) + yield* Scope.close(scope, Exit.void) + yield* TestClock.adjust(ClaudeSessions.HANDOVER_MS - 1) + const next = yield* Scope.make() + const fresh = yield* TestSessions.model( + yield* registry.attach(input).pipe(Effect.provideService(Scope.Scope, next)), + ) + TestTools.append(prompt, yield* TestTools.step(fresh, prompt), TestTools.output) + expect(TestSessions.text(yield* TestTools.step(fresh, prompt))).toBe("COPPER-OTTER") + expect(claude.runs).toHaveLength(1) + yield* Scope.close(next, Exit.void) + yield* TestClock.adjust(ClaudeSessions.HANDOVER_MS) + yield* Effect.promise(() => claude.runs[0]!.closed) + claude.check() + }), + ) + + it.effect("closing the registry waits for a running tool call to drain, without answering it", () => + Effect.gen(function* () { + const cancel = FakeClaude.replay("tools-waiting-cancel") + const nextUser = cancel.findIndex( + (step, index) => index > 1 && step.type === "exchange" && step.input.type === "user", + ) + const steps = cancel.slice(0, nextUser) + const drain = Promise.withResolvers() + const terminal = steps.findIndex( + (step) => step.type === "exchange" && step.replies.some((message) => message.type === "result"), + ) + const ending = steps[terminal]! + if (ending.type !== "exchange") throw new Error("Missing interrupt result recording") + steps[terminal] = { ...ending, beforeResult: drain.promise } + const claude = FakeClaude.make(steps) + const test = yield* TestSessions.start(claude) + yield* Effect.addFinalizer(() => Effect.sync(() => drain.resolve())) + const prompt = TestTools.prompt() + yield* TestTools.step(test.model, prompt) + let stopped = false + const close = yield* test.registry.close().pipe( + Effect.andThen( + Effect.sync(() => { + stopped = true + }), + ), + Effect.forkChild, + ) + yield* Effect.promise(() => claude.started(steps[terminal]!)) + yield* Effect.yieldNow + expect(stopped).toBe(false) + const call = Recorder.load("tools-waiting-cancel").find( + (line) => line.from === "claude" && line.message.request?.message?.method === "tools/call", + ) + if (!call || call.from !== "claude") throw new Error("Missing recorded MCP call") + expect(claude.runs[0]!.received.some((message) => message.response?.request_id === call.message.request_id)).toBe( + false, + ) + drain.resolve() + yield* Fiber.join(close) + expect(claude.runs[0]!.received.filter((message) => message.request?.subtype === "interrupt")).toHaveLength(1) + claude.check() + }), + ) + + it.effect("a turn left waiting for tool results stops IDLE_MS after OpenCode stops running the session", () => + Effect.gen(function* () { + const cancel = FakeClaude.replay("tools-waiting-cancel") + const nextUser = cancel.findIndex( + (step, index) => index > 1 && step.type === "exchange" && step.input.type === "user", + ) + const claude = FakeClaude.make(cancel.slice(0, nextUser)) + const test = yield* TestSessions.start(claude) + yield* TestTools.step(test.model, TestTools.prompt()) + yield* test.sessions.idle("session-a") + yield* TestClock.adjust(ClaudeSessions.IDLE_MS) + yield* Effect.promise(() => claude.runs[0]!.closed) + claude.check() + }), + ) + + it.effect("a process exit while OpenCode runs tools resumes with their saved results", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("tools-cancel") + const interrupt = steps.findIndex( + (step) => step.type === "exchange" && step.input.request?.subtype === "interrupt", + ) + // The first process exits instead of receiving an orderly interrupt. Keep the recorded resume. + const claude = FakeClaude.make([...steps.slice(0, interrupt), ...steps.slice(interrupt + 1)]) + const test = yield* TestSessions.start(claude) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(test.model, prompt), TestTools.output) + yield* Effect.promise(() => claude.runs[0]!.exit(1)) + expect(TestSessions.text(yield* TestTools.step(test.model, prompt))).toContain("CANCELLED-OTTER") + const resumed = claude.runs[1]! + const user = resumed.received.find((message) => message.type === "user") + expect(JSON.stringify(user.message.content)).toContain("LILAC-HERON") + expect(JSON.stringify(user.message.content)).toContain("LEMON-IBIS") + expect(resumed.args.some((arg) => arg.startsWith("--resume="))).toBe(true) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("results that do not answer every waiting call stop the turn and rebuild from the full history", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("tools-cancel")) + const test = yield* TestSessions.start(claude) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(test.model, prompt), TestTools.output) + const results = prompt.at(-1)! + if (results.role !== "tool") throw new Error("Expected tool results") + // Drop the second lookup's result, so one waiting call has no answer. + results.content.pop() + prompt.push({ + role: "user", + content: [{ type: "text", text: "Do not call any tools. Reply only with CANCELLED-OTTER." }], + }) + expect(TestSessions.text(yield* TestTools.step(test.model, prompt))).toContain("CANCELLED-OTTER") + expect(claude.runs).toHaveLength(2) + expect(claude.runs[0]!.received.filter((message) => message.request?.subtype === "interrupt")).toHaveLength(1) + const rebuilt = claude.runs[1]! + expect(rebuilt.args.some((arg) => arg.startsWith("--resume="))).toBe(false) + const user = JSON.stringify(rebuilt.received.find((message) => message.type === "user").message.content) + expect(user).toContain("") + expect(user).toContain("LILAC-HERON") + expect(user).not.toContain("LEMON-IBIS") + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("active-turn steering delivers tool feedback and new input through the SDK queue", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("tools-continue")) + const test = yield* TestSessions.start(claude) + const prompt = TestTools.prompt() + TestTools.append(prompt, yield* TestTools.step(test.model, prompt), () => ({ + type: "execution-denied", + reason: "Cancelled by user.", + })) + prompt.push({ + role: "user", + content: [ + { + type: "text", + text: "The calls were cancelled. Do not call any more tools. Reply only with CONTINUED-OTTER.", + }, + ], + }) + expect(TestSessions.text(yield* TestTools.step(test.model, prompt)).trim()).toBe("CONTINUED-OTTER") + expect(claude.runs).toHaveLength(1) + const messages = claude.runs[0]!.received + expect(messages.some((message) => message.request?.subtype === "interrupt")).toBe(false) + expect(messages.filter((message) => message.type === "user").at(-1).priority).toBe("next") + expect(messages.filter((message) => message.type === "user").at(-1).message.content).toEqual( + prompt.at(-1)!.content, + ) + const results = messages.filter((message) => message.response?.response?.mcp_response?.result) + expect(results).toHaveLength(2) + for (const message of results) + expect(message.response.response.mcp_response.result).toEqual({ + isError: true, + content: [{ type: "text", text: "Cancelled by user." }], + }) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("an aborted tool drains the old turn before sending a fresh user message on the same process", () => + Effect.gen(function* () { + const steps = FakeClaude.replay("tools-abort") + const drain = Promise.withResolvers() + const terminal = steps.findIndex( + (step) => step.type === "exchange" && step.replies.some((message) => message.type === "result"), + ) + const ending = steps[terminal]! + if (ending.type !== "exchange") throw new Error("Missing interrupt result recording") + steps[terminal] = { ...ending, beforeResult: drain.promise } + const claude = FakeClaude.make(steps) + const test = yield* TestSessions.start(claude) + yield* Effect.addFinalizer(() => Effect.sync(() => drain.resolve())) + const prompt = TestTools.prompt() + const first = yield* TestTools.step(test.model, prompt) + TestTools.append(prompt, first, () => ({ type: "execution-denied", reason: "Cancelled by user." }), { + "claude-agent": { toolError: true, toolAborted: true }, + }) + prompt.push({ + role: "user", + content: [ + { + type: "text", + text: "The calls were cancelled. Do not call any more tools. Reply only with CONTINUED-OTTER.", + }, + ], + }) + const next = yield* TestTools.step(test.model, prompt).pipe(Effect.forkChild) + yield* Effect.promise(() => claude.started(steps[terminal]!)) + const messages = claude.runs[0]!.received + // The interrupt receipt is insufficient: no new input until the terminal result flushes. + expect(messages.filter((message) => message.type === "user")).toHaveLength(1) + expect(messages.some((message) => message.response?.response?.mcp_response?.result?.content)).toBe(false) + drain.resolve() + expect(TestSessions.text(yield* Fiber.join(next)).trim()).toBe("CONTINUED-OTTER") + expect(claude.runs).toHaveLength(1) + expect(messages.filter((message) => message.request?.subtype === "interrupt")).toHaveLength(1) + const user = messages.filter((message) => message.type === "user").at(-1) + expect(user.priority).toBeUndefined() + expect(JSON.stringify(user.message.content)).toContain("Cancelled by user.") + expect(user.message.content.at(-1)).toEqual(prompt.at(-1)!.content[0]) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("tool images and PDFs reach Claude as image and document content", () => + Effect.gen(function* () { + // Only the first process: the recording goes on to change the tool list. + const steps = FakeClaude.replay("tools-media") + const restart = steps.findIndex((step, index) => index > 0 && step.type === "startup") + const claude = FakeClaude.make(steps.slice(0, restart)) + const test = yield* TestSessions.start(claude) + const tools = [ + { ...TestTools.tools[0]!, name: "attachment", inputSchema: { type: "object" as const, properties: {} } }, + ] + const prompt = TestSessions.prompt("Call attachment once. Report the image color and the marker from its PDF.") + TestTools.append(prompt, yield* TestTools.step(test.model, prompt, tools), () => ({ + type: "text", + value: "Read the attached image and PDF.", + })) + const image = yield* Effect.promise(() => Bun.file(new URL("../fixtures/red.png", import.meta.url)).bytes()) + const pdf = yield* Effect.promise(() => Bun.file(new URL("../fixtures/marker.pdf", import.meta.url)).bytes()) + prompt.push({ + role: "user", + content: [ + { type: "text", text: "Attached media from tool result:" }, + { type: "file", mediaType: "image/png", data: image }, + { type: "file", mediaType: "application/pdf", data: pdf }, + ], + }) + yield* TestTools.step(test.model, prompt, tools) + const replacement = claude.runs[0]!.received.find( + (message) => message.response?.response?.hookSpecificOutput?.updatedMCPToolOutput, + ) + expect(replacement.response.response.hookSpecificOutput.updatedMCPToolOutput).toEqual([ + { type: "text", text: "Read the attached image and PDF." }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: Buffer.from(image).toString("base64") }, + }, + { + type: "document", + source: { type: "base64", media_type: "application/pdf", data: Buffer.from(pdf).toString("base64") }, + }, + ]) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("a last step without tools stops the parked turn and resumes with the saved result", () => + Effect.gen(function* () { + const claude = FakeClaude.make(FakeClaude.replay("max-steps")) + const test = yield* TestSessions.start(claude) + const lookup = [{ ...TestTools.tools[0]!, inputSchema: { type: "object" as const, properties: {} } }] + const prompt = TestSessions.prompt("Call lookup once, then call it again, then report the marker.") + TestTools.append(prompt, yield* TestTools.step(test.model, prompt, lookup), () => ({ + type: "text", + value: "STEP-OTTER", + })) + prompt.push({ role: "user", content: [{ type: "text", text: "MAXIMUM STEPS REACHED. Respond with text only." }] }) + yield* TestTools.step(test.model, prompt, []) + expect(claude.runs).toHaveLength(2) + const final = claude.runs[1]! + expect(final.args.some((arg) => arg.startsWith("--resume="))).toBe(true) + const init = final.received.find((message) => message.request?.subtype === "initialize").request + expect(init.sdkMcpServers).toBeUndefined() + expect(JSON.stringify(final.received.find((message) => message.type === "user").message.content)).toContain( + "STEP-OTTER", + ) + yield* test.registry.close() + claude.check() + }), + ) + + it.effect("an unavailable OpenCode server fails closed", () => + Effect.gen(function* () { + const turn = FakeClaude.expectTurn() + const init = turn.replies.find((message) => message.type === "system" && message.subtype === "init") + init.mcp_servers = [{ name: "opencode", status: "failed" }] + const claude = FakeClaude.make([ + FakeClaude.expectStartup(), + turn, + FakeClaude.expectInterrupt(), + FakeClaude.expectInterrupt(), + ]) + const test = yield* TestSessions.start(claude) + const error = yield* TestTools.step(test.model, TestTools.prompt()).pipe(Effect.flip) + expect(String(error.cause)).toContain("Altimate Code tool server did not connect") + expect(claude.runs).toHaveLength(1) + yield* test.registry.close() + claude.check() + }), + ) +}) diff --git a/packages/opencode/test/altimate/provider/claude-agent/fixtures/marker.pdf b/packages/opencode/test/altimate/provider/claude-agent/fixtures/marker.pdf new file mode 100644 index 0000000000..a06d95a815 Binary files /dev/null and b/packages/opencode/test/altimate/provider/claude-agent/fixtures/marker.pdf differ diff --git a/packages/opencode/test/altimate/provider/claude-agent/fixtures/recordings/billing-error.jsonl b/packages/opencode/test/altimate/provider/claude-agent/fixtures/recordings/billing-error.jsonl new file mode 100644 index 0000000000..9a1fbb4273 --- /dev/null +++ b/packages/opencode/test/altimate/provider/claude-agent/fixtures/recordings/billing-error.jsonl @@ -0,0 +1,11 @@ +{"claudeCode":"2.1.302","sdk":"0.3.301","date":"2026-10-17","note":"Trimmed from a real failed compaction that hit a billing error. It can't be re-recorded on demand."} +{"from":"sdk","message":{"type":"user","uuid":"edadf65f-1bca-4f6d-99cb-95dcab4c0854","message":{"role":"user","content":[{"type":"text","text":"You MUST summarize the conversation above into a structured summary that will be given to another agent to resume the work.\n\nSummarize only what the user and the assistant said and did. Leave out instructions and setup the assistant was given rather than told by the user: repository conventions, instruction files such as AGENTS.md, and environment details like the session ID. The next agent receives current versions of all of these separately.\n\nYou MUST use this format for your response (you may omit sections that aren't applicable). Do not include the