mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26dd2c5a90 | ||
|
|
b58af43a06 | ||
|
|
1bf3a57a1c |
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user