fix: interrupt scaffold drain and desktop CLI bin layout
This commit is contained in:
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)
|
||||
|
||||
# macOS: MacOS/Paseo is a sibling of Resources/
|
||||
# Linux: the executable sits next to resources/
|
||||
if [ -x "${RESOURCES_DIR}/../MacOS/Paseo" ]; then
|
||||
APP_EXECUTABLE="${RESOURCES_DIR}/../MacOS/Paseo"
|
||||
elif [ -x "${RESOURCES_DIR}/../Paseo" ]; then
|
||||
APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo"
|
||||
else
|
||||
echo "Bundled Paseo executable not found relative to ${RESOURCES_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
|
||||
|
||||
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@"
|
||||
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
set "RESOURCES_DIR=%SCRIPT_DIR%.."
|
||||
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
|
||||
set "CLI_ENTRY=%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js"
|
||||
|
||||
if not exist "%APP_EXECUTABLE%" (
|
||||
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*
|
||||
@@ -19,26 +19,26 @@ mac:
|
||||
notarize: true
|
||||
entitlements: build/entitlements.mac.plist
|
||||
entitlementsInherit: build/entitlements.mac.inherit.plist
|
||||
extraFiles:
|
||||
- from: scripts/paseo
|
||||
to: MacOS/paseo
|
||||
extraResources:
|
||||
- from: bin/paseo
|
||||
to: bin/paseo
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
linux:
|
||||
category: Development
|
||||
icon: assets
|
||||
extraFiles:
|
||||
- from: scripts/paseo
|
||||
to: paseo
|
||||
extraResources:
|
||||
- from: bin/paseo
|
||||
to: bin/paseo
|
||||
target:
|
||||
- AppImage
|
||||
- tar.gz
|
||||
win:
|
||||
icon: assets/icon.ico
|
||||
extraFiles:
|
||||
- from: scripts/paseo.cmd
|
||||
to: paseo.cmd
|
||||
extraResources:
|
||||
- from: bin/paseo.cmd
|
||||
to: bin/paseo.cmd
|
||||
target:
|
||||
- nsis
|
||||
- zip
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
APP_EXECUTABLE="${SCRIPT_DIR}/Paseo"
|
||||
|
||||
if [ -d "${SCRIPT_DIR}/../Resources" ]; then
|
||||
RESOURCES_DIR="${SCRIPT_DIR}/../Resources"
|
||||
elif [ -d "${SCRIPT_DIR}/resources" ]; then
|
||||
RESOURCES_DIR="${SCRIPT_DIR}/resources"
|
||||
else
|
||||
echo "Unable to locate Paseo resources directory next to ${SCRIPT_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
|
||||
|
||||
if [ ! -x "${APP_EXECUTABLE}" ]; then
|
||||
echo "Bundled Paseo executable not found at ${APP_EXECUTABLE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${CLI_ENTRY}" ]; then
|
||||
echo "Bundled Paseo CLI entrypoint not found at ${CLI_ENTRY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@"
|
||||
@@ -1,19 +0,0 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
set "APP_EXECUTABLE=%SCRIPT_DIR%Paseo.exe"
|
||||
set "CLI_ENTRY=%SCRIPT_DIR%resources\app.asar\node_modules\@getpaseo\cli\dist\index.js"
|
||||
|
||||
if not exist "%APP_EXECUTABLE%" (
|
||||
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%CLI_ENTRY%" (
|
||||
echo Bundled Paseo CLI entrypoint not found at %CLI_ENTRY% 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*
|
||||
@@ -158,6 +158,8 @@ function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
|
||||
.filter((value) => value.length > 0)
|
||||
);
|
||||
|
||||
origins.add("paseo://app");
|
||||
|
||||
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
|
||||
try {
|
||||
const parsed = new URL(devServerUrl);
|
||||
@@ -479,7 +481,7 @@ function getCliSymlinkInstructions(): CliSymlinkInstructions {
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
const cliPath = path.join(appBundle, "Contents", "MacOS", cliShimFilename);
|
||||
const cliPath = path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||
return {
|
||||
title: "Add paseo to your shell",
|
||||
detail: "Create a symlink to the bundled Paseo CLI shim.",
|
||||
@@ -488,18 +490,20 @@ function getCliSymlinkInstructions(): CliSymlinkInstructions {
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
return {
|
||||
title: "Add paseo to your PATH",
|
||||
detail: "Add the Paseo installation directory to your system PATH so paseo.cmd is available.",
|
||||
commands: `setx PATH "%PATH%;${path.dirname(electronExePath)}"`,
|
||||
commands: `setx PATH "%PATH%;${path.dirname(cliPath)}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Linux
|
||||
const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
return {
|
||||
title: "Add paseo to your shell",
|
||||
detail: "Create a symlink to the bundled Paseo CLI shim.",
|
||||
commands: `sudo ln -sf "${path.join(path.dirname(electronExePath), cliShimFilename)}" /usr/local/bin/paseo`,
|
||||
commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { app, BrowserWindow, nativeImage } from "electron";
|
||||
import { app, BrowserWindow, nativeImage, net, protocol } from "electron";
|
||||
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||
import { parseCliPassthroughArgsFromArgv, runCliPassthroughCommand } from "./daemon/runtime-paths.js";
|
||||
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
||||
@@ -11,8 +12,13 @@ import { registerOpenerHandlers } from "./features/opener.js";
|
||||
import { setupApplicationMenu } from "./features/menu.js";
|
||||
|
||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||
const APP_SCHEME = "paseo";
|
||||
app.setName("Paseo");
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window creation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -21,12 +27,12 @@ function getPreloadPath(): string {
|
||||
return path.join(__dirname, "preload.js");
|
||||
}
|
||||
|
||||
function getProductionEntryPath(): string {
|
||||
function getAppDistDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "app-dist", "index.html");
|
||||
return path.join(process.resourcesPath, "app-dist");
|
||||
}
|
||||
|
||||
return path.resolve(__dirname, "../../app/dist/index.html");
|
||||
return path.resolve(__dirname, "../../app/dist");
|
||||
}
|
||||
|
||||
function getWindowIconPath(): string | null {
|
||||
@@ -94,7 +100,7 @@ async function createMainWindow(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
await mainWindow.loadFile(getProductionEntryPath());
|
||||
await mainWindow.loadURL(`${APP_SCHEME}://app/`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,6 +154,34 @@ async function bootstrap(): Promise<void> {
|
||||
}
|
||||
|
||||
await app.whenReady();
|
||||
|
||||
const appDistDir = getAppDistDir();
|
||||
protocol.handle(APP_SCHEME, (request) => {
|
||||
const { pathname, search, hash } = new URL(request.url);
|
||||
const decodedPath = decodeURIComponent(pathname);
|
||||
|
||||
// Chromium can occasionally request the exported entrypoint directly.
|
||||
// Canonicalize it back to the route URL so Expo Router sees `/`, not `/index.html`.
|
||||
if (decodedPath.endsWith("/index.html")) {
|
||||
const normalizedPath = decodedPath.slice(0, -"/index.html".length) || "/";
|
||||
return Response.redirect(`${APP_SCHEME}://app${normalizedPath}${search}${hash}`, 307);
|
||||
}
|
||||
|
||||
const filePath = path.join(appDistDir, decodedPath);
|
||||
const relativePath = path.relative(appDistDir, filePath);
|
||||
|
||||
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for routes without a file extension
|
||||
if (!relativePath || !path.extname(relativePath)) {
|
||||
return net.fetch(pathToFileURL(path.join(appDistDir, "index.html")).toString());
|
||||
}
|
||||
|
||||
return net.fetch(pathToFileURL(filePath).toString());
|
||||
});
|
||||
|
||||
applyAppIcon();
|
||||
setupApplicationMenu();
|
||||
ensureNotificationCenterRegistration();
|
||||
|
||||
+180
@@ -224,6 +224,16 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function collectUserText(events: AgentStreamEvent[]): string {
|
||||
return events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "user_message"
|
||||
)
|
||||
.map((event) => event.item.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function createTimedIteratorReader<T>(params: { iterator: AsyncIterator<T> }) {
|
||||
const { iterator } = params;
|
||||
let pendingNext: Promise<IteratorResult<T>> | null = null;
|
||||
@@ -314,6 +324,176 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("restarts after interrupt scaffold query drain without surfacing placeholder transcript noise", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryCreateCount = 0;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryCreateCount += 1;
|
||||
|
||||
if (queryCreateCount === 1) {
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "interrupt-scaffold-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: "interrupt-scaffold-1",
|
||||
session_id: "interrupt-scaffold-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "Got it. Sorry for the mess.",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "No response requested.",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
}
|
||||
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "interrupt-scaffold-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "fresh prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: promptUuid,
|
||||
session_id: "interrupt-scaffold-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "FRESH_RESPONSE_AFTER_INTERRUPT",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const events = await collectUntilTerminal(session.stream("fresh prompt"));
|
||||
const assistantText = collectAssistantText(events);
|
||||
const userText = collectUserText(events);
|
||||
await session.close();
|
||||
|
||||
expect(queryCreateCount).toBeGreaterThanOrEqual(2);
|
||||
expect(assistantText).toContain("FRESH_RESPONSE_AFTER_INTERRUPT");
|
||||
expect(assistantText).not.toContain("Got it. Sorry for the mess.");
|
||||
expect(assistantText).not.toContain("No response requested.");
|
||||
expect(userText).not.toContain("[Request interrupted by user]");
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "turn_failed" &&
|
||||
event.error.includes("Claude stream ended before terminal result")
|
||||
)
|
||||
).toBe(false);
|
||||
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores stale interrupted query completion after the replacement run starts", async () => {
|
||||
const logger = createTestLogger();
|
||||
const releaseOldDone = deferred<void>();
|
||||
|
||||
@@ -112,6 +112,31 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
expect(mapBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips interrupt placeholder transcript noise", () => {
|
||||
const interruptEntry = {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||
},
|
||||
};
|
||||
|
||||
const assistantNoiseEntry = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "No response requested.",
|
||||
},
|
||||
};
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue([
|
||||
{ type: "assistant_message", text: "No response requested." },
|
||||
]);
|
||||
|
||||
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
|
||||
});
|
||||
|
||||
test("maps task notifications to synthetic tool calls", () => {
|
||||
const entry = {
|
||||
type: "system",
|
||||
|
||||
@@ -334,6 +334,9 @@ const REWIND_COMMAND: AgentSlashCommand = {
|
||||
argumentHint: "[user_message_uuid]",
|
||||
};
|
||||
const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]";
|
||||
const INTERRUPT_PLACEHOLDER_PATTERN =
|
||||
/^\[Request interrupted by user(?:[^\]]*)\]$/;
|
||||
const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested.";
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -584,11 +587,88 @@ function coerceToolResultContentToString(content: unknown): string {
|
||||
return deterministicStringify(content);
|
||||
}
|
||||
|
||||
function normalizeClaudeTranscriptText(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function isClaudeInterruptPlaceholderText(value: unknown): boolean {
|
||||
const normalized = normalizeClaudeTranscriptText(value);
|
||||
return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
|
||||
return (
|
||||
normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER
|
||||
);
|
||||
}
|
||||
|
||||
function isClaudeTranscriptNoiseText(value: unknown): boolean {
|
||||
return (
|
||||
isClaudeInterruptPlaceholderText(value) ||
|
||||
isClaudeNoResponsePlaceholderText(value)
|
||||
);
|
||||
}
|
||||
|
||||
function collectClaudeTextContentParts(content: unknown): string[] {
|
||||
if (typeof content === "string") {
|
||||
const normalized = normalizeClaudeTranscriptText(content);
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const text = normalizeClaudeTranscriptText(
|
||||
(block as { text?: unknown }).text
|
||||
);
|
||||
if (text) {
|
||||
parts.push(text);
|
||||
continue;
|
||||
}
|
||||
const input = normalizeClaudeTranscriptText(
|
||||
(block as { input?: unknown }).input
|
||||
);
|
||||
if (input) {
|
||||
parts.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function isClaudeInterruptScaffoldContent(content: unknown): boolean {
|
||||
const parts = collectClaudeTextContentParts(content);
|
||||
return (
|
||||
parts.length > 0 &&
|
||||
parts.every((part) => isClaudeInterruptPlaceholderText(part))
|
||||
);
|
||||
}
|
||||
|
||||
function isClaudeTranscriptNoiseContent(content: unknown): boolean {
|
||||
const parts = collectClaudeTextContentParts(content);
|
||||
return (
|
||||
parts.length > 0 &&
|
||||
parts.every((part) => isClaudeTranscriptNoiseText(part))
|
||||
);
|
||||
}
|
||||
|
||||
export function extractUserMessageText(content: unknown): string | null {
|
||||
if (typeof content === "string") {
|
||||
const normalized = content.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
if (!normalized || isClaudeTranscriptNoiseText(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
@@ -602,12 +682,18 @@ export function extractUserMessageText(content: unknown): string | null {
|
||||
}
|
||||
const text = typeof block.text === "string" ? block.text : undefined;
|
||||
if (text && text.trim()) {
|
||||
parts.push(text.trim());
|
||||
const trimmed = text.trim();
|
||||
if (!isClaudeTranscriptNoiseText(trimmed)) {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const input = typeof block.input === "string" ? block.input : undefined;
|
||||
if (input && input.trim()) {
|
||||
parts.push(input.trim());
|
||||
const trimmed = input.trim();
|
||||
if (!isClaudeTranscriptNoiseText(trimmed)) {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,7 +1294,8 @@ class TimelineAssembler {
|
||||
const nextAssistantText = state.assistantText.slice(state.emittedAssistantLength);
|
||||
if (
|
||||
nextAssistantText.length > 0 &&
|
||||
nextAssistantText !== INTERRUPT_TOOL_USE_PLACEHOLDER
|
||||
nextAssistantText !== INTERRUPT_TOOL_USE_PLACEHOLDER &&
|
||||
!isClaudeTranscriptNoiseText(nextAssistantText)
|
||||
) {
|
||||
state.emittedAssistantLength = state.assistantText.length;
|
||||
items.push({ type: "assistant_message", text: nextAssistantText });
|
||||
@@ -1332,12 +1419,21 @@ function isMetadataOnlySdkMessage(message: SDKMessage): boolean {
|
||||
if (message.type === "system") {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
isClaudeTranscriptNoiseContent(message.message?.content)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (message.type !== "user") {
|
||||
return false;
|
||||
}
|
||||
if (isSyntheticUserEntry(message)) {
|
||||
return true;
|
||||
}
|
||||
if (isClaudeInterruptScaffoldContent(message.message?.content)) {
|
||||
return true;
|
||||
}
|
||||
return isTaskNotificationUserContent(message.message?.content);
|
||||
}
|
||||
|
||||
@@ -1491,6 +1587,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private toolUseInputBuffers = new Map<string, string>();
|
||||
private pendingPermissions = new Map<string, PendingPermission>();
|
||||
private activeForegroundTurn: ForegroundTurnState | null = null;
|
||||
private activeForegroundPrompt: SDKUserMessage | null = null;
|
||||
private liveEventQueue = new Pushable<AgentStreamEvent>();
|
||||
private readonly runTracker = new RunTracker();
|
||||
private readonly timelineAssembler = new TimelineAssembler();
|
||||
@@ -1501,6 +1598,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private historyLineFragment = "";
|
||||
private turnState: TurnState = "idle";
|
||||
private preReplayMetadataSeen = false;
|
||||
private preReplayDoneRecoveryUsed = false;
|
||||
private pendingAutonomousWakeReservations = 0;
|
||||
private nextRunOrdinal = 1;
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
@@ -1627,6 +1725,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.suppressLocalReplayActivity = false;
|
||||
this.pendingAutonomousWakeReservations = 0;
|
||||
this.preReplayDoneRecoveryUsed = false;
|
||||
|
||||
const slashCommand = this.resolveSlashCommandInvocation(prompt);
|
||||
if (slashCommand?.commandName === REWIND_COMMAND_NAME) {
|
||||
@@ -1655,6 +1754,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
queue,
|
||||
};
|
||||
this.activeForegroundTurn = foregroundTurn;
|
||||
this.activeForegroundPrompt = sdkMessage;
|
||||
this.preReplayMetadataSeen = false;
|
||||
this.transitionTurnState("foreground", "foreground stream started");
|
||||
this.clearRecentStderr();
|
||||
@@ -1672,6 +1772,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
cancelIssued = true;
|
||||
if (this.activeForegroundTurn?.runId === run.id) {
|
||||
this.activeForegroundTurn = null;
|
||||
this.activeForegroundPrompt = null;
|
||||
}
|
||||
if (this.cancelCurrentTurn === requestCancel) {
|
||||
this.cancelCurrentTurn = null;
|
||||
@@ -1925,6 +2026,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.cancelCurrentTurn?.();
|
||||
this.activeForegroundTurn?.queue.end();
|
||||
this.activeForegroundTurn = null;
|
||||
this.activeForegroundPrompt = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.turnState = "idle";
|
||||
this.suppressLocalReplayActivity = false;
|
||||
@@ -2533,7 +2635,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (this.activeForegroundTurn?.runId === run.id) {
|
||||
this.activeForegroundTurn = null;
|
||||
this.activeForegroundPrompt = null;
|
||||
this.preReplayMetadataSeen = false;
|
||||
this.preReplayDoneRecoveryUsed = false;
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -2930,6 +3034,31 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.input = null;
|
||||
}
|
||||
const activeRuns = this.runTracker.listActiveRuns();
|
||||
if (this.shouldRecoverFromPreReplayQueryDone(activeRuns)) {
|
||||
this.logger.warn(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
activeRunCount: activeRuns.length,
|
||||
preReplayMetadataSeen: this.preReplayMetadataSeen,
|
||||
},
|
||||
"Claude query ended before prompt replay after interrupt scaffold; retrying fresh query"
|
||||
);
|
||||
try {
|
||||
await this.ensureQuery();
|
||||
if (this.input && this.activeForegroundPrompt) {
|
||||
this.input.push(this.activeForegroundPrompt);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const run of activeRuns) {
|
||||
this.failRun(
|
||||
run,
|
||||
error instanceof Error ? error.message : "Claude stream failed"
|
||||
);
|
||||
}
|
||||
await this.waitForLiveHistoryPoll();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (activeRuns.length > 0) {
|
||||
for (const run of activeRuns) {
|
||||
this.failRun(run, "Claude stream ended before terminal result");
|
||||
@@ -3133,6 +3262,28 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRecoverFromPreReplayQueryDone(activeRuns: RunRecord[]): boolean {
|
||||
if (
|
||||
this.preReplayDoneRecoveryUsed ||
|
||||
!this.preReplayMetadataSeen ||
|
||||
!this.activeForegroundTurn ||
|
||||
activeRuns.length !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const foregroundRun = this.runTracker.getRun(this.activeForegroundTurn.runId);
|
||||
if (!foregroundRun || activeRuns[0]?.id !== foregroundRun.id) {
|
||||
return false;
|
||||
}
|
||||
if (foregroundRun.owner !== "foreground" || foregroundRun.promptReplaySeen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.preReplayDoneRecoveryUsed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private updateRunLifecycleForMessage(
|
||||
run: RunRecord,
|
||||
message: SDKMessage,
|
||||
@@ -3146,6 +3297,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
) {
|
||||
run.promptReplaySeen = true;
|
||||
this.preReplayMetadataSeen = false;
|
||||
this.preReplayDoneRecoveryUsed = false;
|
||||
}
|
||||
|
||||
if (run.state === "queued") {
|
||||
@@ -3185,11 +3337,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private shouldSuppressLocalReplayActivity(message: SDKMessage): boolean {
|
||||
const localReplay = this.isLocalReplayUserMessage(message);
|
||||
if (!this.activeForegroundTurn && localReplay) {
|
||||
const replayUuid = this.getLocalReplayUserMessageUuid(message);
|
||||
if (replayUuid) {
|
||||
// Don't suppress the echo of the current foreground turn's prompt —
|
||||
// updateRunLifecycleForMessage needs it to set promptReplaySeen.
|
||||
if (this.activeForegroundTurn) {
|
||||
const foregroundRun = this.runTracker.getRun(
|
||||
this.activeForegroundTurn.runId
|
||||
);
|
||||
if (foregroundRun?.messageIds.has(replayUuid)) {
|
||||
this.suppressLocalReplayActivity = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.suppressLocalReplayActivity = true;
|
||||
this.logger.debug(
|
||||
{ uuid: (message as { uuid?: string }).uuid },
|
||||
{ uuid: replayUuid },
|
||||
"Suppressing local replay user message from live pump"
|
||||
);
|
||||
return true;
|
||||
@@ -3201,7 +3364,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
// Suppress only replay scaffolding. Do not suppress autonomous
|
||||
// assistant/result events; otherwise task-notification replies can be dropped.
|
||||
if (localReplay) {
|
||||
if (replayUuid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3231,17 +3394,17 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return false;
|
||||
}
|
||||
|
||||
private isLocalReplayUserMessage(message: SDKMessage): boolean {
|
||||
private getLocalReplayUserMessageUuid(message: SDKMessage): string | null {
|
||||
if (message.type !== "user") {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const uuid = readTrimmedString(
|
||||
(message as unknown as { uuid?: unknown }).uuid
|
||||
);
|
||||
if (!uuid) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
return this.localUserMessageIds.has(uuid);
|
||||
return this.localUserMessageIds.has(uuid) ? uuid : null;
|
||||
}
|
||||
|
||||
private async interruptActiveTurn(): Promise<void> {
|
||||
@@ -3652,15 +3815,17 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
if (typeof content === "string" && content.length > 0) {
|
||||
// String content from user messages (e.g., local command output)
|
||||
events.push({
|
||||
type: "timeline",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: content,
|
||||
...(messageId ? { messageId } : {}),
|
||||
},
|
||||
provider: "claude",
|
||||
});
|
||||
if (!isClaudeTranscriptNoiseText(content)) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: content,
|
||||
...(messageId ? { messageId } : {}),
|
||||
},
|
||||
provider: "claude",
|
||||
});
|
||||
}
|
||||
} else if (Array.isArray(content)) {
|
||||
// User SDK entries with array content (e.g. interrupt messages, replayed JSONL entries)
|
||||
// must declare textMessageType so mapBlocksToTimeline emits user_message, not assistant_message.
|
||||
@@ -4210,7 +4375,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const suppressReasoning = options?.suppressReasoning ?? false;
|
||||
|
||||
if (typeof content === "string") {
|
||||
if (!content || content === INTERRUPT_TOOL_USE_PLACEHOLDER) {
|
||||
if (
|
||||
!content ||
|
||||
content === INTERRUPT_TOOL_USE_PLACEHOLDER ||
|
||||
isClaudeTranscriptNoiseText(content)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (suppressText) {
|
||||
@@ -4226,7 +4395,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
case "text_delta":
|
||||
if (block.text && block.text !== INTERRUPT_TOOL_USE_PLACEHOLDER) {
|
||||
if (
|
||||
block.text &&
|
||||
block.text !== INTERRUPT_TOOL_USE_PLACEHOLDER &&
|
||||
!isClaudeTranscriptNoiseText(block.text)
|
||||
) {
|
||||
if (textMessageType === "user_message") {
|
||||
const trimmed = block.text.trim();
|
||||
if (trimmed) {
|
||||
@@ -4802,6 +4975,12 @@ export function convertClaudeHistoryEntry(
|
||||
}
|
||||
|
||||
const content = message.content;
|
||||
if (
|
||||
(entry.type === "user" || entry.type === "assistant") &&
|
||||
isClaudeTranscriptNoiseContent(content)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const normalizedBlocks = normalizeHistoryBlocks(content);
|
||||
const contentValue =
|
||||
typeof content === "string"
|
||||
@@ -5040,15 +5219,24 @@ function extractClaudeUserText(message: any): string | null {
|
||||
return null;
|
||||
}
|
||||
if (typeof message.content === "string") {
|
||||
return message.content.trim();
|
||||
const normalized = message.content.trim();
|
||||
return normalized && !isClaudeTranscriptNoiseText(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
if (typeof message.text === "string") {
|
||||
return message.text.trim();
|
||||
const normalized = message.text.trim();
|
||||
return normalized && !isClaudeTranscriptNoiseText(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block && typeof block.text === "string") {
|
||||
return block.text.trim();
|
||||
const normalized = block.text.trim();
|
||||
if (normalized && !isClaudeTranscriptNoiseText(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user