diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d48ed0..d5c37727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. - Retry failed CacheKeep prewarms while the last confirmed cache can still be alive, and serialize overlapping manager ticks to avoid duplicate requests. - Preserve fresh scoped-only fallback quota snapshots, permanent refresh-error classification across lock contention, and explicit re-login guidance for unusable fallback accounts. - Evict complete request artifact groups when enforcing the dump-directory size cap. +- Add the OpenCode-only `/claude-start` command for explicit synthetic one-token lane starts; start requests use the `-start-` dump marker. - Capture Anthropic cache diagnostics in versioned `MC-CACHE-DIAG ` debug records, preserve provider response IDs across requests and cachekeep prewarms, and write response/request dump artifacts without response content. Finalize response-artifact writes before the transformed response stream completes so a valid provider envelope cannot leave the initial status-only artifact behind. Document the beta states and known fingerprint, organization, workspace, and beta-set limitations. - Fix Pi OAuth billing rejection by relocating its documentation paragraph from top-level `system[]` to a cached block before the first user's text, with a safe whole-prompt fallback for unknown future Pi prompt layouts, and expose Claude Opus 5 in Pi's provider catalog. diff --git a/README.md b/README.md index 134cbf7e..d0141a81 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one | Provider integration point | OpenCode plugin fetch/request transform | Pi `registerProvider("anthropic")` provider override | | Sidecar config | `~/.config/opencode/anthropic-auth.json` | `~/.pi/agent/anthropic-auth.json` | | Runtime state | `~/.config/opencode/anthropic-auth-state.json` | next to the Pi sidecar as `anthropic-auth-state.json` | -| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-prime` (status only), `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | +| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-start`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-prime` (status only), `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | | Quota sidebar widget | OpenCode TUI plugin via `tui.json` | Not available | | Fallback accounts, quota routing, killswitch, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar | @@ -32,6 +32,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes. - **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. - **Quota window priming**: opt in with `/claude-prime on` to start each 5-hour quota window about one minute after it resets instead of waiting for the next normal prompt. +- **Lane start (OpenCode only)**: use `/claude-start` to fire one synthetic, one-token turn through the current session's normal model, agent, variant, quota, routing, cache, and request pipeline. - **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models. - **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5, Mythos 5, and Opus 5. OpenCode receives native `low`, `medium`, `high`, `xhigh`, and `max` Opus 5 effort variants rather than legacy manual-thinking budgets. - **Fable/Opus 5 safety fallback (OpenCode)**: eligible OAuth requests try Anthropic's server-side safety fallback first. The plugin preserves Anthropic's fallback conversation boundary across OpenCode history and automatically starts its deterministic 10-response Opus 4.8 recovery if the response still ends in refusal. The TUI sidebar and OpenCode Desktop report the active target model and restoration. Set `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` to bypass the server policy and use client-side recovery exclusively. @@ -50,7 +51,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - Support fallback Claude accounts stored in a local per-agent sidecar file. - Keep fallback OAuth tokens fresh in the background. - Apply quota thresholds before routing to main or fallback accounts. -- Add `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands. +- Add `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-start`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands to OpenCode. - Optionally relay large requests through a Cloudflare Worker owned by the user. ## Install @@ -220,6 +221,9 @@ The `routing` block controls `/claude-routing`, `claudeCache` controls `/claude- Runtime data is stored separately in `anthropic-auth-state.json`: fallback OAuth tokens, API-route keys, token refresh backoff, quota snapshots, and quota API backoff. `sticky-balanced` session assignments use a separate `anthropic-auth-routing-state.json`; session IDs are SHA-256 hashed in that file. Background refresh and quota checks write only runtime state, so editing `anthropic-auth.json` does not get overwritten by another running plugin instance. +## OpenCode lane-start setting + + ## Fallback accounts Fallback accounts are separate Claude OAuth accounts or Anthropic-compatible API-key routes managed by this plugin. By default, the main account is tried first unless quota policy says it is currently unusable. Fallbacks are then tried in sidecar order when the primary request returns a configured fallback status. @@ -507,6 +511,18 @@ Prime marker identities live in `anthropic-auth-state.json`. Plugin-owned refres Pi exposes `/claude-prime` as a status-only command. Its `on` and `off` arguments are ignored; enable or disable priming from OpenCode. +## OpenCode lane start + +`/claude-start` is an OpenCode-only command. It queues one synthetic turn for the current session: + +```text +/claude-start +``` + +The bare command fires immediately. The synthetic prompt uses the session's current model, agent, and variant, then travels through the ordinary quota, routing, cache, relay, signing, and response pipeline. OpenCode shapes that OAuth request to `max_tokens: 1` while keeping streaming enabled, and correlates the request by its synthetic message ID. A queued modal is a request to start the turn, not a provider-success claim. + +Pi does not expose this command. + ## Claude fast mode Both OpenCode and Pi packages can persistently request Anthropic fast mode for supported Opus models: @@ -638,6 +654,8 @@ Each filename includes a sanitized session/affinity segment so dumps from differ - `*.relay.json` — redacted relay payload/frame metadata for relay requests. - `*.request.json` — redacted direct request URL, method, and headers for direct requests. +Lane-start requests use the `-start-` dump marker; CacheKeep keeps `-prewarm-cachekeep-`. Their cache-diagnostics records use `source: "start"` with `synthetic: true`, alongside ordinary `turn` records. + Dump state is persisted in the active sidecar config as `dump.enabled` (`~/.config/opencode/anthropic-auth.json` for OpenCode, `~/.pi/agent/anthropic-auth.json` for Pi). Dumps may contain prompt content and should be treated as sensitive local debugging artifacts. ## Environment variables diff --git a/packages/core/src/dump.ts b/packages/core/src/dump.ts index 30b718c5..5b65715b 100644 --- a/packages/core/src/dump.ts +++ b/packages/core/src/dump.ts @@ -55,7 +55,7 @@ export type DumpCommandAction = | { type: 'disable' } | { type: 'usage' } -export type DumpTag = 'cachekeep' +export type DumpTag = 'cachekeep' | 'start' export type DumpHandle = { responsePath: string @@ -362,7 +362,9 @@ function dumpRequestSegment(input: { } function dumpTagSegment(tag: DumpTag | undefined) { - return tag ? `-prewarm-${tag}` : '' + if (tag === 'cachekeep') return '-prewarm-cachekeep' + if (tag === 'start') return '-start' + return '' } function directDumpPreviousKey(input: { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 80b11538..190104cc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,4 +22,5 @@ export * from './quota-manager.ts' export * from './quotas.ts' export * from './relay.ts' export * from './routing.ts' +export * from './start.ts' export * from './sticky-routing.ts' diff --git a/packages/core/src/relay.ts b/packages/core/src/relay.ts index 085cd46d..2db67cb3 100644 --- a/packages/core/src/relay.ts +++ b/packages/core/src/relay.ts @@ -1109,7 +1109,8 @@ export async function sendViaRelay(options: { * within an attempt are ignored. */ onResponseHeaders?: (headers: Headers) => void - onDumpCreated?: (handle: { responsePath: string; tag?: 'cachekeep' }) => void + dumpTag?: import('./dump.ts').DumpTag + onDumpCreated?: (handle: import('./dump.ts').DumpHandle) => void setTimeoutImpl?: typeof globalThis.setTimeout clearTimeoutImpl?: typeof globalThis.clearTimeout }): Promise { @@ -1123,6 +1124,7 @@ export async function sendViaRelay(options: { affinity: explicitAffinity, optimisticResponse, onResponseHeaders, + dumpTag, onDumpCreated, setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, @@ -1249,6 +1251,7 @@ export async function sendViaRelay(options: { previousBodyText: previous?.body, payload: result.payload, relayBytes: actualPayloadBytes, + tag: dumpTag, }) try { if (dumpHandle) onDumpCreated?.(dumpHandle) diff --git a/packages/core/src/start.ts b/packages/core/src/start.ts new file mode 100644 index 00000000..58048ac1 --- /dev/null +++ b/packages/core/src/start.ts @@ -0,0 +1,39 @@ +export const CLAUDE_START_COMMAND_NAME = 'claude-start' + +const START_STATUS_TITLE = '## Claude Lane Start Status' +const START_QUEUED_TITLE = '## Claude Lane Start Queued' +const START_USAGE_TITLE = '## Claude Lane Start Usage' +const START_USAGE = 'Usage: `/claude-start`.' + +export type LaneStartCommandAction = { type: 'fire' } | { type: 'usage' } + +export function parseLaneStartCommandAction( + input: string, +): LaneStartCommandAction { + const normalized = input.trim().split(/\s+/).filter(Boolean) + if (normalized.length === 0) return { type: 'fire' } + return { type: 'usage' } +} + +export function executeLaneStartCommand(input: { argumentsText: string }): { + action: LaneStartCommandAction + text: string +} { + const action = parseLaneStartCommandAction(input.argumentsText) + if (action.type === 'fire') { + return { + action, + text: [ + START_QUEUED_TITLE, + '', + '- Queued an explicit lane-start request.', + ].join('\n'), + } + } + return { + action, + text: [START_USAGE_TITLE, '', START_USAGE, '', START_STATUS_TITLE].join( + '\n', + ), + } +} diff --git a/packages/core/src/tests/dump.test.ts b/packages/core/src/tests/dump.test.ts index 75614da1..f7a7df8b 100644 --- a/packages/core/src/tests/dump.test.ts +++ b/packages/core/src/tests/dump.test.ts @@ -195,6 +195,31 @@ test('tagged prewarm dumps include the tag in filenames and metadata', async () expect(metadata.tag).toBe('cachekeep') }) +test('start dumps use the distinct start filename segment', async () => { + const dumpDir = await mkdtemp( + join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), + ) + dumpDirs.push(dumpDir) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + setDumpEnabled(true) + const handle = await dumpDirectRequest({ + affinity: 'ses-start', + bodyText: '{}', + tag: 'start', + }) + expect(handle?.tag).toBe('start') + expect(handle?.responsePath).toMatch(/-start-direct\.response\.json$/) + expect(handle?.responsePath).not.toContain('-prewarm-start') + const files = await readdir(dumpDir) + const metadata = JSON.parse( + await readFile( + join(dumpDir, files.find((name) => name.endsWith('.meta.json'))!), + 'utf8', + ), + ) + expect(metadata.tag).toBe('start') +}) + test('dump sweep recognizes response artifacts', async () => { const dumpDir = await mkdtemp( join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 4d085fed..8c6e2d9e 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -20,7 +20,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one | Provider integration point | OpenCode plugin fetch/request transform | Pi `registerProvider("anthropic")` provider override | | Sidecar config | `~/.config/opencode/anthropic-auth.json` | `~/.pi/agent/anthropic-auth.json` | | Runtime state | `~/.config/opencode/anthropic-auth-state.json` | next to the Pi sidecar as `anthropic-auth-state.json` | -| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-prime` (status only), `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | +| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-start`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-prime` (status only), `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | | Fallback accounts, quota routing, killswitch, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar | ## What CortexKit adds over the original plugin @@ -31,6 +31,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes. - **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. - **Quota window priming**: opt in with `/claude-prime on` to start each 5-hour quota window about one minute after it resets instead of waiting for the next normal prompt. +- **Lane start (OpenCode only)**: use `/claude-start` to fire one synthetic, one-token turn through the current session's normal model, agent, variant, quota, routing, cache, and request pipeline. - **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models. - **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5, Mythos 5, and Opus 5. OpenCode receives native `low`, `medium`, `high`, `xhigh`, and `max` Opus 5 effort variants rather than legacy manual-thinking budgets. - **Fable/Opus 5 safety fallback**: eligible OAuth requests try Anthropic's server-side safety fallback first. The plugin preserves Anthropic's fallback conversation boundary across OpenCode history and automatically starts its deterministic 10-response Opus 4.8 recovery if the response still ends in refusal. The TUI sidebar and OpenCode Desktop report the active target model and restoration. Set `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` to bypass the server policy and use client-side recovery exclusively. @@ -218,6 +219,9 @@ The `routing` block controls `/claude-routing`, `claudeCache` controls `/claude- Runtime data is stored separately in `anthropic-auth-state.json`: fallback OAuth tokens, API-route keys, token refresh backoff, quota snapshots, and quota API backoff. Sticky session assignments use `anthropic-auth-routing-state.json` and store only SHA-256 hashes of session IDs. Background refresh and quota checks write only runtime state, so editing `anthropic-auth.json` does not get overwritten by another running plugin instance. +## OpenCode lane-start setting + + ## Fallback accounts Fallback accounts are separate Claude OAuth accounts or Anthropic-compatible API-key routes managed by this plugin. By default, the main account is tried first unless quota policy says it is currently unusable. Fallbacks are then tried in sidecar order when the primary request returns a configured fallback status. @@ -412,6 +416,18 @@ Prime marker identities live in `anthropic-auth-state.json`. Plugin-owned refres Pi exposes `/claude-prime` as a status-only command. Its `on` and `off` arguments are ignored; enable or disable priming from OpenCode. +## OpenCode lane start + +`/claude-start` is an OpenCode-only command. It queues one synthetic turn for the current session: + +```text +/claude-start +``` + +The bare command fires immediately. The synthetic prompt uses the session's current model, agent, and variant, then travels through the ordinary quota, routing, cache, relay, signing, and response pipeline. OpenCode shapes that OAuth request to `max_tokens: 1` while keeping streaming enabled, and correlates the request by its synthetic message ID. A queued modal is a request to start the turn, not a provider-success claim. + +Pi does not expose this command. + ### Cache diagnostics (beta) The `cache-diagnosis-2026-04-07` beta is measure-only. It asks Anthropic to report prompt-cache diagnostics; it does not change cache controls or routing. OpenCode captures the provider's top-level response ID as an opaque string and sends it as `diagnostics.previous_message_id` on the next request in the same session. The first request sends `null`. @@ -421,7 +437,7 @@ The `MC-CACHE-DIAG ` line is a versioned, one-line JSON record. Records and thei | Field | Type | Source | | --- | --- | --- | | `v` | `2` | Capture schema | -| `source` | string | Observation path; known values are `"turn"` and `"prewarm_cachekeep"`, but consumers must tolerate future values | +| `source` | string | Observation path; known values are `"turn"`, `"start"`, and `"prewarm_cachekeep"`, but consumers must tolerate future values | | `synthetic` | boolean | Whether this observation was generated by plugin machinery rather than a real turn | | `account_id` | string | Persisted plugin-internal OAuth account identifier used by routing and the sidebar; stable across restarts and token refreshes, so consumers may key timelines on it. Opaque mixed key space (the main account is a sentinel string, fallbacks are UUIDs) — never validate its shape | | `betas_hash` | 16-character lowercase hex | xxHash64 (seed `0`) of the sorted `anthropic-beta` list actually sent, truncated to its first 16 hexadecimal characters | @@ -453,8 +469,11 @@ Known sources map to `synthetic` as follows: | `source` | `synthetic` | | --- | --- | | `turn` | `false` | +| `start` | `true` | | `prewarm_cachekeep` | `true` | +Lane-start cache observations therefore appear as ordinary `MC-CACHE-DIAG ` records with `source: "start"`; the row above is the diagnostics-side companion to the `/claude-start` pipeline described earlier. + `synthetic` wins on conflict. A disagreement for a known source is an emitter defect: OpenCode writes one warning and still emits the record with the supplied `synthetic` value. Unknown future sources have no mapping and must not be rejected by consumers. The first observation of each `betas_hash` in a process also writes `MC-CACHE-DIAG-BETAS {"hash":"…","betas":[…]}` at debug level on the `cache-diagnostics` logger channel. Its sorted beta list makes an observed hash interpretable without reconstructing headers; repeated hashes do not emit another side-channel line. @@ -480,7 +499,7 @@ Diagnostics comparison requires a cacheable prefix. Requests below the model's c Version 1 records come from the unversioned-source era and cannot distinguish prewarms from turns; consumers must treat their source as unknown and cannot split machinery from traffic retroactively. Version 2 always states the source. -When request dumps are enabled, each response gets a `.response.json` artifact containing status and parsed response metadata, but no response content. Cache keepalive prewarms are tagged `-prewarm-cachekeep` in dump filenames and metadata. If Prime prewarming is enabled in a build that supports it, those artifacts use `-prewarm-prime`. Treat request bodies and related dump files as sensitive local debugging data. +When request dumps are enabled, each response gets a `.response.json` artifact containing status and parsed response metadata, but no response content. Lane-start requests are tagged `-start-`; CacheKeep prewarms retain `-prewarm-cachekeep-`. If Prime prewarming is enabled in a build that supports it, those artifacts use `-prewarm-prime`. Treat request bodies and related dump files as sensitive local debugging data. ## Claude fast mode diff --git a/packages/opencode/src/cache-diagnostics.ts b/packages/opencode/src/cache-diagnostics.ts index 54a87a59..0236c645 100644 --- a/packages/opencode/src/cache-diagnostics.ts +++ b/packages/opencode/src/cache-diagnostics.ts @@ -18,6 +18,7 @@ export type CacheDiagnosticsSource = string export const CACHE_DIAGNOSTICS_SOURCE_SYNTHETIC = { turn: false, + start: true, prewarm_cachekeep: true, } as const diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5aa460b9..73a4c629 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,6 +28,7 @@ import { CLAUDE_PRIME_COMMAND_NAME, CLAUDE_QUOTAS_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, + CLAUDE_START_COMMAND_NAME, computeXxhash64Hex, continueMainPrimeAuthLineageAfterRefresh, createEmptyStorage, @@ -43,6 +44,7 @@ import { executeDumpCommand, executeFastModeCommand, executeKillswitchCommand, + executeLaneStartCommand, executeLoggingCommand, executePrimeCommand, executeRoutingCommand, @@ -110,6 +112,7 @@ import { parseCacheKeepCommandAction, parseDumpCommandAction, parseFastModeCommandAction, + parseLaneStartCommandAction, parseLoggingCommandAction, parsePrimeCommandAction, parseRoutingCommandAction, @@ -174,6 +177,11 @@ import { isRecoverableRefusalModel, recoverableRefusalFamily, } from './fable-fallback.ts' +import { + fireLaneStart, + LANE_START_REQUEST_HEADER, + LaneStartTracker, +} from './lane-start.ts' import { adoptPrimeManager } from './prime-manager-registry.ts' import { resolvePromptContext } from './prompt-context.ts' import { @@ -900,6 +908,7 @@ const anthropicAuthPlugin = async ( process.env.OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE, ) const fableFallbackManager = new FableFallbackManager() + const laneStartTracker = new LaneStartTracker() const serverFallbackTargets = new Map() const pendingDesktopNotices = new Map() const pendingRecoveryDesktopNotices = new Map() @@ -2625,6 +2634,25 @@ const anthropicAuthPlugin = async ( return executeFastModeCommand({ argumentsText, enabled }) } + async function executePersistentStartCommand( + argumentsText: string, + sessionId?: string, + ) { + const action = parseLaneStartCommandAction(argumentsText) + if (action.type === 'fire') { + if (!sessionId) { + return '## Claude Start Failed\n\n- OpenCode did not provide a session ID.' + } + try { + await fireLaneStart(ctx.client, sessionId) + return executeLaneStartCommand({ argumentsText }).text + } catch (error) { + return `## Claude Start Failed\n\n- ${error instanceof Error ? error.message : String(error)}` + } + } + return executeLaneStartCommand({ argumentsText }).text + } + async function executePersistentRoutingCommand( argumentsText: string, sessionId?: string, @@ -2960,6 +2988,14 @@ const anthropicAuthPlugin = async ( ): Promise { if (command === 'claude-quota') return { command, text: await buildQuotaCommandSummary(), knobs: {} } + if (command === 'claude-start') { + const text = await executePersistentStartCommand(args, sessionId) + return { + command, + text, + knobs: {}, + } + } if (command === 'claude-logging') { const text = await executePersistentLoggingCommand(args) const storage = await loadAccounts(accountStoragePath) @@ -3185,6 +3221,36 @@ const anthropicAuthPlugin = async ( } return { + 'chat.message': async ( + { + sessionID, + }: { + sessionID: string + }, + output: { message: { id: string }; parts: unknown[] }, + ) => { + laneStartTracker.observeSyntheticMessage({ + sessionId: sessionID, + messageId: output.message.id, + parts: output.parts, + }) + }, + 'chat.headers': async ( + { + sessionID, + message, + }: { + sessionID: string + message: { id: string } + }, + output: { headers: Record }, + ) => { + laneStartTracker.markHeaders({ + sessionId: sessionID, + messageId: message.id, + headers: output.headers, + }) + }, event: async ({ event }: { event: unknown }) => { const value = event as unknown as { type?: string @@ -3224,6 +3290,7 @@ const anthropicAuthPlugin = async ( } if (value.type === 'session.deleted') { + laneStartTracker.clearSession(sessionId) fableRecoveryNotices.delete(sessionId) pendingDesktopNotices.delete(sessionId) desktopNoticeSafeSessions.delete(sessionId) @@ -3258,6 +3325,11 @@ const anthropicAuthPlugin = async ( description: "Start each OAuth account's five-hour quota window after reset.", }, + [CLAUDE_START_COMMAND_NAME]: { + template: CLAUDE_START_COMMAND_NAME, + description: + 'Warm and renew the current Claude session cache with a one-token synthetic turn.', + }, [CLAUDE_QUOTAS_COMMAND_NAME]: { template: CLAUDE_QUOTAS_COMMAND_NAME, @@ -3331,6 +3403,7 @@ const anthropicAuthPlugin = async ( 'claude-cache', 'claude-cachekeep', 'claude-prime', + 'claude-start', 'claude-quota', 'claude-dump', 'claude-fast', @@ -4124,6 +4197,7 @@ const anthropicAuthPlugin = async ( currentStorage?: Awaited>, oauthAccountId = 'main', fableRequest?: FableRequestContext, + laneStartRequest = false, ) { const start = nowMs() let requestStorage = currentStorage @@ -4204,6 +4278,7 @@ const anthropicAuthPlugin = async ( identity, hybridStandbyAnchor: standbyCacheAnchor, serverSideFallbackEnabled: fallbackMode === 'server', + laneStart: laneStartRequest, cacheDiagnosticsPreviousMessageId, perf: (stage, data) => { trace?.mark(`rewrite_body_${stage}`, { route, ...data }) @@ -4361,6 +4436,7 @@ const anthropicAuthPlugin = async ( fetchInputUrl(rewritten.input), method: fetchMethod(input, init), headers: requestHeaders, + tag: laneStartRequest ? 'start' : undefined, }) } return response @@ -4376,6 +4452,7 @@ const anthropicAuthPlugin = async ( fetchInputUrl(rewritten.input), method: fetchMethod(input, init), headers: requestHeaders, + tag: laneStartRequest ? 'start' : undefined, }) } throw error @@ -4402,6 +4479,7 @@ const anthropicAuthPlugin = async ( onDumpCreated: (handle) => { relayDump = handle }, + dumpTag: laneStartRequest ? 'start' : undefined, }) trace?.mark('send_headers_received', { route, @@ -4413,9 +4491,9 @@ const anthropicAuthPlugin = async ( if (usedDirectFetch) harvestQuotaHeaders(response.headers, served) attachCacheDiagnosticsResponse(response, { - source: 'turn', + source: laneStartRequest ? 'start' : 'turn', accountId: oauthAccountId, - synthetic: false, + synthetic: laneStartRequest, ...cacheDiagnosticsBetas, requestedModel: parseRequestModel(body), request: cacheDiagnosticsRequest, @@ -4685,6 +4763,8 @@ const anthropicAuthPlugin = async ( } } + const responseRouteKinds = new WeakMap() + async function tryUsableFallbackAccounts( input: string | URL | Request, init: RequestInit | undefined, @@ -4699,6 +4779,7 @@ const anthropicAuthPlugin = async ( access?: string }) => void | Promise fableRequest?: FableRequestContext + laneStartRequest?: boolean }, ) { if (!accounts.length) return currentResponse ?? null @@ -4732,6 +4813,7 @@ const anthropicAuthPlugin = async ( storage, account.id, options?.fableRequest, + options?.laneStartRequest, ) } lastResponse = response @@ -4746,6 +4828,10 @@ const anthropicAuthPlugin = async ( fallbackAgain = inspected.rateLimited } if (!fallbackAgain) { + responseRouteKinds.set( + response, + isApiKeyAccount(account) ? 'api' : 'oauth', + ) await fallbackManager.markUsed(account) await options?.onSuccess?.(account) // Active-route every-N refresh: this fallback just served the @@ -4785,6 +4871,7 @@ const anthropicAuthPlugin = async ( }) => void, modelId?: string, fableRequest?: FableRequestContext, + laneStartRequest = false, ) { if (!isReplayableRequest(input, init?.body)) return mainResponse @@ -4883,6 +4970,7 @@ const anthropicAuthPlugin = async ( { onSuccess: onFallbackSuccess, fableRequest, + laneStartRequest, }, )) ?? currentResponse ) @@ -4892,6 +4980,10 @@ const anthropicAuthPlugin = async ( apiKey: '', async fetch(input: string | URL | Request, init?: RequestInit) { const incomingHeaders = mergeHeaders(input, init) + const laneStartRequest = + incomingHeaders.get(LANE_START_REQUEST_HEADER) === '1' + incomingHeaders.delete(LANE_START_REQUEST_HEADER) + init = { ...init, headers: incomingHeaders } const sessionId = incomingHeaders.get('x-session-affinity') || incomingHeaders.get('x-opencode-session') @@ -4930,6 +5022,9 @@ const anthropicAuthPlugin = async ( cacheDiagnosticsResponses.get(response) return createStrippedStream(response, { perf: (stage, data) => trace.mark(stage, data), + laneStart: laneStartRequest, + laneStartOAuthServed: + responseRouteKinds.get(response) !== 'api', ...(diagnosticsContext ? diagnosticsContext.streaming ? { @@ -5258,6 +5353,7 @@ const anthropicAuthPlugin = async ( stickyRoutes.storage, selected.id, fableRequest, + laneStartRequest, ) const completeRoute = async ( selected: StickyOAuthRoute, @@ -5490,6 +5586,7 @@ const anthropicAuthPlugin = async ( 'sticky-balanced', ), fableRequest, + laneStartRequest, }, ) if (apiResponse) { @@ -5548,6 +5645,7 @@ const anthropicAuthPlugin = async ( onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback-first'), fableRequest, + laneStartRequest, }, ) if (fallbackResponse) { @@ -5747,6 +5845,7 @@ const anthropicAuthPlugin = async ( onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), fableRequest, + laneStartRequest, }, ) if (fallbackResponse) { @@ -5890,6 +5989,7 @@ const anthropicAuthPlugin = async ( // is wrong once the killswitch hands off to a fallback. onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), + laneStartRequest, }, ) // The killswitch is a HARD block: it must never fall through to @@ -5959,6 +6059,7 @@ const anthropicAuthPlugin = async ( storage, 'main', fableRequest, + laneStartRequest, ) let fallbackServed = false const response = await tryFallbackAccounts( @@ -5975,6 +6076,7 @@ const anthropicAuthPlugin = async ( }, requestModelId, fableRequest, + laneStartRequest, ) if (!fallbackServed) writeCurrentSidebarState('main', 'main') diff --git a/packages/opencode/src/lane-start.ts b/packages/opencode/src/lane-start.ts new file mode 100644 index 00000000..a6b0c599 --- /dev/null +++ b/packages/opencode/src/lane-start.ts @@ -0,0 +1,84 @@ +import { resolvePromptContext } from './prompt-context' + +export const LANE_START_TEXT = + '[lane start] — automated cache warm; no response needed.' +export const LANE_START_REQUEST_HEADER = 'x-cortexkit-lane-start' + +const MAX_PENDING_MESSAGE_IDS = 1_000 + +type PluginSessionClient = { + promptAsync?: (request: unknown) => Promise | unknown +} + +function isLaneStartPart(part: unknown) { + if (!part || typeof part !== 'object') return false + const record = part as Record + return ( + record.type === 'text' && + record.text === LANE_START_TEXT && + record.synthetic === true + ) +} + +export async function fireLaneStart( + client: unknown, + sessionId: string, +): Promise { + const session = (client as { session?: PluginSessionClient } | null)?.session + if (typeof session?.promptAsync !== 'function') { + throw new Error( + 'OpenCode plugin client does not support session.promptAsync', + ) + } + + const promptContext = await resolvePromptContext(client, sessionId) + const body: Record = { + noReply: false, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + } + if (promptContext?.agent) body.agent = promptContext.agent + if (promptContext?.model) body.model = promptContext.model + if (promptContext?.variant) body.variant = promptContext.variant + + await Promise.resolve(session.promptAsync({ path: { id: sessionId }, body })) +} + +export class LaneStartTracker { + #pending = new Map() + + observeSyntheticMessage(input: { + sessionId: string + messageId: string + parts: unknown[] + }): boolean { + if (!input.parts.some(isLaneStartPart)) return false + const key = `${input.sessionId}\u0000${input.messageId}` + if ( + !this.#pending.has(key) && + this.#pending.size >= MAX_PENDING_MESSAGE_IDS + ) { + const oldest = this.#pending.keys().next().value + if (oldest !== undefined) this.#pending.delete(oldest) + } + this.#pending.set(key, input.sessionId) + return true + } + + markHeaders(input: { + sessionId: string + messageId: string + headers: Record + }): boolean { + const key = `${input.sessionId}\u0000${input.messageId}` + if (!this.#pending.has(key)) return false + this.#pending.delete(key) + input.headers[LANE_START_REQUEST_HEADER] = '1' + return true + } + + clearSession(sessionId: string): void { + for (const [key, pendingSessionId] of this.#pending) { + if (pendingSessionId === sessionId) this.#pending.delete(key) + } + } +} diff --git a/packages/opencode/src/prompt-context.ts b/packages/opencode/src/prompt-context.ts index 08267d2c..6a8c651a 100644 --- a/packages/opencode/src/prompt-context.ts +++ b/packages/opencode/src/prompt-context.ts @@ -22,7 +22,12 @@ interface RawInfo { variant?: string providerID?: string modelID?: string - model?: { providerID?: string; modelID?: string; variant?: string } + model?: { + providerID?: string + modelID?: string + id?: string + variant?: string + } } function isRecord(value: unknown): value is Record { @@ -65,9 +70,11 @@ function extractFromMessage(message: unknown): ResolvedPromptContext | null { const modelID = typeof modelInfo?.modelID === 'string' ? modelInfo.modelID - : typeof info.modelID === 'string' - ? info.modelID - : undefined + : typeof modelInfo?.id === 'string' + ? modelInfo.id + : typeof info.modelID === 'string' + ? info.modelID + : undefined const variant = typeof modelInfo?.variant === 'string' ? modelInfo.variant @@ -115,24 +122,26 @@ export async function resolvePromptContext( | Promise<{ data?: unknown[] } | unknown[]> | { data?: unknown[] } | unknown[] + get?: (input: { + path: { id: string } + }) => Promise<{ data?: unknown } | unknown> | { data?: unknown } | unknown } } - if (typeof typedClient.session?.messages !== 'function') return null - let messages: unknown[] = [] - try { - messages = extractMessages( - await Promise.resolve( - typedClient.session.messages({ - path: { id: sessionId }, - query: { limit: 100 }, - }), - ), - ) - } catch { - return null + if (typeof typedClient.session?.messages === 'function') { + try { + messages = extractMessages( + await Promise.resolve( + typedClient.session.messages({ + path: { id: sessionId }, + query: { limit: 100 }, + }), + ), + ) + } catch { + messages = [] + } } - if (messages.length === 0) return null let latestAssistantMessageId: string | undefined let latestUserMessageId: string | undefined @@ -170,6 +179,24 @@ export async function resolvePromptContext( if (isComplete(result)) return result } + if (typeof typedClient.session?.get === 'function') { + try { + const response = await Promise.resolve( + typedClient.session.get({ path: { id: sessionId } }), + ) + const session = + isRecord(response) && isRecord(response.data) + ? response.data + : isRecord(response) + ? response + : undefined + const metadata = session ? extractFromMessage({ info: session }) : null + if (metadata) result = mergeContexts(result, metadata) + } catch { + // Message-derived context remains usable when the metadata fallback fails. + } + } + if ( !result.agent && !result.model && diff --git a/packages/opencode/src/rpc/protocol.ts b/packages/opencode/src/rpc/protocol.ts index d0b1b48b..1e578ee1 100644 --- a/packages/opencode/src/rpc/protocol.ts +++ b/packages/opencode/src/rpc/protocol.ts @@ -3,6 +3,7 @@ export type CommandModalName = | 'claude-cache' | 'claude-cachekeep' | 'claude-prime' + | 'claude-start' | 'claude-quota' | 'claude-dump' | 'claude-fast' diff --git a/packages/opencode/src/tests/cache-diagnostics.test.ts b/packages/opencode/src/tests/cache-diagnostics.test.ts index e248da70..6d95c46f 100644 --- a/packages/opencode/src/tests/cache-diagnostics.test.ts +++ b/packages/opencode/src/tests/cache-diagnostics.test.ts @@ -161,6 +161,7 @@ describe('cache diagnostics v2 contract', () => { test('exports the known source synthetic mapping', () => { expect(CACHE_DIAGNOSTICS_SOURCE_SYNTHETIC).toEqual({ turn: false, + start: true, prewarm_cachekeep: true, }) }) diff --git a/packages/opencode/src/tests/cachekeep.test.ts b/packages/opencode/src/tests/cachekeep.test.ts index 783289c4..f939cad1 100644 --- a/packages/opencode/src/tests/cachekeep.test.ts +++ b/packages/opencode/src/tests/cachekeep.test.ts @@ -227,9 +227,9 @@ describe('CacheKeepManager', () => { bodyText: body, }) expect(result.ok).toBe(true) + expect(sent[0]).toBeDefined() expect(sent[0]).toContain('prepared') expect(observed).toHaveLength(1) - expect(sent[0]).toBeDefined() expect((observed[0] as { bodyText: string }).bodyText).toBe(sent[0]!) expect( (observed[0] as { data: { usage: { cache_creation: unknown } } }).data diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index d3656223..4481a90f 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -28,6 +28,7 @@ import { tokenFingerprint, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin, primeQuotaSnapshotIsFreshSince } from '../index' +import { LANE_START_REQUEST_HEADER, LANE_START_TEXT } from '../lane-start' import { drainNotifications, resetNotificationsForTest, @@ -1458,7 +1459,23 @@ describe('auth.loader', () => { createFallbackStorage({ accounts: [], dump: { enabled: true }, - quota: { enabled: false }, + quota: { + enabled: false, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('main-access'), + }, }), ) @@ -3200,6 +3217,7 @@ describe('auth.loader', () => { 'claude-cache', 'claude-cachekeep', 'claude-prime', + 'claude-start', 'claude-quota', 'claude-dump', 'claude-fast', @@ -3222,6 +3240,51 @@ describe('auth.loader', () => { expect([...claudeRegistered].sort()).toEqual([...required].sort()) }) + test('handles /claude-start by injecting one visible synthetic prompt', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + const plugin = await getPlugin(mockClient) + + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-start', + arguments: '', + sessionID: 'session-start', + }), + ) + + const promptCalls = ( + mockClient.session.promptAsync as unknown as { + mock: { + calls: Array<[{ body: { parts: Array> } }]> + } + } + ).mock.calls as Array< + [ + { + path: { id: string } + body: { noReply: boolean; parts: Array> } + }, + ] + > + const startCall = promptCalls + .map(([call]) => call) + .find((call) => call.body.parts[0]?.synthetic === true) + expect(startCall).toEqual({ + path: { id: 'session-start' }, + body: { + noReply: false, + parts: [ + { + type: 'text', + text: '[lane start] — automated cache warm; no response needed.', + synthetic: true, + }, + ], + }, + }) + }) + test('handles /claude-cachekeep command and persists window', async () => { await useTempAccountFile( createFallbackStorage({ @@ -9464,6 +9527,569 @@ describe('auth.loader', () => { }) }) +describe('claude-start integration', () => { + const originalFetch = globalThis.fetch + + beforeEach(async () => { + pluginTimerOverrides = { + setInterval: mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + } + resetCache1hState() + resetDumpState() + setLogLevel('info') + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { enabled: false }, + claudeCache: { enabled: true, mode: 'hybrid' }, + cacheKeep: { enabled: true, always: true }, + }), + ) + }) + + afterEach(async () => { + __setLogTestSink(null) + globalThis.fetch = originalFetch + pluginTimerOverrides = {} + resetDumpState() + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await drainSidebarWrites() + restoreProcessTestFiles() + if (tempConfigDir) { + await rm(tempConfigDir, { recursive: true, force: true }) + tempConfigDir = undefined + } + }) + + test('claude-start request shapes only its correlated OAuth turn and emits diagnostics', async () => { + const sent: Array<{ body: Record; headers: Headers }> = [] + const records: LogTestRecord[] = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + sent.push({ + body: JSON.parse(String(init?.body)), + headers: new Headers(init?.headers), + }) + return Promise.resolve( + new Response( + `event: message_start\ndata: ${JSON.stringify({ + type: 'message_start', + message: { + id: 'provider-start', + model: 'claude-opus-4-8', + usage: { + input_tokens: 1, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 1, + }, + }, + diagnostics: { cache_miss_reason: null }, + }, + })}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n`, + { status: 200 }, + ), + ) + }) as unknown as typeof fetch + __setLogTestSink((record) => records.push(record)) + + const client = createMockClient() + const plugin = await getPlugin(client) + // Diagnostics records emit at debug level; enable it after plugin + // load so boot-time level application cannot reset it. + setLogLevel('debug') + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-start' }, + { + message: { id: 'msg-start' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-start', message: { id: 'msg-start' } }, + { headers }, + ) + expect(headers).toEqual({ [LANE_START_REQUEST_HEADER]: '1' }) + + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await ( + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-start', ...headers }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + max_tokens: 99, + thinking: { type: 'enabled', budget_tokens: 10 }, + messages: [{ role: 'user', content: 'start' }], + }), + }) + ).text() + + expect(sent).toHaveLength(1) + expect(sent[0]?.body).toMatchObject({ max_tokens: 1, stream: true }) + expect(sent[0]?.body.thinking).toBeUndefined() + expect(sent[0]?.headers.has(LANE_START_REQUEST_HEADER)).toBe(false) + const record = records.find( + (entry) => + entry.channel === 'cache-diagnostics' && + entry.message.includes('provider-start'), + ) + expect(record).toBeDefined() + expect( + JSON.parse(record!.message.replace('MC-CACHE-DIAG ', '')), + ).toMatchObject({ + v: 2, + source: 'start', + synthetic: true, + account_id: 'main', + session_id: 'ses-start', + }) + + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-cachekeep', + arguments: '', + sessionID: 'ses-start', + }), + ) + const latest = ( + client.session.promptAsync as unknown as { + mock: { calls: Array<[{ body: { parts: Array<{ text: string }> } }]> } + } + ).mock.calls.at(-1)?.[0] + expect(latest?.body.parts[0]?.text).toContain('ses-start') + setLogLevel('info') + }) + + test('claude-start concurrency does not shape an interleaved real turn', async () => { + const sent: Array<{ body: Record; headers: Headers }> = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + sent.push({ + body: JSON.parse(String(init?.body)), + headers: new Headers(init?.headers), + }) + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const startHeaders: Record = {} + const realHeaders: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-race' }, + { + message: { id: 'msg-start' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-race', message: { id: 'msg-start' } }, + { headers: startHeaders }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-race', message: { id: 'msg-real' } }, + { headers: realHeaders }, + ) + expect(realHeaders).toEqual({}) + + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const request = (headers: Record, maxTokens: number) => + result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-race', ...headers }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + max_tokens: maxTokens, + thinking: { type: 'enabled', budget_tokens: 10 }, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + await Promise.all([request(startHeaders, 99), request(realHeaders, 77)]) + + expect(sent.map((entry) => entry.body.max_tokens).sort()).toEqual([1, 77]) + expect( + sent.find((entry) => entry.body.max_tokens === 77)?.body.thinking, + ).toEqual({ + type: 'enabled', + budget_tokens: 10, + }) + expect( + sent.every((entry) => !entry.headers.has(LANE_START_REQUEST_HEADER)), + ).toBe(true) + }) + + test('claude-start tags direct dumps', async () => { + const previousDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + const dumpDir = await mkdtemp(join(tmpdir(), 'anthropic-start-dump-test-')) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + try { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { enabled: false }, + dump: { enabled: true }, + }), + ) + globalThis.fetch = mock(() => + Promise.resolve( + new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), + ), + ) as unknown as typeof fetch + const plugin = await getPlugin() + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-start-dump' }, + { + message: { id: 'msg-start-dump' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-start-dump', message: { id: 'msg-start-dump' } }, + { headers }, + ) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-start-dump', ...headers }, + body: JSON.stringify({ + messages: [{ role: 'user', content: 'start' }], + }), + }) + expect( + (await readdir(dumpDir)).some((file) => file.includes('-start-')), + ).toBe(true) + } finally { + if (previousDumpDir === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = previousDumpDir + } + await rm(dumpDir, { recursive: true, force: true }) + } + }) + + test('claude-start keeps a fallback-first API-key send ordinary', async () => { + const previousDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + const dumpDir = await mkdtemp(join(tmpdir(), 'anthropic-api-start-dump-')) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + try { + await useTempAccountFile( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + accounts: [ + { + id: 'api-start', + type: 'api', + apiKey: 'api-start-key', + baseURL: 'https://api.example.test', + authHeader: 'x-api-key', + }, + ], + quota: { enabled: false }, + dump: { enabled: true }, + }), + ) + const sent: Array<{ body: Record; headers: Headers }> = + [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + const headers = new Headers(init?.headers) + sent.push({ body: JSON.parse(String(init?.body)), headers }) + return Promise.resolve( + new Response('{}', { + status: 200, + headers: + headers.get('authorization') === 'Bearer main-access' + ? { + 'anthropic-ratelimit-unified-representative-claim': + 'five_hour', + 'anthropic-ratelimit-unified-5h-utilization': '1', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + } + : undefined, + }), + ) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const body = (maxTokens: number) => + JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + max_tokens: maxTokens, + thinking: { type: 'enabled', budget_tokens: 10 }, + messages: [{ role: 'user', content: 'start' }], + }) + await result.fetch(MESSAGES_URL, { method: 'POST', body: body(50) }) + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-api-start' }, + { + message: { id: 'msg-api-start' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-api-start', message: { id: 'msg-api-start' } }, + { headers }, + ) + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-api-start', ...headers }, + body: body(99), + }) + + const apiSend = sent.find( + (entry) => entry.headers.get('x-api-key') === 'api-start-key', + ) + expect(apiSend?.body).toMatchObject({ max_tokens: 99 }) + expect(apiSend?.body.thinking).toEqual({ + type: 'enabled', + budget_tokens: 10, + }) + expect(apiSend?.headers.has(LANE_START_REQUEST_HEADER)).toBe(false) + const metadata = await Promise.all( + (await readdir(dumpDir)) + .filter((file) => file.endsWith('.meta.json')) + .map( + async (file) => + JSON.parse(await readFile(join(dumpDir, file), 'utf8')) as { + tag?: string + }, + ), + ) + expect(metadata.some((entry) => entry.tag === 'start')).toBe(false) + } finally { + if (previousDumpDir === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = previousDumpDir + } + await rm(dumpDir, { recursive: true, force: true }) + } + }) + + test('claude-start shapes the OAuth fallback after an API-key failure', async () => { + await useTempAccountFile( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + accounts: [ + { + id: 'api-fails', + type: 'api', + apiKey: 'api-fails-key', + baseURL: 'https://api.example.test', + authHeader: 'x-api-key', + }, + { + id: 'fallback-1', + type: 'oauth', + access: 'fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: Date.now(), + }, + }, + }, + ], + quota: { + enabled: false, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('main-access'), + }, + }), + ) + const sent: Array<{ body: Record; headers: Headers }> = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + const headers = new Headers(init?.headers) + const body = JSON.parse(String(init?.body)) as Record + sent.push({ body, headers }) + if (headers.get('x-api-key') === 'api-fails-key') { + return Promise.resolve(new Response('{}', { status: 429 })) + } + return Promise.resolve( + new Response('{}', { + status: 200, + headers: + headers.get('authorization') === 'Bearer main-access' + ? { + 'anthropic-ratelimit-unified-representative-claim': + 'five_hour', + 'anthropic-ratelimit-unified-5h-utilization': '1', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + } + : undefined, + }), + ) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const body = JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + max_tokens: 99, + thinking: { type: 'enabled', budget_tokens: 10 }, + messages: [{ role: 'user', content: 'start' }], + }) + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-api-then-oauth' }, + { + message: { id: 'msg-api-then-oauth' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { + sessionID: 'ses-api-then-oauth', + message: { id: 'msg-api-then-oauth' }, + }, + { headers }, + ) + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-api-then-oauth', ...headers }, + body, + }) + + const apiSend = sent.find( + (entry) => entry.headers.get('x-api-key') === 'api-fails-key', + ) + const oauthSend = sent.find( + (entry) => + entry.headers.get('authorization') === 'Bearer fallback-access', + ) + expect(apiSend?.body).toMatchObject({ max_tokens: 99 }) + expect(apiSend?.body.thinking).toEqual({ + type: 'enabled', + budget_tokens: 10, + }) + expect(oauthSend?.body).toMatchObject({ max_tokens: 1, stream: true }) + expect(oauthSend?.body.thinking).toBeUndefined() + }) + + test('claude-start clears the one-shot header before non-OAuth passthrough and session reuse', async () => { + const seenHeaders: Headers[] = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + seenHeaders.push(new Headers(init?.headers)) + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-deleted' }, + { + message: { id: 'reused-message' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin.event({ + event: { + type: 'session.deleted', + properties: { sessionID: 'ses-deleted' }, + }, + }) + await plugin['chat.headers']( + { sessionID: 'ses-deleted', message: { id: 'reused-message' } }, + { headers }, + ) + expect(headers).toEqual({}) + + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { [LANE_START_REQUEST_HEADER]: '1' }, + body: JSON.stringify({ max_tokens: 99, thinking: { type: 'enabled' } }), + }) + expect(seenHeaders[0]?.has(LANE_START_REQUEST_HEADER)).toBe(false) + }) +}) + describe('cache diagnostics', () => { const originalFetch = globalThis.fetch const originalDateNow = Date.now diff --git a/packages/opencode/src/tests/lane-start.test.ts b/packages/opencode/src/tests/lane-start.test.ts new file mode 100644 index 00000000..755c6bd1 --- /dev/null +++ b/packages/opencode/src/tests/lane-start.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from 'bun:test' +import { + fireLaneStart, + LANE_START_REQUEST_HEADER, + LANE_START_TEXT, + LaneStartTracker, +} from '../lane-start' + +function startParts() { + return [{ type: 'text', text: LANE_START_TEXT, synthetic: true }] +} + +describe('fireLaneStart', () => { + test('sends the exact visible synthetic prompt with resolved context', async () => { + const calls: unknown[] = [] + await fireLaneStart( + { + session: { + messages: async () => ({ data: [] }), + get: async () => ({ + data: { + agent: 'build', + model: { + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + variant: 'high', + }, + }, + }), + promptAsync: async (request: unknown) => calls.push(request), + }, + }, + 'session-a', + ) + + expect(calls).toEqual([ + { + path: { id: 'session-a' }, + body: { + noReply: false, + parts: startParts(), + agent: 'build', + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }, + variant: 'high', + }, + }, + ]) + }) + + test('rejects when the plugin client cannot create the prompt', async () => { + await expect(fireLaneStart({ session: {} }, 'session-a')).rejects.toThrow( + 'OpenCode plugin client does not support session.promptAsync', + ) + }) +}) + +describe('LaneStartTracker', () => { + test('binds only the exact synthetic marker and consumes the matching header once', () => { + const tracker = new LaneStartTracker() + expect( + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: 'start-a', + parts: startParts(), + }), + ).toBe(true) + expect( + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: 'real-a', + parts: [{ type: 'text', text: LANE_START_TEXT }], + }), + ).toBe(false) + + const headers: Record = {} + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'real-a', + headers, + }), + ).toBe(false) + expect(headers).toEqual({}) + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'start-a', + headers, + }), + ).toBe(true) + expect(headers).toEqual({ [LANE_START_REQUEST_HEADER]: '1' }) + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'start-a', + headers: {}, + }), + ).toBe(false) + }) + + test('does not cross-mark concurrent sessions or an interleaved real turn', () => { + const tracker = new LaneStartTracker() + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: 'start-a', + parts: startParts(), + }) + tracker.observeSyntheticMessage({ + sessionId: 'session-b', + messageId: 'start-b', + parts: startParts(), + }) + + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'real-a', + headers: {}, + }), + ).toBe(false) + expect( + tracker.markHeaders({ + sessionId: 'session-b', + messageId: 'start-a', + headers: {}, + }), + ).toBe(false) + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'start-a', + headers: {}, + }), + ).toBe(true) + expect( + tracker.markHeaders({ + sessionId: 'session-b', + messageId: 'start-b', + headers: {}, + }), + ).toBe(true) + }) + + test('tracks multiple starts, evicts the oldest pending ID, and clears one session', () => { + const tracker = new LaneStartTracker() + for (let index = 0; index <= 1000; index++) { + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: + index === 0 ? 'first' : index === 1 ? 'second' : `start-${index}`, + parts: startParts(), + }) + } + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'first', + headers: {}, + }), + ).toBe(false) + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'second', + headers: {}, + }), + ).toBe(true) + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: 'second', + parts: startParts(), + }) + tracker.observeSyntheticMessage({ + sessionId: 'session-b', + messageId: 'other', + parts: startParts(), + }) + tracker.clearSession('session-a') + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'second', + headers: {}, + }), + ).toBe(false) + expect( + tracker.markHeaders({ + sessionId: 'session-b', + messageId: 'other', + headers: {}, + }), + ).toBe(true) + }) +}) diff --git a/packages/opencode/src/tests/prompt-context.test.ts b/packages/opencode/src/tests/prompt-context.test.ts new file mode 100644 index 00000000..62ebdfd1 --- /dev/null +++ b/packages/opencode/src/tests/prompt-context.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'bun:test' +import { resolvePromptContext } from '../prompt-context' + +describe('resolvePromptContext', () => { + test('falls back to empty-session metadata and normalizes modelID', async () => { + const context = await resolvePromptContext( + { + session: { + messages: async () => ({ data: [] }), + get: async () => ({ + data: { + agent: 'build', + model: { + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + variant: 'high', + }, + }, + }), + }, + }, + 'session-empty', + ) + + expect(context).toEqual({ + agent: 'build', + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }, + variant: 'high', + }) + }) + + test('accepts the session metadata id model shape', async () => { + const context = await resolvePromptContext( + { + session: { + messages: async () => ({ data: [] }), + get: async () => ({ + data: { + model: { + providerID: 'anthropic', + id: 'claude-opus-4-1', + variant: 'max', + }, + }, + }), + }, + }, + 'session-empty', + ) + + expect(context).toEqual({ + model: { providerID: 'anthropic', modelID: 'claude-opus-4-1' }, + variant: 'max', + }) + }) + + test('prefers message history while retaining metadata fields it does not supply', async () => { + const context = await resolvePromptContext( + { + session: { + messages: async () => ({ + data: [ + { + info: { + id: 'msg_2', + role: 'assistant', + agent: 'history-agent', + model: { + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + }, + }, + }, + ], + }), + get: async () => ({ + data: { + agent: 'metadata-agent', + model: { + providerID: 'anthropic', + modelID: 'claude-opus-4-1', + variant: 'high', + }, + }, + }), + }, + }, + 'session-history', + ) + + expect(context).toEqual({ + agent: 'history-agent', + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }, + variant: 'high', + latestAssistantMessageId: 'msg_2', + latestUserMessageId: undefined, + }) + }) + + test('uses metadata when message history fails and returns null when both fail', async () => { + const fallback = await resolvePromptContext( + { + session: { + messages: async () => { + throw new Error('unavailable') + }, + get: async () => ({ data: { agent: 'metadata-agent' } }), + }, + }, + 'session-fallback', + ) + expect(fallback).toEqual({ + agent: 'metadata-agent', + latestAssistantMessageId: undefined, + latestUserMessageId: undefined, + }) + + const missing = await resolvePromptContext( + { + session: { + messages: async () => { + throw new Error('unavailable') + }, + get: async () => { + throw new Error('unavailable') + }, + }, + }, + 'session-missing', + ) + expect(missing).toBeNull() + }) +}) diff --git a/packages/opencode/src/tests/relay.test.ts b/packages/opencode/src/tests/relay.test.ts index 0ed21908..05dbb09b 100644 --- a/packages/opencode/src/tests/relay.test.ts +++ b/packages/opencode/src/tests/relay.test.ts @@ -223,6 +223,7 @@ describe('relay client', () => { input: 'https://api.anthropic.com/v1/messages?beta=true', init: { method: 'POST' }, headers: headers('session relay/dump:alpha'), + dumpTag: 'start', body: JSON.stringify({ model: 'claude-sonnet-4-6', stream: true, @@ -249,9 +250,9 @@ describe('relay client', () => { expect(metaPath).toBeString() expect(bodyPath).toBeString() expect(relayPath).toBeString() - expect(metaPath).toInclude('session-relay-dump-alpha') - expect(bodyPath).toInclude('session-relay-dump-alpha') - expect(relayPath).toInclude('session-relay-dump-alpha') + expect(metaPath).toInclude('session-relay-dump-alpha-start-http-p1-') + expect(bodyPath).toInclude('session-relay-dump-alpha-start-http-p1-') + expect(relayPath).toInclude('session-relay-dump-alpha-start-http-p1-') const meta = JSON.parse( await readFile(`${getDumpDirectory()}/${metaPath}`, 'utf8'), @@ -262,6 +263,7 @@ describe('relay client', () => { systemCount: 2, cch: 'abcde', }) + expect(meta.tag).toBe('start') expect( await readFile(`${getDumpDirectory()}/${bodyPath}`, 'utf8'), ).toContain('first') diff --git a/packages/opencode/src/tests/start.test.ts b/packages/opencode/src/tests/start.test.ts new file mode 100644 index 00000000..55f28ccd --- /dev/null +++ b/packages/opencode/src/tests/start.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { + executeLaneStartCommand, + parseLaneStartCommandAction, +} from '@cortexkit/anthropic-auth-core' + +describe('claude-start command contract', () => { + test('bare and whitespace-only input queues a start turn', () => { + expect(parseLaneStartCommandAction('')).toEqual({ type: 'fire' }) + expect(parseLaneStartCommandAction(' \t')).toEqual({ type: 'fire' }) + expect(executeLaneStartCommand({ argumentsText: ' ' })).toEqual({ + action: { type: 'fire' }, + text: expect.stringContaining('Queued'), + }) + }) + + test('multiple or invalid arguments return usage', () => { + expect(parseLaneStartCommandAction('automatic now')).toEqual({ + type: 'usage', + }) + expect(parseLaneStartCommandAction('unknown')).toEqual({ type: 'usage' }) + const result = executeLaneStartCommand({ argumentsText: 'unknown' }) + expect(result.action).toEqual({ type: 'usage' }) + expect(result.text).toContain('Usage: `/claude-start`.') + }) +}) diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index 42116ea9..cf659152 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -87,6 +87,75 @@ describe('mergeHeaders', () => { }) }) +describe('lane start request shaping', () => { + const laneStartBody = ( + model: string, + thinking: unknown = { type: 'enabled' }, + ) => + JSON.stringify({ + model, + max_tokens: 512, + stream: true, + thinking, + output_config: { effort: 'high' }, + tools: [{ name: 'lookup', input_schema: { type: 'object' } }], + messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }], + cache_control: { type: 'ephemeral' }, + speed: 'fast', + }) + + test('sets one streaming token and strips thinking after model normalization', async () => { + const thinkingShapes = [ + { type: 'enabled' }, + { type: 'adaptive' }, + { type: 'summarized' }, + { type: 'disabled' }, + ] + for (const model of [ + 'claude-fable-5', + 'claude-sonnet-5', + 'claude-opus-5', + ]) { + for (const thinking of thinkingShapes) { + const result = JSON.parse( + await rewriteRequestBody(laneStartBody(model, thinking), { + laneStart: true, + }), + ) + expect(result.max_tokens).toBe(1) + expect(result.stream).toBe(true) + expect(result.model).toBe(model) + expect(result.thinking).toBeUndefined() + } + } + }) + + test('preserves prompt and request features governed by existing paths', async () => { + const result = JSON.parse( + await rewriteRequestBody(laneStartBody('claude-opus-4-8'), { + laneStart: true, + fastModeEnabled: true, + }), + ) + expect(result.output_config).toEqual({ effort: 'high' }) + expect(result.tools).toHaveLength(1) + expect(result.messages).toHaveLength(1) + expect(result.cache_control).toBeUndefined() + expect(result.speed).toBe('fast') + }) + + test('leaves ordinary requests unchanged and fails closed on invalid JSON', async () => { + const body = laneStartBody('claude-opus-4-8') + expect(await rewriteRequestBody(body)).not.toContain('"max_tokens":1') + expect(await rewriteRequestBody(body, { laneStart: false })).not.toContain( + '"max_tokens":1', + ) + expect(await rewriteRequestBody('{not-json', { laneStart: true })).toBe( + '{not-json', + ) + }) +}) + describe('mergeBetaHeaders', () => { test('includes required betas when no incoming betas', () => { const headers = new Headers() @@ -485,6 +554,87 @@ describe('isInsecure', () => { }) describe('createStrippedStream', () => { + test('rewrites the lane-start max_tokens finish as end_turn before completion', async () => { + const finishReasons: string[] = [] + const body = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'max_tokens' }, + usage: { output_tokens: 1 }, + }) + + const splitAt = body.indexOf('max_tokens') + 4 + const text = await createStrippedStream( + new Response( + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + controller.enqueue(encoder.encode(body.slice(0, splitAt))) + controller.enqueue(encoder.encode(body.slice(splitAt))) + controller.close() + }, + }), + ), + { + laneStart: true, + onComplete: (finishReason) => finishReasons.push(finishReason), + }, + ).text() + + expect(text).toContain('"stop_reason":"end_turn"') + expect(text).toContain('"output_tokens":1') + expect(finishReasons).toEqual(['end_turn']) + }) + + test('leaves API-key-served lane-start max_tokens finish unchanged', async () => { + const body = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'max_tokens' }, + usage: { output_tokens: 1 }, + }) + + const text = await createStrippedStream(new Response(body), { + laneStart: true, + laneStartOAuthServed: false, + }).text() + + expect(text).toBe(body) + }) + + test('leaves max_tokens bytes unchanged without the lane-start marker', async () => { + const finishReasons: string[] = [] + const body = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'max_tokens' }, + usage: { output_tokens: 1 }, + }) + + const text = await createStrippedStream(new Response(body), { + onComplete: (finishReason) => finishReasons.push(finishReason), + }).text() + + expect(text).toBe(body) + expect(finishReasons).toEqual(['max_tokens']) + }) + + test('preserves lane-start refusal handling', async () => { + let refusals = 0 + const body = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'refusal' }, + }) + + const text = await createStrippedStream(new Response(body), { + laneStart: true, + onContentFilter: () => { + refusals++ + return false + }, + }).text() + + expect(text).toBe(body) + expect(refusals).toBe(1) + }) + test('observes a split message_start envelope exactly once', async () => { const message = { id: 'msg_provider_1', diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index 181d18e9..f60d4867 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -1184,6 +1184,7 @@ export async function rewriteRequestBody( perf?: RewritePerfCallback hybridStandbyAnchor?: HybridMessageCacheAnchor serverSideFallbackEnabled?: boolean + laneStart?: boolean cacheDiagnosticsPreviousMessageId?: string | null } = {}, ): Promise { @@ -1244,6 +1245,11 @@ export async function rewriteRequestBody( hasOutputConfig: Object.hasOwn(parsed, 'output_config'), }) + if (options.laneStart === true) { + parsed.max_tokens = 1 + delete parsed.thinking + } + const billingStart = rewriteNowMs() const billingHeader = Array.isArray(parsed.messages) && @@ -1614,6 +1620,63 @@ function updateSseFinishState( return update } +type SseLaneStartFinishRewriteState = { + pending: string + disabled: boolean +} + +function createSseLaneStartFinishRewriteState(): SseLaneStartFinishRewriteState { + return { pending: '', disabled: false } +} + +function rewriteLaneStartFinishEvent(rawEvent: string) { + const summary = summarizeSseEvent(rawEvent) + if ( + summary?.type !== 'message_delta' || + summary.stopReason !== 'max_tokens' + ) { + return rawEvent + } + return rawEvent.replace(/("stop_reason"\s*:\s*)"max_tokens"/, '$1"end_turn"') +} + +function updateSseLaneStartFinishRewriteState( + state: SseLaneStartFinishRewriteState, + text: string, + maxPendingBytes: number, + flush = false, +) { + if (!text && !flush) return '' + if (state.disabled) return text + if ( + new TextEncoder().encode(state.pending + text).byteLength > maxPendingBytes + ) { + const passthrough = state.pending + text + state.pending = '' + state.disabled = true + return passthrough + } + state.pending += text + let rewritten = '' + while (true) { + const boundary = findSseBoundary(state.pending) + if (!boundary) break + const rawEvent = state.pending.slice(0, boundary.index) + const separator = state.pending.slice( + boundary.index, + boundary.index + boundary.length, + ) + state.pending = state.pending.slice(boundary.index + boundary.length) + rewritten += rewriteLaneStartFinishEvent(rawEvent) + rewritten += separator + } + if (flush && state.pending) { + rewritten += rewriteLaneStartFinishEvent(state.pending) + state.pending = '' + } + return rewritten +} + type RetryableAnthropicStreamError = Error & { code: 'ECONNRESET' syscall: 'anthropic-sse' @@ -1790,6 +1853,8 @@ export function createStrippedStream( onMessageResponse?: (message: Record) => void onStreamEnd?: () => void | Promise responseMode?: 'json' + laneStart?: boolean + laneStartOAuthServed?: boolean } = {}, ): Response { if (!response.body) return response @@ -1826,6 +1891,10 @@ export function createStrippedStream( options.onContentFilter || options.onComplete ? createSseFinishState() : undefined + const laneStartFinish = + options.laneStart && options.laneStartOAuthServed !== false + ? createSseLaneStartFinishRewriteState() + : undefined let contentFilterInvoked = false let contentFilterHandled = false const invokeContentFilter = (completedToolUse = false) => { @@ -1863,6 +1932,15 @@ export function createStrippedStream( if (update?.type === 'complete') options.onComplete?.(update.finishReason) return null } + const rewriteLaneStartFinish = (text: string, flush = false) => + laneStartFinish + ? updateSseLaneStartFinishRewriteState( + laneStartFinish, + text, + NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + flush, + ) + : text const releaseReader = () => { if (readerReleased) return @@ -1956,13 +2034,17 @@ export function createStrippedStream( ? serverSideFallback.push(finalDecoded) + serverSideFallback.flush() : finalDecoded + const laneStartRewritten = rewriteLaneStartFinish( + serverRewritten, + true, + ) const retryableStreamError = jsonMode - ? updateFinish(serverRewritten) + ? updateFinish(laneStartRewritten) : (updateSseErrorState( sseErrors, finalDecoded, NON_STREAMING_DIAGNOSTICS_MAX_BYTES, - ) ?? updateFinish(serverRewritten)) + ) ?? updateFinish(laneStartRewritten)) if (retryableStreamError) { logProgress('stream_tool_prefix_retryable_error', { error: retryableStreamError.message, @@ -1972,7 +2054,7 @@ export function createStrippedStream( throw retryableStreamError } const flushed = splitToolPrefixRewriteBuffer( - `${pending}${serverRewritten}`, + `${pending}${laneStartRewritten}`, true, ) rewriteMs += rewriteNowMs() - rewriteStart @@ -2022,13 +2104,14 @@ export function createStrippedStream( const serverRewritten = serverSideFallback ? serverSideFallback.push(decoded) : decoded + const laneStartRewritten = rewriteLaneStartFinish(serverRewritten) const retryableStreamError = jsonMode - ? updateFinish(serverRewritten) + ? updateFinish(laneStartRewritten) : (updateSseErrorState( sseErrors, decoded, NON_STREAMING_DIAGNOSTICS_MAX_BYTES, - ) ?? updateFinish(serverRewritten)) + ) ?? updateFinish(laneStartRewritten)) if (retryableStreamError) { logProgress('stream_tool_prefix_retryable_error', { error: retryableStreamError.message, @@ -2039,7 +2122,7 @@ export function createStrippedStream( releaseReader() throw retryableStreamError } - const text = pending + serverRewritten + const text = pending + laneStartRewritten const rewritten = splitToolPrefixRewriteBuffer(text) rewriteMs += rewriteNowMs() - rewriteStart pending = rewritten.pending