Compare commits

..
8 changed files with 251 additions and 178 deletions
-6
View File
@@ -86,12 +86,6 @@ buildNpmPackage {
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
# Metro's worker runs out of heap transforming the generated
# ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is
# 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB
# macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this
# only raises the limit on smaller builders, like desktop-release.yml does.
NODE_OPTIONS = "--max-old-space-size=4096";
};
buildPhase = ''
@@ -3,71 +3,6 @@ import { describe, expect, it } from "vitest";
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
describe("splitHtmlishMarkdown", () => {
it("renders heading text beside a linked picture without literal heading tags", () => {
const source =
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
alt: "Retrigger",
src: "https://example.com/l.svg",
href: "https://example.com/r",
flowsWithText: true,
},
{ kind: "markdown", text: "Confidence Score: 5/5" },
]);
});
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
"keeps a mixed %s heading together after preceding markdown",
(heading) => {
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
expect(splitHtmlishMarkdown(source)).toEqual([
{ kind: "markdown", text: "Intro\n\n" },
{
kind: "inlineImage",
alt: "Icon",
src: "https://example.com/icon.svg",
flowsWithText: true,
},
{ kind: "markdown", text: "Caption\n\nOutro" },
]);
},
);
it("preserves formatted text before and after images in a details heading", () => {
const source =
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "details",
summary: "More",
body: "**Before***After*",
bodyParts: [
{ kind: "markdown", text: "**Before**" },
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
{ kind: "markdown", text: "*After*" },
],
},
]);
});
it("keeps mixed HTML headings literal in code examples", () => {
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("preserves unknown wrappers around text-only headings", () => {
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
{ kind: "markdown", text: "<section>Heading</section>" },
]);
});
const inlineImageBody = [
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
"",
@@ -125,20 +125,9 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
continue;
}
if (isHeadingTag(token)) {
const closeIndex = findMixedHeadingClose(tokens, cursor);
if (closeIndex !== null) {
// Headings render as their contents. Split those contents together so an
// embedded image cannot strand the opening heading tag in a text slice.
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
appendDisplayPart(parts, part);
}
cursor = closeIndex + 1;
continue;
}
}
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
cursor = end;
}
@@ -270,44 +259,15 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
return false;
}
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (
isOpenTag(tokens[index], "details") ||
findMixedHeadingClose(tokens, index) !== null ||
parseInlineImageAt(tokens, index)
) {
if (parseInlineImageAt(tokens, index)) {
return index;
}
}
return null;
}
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
const token = tokens[start];
if (!isHeadingTag(token)) {
return null;
}
const closeIndex = findMatchingClose(tokens, start, token.name);
if (closeIndex === null) {
return null;
}
for (let index = start + 1; index < closeIndex; index += 1) {
if (parseInlineImageAt(tokens, index)) {
return closeIndex;
}
}
return null;
}
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
if (part.kind === "markdown") {
appendMarkdownPart(parts, part.text);
} else {
parts.push(part);
}
}
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
if (!text) {
return;
@@ -1,54 +0,0 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
const hostedEnvironments = [
{ transport: "Bedrock", env: { CLAUDE_CODE_USE_BEDROCK: "1" } },
{ transport: "Vertex", env: { CLAUDE_CODE_USE_VERTEX: "true" } },
];
test.each(hostedEnvironments)(
"offers Auto mode on $transport while preserving its default mode",
async ({ env }) => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env },
resolveVersion: async () => "2.1.263",
discoverModels: async () => [],
});
const catalog = await client.fetchCatalog({ cwd: process.cwd() });
expect(catalog.modes?.map((mode) => mode.id)).toContain("auto");
expect(catalog.defaultModeId).toBe("default");
},
);
test.each(hostedEnvironments)(
"passes an explicit Auto selection on $transport to Claude Code",
async ({ env }) => {
const sdkError = new Error("Claude Code rejected this request");
let requestedMode: string | undefined;
let requestedEnv: NodeJS.ProcessEnv | undefined;
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env },
resolveBinary: async () => "/test/claude",
queryFactory: ({ options }) => {
requestedMode = options.permissionMode;
requestedEnv = options.env;
throw sdkError;
},
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await expect(session.setMode("auto")).rejects.toBe(sdkError);
expect(requestedMode).toBe("auto");
expect(requestedEnv).toMatchObject(env);
} finally {
await session.close();
}
},
);
@@ -0,0 +1,78 @@
import { afterEach, expect, test } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentTimelineItem } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
const sessions: AgentSession[] = [];
const directories: string[] = [];
afterEach(async () => {
for (const session of sessions.splice(0)) await session.close();
for (const cwd of directories.splice(0)) rmSync(cwd, { recursive: true, force: true });
});
test("a marked progress update stays assistant text when the session is reopened", async () => {
const markedTexts = new Set<string>();
const client = new ClaudeAgentClient({
logger: pino(
{ level: "trace" },
{
write(line: string) {
const record = JSON.parse(line);
const event = record.rawEvent;
if (event?.type !== "assistant") return;
for (const index of event.narration_block_indexes ?? []) {
const text = event.message.content[index]?.thinking;
if (text) markedTexts.add(text);
}
},
},
),
});
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-progress-replay-"));
directories.push(cwd);
for (const [name, text] of [
["a", "Plan A\nCost: 3 credits\nTime: 2 days\nRisk: needs a manual review step\n"],
["b", "Plan B\nCost: 5 credits\nTime: 1 day\nRisk: none known\n"],
["c", "Plan C\nCost: 2 credits\nTime: 4 days\nRisk: depends on an external vendor\n"],
])
writeFileSync(path.join(cwd, `plan-${name}.txt`), text);
const session = await client.createSession({
provider: "claude",
cwd,
model: "claude-opus-5-5",
thinkingOptionId: "xhigh",
modeId: "bypassPermissions",
});
sessions.push(session);
await new Promise<void>((resolve, reject) => {
session.subscribe((event) => {
if (event.type === "turn_failed") reject(new Error(event.error));
if (event.type === "permission_requested" || event.type === "turn_completed") resolve();
});
void session
.startTurn(
"There are three plan files in this directory: plan-a.txt, plan-b.txt and plan-c.txt. Read each one, tell me how they compare on cost, time and risk, and then ask me which plan to pick using your AskUserQuestion tool. Keep me posted as you go.",
)
.catch(reject);
});
expect(
markedTexts.size,
"provider must produce a marked update for this reproduction",
).toBeGreaterThan(0);
const persistence = session.describePersistence();
expect(persistence).not.toBeNull();
await session.close();
sessions.splice(sessions.indexOf(session), 1);
const resumed = await client.resumeSession(persistence!, { cwd });
sessions.push(resumed);
const items: AgentTimelineItem[] = [];
for await (const event of resumed.streamHistory()) {
if (event.type === "timeline") items.push(event.item);
}
for (const text of markedTexts) {
expect(items).toContainEqual(expect.objectContaining({ type: "assistant_message", text }));
}
}, 280_000);
@@ -0,0 +1,81 @@
import { afterEach, expect, test } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
// Reproduces #6199 through the real Claude provider. Claude Opus 5.5 writes the
// text it means for the user between tool calls as a "progress update": a
// thinking block that Claude Code marks with `narration_block_indexes`.
const PROMPT =
"There are three plan files in this directory: plan-a.txt, plan-b.txt and plan-c.txt. Read each one, tell me how they compare on cost, time and risk, and then ask me which plan to pick using your AskUserQuestion tool. Keep me posted as you go.";
const handles: Array<{ cwd: string; session: AgentSession }> = [];
afterEach(async () => {
for (const handle of handles.splice(0)) {
await handle.session.close().catch(() => undefined);
rmSync(handle.cwd, { recursive: true, force: true });
}
});
function writePlans(cwd: string): void {
writeFileSync(
path.join(cwd, "plan-a.txt"),
"Plan A\nCost: 3 credits\nTime: 2 days\nRisk: needs a manual review step\n",
);
writeFileSync(
path.join(cwd, "plan-b.txt"),
"Plan B\nCost: 5 credits\nTime: 1 day\nRisk: none known\n",
);
writeFileSync(
path.join(cwd, "plan-c.txt"),
"Plan C\nCost: 2 credits\nTime: 4 days\nRisk: depends on an external vendor\n",
);
}
test("the comparison written before AskUserQuestion reaches the timeline as assistant text", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "claude-progress-updates-"));
writePlans(cwd);
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
const session = await client.createSession({
provider: "claude",
cwd,
model: "claude-opus-5-5",
thinkingOptionId: "xhigh",
modeId: "bypassPermissions",
});
handles.push({ cwd, session });
const items: AgentTimelineItem[] = [];
await new Promise<void>((resolve, reject) => {
session.subscribe((event: AgentStreamEvent) => {
if (event.type === "timeline") items.push(event.item);
if (event.type === "turn_failed") reject(new Error(event.error));
if (
event.type === "permission_requested" ||
event.type === "turn_completed" ||
event.type === "turn_failed"
) {
resolve();
}
});
void session.startTurn(PROMPT).catch(reject);
});
const lastReadIndex = items.findLastIndex(
(item) => item.type === "tool_call" && item.name !== "AskUserQuestion",
);
const askIndex = items.findIndex(
(item) => item.type === "tool_call" && item.name === "AskUserQuestion",
);
expect(askIndex).toBeGreaterThan(lastReadIndex);
const between = items.slice(lastReadIndex + 1, askIndex);
const assistantText = between
.flatMap((item) => (item.type === "assistant_message" ? [item.text] : []))
.join("");
expect(assistantText).toMatch(/plan/i);
}, 280_000);
@@ -201,6 +201,23 @@ test("exposes and applies auto permission mode", async () => {
}
});
test("rejects auto mode when Claude Code uses Bedrock", async () => {
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
const session = await createSession();
try {
await expect(session.setMode("auto")).rejects.toThrow(
"Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses Bedrock",
);
expect(sdkQueryFactory).not.toHaveBeenCalled();
} finally {
restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock);
await session.close();
}
});
test.each([
{ env: {}, expected: "auto" },
{ env: { CLAUDE_CODE_USE_BEDROCK: "1" }, expected: "default" },
@@ -276,6 +293,40 @@ test("allows launch env to disable inherited Bedrock transport for auto mode", a
}
});
test("fails an auto mode turn when Claude Code uses Vertex", async () => {
const previousVertex = process.env.CLAUDE_CODE_USE_VERTEX;
process.env.CLAUDE_CODE_USE_VERTEX = "true";
sdkQueryFactory.mockImplementation(() => {
throw new Error("query should not start");
});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdkQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "auto",
});
try {
const events = await collectUntilTerminal(streamSession(session, "hello"));
const failure = events.find(
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
event.type === "turn_failed",
);
expect(failure?.error).toContain(
"Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses Vertex",
);
expect(sdkQueryFactory).not.toHaveBeenCalled();
} finally {
restoreEnvValue("CLAUDE_CODE_USE_VERTEX", previousVertex);
await session.close();
}
});
test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => {
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
@@ -947,11 +947,37 @@ function isTruthyEnvValue(value: string | undefined): boolean {
);
}
function claudeDefaultMode(env: NodeJS.ProcessEnv): PermissionMode {
return isTruthyEnvValue(env.CLAUDE_CODE_USE_BEDROCK) ||
isTruthyEnvValue(env.CLAUDE_CODE_USE_VERTEX)
? "default"
: "auto";
function claudeAutoModeUnavailableOn(env: NodeJS.ProcessEnv): "Bedrock" | "Vertex" | null {
if (isTruthyEnvValue(env.CLAUDE_CODE_USE_BEDROCK)) {
return "Bedrock";
}
if (isTruthyEnvValue(env.CLAUDE_CODE_USE_VERTEX)) {
return "Vertex";
}
return null;
}
function assertClaudeModeCanRun(mode: PermissionMode, env: NodeJS.ProcessEnv): void {
if (mode !== "auto") {
return;
}
const transport = claudeAutoModeUnavailableOn(env);
if (transport === null) {
return;
}
throw new Error(
`Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses ${transport}. Select another permission mode or unset the ${transport === "Bedrock" ? "CLAUDE_CODE_USE_BEDROCK" : "CLAUDE_CODE_USE_VERTEX"} environment variable.`,
);
}
function claudeModeCatalog(env: NodeJS.ProcessEnv): {
modes: AgentMode[];
defaultModeId: PermissionMode;
} {
if (claudeAutoModeUnavailableOn(env)) {
return { modes: DEFAULT_MODES.filter((mode) => mode.id !== "auto"), defaultModeId: "default" };
}
return { modes: DEFAULT_MODES, defaultModeId: "auto" };
}
function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<AgentSessionConfig> {
@@ -1616,15 +1642,15 @@ export class ClaudeAgentClient implements AgentClient {
discoveredModelIds,
),
);
const modeCatalog = claudeModeCatalog(env);
return {
models,
modes: DEFAULT_MODES,
defaultModeId: claudeDefaultMode(env),
...modeCatalog,
};
}
async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise<string> {
return claudeDefaultMode(this.buildProviderEnv(launchEnv));
return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId;
}
private buildProviderEnv(launchEnv?: Record<string, string>): NodeJS.ProcessEnv {
@@ -2514,6 +2540,7 @@ class ClaudeAgentSession implements AgentSession {
}
const normalized = isPermissionMode(modeId) ? modeId : "default";
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
const previousMode = this.currentMode;
const launchesQuery = !this.query || this.queryRestartNeeded;
const activeQuery = await this.ensureQuery(normalized);
@@ -3397,6 +3424,7 @@ class ClaudeAgentSession implements AgentSession {
);
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv);
const claudeBinary = await this.resolveBinary();
this.logger.debug(