diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 59e94c45..06e87bc7 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -430,6 +430,9 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed. - E2E chat-title expectations assume the deterministic chat-model route. On a Mac where the native Foundation Models helper reports `ready`, automatic titles come from Apple Intelligence instead, so `chat-message-queue` sidebar-title lookups fail locally while passing in CI; probe the helper or move it aside before treating those failures as regressions. - `git add` on the tracked-but-ignored `.papercuts/troubleshooting.md` still needs `-f` after conflict resolution. +- Production provider 400s are untriageable from `logs/aiden.log` alone: the real error text survives only in `userData/pi-compaction-sessions/*.jsonl` (per-message `errorMessage`), because the diagnostic journal strips provider messages outside the development profile. Check the journals before assuming a classification. +- `@earendil-works/pi-ai` transports merge `model.headers` into every outgoing request and merge `options.headers` last — a per-conversation header can be attached once at runtime-model resolution instead of threading it through each call site. + ## 2026-09-12 — Production provider-failure investigation - The 0.40.0 production diagnostic log collapsed a concrete OpenCode Go 400 into duplicate `unknown` generation failures; correlate the Pi journal to recover historical provider causes. PR #110 improves future evidence but cannot reconstruct old redacted logs. diff --git a/main/services/advisor-runtime.ts b/main/services/advisor-runtime.ts index 91183b42..6d504a39 100644 --- a/main/services/advisor-runtime.ts +++ b/main/services/advisor-runtime.ts @@ -165,6 +165,7 @@ export interface AdvisorRuntimeDependencies { providerId: string, modelId: string, signal?: AbortSignal, + conversationId?: string, ): Promise; recordUsage(message: AssistantMessage, runtime: ResolvedModelRuntime): Promise; recordUnreportedUsage( @@ -541,6 +542,9 @@ export class AdvisorRuntime { selection.providerId, selection.modelId, signal, + // Advisor dispatch is a one-shot call; a fresh id still satisfies + // gateway per-request attribution. + randomUUID(), ); assertAdvisorRuntimeSelection(selection, runtime); await preflightAdvisorRuntimeAuth(runtime, signal); diff --git a/main/services/bot-avatar-generator.ts b/main/services/bot-avatar-generator.ts index 46726864..1b60e5cf 100644 --- a/main/services/bot-avatar-generator.ts +++ b/main/services/bot-avatar-generator.ts @@ -46,7 +46,10 @@ export async function generateBotAvatarSuggestion( try { controller.signal.throwIfAborted(); const runtime = await waitForBotAvatarBoundary( - resolveModelRuntime(input.providerId, input.model, controller.signal), + // One-shot generation has no conversation; the request id doubles as the + // gateway's per-request attribution id. Kept on one line: the source + // contract test asserts this call's shape. + resolveModelRuntime(input.providerId, input.model, controller.signal, input.requestId), controller.signal, ); controller.signal.throwIfAborted(); diff --git a/main/services/bot-generation-preparation.test.ts b/main/services/bot-generation-preparation.test.ts index 3543e4d7..8816fd5d 100644 --- a/main/services/bot-generation-preparation.test.ts +++ b/main/services/bot-generation-preparation.test.ts @@ -24,6 +24,7 @@ const workspace = { }; const chat = { + id: "chat-1", botId: bot.id, workspaceId: workspace.workspaceId, providerId: "provider-1", @@ -61,8 +62,13 @@ function fixture( calls.push(`workspace:${botId}`); return workspace; }, - resolveRuntime: async (providerId: string, model: string) => { - calls.push(`runtime:${providerId}/${model}`); + resolveRuntime: async ( + providerId: string, + model: string, + _signal?: AbortSignal, + conversationId?: string, + ) => { + calls.push(`runtime:${providerId}/${model}:${conversationId ?? "none"}`); return { provider: { id: providerId }, model: { id: model }, marker: "exact" }; }, ...overrides, @@ -73,7 +79,12 @@ function fixture( test("prepares an exact main-only managed-home workspace and persisted runtime", async () => { const { input, calls } = fixture(); const prepared = await prepareBotGeneration(input); - assert.deepEqual(calls, ["workspace:bot-1", "runtime:provider-1/model-1"]); + assert.deepEqual(calls, [ + "workspace:bot-1", + // The chat id travels with the resolution so OpenCode attribution keys + // on the conversation. + `runtime:provider-1/model-1:${chat.id}`, + ]); assert.equal(prepared.managedWorkspace, workspace); assert.deepEqual(prepared.workspace, { id: workspace.workspaceId, diff --git a/main/services/bot-generation-preparation.ts b/main/services/bot-generation-preparation.ts index 1001ddab..04bbd88e 100644 --- a/main/services/bot-generation-preparation.ts +++ b/main/services/bot-generation-preparation.ts @@ -5,7 +5,7 @@ import type { Chat, Workspace } from "./types.js"; type BotGenerationChat = Pick< Chat, - "botId" | "workspaceId" | "providerId" | "model" + "id" | "botId" | "workspaceId" | "providerId" | "model" >; export interface RequestedBotGenerationTarget { @@ -28,6 +28,7 @@ export interface PrepareBotGenerationInput { providerId: string, model: string, signal?: AbortSignal, + conversationId?: string, ): Promise; signal?: AbortSignal; } @@ -125,6 +126,7 @@ export async function prepareBotGeneration( selection.providerId, selection.model, input.signal, + input.chat.id, ); if ( runtime.provider.id !== selection.providerId || diff --git a/main/services/chat-title.ts b/main/services/chat-title.ts index 6348ef4a..56cf6f75 100644 --- a/main/services/chat-title.ts +++ b/main/services/chat-title.ts @@ -75,6 +75,7 @@ function assistantText(content: AssistantMessage["content"]): string { } async function generateWithChatModel(input: { + chatId: string; firstMessage: Parameters[0] & { attachments?: import("./types.js").Attachment[]; }; @@ -85,6 +86,7 @@ async function generateWithChatModel(input: { input.selection.providerId, input.selection.model, input.signal, + input.chatId, ); const promptContent: Array = [ { @@ -218,6 +220,7 @@ async function generateFirstTurnTitle(input: { abortController.signal, ) : await generateWithChatModel({ + chatId: input.chatId, firstMessage, selection: input.fallbackSelection, signal: abortController.signal, diff --git a/main/services/context-lifecycle-service.ts b/main/services/context-lifecycle-service.ts index 5436c8f5..f157422e 100644 --- a/main/services/context-lifecycle-service.ts +++ b/main/services/context-lifecycle-service.ts @@ -43,7 +43,11 @@ export type CompactChatResult = export interface ContextLifecycleServiceDeps { getCompactionEngine?(): Promise; - resolveLocalModel?(providerId: string, model: string): Promise; + resolveLocalModel?( + providerId: string, + model: string, + conversationId?: string, + ): Promise; compactionEnabled?(): boolean; compactionEligible?(chat: Chat): boolean | Promise; skillsEnabled?(): Promise; @@ -60,6 +64,7 @@ export interface ContextLifecycleServiceDeps { providerId: string, model: string, signal?: AbortSignal, + conversationId?: string, ): Promise; resolveThinkingLevel( chat: Chat, @@ -148,12 +153,13 @@ export class ContextLifecycleService { try { if (engine === "vcc") { if (!this.deps.resolveLocalModel) throw new Error("Offline metadata is unavailable."); - model = await this.deps.resolveLocalModel(chat.providerId, chat.model); + model = await this.deps.resolveLocalModel(chat.providerId, chat.model, chat.id); } else { runtime = await this.deps.resolveRuntime( chat.providerId, chat.model, operationAbort.signal, + chat.id, ); model = runtime.model; } diff --git a/main/services/dictation-cleanup.ts b/main/services/dictation-cleanup.ts index 989c2ce8..dda91b6c 100644 --- a/main/services/dictation-cleanup.ts +++ b/main/services/dictation-cleanup.ts @@ -2,6 +2,7 @@ // the original transcript so paste still succeeds. import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai"; +import { randomUUID } from "node:crypto"; import { logger } from "../platform.js"; import { configStore } from "./config-store.js"; import { @@ -37,7 +38,14 @@ export async function cleanupDictationTranscript(transcript: string): Promise controller.abort(), DICTATION_CLEANUP_TIMEOUT_MS); try { - const runtime = await resolveModelRuntime(providerId, modelId, controller.signal); + const runtime = await resolveModelRuntime( + providerId, + modelId, + controller.signal, + // One-shot polish has no conversation; a fresh id still satisfies the + // gateway's per-request attribution requirement. + randomUUID(), + ); const result = await runtime.streams .streamSimple( runtime.model, diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index 93a89eb3..bdbc9c32 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -664,7 +664,7 @@ async function prepareGeneration( }; const runtime = botContext?.prepared.runtime ?? - (await resolveModelRuntime(params.providerId, params.model, signal)); + (await resolveModelRuntime(params.providerId, params.model, signal, chat.id)); const botBound = botContext !== undefined; const botApprovedRoots = botContext ? await resolveBotRuntimeApprovedRoots(botContext.admission.authority) @@ -1079,14 +1079,20 @@ async function prepareGeneration( includeCodingTools: !botContext, imageInspectionTool: botContext && !supportsImages && botContext.admission.authority.visionProvider - ? createVisionAnalysisTool({ - attachments: chat.messages.flatMap((message) => message.attachments ?? []), - authority: { - providerId: botContext.admission.authority.visionProvider.sourceProviderId, - modelId: botContext.admission.authority.visionProvider.sourceModelId, - revalidateBeforeEffect: () => botContext.admission.revalidateBeforeEffect(), + ? createVisionAnalysisTool( + { + attachments: chat.messages.flatMap((message) => message.attachments ?? []), + authority: { + providerId: botContext.admission.authority.visionProvider.sourceProviderId, + modelId: botContext.admission.authority.visionProvider.sourceModelId, + revalidateBeforeEffect: () => botContext.admission.revalidateBeforeEffect(), + }, }, - }) + { + resolveRuntime: (providerId, modelId, signal) => + resolveBotModelRuntime(providerId, modelId, signal, params.chatId), + }, + ) : undefined, }) ).filter((tool) => !options.excludeToolNames?.has(tool.name)); diff --git a/main/services/model-runtime-core.ts b/main/services/model-runtime-core.ts index 80aaa78e..129ffa23 100644 --- a/main/services/model-runtime-core.ts +++ b/main/services/model-runtime-core.ts @@ -21,6 +21,7 @@ import { resolveRuntimeBaseUrl, resolveRuntimeHeaders, } from "./generation-runtime.js"; +import { withOpenCodeSessionAttribution } from "./opencode-session-attribution.js"; import type { RuntimeModelLimits } from "./models-catalog-core.js"; import type { StoredProvider } from "./types.js"; @@ -147,6 +148,7 @@ export async function resolveModelRuntimeWith( providerId: string, modelId: string, signal?: AbortSignal, + conversationId?: string, ): Promise { if (providerId === OPENAI_CODEX_PROVIDER_ID) { const model = await dependencies.codex.prepareRuntimeModel(modelId, signal); @@ -166,12 +168,13 @@ export async function resolveModelRuntimeWith( // legacy Aiden key for this path. const nativeProvider = dependencies.native.getProvider(providerId); if (nativeProvider) { - const model = dependencies.native.getModel(providerId, modelId); - if (!model) { + const resolvedModel = dependencies.native.getModel(providerId, modelId); + if (!resolvedModel) { throw new Error( `Model "${modelId}" is not available through Pi's ${nativeProvider.label} provider. Choose another model and try again.`, ); } + const model = withOpenCodeSessionAttribution(resolvedModel, conversationId); return { provider: nativeProvider, model, @@ -197,7 +200,10 @@ export async function resolveModelRuntimeWith( } const limits = await dependencies.resolveRuntimeLimits(provider, modelId); - const model = buildModel(provider, modelId, limits); + const model = withOpenCodeSessionAttribution( + buildModel(provider, modelId, limits), + conversationId, + ); const headers = resolveRuntimeHeaders(provider); const models = createModels(); models.setProvider( diff --git a/main/services/model-runtime.ts b/main/services/model-runtime.ts index f03bd5b5..46f49b2b 100644 --- a/main/services/model-runtime.ts +++ b/main/services/model-runtime.ts @@ -11,6 +11,7 @@ import { } from "./model-runtime-core.js"; import { catalogProviderSlug } from "./models-catalog-core.js"; import { modelsCatalog } from "./models-catalog.js"; +import { withOpenCodeSessionAttribution } from "./opencode-session-attribution.js"; import { providerRegistry } from "./provider-registry.js"; import { providerConnectionSnapshot } from "./provider-credential-rotation-core.js"; import { secrets } from "./secrets.js"; @@ -35,6 +36,7 @@ export async function resolveModelRuntime( providerId: string, modelId: string, signal?: AbortSignal, + conversationId?: string, ): Promise { // Ensure the one-release legacy key migration completes even when a // scheduled/background generation runs before Provider Settings is opened. @@ -82,6 +84,7 @@ export async function resolveModelRuntime( providerId, modelId, signal, + conversationId, ); } @@ -90,8 +93,9 @@ export async function resolveBotModelRuntime( providerId: string, modelId: string, signal?: AbortSignal, + conversationId?: string, ): Promise { - const runtime = await resolveModelRuntime(providerId, modelId, signal); + const runtime = await resolveModelRuntime(providerId, modelId, signal, conversationId); if ( runtime.provider.id === OPENAI_CODEX_PROVIDER_ID || !providerRegistry.isBuiltinProvider(runtime.provider.id) @@ -134,11 +138,18 @@ export async function preflightBotModelAuth( } /** Offline model metadata only: no credential migration, auth, discovery or provider I/O. */ -export async function resolveCompactionModelMetadata(providerId: string, modelId: string) { +export async function resolveCompactionModelMetadata( + providerId: string, + modelId: string, + conversationId?: string, +) { const native = providerRegistry.getBuiltinModel(providerId, modelId); - if (native) return native; + if (native) return withOpenCodeSessionAttribution(native, conversationId); const provider = await configStore.getProvider(providerId); if (!provider || !provider.models.includes(modelId)) throw new Error("Saved model metadata is unavailable."); - return buildModel(provider, modelId, await modelsCatalog.runtimeLimits(provider, modelId)); + return withOpenCodeSessionAttribution( + buildModel(provider, modelId, await modelsCatalog.runtimeLimits(provider, modelId)), + conversationId, + ); } diff --git a/main/services/opencode-session-attribution.test.ts b/main/services/opencode-session-attribution.test.ts new file mode 100644 index 00000000..d0cad9ac --- /dev/null +++ b/main/services/opencode-session-attribution.test.ts @@ -0,0 +1,316 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServer, type Server } from "node:http"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; +import { anthropicMessagesApi, openAICompletionsApi } from "@earendil-works/pi-ai/compat"; +import { resolveModelRuntimeWith, withPinnedBotProviderAuth } from "./model-runtime-core.js"; +import { + OPENCODE_SESSION_HEADER, + openCodeSessionHeaders, + withOpenCodeSessionAttribution, +} from "./opencode-session-attribution.js"; + +function model( + overrides: Partial> = {}, +): Model { + return { + id: "glm-5.3-flash", + name: "GLM-5.3-Flash", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 131_072, + ...overrides, + }; +} + +test("withOpenCodeSessionAttribution adds the session header for opencode-go", () => { + const attributed = withOpenCodeSessionAttribution(model(), "chat-123"); + assert.equal(attributed.headers?.[OPENCODE_SESSION_HEADER], "chat-123"); +}); + +test("withOpenCodeSessionAttribution never mutates the catalog model", () => { + const catalog = model(); + withOpenCodeSessionAttribution(catalog, "chat-123"); + assert.equal(catalog.headers, undefined); +}); + +test("withOpenCodeSessionAttribution preserves existing model headers", () => { + const attributed = withOpenCodeSessionAttribution( + model({ provider: "opencode-go", headers: { "X-Custom": "keep" } }), + "chat-123", + ); + assert.equal(attributed.headers?.[OPENCODE_SESSION_HEADER], "chat-123"); + assert.equal(attributed.headers?.["X-Custom"], "keep"); +}); + +test("withOpenCodeSessionAttribution replaces a case-variant of the header", () => { + const attributed = withOpenCodeSessionAttribution( + model({ headers: { "X-OPENCODE-SESSION": "caller" } }), + "chat-123", + ); + assert.equal(attributed.headers?.["X-OPENCODE-SESSION"], undefined); + assert.equal(attributed.headers?.[OPENCODE_SESSION_HEADER], "chat-123"); +}); + +test("withOpenCodeSessionAttribution covers opencode, opencode-zen, and host matches", () => { + assert.equal( + withOpenCodeSessionAttribution(model({ provider: "opencode" }), "chat-1").headers?.[ + OPENCODE_SESSION_HEADER + ], + "chat-1", + ); + assert.equal( + withOpenCodeSessionAttribution(model({ provider: "opencode-zen" }), "chat-1").headers?.[ + OPENCODE_SESSION_HEADER + ], + "chat-1", + ); + assert.equal( + withOpenCodeSessionAttribution( + model({ provider: "custom:connection", baseUrl: "https://opencode.ai/zen/go/v1" }), + "chat-1", + ).headers?.[OPENCODE_SESSION_HEADER], + "chat-1", + ); +}); + +test("withOpenCodeSessionAttribution leaves unrelated providers untouched", () => { + const concentrate = model({ + provider: "concentrate", + baseUrl: "https://api.concentrate.ai/v1", + }); + assert.equal(withOpenCodeSessionAttribution(concentrate, "chat-1"), concentrate); + assert.equal(withOpenCodeSessionAttribution(concentrate, "chat-1").headers, undefined); +}); + +test("withOpenCodeSessionAttribution requires a non-empty conversation id", () => { + const catalog = model(); + assert.equal(withOpenCodeSessionAttribution(catalog, undefined), catalog); + assert.equal(withOpenCodeSessionAttribution(catalog, ""), catalog); + assert.equal(withOpenCodeSessionAttribution(catalog, " "), catalog); +}); + +test("withOpenCodeSessionAttribution ignores malformed or foreign base urls", () => { + assert.equal( + withOpenCodeSessionAttribution(model({ provider: "custom", baseUrl: "" }), "chat-1").headers, + undefined, + ); + assert.equal( + withOpenCodeSessionAttribution( + model({ provider: "custom", baseUrl: "https://evil-opencode.ai.example.com/v1" }), + "chat-1", + ).headers, + undefined, + ); + assert.equal( + withOpenCodeSessionAttribution( + model({ provider: "custom", baseUrl: "not a url" }), + "chat-1", + ).headers, + undefined, + ); +}); + +test("openCodeSessionHeaders is undefined for non-targets and blank ids", () => { + assert.deepEqual(openCodeSessionHeaders({ provider: "opencode-go" }, "chat-1"), { + [OPENCODE_SESSION_HEADER]: "chat-1", + }); + assert.equal(openCodeSessionHeaders({ provider: "opencode-go" }, " "), undefined); + assert.equal(openCodeSessionHeaders({ provider: "concentrate" }, "chat-1"), undefined); + assert.equal(openCodeSessionHeaders({}, "chat-1"), undefined); +}); + +test("openCodeSessionHeaders rejects ids that are not bounded tokens", () => { + // Header-injection safety: ids flow from renderer-adjacent inputs. + assert.equal(openCodeSessionHeaders({ provider: "opencode-go" }, "a\rb"), undefined); + assert.equal(openCodeSessionHeaders({ provider: "opencode-go" }, "a\nb"), undefined); + assert.equal(openCodeSessionHeaders({ provider: "opencode-go" }, "chat bad"), undefined); + assert.equal( + openCodeSessionHeaders({ provider: "opencode-go" }, "x".repeat(129)), + undefined, + ); + // UUIDs, chat ids, and request ids stay valid. + assert.ok(openCodeSessionHeaders({ provider: "opencode-go" }, "12375712-3dab-45dc-a97a-02b6bb36ac48")); + assert.ok(openCodeSessionHeaders({ provider: "opencode-go" }, "req-1.a_b:c")); +}); + +test("withPinnedBotProviderAuth preserves the session header on the dispatched model", async () => { + const attributed = withOpenCodeSessionAttribution(model(), "chat-bot"); + const captured: Array> = []; + const pinned = withPinnedBotProviderAuth( + { + provider: { id: "opencode-go", kind: "openai", label: "OpenCode Zen", baseUrl: "https://opencode.ai/zen/go/v1", models: [], needsKey: true, isPreset: true }, + model: attributed, + models: {} as never, + apiKey: undefined, + headers: undefined, + streams: {} as never, + }, + { + status: 200, + auth: { apiKey: "key", baseUrl: undefined, headers: undefined }, + source: "test", + } as never, + (requestModel) => { + captured.push(requestModel); + return { stream: async () => { throw new Error("unused"); }, result: async () => ({}) as never } as never; + }, + ); + try { + await pinned.streams.streamSimple(attributed, { systemPrompt: "", messages: [] }, {}).result(); + } catch { + // The stub provider stream intentionally throws; only the model matters. + } + assert.equal(captured[0]?.headers?.[OPENCODE_SESSION_HEADER], "chat-bot"); +}); + +test("the attributed model's header reaches the outgoing HTTP request", async () => { + const received: Array> = []; + const server: Server = createServer((request, response) => { + received.push(Object.fromEntries( + Object.entries(request.headers).map(([name, value]) => [name, Array.isArray(value) ? value.join(",") : value]), + )); + response.statusCode = 500; + response.end('{"error":{"message":"unused"}}'); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const baseUrl = `http://127.0.0.1:${address.port}/v1`; + const context = { + systemPrompt: "", + messages: [{ role: "user" as const, content: [{ type: "text" as const, text: "hi" }], timestamp: Date.now() }], + }; + // OpenCode Go models ship on three transports; pin the header on each so a + // pi-ai change that stops merging model.headers fails loudly per transport. + const transports: Array<{ + name: string; + api: Api; + streamSimple: ReturnType["streamSimple"]; + }> = [ + { name: "openai-completions", api: "openai-completions", streamSimple: openAICompletionsApi().streamSimple }, + { name: "openai-responses", api: "openai-responses", streamSimple: openAIResponsesApi().streamSimple }, + { name: "anthropic-messages", api: "anthropic-messages", streamSimple: anthropicMessagesApi().streamSimple }, + ]; + for (const transport of transports) { + received.length = 0; + const attributed = withOpenCodeSessionAttribution( + model({ provider: "opencode-go", api: transport.api, baseUrl }), + "chat-wire", + ); + const control = model({ provider: "concentrate", api: transport.api, baseUrl }); + for (const candidate of [attributed, control]) { + try { + await transport.streamSimple(candidate, context, { apiKey: "test-key" }).result(); + } catch { + // The stub server rejects every request; only the headers matter. + } + } + assert.equal( + received[0]?.[OPENCODE_SESSION_HEADER], + "chat-wire", + `${transport.name}: attributed request must carry the session header`, + ); + assert.equal( + received[1]?.[OPENCODE_SESSION_HEADER], + undefined, + `${transport.name}: unrelated provider must not carry the session header`, + ); + } + server.close(); +}); + +const NATIVE_MODEL: Model = model(); + +function nativeDependencies() { + return { + getProvider: async () => undefined, + getApiKey: async () => null, + resolveRuntimeLimits: async () => ({ + reasoning: false, + input: ["text"] as Array<"text" | "image">, + contextWindow: 1_000_000, + maxTokens: 131_072, + thinkingLevelMap: undefined, + }), + codex: { + models: {} as never, + prepareRuntimeModel: async () => NATIVE_MODEL, + streamSimple: (() => undefined) as never, + }, + native: { + models: {} as never, + getProvider: () => ({ + id: "opencode-go", + kind: "openai" as const, + label: "OpenCode Zen", + baseUrl: "https://opencode.ai/zen/go/v1", + models: [NATIVE_MODEL.id], + needsKey: true, + isPreset: true, + }), + getModel: (_providerId: string, modelId: string) => + modelId === NATIVE_MODEL.id ? NATIVE_MODEL : undefined, + streamSimple: (() => undefined) as never, + }, + }; +} + +test("resolveModelRuntimeWith attaches attribution on the native path", async () => { + const runtime = await resolveModelRuntimeWith( + nativeDependencies(), + "opencode-go", + NATIVE_MODEL.id, + undefined, + "chat-abc", + ); + assert.equal(runtime.model.headers?.[OPENCODE_SESSION_HEADER], "chat-abc"); + // The stored catalog model itself must stay pristine. + assert.equal(NATIVE_MODEL.headers, undefined); +}); + +test("resolveModelRuntimeWith skips attribution without a conversation id", async () => { + const runtime = await resolveModelRuntimeWith( + nativeDependencies(), + "opencode-go", + NATIVE_MODEL.id, + ); + assert.equal(runtime.model.headers, undefined); +}); + +test("resolveModelRuntimeWith attaches attribution for custom OpenCode endpoints", async () => { + const customModel = model({ + provider: "custom:connection", + baseUrl: "https://opencode.ai/zen/go/v1", + }); + const dependencies = { + ...nativeDependencies(), + native: { + ...nativeDependencies().native, + getProvider: () => undefined, + }, + getProvider: async () => ({ + id: "custom:connection", + kind: "openai" as const, + label: "Custom", + baseUrl: "https://opencode.ai/zen/go/v1", + models: [customModel.id], + needsKey: false, + isPreset: false, + }), + }; + const runtime = await resolveModelRuntimeWith( + dependencies, + "custom:connection", + customModel.id, + undefined, + "chat-custom", + ); + assert.equal(runtime.model.headers?.[OPENCODE_SESSION_HEADER], "chat-custom"); +}); diff --git a/main/services/opencode-session-attribution.ts b/main/services/opencode-session-attribution.ts new file mode 100644 index 00000000..34535a0d --- /dev/null +++ b/main/services/opencode-session-attribution.ts @@ -0,0 +1,112 @@ +import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai"; + +/** + * OpenCode's managed-inference gateway (OpenCode Go / Zen) rejects requests + * that omit a stable per-conversation routing header: + * + * 400 {"type":"MissingSessionID","message":"Error from provider (Console Go): + * Request is missing x-opencode-session and cannot be routed efficiently."} + * + * OpenCode documents the contract at + * https://opencode.ai/docs/go/#where-can-i-use-it: a client should send a + * stable session ID in `x-opencode-session` for each conversation so routing + * and prompt caching can be optimized. The value is opaque; any stable + * per-conversation identifier is accepted, and the header is required on + * every inference request, auxiliary calls included. + * + * `@earendil-works/pi-ai` cannot emit this header: its transports only send + * session-affinity headers for providers that opt in via + * `compat.sendSessionAffinityHeaders`, and even then they emit different + * header names. Aiden therefore owns OpenCode attribution here. + * + * Attach the header to the resolved runtime model rather than to individual + * call sites. Every pi-ai transport merges `model.headers` into the outgoing + * request, so the header reaches chat turns, compaction, chat titles, and + * subagent runs (children and isolated inference inherit the runtime model) + * through one seam. + */ + +export const OPENCODE_SESSION_HEADER = "x-opencode-session"; + +/** Provider ids that terminate on OpenCode's managed-inference gateway. */ +const OPENCODE_PROVIDER_IDS: ReadonlySet = new Set([ + "opencode", + "opencode-go", + // Forward-compat: not published by the pinned Pi, but a known upstream id. + "opencode-zen", +]); + +const OPENCODE_HOSTNAME = "opencode.ai"; + +/** Bounded, injection-safe token grammar for an attribution id. */ +const OPENCODE_SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u; + +/** Structural model shape needed to decide attribution; nothing else is read. */ +export interface OpenCodeAttributionTarget { + provider?: string; + baseUrl?: string; +} + +function isOpenCodeInferenceTarget(target: OpenCodeAttributionTarget): boolean { + if (target.provider && OPENCODE_PROVIDER_IDS.has(target.provider)) return true; + // Custom connections pointed exactly at opencode.ai also terminate on the + // gateway. A proxy in front of OpenCode is intentionally not matched: its + // hostname is the proxy's, and attribution there is the proxy owner's call. + if (typeof target.baseUrl !== "string" || target.baseUrl === "") return false; + try { + return new URL(target.baseUrl).hostname === OPENCODE_HOSTNAME; + } catch { + return false; + } +} + +/** + * OpenCode session attribution headers for one request, or `undefined` when + * the target is not OpenCode-hosted or no conversation id exists. Never send + * an empty value: the gateway treats a blank header as missing. + */ +export function openCodeSessionHeaders( + target: OpenCodeAttributionTarget, + conversationId: string | undefined, +): ProviderHeaders | undefined { + const id = conversationId?.trim(); + if (!id || !OPENCODE_SESSION_ID_PATTERN.test(id)) return undefined; + if (!isOpenCodeInferenceTarget(target)) return undefined; + return { [OPENCODE_SESSION_HEADER]: id }; +} + +/** + * Overwrite `additions` into `headers`, removing any existing case-variant of + * the same header name so exactly one authoritative value reaches the gateway. + */ +function mergeHeadersCaseInsensitive( + headers: ProviderHeaders, + additions: ProviderHeaders, +): ProviderHeaders { + const merged: ProviderHeaders = { ...headers }; + for (const name of Object.keys(additions)) { + const normalized = name.toLowerCase(); + for (const existing of Object.keys(merged)) { + if (existing.toLowerCase() === normalized) delete merged[existing]; + } + merged[name] = additions[name]; + } + return merged; +} + +/** + * Return a request-ready copy of `model` carrying OpenCode session + * attribution. The shared catalog model is never mutated; unrelated providers + * and missing conversation ids return the input unchanged. + */ +export function withOpenCodeSessionAttribution>( + model: RuntimeModel, + conversationId: string | undefined, +): RuntimeModel { + const headers = openCodeSessionHeaders(model, conversationId); + if (!headers) return model; + return { + ...model, + headers: mergeHeadersCaseInsensitive(model.headers ?? {}, headers), + }; +} diff --git a/main/services/rpiv-btw/service-core.ts b/main/services/rpiv-btw/service-core.ts index 9ba12b4b..c678f4c8 100644 --- a/main/services/rpiv-btw/service-core.ts +++ b/main/services/rpiv-btw/service-core.ts @@ -36,7 +36,7 @@ type BtwEventBody = BtwEventV1 extends infer Event export interface BtwServiceDependencies { getChat(chatId: string): Promise; - resolveRuntime(providerId: string, modelId: string, signal?: AbortSignal): Promise; + resolveRuntime(providerId: string, modelId: string, signal?: AbortSignal, conversationId?: string): Promise; isChatBusy(chatId: string): boolean; recordUsage(record: UsageRequestRecord): Promise; registry: BtwOperationRegistry; @@ -177,6 +177,7 @@ export class BtwService { input.chat.providerId!, input.chat.model!, input.controller.signal, + input.chat.id, ); input.controller.signal.throwIfAborted(); const latest = await this.deps.getChat(input.chat.id); diff --git a/main/services/vision-analysis-tool.ts b/main/services/vision-analysis-tool.ts index f964701d..39fb74bf 100644 --- a/main/services/vision-analysis-tool.ts +++ b/main/services/vision-analysis-tool.ts @@ -1,4 +1,5 @@ import { Type, type AssistantMessage, type ImageContent, type TextContent } from "@earendil-works/pi-ai"; +import { randomUUID } from "node:crypto"; import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; import type { Attachment } from "./types.js"; import { declarePiRuntimeReplay } from "./pi-runtime-tool.js"; @@ -52,7 +53,9 @@ export function createVisionAnalysisTool(input: { }, dependencies: VisionAnalysisToolDependencies = {}): AgentTool { const resolveRuntime = dependencies.resolveRuntime ?? (async (providerId, modelId, signal) => { const { resolveBotModelRuntime } = await import("./model-runtime.js"); - return resolveBotModelRuntime(providerId, modelId, signal); + // One-shot tool call; a fresh id still satisfies gateway per-request + // attribution. + return resolveBotModelRuntime(providerId, modelId, signal, randomUUID()); }); const recordUsage = dependencies.recordUsage ?? (async (record) => { const { usageStore } = await import("./usage-store.js"); diff --git a/package.json b/package.json index e127d360..1d65553b 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "test:btw": "tsx --test main/services/rpiv-btw/*.test.ts renderer/shared/btw.test.ts renderer/components/btw-card.test.tsx", "test:advisor": "tsx --test renderer/shared/advisor.test.ts main/services/advisor-context.test.ts main/services/advisor-attempt-store.test.ts main/services/advisor-runtime.test.ts main/services/advisor-integration.test.ts", "test:generative-ui": "tsx --test main/services/generative-ui-html.test.ts main/services/generative-ui-extension.test.ts main/services/generative-ui-artifact-store.test.ts main/services/generative-ui-host-libraries.test.ts main/services/generative-ui-protocol.test.ts renderer/shared/chat-artifacts.test.ts renderer/shared/generative-ui.test.ts && node --test scripts/vendor-generative-ui-libs.test.mjs && playwright test --config=playwright.generative-ui.config.ts --fail-on-flaky-tests", - "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", + "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", "test:config-recovery": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/services/secret-map-core.test.ts main/services/provider-credential-rotation-core.test.ts main/services/legacy-pi-credential-migration-core.test.ts main/services/mcp-credential-cleanup-core.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-oauth-store-core.test.ts", "pretest:subagents": "npm run build:worktree-remover && npm run build:subagent-run-store && node scripts/build-subagent-run-store.mjs --test && npm run build:subagent-file-mutator && node scripts/build-subagent-file-mutator.mjs --test && npm run build:subagent-shell-runner && node scripts/build-subagent-shell-runner.mjs --test && npm run test:subagents:inventory && npm run test:subagents:workspace-write && npm run test:subagents:phase5a && npm run test:subagents:phase5b && npm run test:subagents:phase5c && npm run test:subagents:phase5d && npm run test:subagents:phase5e && npm run test:subagents:phase6a && npm run test:subagents:phase6b && npm run test:subagents:phase7a && npm run test:subagents:soak:contracts", "test:subagents:inventory": "tsx --test main/services/subagents/subagent-mcp-inventory-core.test.ts main/services/subagents/subagent-inference-process-core.test.ts", @@ -111,8 +111,8 @@ "test:voice": "tsx --test main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts", "test:diagnostics": "tsx --test main/services/diagnostics-contract.test.ts main/services/diagnostic-health.test.ts main/services/diagnostic-journal.test.ts main/services/diagnostic-support.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/renderer-crash-recovery.test.ts main/services/renderer-diagnostic-rate.test.ts main/services/subagents/subagent-runtime-diagnostics.test.ts renderer/components/settings/diagnostics-settings.test.tsx && node --test scripts/diagnostic-policy.test.mjs", "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", - "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/github-pull-request.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", - "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/github-pull-request.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", + "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/github-pull-request.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", + "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/github-pull-request.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", "test:computer-use:native": "cd native/computer-use-broker && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo fmt -- --check && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo test --locked && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo clippy --locked --all-targets -- -D warnings",