diff --git a/packages/ai/src/native-tools.ts b/packages/ai/src/native-tools.ts index fa8832af..99c2dbeb 100644 --- a/packages/ai/src/native-tools.ts +++ b/packages/ai/src/native-tools.ts @@ -99,7 +99,7 @@ export function resolveNativeTool(spec: CuaNativeToolSpec, model: Model, mo const info = NATIVE_TOOL_INFO[spec.type]; if (!info) throw new Error(`unknown native tool type "${(spec as { type: string }).type}"`); if (model.provider !== info.provider) { - throw new Error(`native tool "${spec.type}" requires an ${info.provider} model; got provider "${model.provider}"`); + throw new Error(`native tool "${spec.type}" requires an ${info.provider} model paired with mode "${info.mode}"; got provider "${model.provider}"`); } if (mode !== info.mode) { throw new Error(`native tool "${spec.type}" requires mode "${info.mode}"; got "${mode}"`); diff --git a/packages/ai/src/providers/common.ts b/packages/ai/src/providers/common.ts index 6430fae2..3b667703 100644 --- a/packages/ai/src/providers/common.ts +++ b/packages/ai/src/providers/common.ts @@ -303,17 +303,19 @@ export interface ResponseThreadingDelta { * Anchors on the most recent assistant turn: returns its `responseId` and the * messages after it (the delta). An errored or aborted turn may carry a * `responseId` captured from an incomplete response the server never stored, so - * its id is ignored. When the anchor has no usable `responseId`, or there is no - * assistant turn yet, returns every message and no id so the caller replays the - * full history, rather than chaining to a phantom id and pruning past it. + * its id is ignored. A turn produced by a different `api` (e.g. after a + * mid-session `-m` provider switch) is ignored too — its id would be foreign to + * the current provider. When the anchor has no usable `responseId`, or there is + * no assistant turn yet, returns every message and no id so the caller replays + * the full history, rather than chaining to a phantom id and pruning past it. */ -export function responseThreadingDelta(messages: readonly Message[]): ResponseThreadingDelta { +export function responseThreadingDelta(messages: readonly Message[], api: Api): ResponseThreadingDelta { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]!; if (message.role !== "assistant") continue; const assistant = message as AssistantMessage; const failed = assistant.stopReason === "error" || assistant.stopReason === "aborted"; - const responseId = failed ? undefined : assistant.responseId; + const responseId = failed || assistant.api !== api ? undefined : assistant.responseId; return responseId ? { previousResponseId: responseId, deltaMessages: messages.slice(index + 1) } : { deltaMessages: [...messages] }; } return { deltaMessages: [...messages] }; diff --git a/packages/ai/src/providers/openai/provider.ts b/packages/ai/src/providers/openai/provider.ts index cb8bc75d..bca616a3 100644 --- a/packages/ai/src/providers/openai/provider.ts +++ b/packages/ai/src/providers/openai/provider.ts @@ -29,7 +29,7 @@ export function threadRequest( context: Context, options: (ResponseThreadingOptions & { onPayload?: OnPayload }) | undefined, ): { context: Context; onPayload: OnPayload } { - const delta = responseThreadingEnabled(options) ? responseThreadingDelta(context.messages) : undefined; + const delta = responseThreadingEnabled(options) ? responseThreadingDelta(context.messages, OPENAI_CUA_RESPONSES_API) : undefined; const previousResponseId = delta?.previousResponseId; const messages = previousResponseId && delta ? delta.deltaMessages : context.messages; const onPayload: OnPayload = async (payload, model) => { diff --git a/packages/ai/src/providers/tzafon/provider.ts b/packages/ai/src/providers/tzafon/provider.ts index db64cc90..95f31de9 100644 --- a/packages/ai/src/providers/tzafon/provider.ts +++ b/packages/ai/src/providers/tzafon/provider.ts @@ -76,7 +76,7 @@ export function buildTzafonRequestInput(model: Model, context: Context, opt max_output_tokens: options?.maxTokens ?? model.maxTokens, }; if (!responseThreadingEnabled(options)) return body; - const { previousResponseId, deltaMessages } = responseThreadingDelta(context.messages); + const { previousResponseId, deltaMessages } = responseThreadingDelta(context.messages, TZAFON_RESPONSES_API); if (!previousResponseId) return body; return { ...body, input: convertMessages(deltaMessages), previous_response_id: previousResponseId, store: true }; } diff --git a/packages/ai/test/native-tools.test.ts b/packages/ai/test/native-tools.test.ts index fa815fc7..399bc498 100644 --- a/packages/ai/test/native-tools.test.ts +++ b/packages/ai/test/native-tools.test.ts @@ -35,7 +35,7 @@ describe("native tool validation", () => { it("rejects native tools on non-anthropic models", () => { expect(() => resolveCuaRuntimeSpec("openai:gpt-5.5", { nativeTool: { type: "computer_20260701" } })).toThrow( - /requires an anthropic model/, + /requires an anthropic model paired with mode "computer"/, ); }); }); diff --git a/packages/ai/test/openai-threading.test.ts b/packages/ai/test/openai-threading.test.ts index 7ba25602..d4821d06 100644 --- a/packages/ai/test/openai-threading.test.ts +++ b/packages/ai/test/openai-threading.test.ts @@ -96,6 +96,25 @@ describe("openai threadRequest", () => { expect(((await onPayload({}, model)) as Record).previous_response_id).toBeUndefined(); }); + it("never anchors previous_response_id on an assistant turn from a different api", async () => { + const ctx = multiTurnContext(); + // A mid-session -m provider switch leaves the prior provider's turn (and its foreign id) as the anchor. + ctx.messages.push({ + role: "assistant", + content: [{ type: "text", text: "done" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-opus-4-8", + responseId: "msg_anthropic", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", + timestamp: 0, + }); + const { context, onPayload } = threadRequest(ctx, undefined); + expect(context).toBe(ctx); + expect(((await onPayload({}, model)) as Record).previous_response_id).toBeUndefined(); + }); + it("composes a caller onPayload on top of the threaded payload", async () => { const { onPayload } = threadRequest(multiTurnContext(), { onPayload: (payload) => ({ wrapped: payload }), diff --git a/packages/ai/test/tzafon-threading.test.ts b/packages/ai/test/tzafon-threading.test.ts index e7ba1f15..959b55c5 100644 --- a/packages/ai/test/tzafon-threading.test.ts +++ b/packages/ai/test/tzafon-threading.test.ts @@ -115,6 +115,25 @@ describe("buildTzafonRequestInput response threading", () => { expect(screenshotImageUrls(body.input)).toHaveLength(TURNS); }); + it("replays full history when the latest assistant turn is from a different api", () => { + const context = multiTurnContext(); + context.messages.push({ + role: "assistant", + content: [{ type: "text", text: "done" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-opus-4-8", + responseId: "msg_anthropic", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", + timestamp: 0, + }); + + const body = tzafon.buildTzafonRequestInput(model, context); + expect(body.previous_response_id).toBeUndefined(); + expect(screenshotImageUrls(body.input)).toHaveLength(TURNS); + }); + // Off-path screenshot count scales with turn count; on-path stays constant at one. it("grows the payload per turn when off but stays flat when on", () => { const counts = (turns: number, disable: boolean) => { diff --git a/packages/cli/src/action/prompts.ts b/packages/cli/src/action/prompts.ts index 6772c0b8..18b552a5 100644 --- a/packages/cli/src/action/prompts.ts +++ b/packages/cli/src/action/prompts.ts @@ -90,5 +90,5 @@ Be concise and factual. Do NOT perform any actions. Only observe and respond.`; } function urlPrompt(): string { - return `Report the current page URL. Use the url action to read it. Do not perform any other actions.`; + return `Report the current page URL. Use the url action to read it. Do not perform any other actions. Respond with only the bare URL, no markdown or other formatting.`; } diff --git a/packages/cli/src/action/result.ts b/packages/cli/src/action/result.ts index 61b81e36..e656b5cd 100644 --- a/packages/cli/src/action/result.ts +++ b/packages/cli/src/action/result.ts @@ -94,7 +94,7 @@ function extractFirstUrl(text: string): string | undefined { /(?:https?:\/\/\S+|about:blank|file:\/\/\S+|chrome:\/\/\S+|chrome-extension:\/\/\S+|edge:\/\/\S+|brave:\/\/\S+)/gi, ); if (!matches || matches.length === 0) return undefined; - return matches[matches.length - 1]!.replace(/[),.;!?]+$/, ""); + return matches[matches.length - 1]!.replace(/[)*_`,.;!?]+$/, ""); } export function formatCompact(r: ActionResult): string { diff --git a/packages/cli/src/cli-harness.ts b/packages/cli/src/cli-harness.ts index 9c028047..51a36c27 100644 --- a/packages/cli/src/cli-harness.ts +++ b/packages/cli/src/cli-harness.ts @@ -12,6 +12,7 @@ import { type CuaNativeToolSpec, parseCuaModelRef, requireCuaEnvApiKey, + resolveCuaRuntimeSpec, } from "@onkernel/cua-ai"; import { parseArgs } from "node:util"; import { stderr, stdout } from "node:process"; @@ -29,6 +30,8 @@ import { formatRelativeAge, listNamedSessions, type NamedSessionMetadata, + readNamedSession, + recordSessionModel, recordTranscriptPath, shortKernelId, startNamedSession, @@ -362,10 +365,24 @@ export interface SetupHarnessRuntimeOptions { skipDiskSession?: boolean; } +/** Default -m/--mode/--native-tool from a named session's stored values when not passed explicitly. */ +export function applyNamedSessionDefaults(flags: HarnessCliFlags, meta: NamedSessionMetadata): HarnessCliFlags { + return { + ...flags, + model: flags.model ?? meta.model, + mode: flags.mode ?? meta.mode, + nativeTool: flags.nativeTool ?? meta.native_tool, + }; +} + async function setupHarnessRuntime( flags: HarnessCliFlags, opts: SetupHarnessRuntimeOptions = {}, ): Promise { + if (flags.namedSession) { + const named = await readNamedSession(flags.namedSession); + if (named) flags = applyNamedSessionDefaults(flags, named); + } const auth = resolveAuth(flags); const cwd = process.cwd(); const env = new NodeExecutionEnv({ cwd }); @@ -380,6 +397,7 @@ async function setupHarnessRuntime( // never leaves an orphaned browser behind. const mode = parseMode(flags.mode); const nativeTool = parseNativeTool(flags.nativeTool); + resolveCuaRuntimeSpec(auth.modelRef, { mode, nativeTool }); const provisioned = await provisionForFlags(flags, auth); try { @@ -429,6 +447,11 @@ async function finishHarnessRuntime( }); if (provisioned.named) { await recordTranscriptPath(provisioned.named.name, resolved.transcriptPath); + await recordSessionModel(provisioned.named.name, { + model: auth.modelRef, + mode: flags.mode, + native_tool: flags.nativeTool, + }); } if (flags.verbose) { stderr.write(`[cua] session=${resolved.transcriptPath}\n`); @@ -653,6 +676,7 @@ export async function runSessionSubcommand(args: string[], flags: HarnessCliFlag browserTimeoutSeconds: flags.browserTimeout, profileSelector: flags.browserProfile, saveProfileChanges: flags.profileSaveChanges, + model: flags.model ? resolveCuaModelRef(flags.model) : undefined, }); stdout.write(`name=${meta.name}\n`); stdout.write(`kernel_session_id=${browser.session_id}\n`); diff --git a/packages/cli/src/harness-named-sessions.ts b/packages/cli/src/harness-named-sessions.ts index 9b703d3d..663b91b9 100644 --- a/packages/cli/src/harness-named-sessions.ts +++ b/packages/cli/src/harness-named-sessions.ts @@ -19,6 +19,10 @@ export interface NamedSessionMetadata { live_url?: string; profile_id?: string; transcript_path?: string; + /** Model ref last used with this session; chained invocations without -m default to it. */ + model?: string; + mode?: string; + native_tool?: string; created_at: number; } @@ -99,6 +103,8 @@ export interface StartNamedSessionOptions { /** Profile id or name (created if missing). Same semantics as `--profile`. */ profileSelector?: string; saveProfileChanges?: boolean; + /** Canonical model ref to seed the session with (same semantics as `-m`). */ + model?: string; } export interface StartNamedSessionResult { @@ -138,6 +144,7 @@ export async function startNamedSession(opts: StartNamedSessionOptions): Promise kernel_session_id: browser.session_id, live_url: browser.browser_live_view_url, profile_id: profileId, + model: opts.model, created_at: Date.now(), }; const metadataPath = await writeNamedSession(meta); @@ -232,6 +239,20 @@ export async function recordTranscriptPath(name: string, transcriptPath: string) await writeNamedSession(meta); } +/** Persist the model/mode/native-tool used with a named session so chained invocations reuse them. */ +export async function recordSessionModel( + name: string, + runtime: { model: string; mode?: string; native_tool?: string }, +): Promise { + const meta = await readNamedSession(name); + if (!meta) return; + if (meta.model === runtime.model && meta.mode === runtime.mode && meta.native_tool === runtime.native_tool) return; + meta.model = runtime.model; + meta.mode = runtime.mode; + meta.native_tool = runtime.native_tool; + await writeNamedSession(meta); +} + export function shortKernelId(id: string): string { return id.length > 10 ? `${id.slice(0, 8)}…` : id; } diff --git a/packages/cli/test/action-result.test.ts b/packages/cli/test/action-result.test.ts new file mode 100644 index 00000000..f1b25d11 --- /dev/null +++ b/packages/cli/test/action-result.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { parseResult } from "../src/action/result"; + +describe("parseResult url extraction", () => { + it("passes through a plain url", () => { + const res = parseResult("url", "https://quotes.toscrape.com/page/2/", [], 10); + expect(res.status).toBe("ok"); + expect(res.url).toBe("https://quotes.toscrape.com/page/2/"); + }); + + it("strips markdown bold markers", () => { + const res = parseResult("url", "**https://quotes.toscrape.com/page/2/**", [], 10); + expect(res.url).toBe("https://quotes.toscrape.com/page/2/"); + }); + + it("strips wrapping backticks", () => { + const res = parseResult("url", "`https://example.com/path`", [], 10); + expect(res.url).toBe("https://example.com/path"); + }); + + it("strips trailing punctuation", () => { + const res = parseResult("url", "The current URL is https://example.com/page.", [], 10); + expect(res.url).toBe("https://example.com/page"); + }); +}); diff --git a/packages/cli/test/cli-harness-validation.test.ts b/packages/cli/test/cli-harness-validation.test.ts new file mode 100644 index 00000000..c8f20ce3 --- /dev/null +++ b/packages/cli/test/cli-harness-validation.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type HarnessCliFlags, runActionCommand } from "../src/cli-harness"; +import { provisionBrowser } from "../src/harness-browser"; + +vi.mock("../src/harness-browser", () => ({ provisionBrowser: vi.fn() })); + +function flagsWith(overrides: Partial): HarnessCliFlags { + return { + verbose: false, + profileSaveChanges: false, + continueLatest: false, + resumePicker: false, + noSession: true, + noSkills: true, + debugTui: false, + jsonlIncludeDeltas: false, + jsonlIncludeImages: false, + playwright: false, + skillPaths: [], + ...overrides, + }; +} + +describe("mode/native-tool validation before provisioning", () => { + beforeEach(() => { + vi.stubEnv("KERNEL_API_KEY", "test-kernel-key"); + vi.stubEnv("ANTHROPIC_API_KEY", "test-anthropic-key"); + vi.stubEnv("OPENAI_API_KEY", "test-openai-key"); + vi.stubEnv("GOOGLE_API_KEY", "test-google-key"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.mocked(provisionBrowser).mockClear(); + }); + + it("rejects a native tool whose mode conflicts without provisioning a browser", async () => { + await expect( + runActionCommand("url", [], flagsWith({ + model: "anthropic:claude-opus-4-8", + mode: "hybrid", + nativeTool: "computer_20260701", + })), + ).rejects.toThrow('native tool "computer_20260701" requires mode "computer"; got "hybrid"'); + expect(provisionBrowser).not.toHaveBeenCalled(); + }); + + it("rejects a native tool on a non-anthropic model without provisioning a browser", async () => { + await expect( + runActionCommand("url", [], flagsWith({ + model: "openai:gpt-5.5", + nativeTool: "computer_20260701", + })), + ).rejects.toThrow('native tool "computer_20260701" requires an anthropic model paired with mode "computer"; got provider "openai"'); + expect(provisionBrowser).not.toHaveBeenCalled(); + }); + + it("rejects an unsupported provider/mode pair without provisioning a browser", async () => { + await expect( + runActionCommand("url", [], flagsWith({ + model: "google:gemini-3-flash-preview", + mode: "browser", + })), + ).rejects.toThrow('provider "google" does not support mode "browser" (computer only)'); + expect(provisionBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/harness-named-sessions.test.ts b/packages/cli/test/harness-named-sessions.test.ts new file mode 100644 index 00000000..f8db3597 --- /dev/null +++ b/packages/cli/test/harness-named-sessions.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyNamedSessionDefaults, type HarnessCliFlags } from "../src/cli-harness"; +import { + type NamedSessionMetadata, + readNamedSession, + recordSessionModel, + writeNamedSession, +} from "../src/harness-named-sessions"; + +const originalXdg = process.env.XDG_DATA_HOME; + +function baseMeta(overrides: Partial = {}): NamedSessionMetadata { + return { name: "foo", kernel_session_id: "ks_123", created_at: Date.now(), ...overrides }; +} + +function baseFlags(overrides: Partial = {}): HarnessCliFlags { + return { + verbose: false, + profileSaveChanges: false, + continueLatest: false, + resumePicker: false, + noSession: false, + noSkills: false, + debugTui: false, + jsonlIncludeDeltas: false, + jsonlIncludeImages: false, + playwright: false, + namedSession: "foo", + skillPaths: [], + ...overrides, + }; +} + +describe("named session model persistence", () => { + beforeEach(() => { + process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "cua-cli-named-")); + }); + + afterEach(() => { + if (originalXdg === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = originalXdg; + }); + + it("records the model/mode/native-tool onto the metadata file", async () => { + await writeNamedSession(baseMeta()); + await recordSessionModel("foo", { model: "anthropic:claude-opus-4-8", mode: "hybrid", native_tool: "computer_20260701" }); + const meta = await readNamedSession("foo"); + expect(meta?.model).toBe("anthropic:claude-opus-4-8"); + expect(meta?.mode).toBe("hybrid"); + expect(meta?.native_tool).toBe("computer_20260701"); + }); + + it("overwrites a previously recorded model on an explicit switch", async () => { + await writeNamedSession(baseMeta({ model: "openai:gpt-5.5" })); + await recordSessionModel("foo", { model: "anthropic:claude-opus-4-8", mode: "hybrid" }); + const meta = await readNamedSession("foo"); + expect(meta?.model).toBe("anthropic:claude-opus-4-8"); + expect(meta?.mode).toBe("hybrid"); + }); + + it("is a no-op for an unknown session", async () => { + await recordSessionModel("missing", { model: "openai:gpt-5.5" }); + expect(await readNamedSession("missing")).toBeUndefined(); + }); + + it("defaults flags from the stored session model when -m is omitted", () => { + const meta = baseMeta({ model: "anthropic:claude-opus-4-8", mode: "hybrid", native_tool: "computer_20260701" }); + const flags = applyNamedSessionDefaults(baseFlags(), meta); + expect(flags.model).toBe("anthropic:claude-opus-4-8"); + expect(flags.mode).toBe("hybrid"); + expect(flags.nativeTool).toBe("computer_20260701"); + }); + + it("keeps explicit flags over stored session values", () => { + const meta = baseMeta({ model: "anthropic:claude-opus-4-8", mode: "hybrid" }); + const flags = applyNamedSessionDefaults(baseFlags({ model: "openai:gpt-5.5", mode: "browser" }), meta); + expect(flags.model).toBe("openai:gpt-5.5"); + expect(flags.mode).toBe("browser"); + }); +});