Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 26dd2c5a90 fix(claude): allow Auto mode on hosted providers
Claude Code supports Auto mode on Bedrock and Vertex, but Paseo removed it from the catalog and rejected explicit selections before reaching Claude Code. Remove that obsolete eligibility gate while preserving the existing default modes and SDK error handling.
2026-10-14 19:16:38 +02:00
paseo-bot[bot] b58af43a06 fix(app): render mixed HTML headings without literal tags (#6650)
Keep a matched heading together before splitting its image and text contents. Splitting first stranded the opening heading tag in a text slice without its closing tag.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 18:14:08 +02:00
truelecter 1bf3a57a1c fix(nix): raise Node heap for the desktop build (#6537)
Metro's transform worker runs out of heap on the generated ws-outbound.aot.js validator on machines where Node's default heap is 2 GB, such as the 7 GB macOS runner of the Nix workflow. Set NODE_OPTIONS=--max-old-space-size=4096 in the desktop derivation, as desktop-release.yml already does.

Closes #6535
2026-10-14 22:32:26 +07:00
6 changed files with 178 additions and 92 deletions
+6
View File
@@ -86,6 +86,12 @@ 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,6 +3,71 @@ 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,9 +125,20 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
continue;
}
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
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;
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
cursor = end;
}
@@ -259,15 +270,44 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
return false;
}
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (parseInlineImageAt(tokens, index)) {
if (
isOpenTag(tokens[index], "details") ||
findMixedHeadingClose(tokens, index) !== null ||
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;
@@ -0,0 +1,54 @@
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();
}
},
);
@@ -201,23 +201,6 @@ 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" },
@@ -293,40 +276,6 @@ 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,37 +947,11 @@ function isTruthyEnvValue(value: string | undefined): boolean {
);
}
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 claudeDefaultMode(env: NodeJS.ProcessEnv): PermissionMode {
return isTruthyEnvValue(env.CLAUDE_CODE_USE_BEDROCK) ||
isTruthyEnvValue(env.CLAUDE_CODE_USE_VERTEX)
? "default"
: "auto";
}
function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<AgentSessionConfig> {
@@ -1642,15 +1616,15 @@ export class ClaudeAgentClient implements AgentClient {
discoveredModelIds,
),
);
const modeCatalog = claudeModeCatalog(env);
return {
models,
...modeCatalog,
modes: DEFAULT_MODES,
defaultModeId: claudeDefaultMode(env),
};
}
async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise<string> {
return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId;
return claudeDefaultMode(this.buildProviderEnv(launchEnv));
}
private buildProviderEnv(launchEnv?: Record<string, string>): NodeJS.ProcessEnv {
@@ -2540,7 +2514,6 @@ 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);
@@ -3424,7 +3397,6 @@ 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(