mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ad19a2b6 | ||
|
|
c49c664b7d | ||
|
|
3e83f433da | ||
|
|
674530152e | ||
|
|
736bbc62c6 | ||
|
|
825476c6a5 | ||
|
|
3e225cf8a9 | ||
|
|
259e9069e1 | ||
|
|
df1a055f51 | ||
|
|
47a6bfdbdb |
@@ -3,7 +3,7 @@ import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
fillComposerDraft,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendDraftWhenReady(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await pressInComposer(page, "Enter");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await submitMessage(page, text);
|
||||
await fillComposerDraft(page, text);
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
|
||||
|
||||
interface ScriptedTurn {
|
||||
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
|
||||
errorReply?: string;
|
||||
// Assistant text the model wrote before or after it.
|
||||
before?: string;
|
||||
after?: string;
|
||||
isError: boolean;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCostUsd: number;
|
||||
};
|
||||
}
|
||||
|
||||
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
|
||||
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
|
||||
return ({ prompt }) => {
|
||||
let index = 0;
|
||||
const assistant = (id: string, text: string, error?: string) => ({
|
||||
type: "assistant",
|
||||
session_id: "error-session",
|
||||
parent_tool_use_id: null,
|
||||
uuid: id,
|
||||
message: {
|
||||
id,
|
||||
role: "assistant",
|
||||
model: error ? "<synthetic>" : "claude-opus-5-5",
|
||||
content: [{ type: "text", text }],
|
||||
stop_reason: "stop_sequence",
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
...(error ? { error } : {}),
|
||||
});
|
||||
const messages = (async function* () {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
for await (const _message of prompt) {
|
||||
const turn = turns[index];
|
||||
index += 1;
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "error-session",
|
||||
permissionMode: "default",
|
||||
model: "claude-opus-5-5",
|
||||
};
|
||||
if (turn.before) yield assistant(`before-${index}`, turn.before);
|
||||
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
|
||||
if (turn.after) yield assistant(`after-${index}`, turn.after);
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "error-session",
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 0,
|
||||
is_error: turn.isError,
|
||||
num_turns: 1,
|
||||
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
|
||||
stop_reason: "stop_sequence",
|
||||
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
|
||||
usage: {
|
||||
input_tokens: turn.usage?.inputTokens ?? 0,
|
||||
output_tokens: turn.usage?.outputTokens ?? 0,
|
||||
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: `result-${index}`,
|
||||
};
|
||||
}
|
||||
})();
|
||||
return Object.assign(messages, {
|
||||
close: () => {
|
||||
void messages.return();
|
||||
},
|
||||
interrupt: async () => undefined,
|
||||
setModel: async () => undefined,
|
||||
setPermissionMode: async () => undefined,
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
}) as ReturnType<ClaudeQueryFactory>;
|
||||
};
|
||||
}
|
||||
|
||||
async function runTurns(turns: ScriptedTurn[]) {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: scriptedQuery(turns),
|
||||
resolveBinary: async () => process.execPath,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
try {
|
||||
const results = [];
|
||||
for (const [index] of turns.entries()) {
|
||||
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
|
||||
return events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
|
||||
return events.find(
|
||||
(event) =>
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled",
|
||||
);
|
||||
}
|
||||
|
||||
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
|
||||
const [limited, overloaded, next] = await runTurns([
|
||||
{ errorReply: LIMIT, isError: true },
|
||||
{
|
||||
before: "Checking the deploy doc.",
|
||||
errorReply: "API Error: 529 Overloaded",
|
||||
isError: true,
|
||||
},
|
||||
{ after: "Done.", isError: false },
|
||||
]);
|
||||
|
||||
expect(terminal(limited)).toMatchObject({
|
||||
type: "turn_failed",
|
||||
error: LIMIT,
|
||||
});
|
||||
expect(assistantTexts(limited)).toEqual([]);
|
||||
|
||||
expect(terminal(overloaded)).toMatchObject({
|
||||
type: "turn_failed",
|
||||
error: "API Error: 529 Overloaded",
|
||||
});
|
||||
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
|
||||
|
||||
// The session keeps working after a failed turn.
|
||||
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(next)).toEqual(["Done."]);
|
||||
});
|
||||
|
||||
test("a failed turn retains its final token counts and cost", async () => {
|
||||
const usage = {
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 80,
|
||||
outputTokens: 30,
|
||||
totalCostUsd: 0.012,
|
||||
};
|
||||
const [failed] = await runTurns([
|
||||
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
|
||||
]);
|
||||
|
||||
expect(failed).toContainEqual(
|
||||
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
|
||||
);
|
||||
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
|
||||
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
|
||||
});
|
||||
|
||||
test("an error reply the turn moves past is still shown", async () => {
|
||||
const [recovered, completed] = await runTurns([
|
||||
{
|
||||
errorReply: "API Error: 529 Overloaded",
|
||||
after: "Recovered.",
|
||||
isError: false,
|
||||
},
|
||||
{ errorReply: LIMIT, isError: false },
|
||||
]);
|
||||
|
||||
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
|
||||
|
||||
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(completed)).toEqual([LIMIT]);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { constants as bufferConstants } from "node:buffer";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
|
||||
|
||||
await expect(importSession(sessionId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("reads the rename from a transcript too large to hold as one string", async () => {
|
||||
const sessionId = "oversized-transcript";
|
||||
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
// A hole stands in for the long session this used to fail on: the file only
|
||||
// has to be longer than a string can be, and the rename sits at its end.
|
||||
await fs.writeFile(transcript, "");
|
||||
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
|
||||
await fs.appendFile(
|
||||
transcript,
|
||||
`\n${[
|
||||
userRecord({ sessionId, content: "Trace the slow query" }),
|
||||
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Slow query hunt");
|
||||
});
|
||||
|
||||
test("reads the rename from behind more records than one read covers", async () => {
|
||||
const sessionId = "late-records-rename";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "Review this project" }),
|
||||
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
|
||||
// One record longer than a single read, then enough records to leave the
|
||||
// rename several reads back from the end of the file.
|
||||
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
|
||||
...Array.from({ length: 64 }, (_, index) =>
|
||||
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Scrollback review");
|
||||
});
|
||||
|
||||
test("imports a large record after the last rename without repeatedly copying it", async () => {
|
||||
const sessionId = "large-record-after-rename";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
|
||||
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Large attachment review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2389,6 +2389,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private mainTurnInFlight = false;
|
||||
private foregroundHasVisibleActivity = false;
|
||||
private activeTurnHasAssistantText = false;
|
||||
/**
|
||||
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
|
||||
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
|
||||
* failed turn shows the same text as its error, so showing both would repeat it.
|
||||
*/
|
||||
private withheldErrorReply: AgentTimelineItem[] = [];
|
||||
private readonly contextUsage: ClaudeContextUsageState;
|
||||
private userMessageIds: string[] = [];
|
||||
private readonly emittedUserMessageIds = new Set<string>();
|
||||
@@ -2531,6 +2537,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.foregroundHasVisibleActivity = false;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.withheldErrorReply = [];
|
||||
this.contextUsage.beginTurn();
|
||||
this.transitionTurnState("foreground", "foreground turn started");
|
||||
this.clearRecentStderr();
|
||||
@@ -4419,6 +4426,21 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}) satisfies AgentStreamEvent,
|
||||
);
|
||||
|
||||
if (!readClaudeParentToolUseId(message)) {
|
||||
if (message.type === "assistant" && message.error) {
|
||||
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
|
||||
return messageEvents;
|
||||
}
|
||||
if (
|
||||
message.type === "assistant" ||
|
||||
message.type === "user" ||
|
||||
message.type === "stream_event"
|
||||
) {
|
||||
const released: AgentStreamEvent[] = [];
|
||||
this.releaseWithheldErrorReply(released);
|
||||
return [...released, ...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
}
|
||||
return [...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
|
||||
@@ -4966,6 +4988,23 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const usage = this.convertUsage(message);
|
||||
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
|
||||
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
|
||||
if (message.subtype === "success" && message.is_error) {
|
||||
const withheld = this.withheldErrorReply;
|
||||
this.withheldErrorReply = [];
|
||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||
const withheldText = withheld
|
||||
.map((item) => (item.type === "assistant_message" ? item.text : ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
|
||||
events.push(...this.sidechainTracker.finishAll("failed"));
|
||||
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
|
||||
return;
|
||||
}
|
||||
const releasedErrorReply = this.withheldErrorReply.length > 0;
|
||||
this.releaseWithheldErrorReply(events);
|
||||
if (message.subtype === "success") {
|
||||
events.push(...this.sidechainTracker.finishAll("completed"));
|
||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||
@@ -4975,7 +5014,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// accounting from provider gateways does not duplicate streamed output.
|
||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||
const outputTokens = message.usage?.output_tokens;
|
||||
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
|
||||
if (
|
||||
resultText.length > 0 &&
|
||||
outputTokens === 0 &&
|
||||
!this.activeTurnHasAssistantText &&
|
||||
!releasedErrorReply
|
||||
) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
@@ -4997,6 +5041,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events.push(this.buildTurnFailedEvent(errorMessage));
|
||||
}
|
||||
|
||||
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
|
||||
if (this.withheldErrorReply.length === 0) return;
|
||||
for (const item of this.withheldErrorReply) {
|
||||
events.push({ type: "timeline", item, provider: "claude" });
|
||||
}
|
||||
this.withheldErrorReply = [];
|
||||
}
|
||||
|
||||
private createClaudeSessionChangedNotice(
|
||||
oldSessionId: string,
|
||||
newSessionId: string,
|
||||
@@ -6853,40 +6905,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
||||
}
|
||||
|
||||
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
||||
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
|
||||
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
|
||||
// The needle also matches a prompt that quotes it, so a record still has to
|
||||
// parse as a `custom-title` entry carrying a usable name. Records without the
|
||||
// needle are never decoded, so a long one costs a byte search and nothing more.
|
||||
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(record.toString("utf8").trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
return null;
|
||||
}
|
||||
return normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `custom-title` record is re-emitted every time the session is saved, so
|
||||
* the current name is the last usable one in the file. Searching backwards finds it without
|
||||
* walking the transcript, which matters because sessions reach tens of
|
||||
* megabytes.
|
||||
* the current name is the last usable one in the file. Reading backwards a chunk
|
||||
* at a time finds it without walking the transcript, which matters because
|
||||
* sessions reach tens of megabytes, and never holds the file as one string, so a
|
||||
* transcript past V8's maximum string length still imports.
|
||||
*/
|
||||
function readLastClaudeCustomTitle(content: string): string | null {
|
||||
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
|
||||
let at = content.lastIndexOf(needle);
|
||||
while (at >= 0) {
|
||||
const lineStart = content.lastIndexOf("\n", at) + 1;
|
||||
const lineEnd = content.indexOf("\n", at);
|
||||
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line.trim());
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
const title = normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
|
||||
const handle = await fsPromises.open(filePath, "r");
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile()) {
|
||||
// Opening a directory succeeds on Windows even though reading it does not,
|
||||
// so reject the way reading the transcript as a file would.
|
||||
throw Object.assign(
|
||||
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
|
||||
{ code: "EISDIR", path: filePath },
|
||||
);
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
// The needle also matches a prompt that quotes it, and a record can carry no
|
||||
// usable name, so keep looking backwards for one that does.
|
||||
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
||||
let end = stats.size;
|
||||
// Retain unfinished record pieces in reverse file order. Assemble each
|
||||
// complete record once, so a large tool/image record costs linear work.
|
||||
let pieces: Buffer[] = [];
|
||||
while (end > 0) {
|
||||
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
|
||||
const chunk = Buffer.alloc(length);
|
||||
await handle.read(chunk, 0, length, end - length);
|
||||
let recordEnd = chunk.length;
|
||||
for (
|
||||
let at = chunk.lastIndexOf(10);
|
||||
at >= 0;
|
||||
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
|
||||
) {
|
||||
pieces.push(chunk.subarray(at + 1, recordEnd));
|
||||
const record = Buffer.concat(pieces.toReversed());
|
||||
const title = readClaudeCustomTitleFromRecord(record);
|
||||
if (title) return title;
|
||||
pieces = [];
|
||||
recordEnd = at;
|
||||
}
|
||||
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
|
||||
end -= length;
|
||||
}
|
||||
// The first record in the file has no newline in front of it.
|
||||
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
@@ -6898,9 +6989,8 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
sessionId: string;
|
||||
configDir: string;
|
||||
}): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
||||
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
|
||||
} catch (error) {
|
||||
// A session with no transcript on disk has no rename to carry over. Every
|
||||
// other read failure is a real fault and stays explicit.
|
||||
@@ -6909,7 +6999,6 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return readLastClaudeCustomTitle(content);
|
||||
}
|
||||
|
||||
function normalizeImportablePromptPreview(text: string): string | null {
|
||||
|
||||
@@ -102,53 +102,52 @@ describe("daemon E2E", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "報告書.md",
|
||||
disposition:
|
||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||
},
|
||||
{
|
||||
fileName: "보고서.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
])(
|
||||
"issues token over WS and downloads $fileName via HTTP",
|
||||
async ({ fileName, disposition }) => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
test("downloads ASCII and Unicode filenames through one workspace", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
for (const { fileName, disposition } of [
|
||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "報告書.md",
|
||||
disposition:
|
||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||
},
|
||||
{
|
||||
fileName: "보고서.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
]) {
|
||||
const filePath = path.join(cwd, fileName);
|
||||
const fileContents = "download test payload";
|
||||
const fileContents = `download test payload: ${fileName}`;
|
||||
writeFileSync(filePath, fileContents, "utf-8");
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||
|
||||
expect(tokenResponse.error).toBeNull();
|
||||
@@ -167,12 +166,11 @@ describe("daemon E2E", () => {
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toBe(fileContents);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const response = await fetch(
|
||||
|
||||
Reference in New Issue
Block a user