fix: interrupt scaffold drain and desktop CLI bin layout

This commit is contained in:
Mohamed Boudra
2026-03-21 18:37:51 +07:00
parent c35d649f6d
commit 7395fc6fd1
10 changed files with 509 additions and 90 deletions
+20
View File
@@ -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}" "$@"
+15
View File
@@ -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%" %*
+9 -9
View File
@@ -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
-28
View File
@@ -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}" "$@"
-19
View File
@@ -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`,
};
}
+39 -5
View File
@@ -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();
@@ -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;
}
}
}
}