Skip to content
2 changes: 1 addition & 1 deletion packages/ai/src/native-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function resolveNativeTool(spec: CuaNativeToolSpec, model: Model<Api>, 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}"`);
Expand Down
12 changes: 7 additions & 5 deletions packages/ai/src/providers/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] };
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/src/providers/openai/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/src/providers/tzafon/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function buildTzafonRequestInput(model: Model<Api>, 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 };
}
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/test/native-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"/,
);
});
});
Expand Down
19 changes: 19 additions & 0 deletions packages/ai/test/openai-threading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,25 @@ describe("openai threadRequest", () => {
expect(((await onPayload({}, model)) as Record<string, unknown>).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<string, unknown>).previous_response_id).toBeUndefined();
});

it("composes a caller onPayload on top of the threaded payload", async () => {
const { onPayload } = threadRequest(multiTurnContext(), {
onPayload: (payload) => ({ wrapped: payload }),
Expand Down
19 changes: 19 additions & 0 deletions packages/ai/test/tzafon-threading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/action/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
}
2 changes: 1 addition & 1 deletion packages/cli/src/action/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,6 +30,8 @@ import {
formatRelativeAge,
listNamedSessions,
type NamedSessionMetadata,
readNamedSession,
recordSessionModel,
recordTranscriptPath,
shortKernelId,
startNamedSession,
Expand Down Expand Up @@ -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<HarnessRuntime> {
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 });
Expand All @@ -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 {
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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`);
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/harness-named-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> {
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;
}
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/test/action-result.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
67 changes: 67 additions & 0 deletions packages/cli/test/cli-harness-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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();
});
});
Loading
Loading