Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bd165f5
feat(sessionTapes): serve session tapes to the desktop app in a read-…
ThomasK33 Oct 4, 2026
f7b5af6
test(e2e): add the perf.tapeReplay scenario, its synthetic tape fixtu…
ThomasK33 Oct 4, 2026
06bb189
test(sessionTapes): cover the read-side hardening (#5587)
ThomasK33 Oct 4, 2026
3e6b860
test(sessionTapes): cover the replay source and the read-only replay …
ThomasK33 Oct 4, 2026
00248d0
test: cover the session tape egress allowlist, fixture drift and the …
ThomasK33 Oct 4, 2026
0bd484d
fix(sessionTapes): keep the replay source off the desktop pre-splash …
ThomasK33 Oct 4, 2026
cc23f1e
test(e2e): compare distinct blocked probe URLs in perf.tapeReplay
ThomasK33 Oct 4, 2026
9c1ebe5
test(e2e): expand the replayed bash card before checking its script
ThomasK33 Oct 4, 2026
fa486ae
fix(sessionTapes): resolve no host names in replay mode and stub the …
ThomasK33 Oct 4, 2026
4de6de0
test(startHere): reuse the Start Here click helpers in the original test
ThomasK33 Oct 4, 2026
51e70a9
fix(sessionTapes): address Codex round 1 (telemetry, pagination, dev …
ThomasK33 Oct 4, 2026
79b7f27
fix(sessionTapes): run no API server in replay mode; isolate gh, code…
ThomasK33 Oct 4, 2026
c9fc75d
fix(sessionTapes): address Codex round 2 (telemetry off, stopped tape…
ThomasK33 Oct 4, 2026
c0f38eb
fix(sessionTapes): address Codex round 3 (deep header rejection, term…
ThomasK33 Oct 4, 2026
ad52106
fix(sessionTapes): address Codex round 4 (static egress latch, local-…
ThomasK33 Oct 4, 2026
e8289e2
fix(sessionTapes): refuse tapes without one successful caught-up (Cod…
ThomasK33 Oct 4, 2026
d8baf20
fix(sessionTapes): address Codex round 6 (UNC tape paths, static impo…
ThomasK33 Oct 4, 2026
62c186e
fix(sessionTapes): allowlist local tape paths and serve tapes only in…
ThomasK33 Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ include fmt.mk
.PHONY: all build dev start clean help
.PHONY: build-renderer version build-icons build-static build-docker-runtime verify-docker-runtime-artifacts
.PHONY: lint lint-fix typecheck static-check static-check-full
.PHONY: test test-unit test-unit-ci test-integration test-watch test-coverage test-e2e test-e2e-perf smoke-test
.PHONY: test test-unit test-unit-ci test-integration test-watch test-coverage test-e2e test-e2e-perf perf-tape-replay smoke-test
.PHONY: dist dist-mac dist-win dist-linux install-mac-arm64 ensure-mac-sharp-runtime-deps check-appimage-icons check-mac-attach-file-runtime
.PHONY: vscode-ext vscode-ext-install
.PHONY: docs-server check-docs-links
Expand Down Expand Up @@ -549,6 +549,10 @@ test-e2e-perf: ## Run automated performance profiling scenarios
@# One worker: parallel Electron apps contend on CPU, so a scenario measures its neighbours' startup (#5209).
@XUM_E2E_RUN_PERF=1 XUM_PROFILE_REACT=1 XUM_E2E_LOAD_DIST=1 XUM_E2E_SKIP_BUILD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 bun x playwright test --project=electron tests/e2e/scenarios/perf*.spec.ts --workers 1 $(PLAYWRIGHT_ARGS)

perf-tape-replay: ## Replay a synthetic session tape through the desktop app (perf.tapeReplay)
@$(MAKE) build
@XUM_E2E_RUN_PERF=1 XUM_PROFILE_REACT=1 XUM_E2E_LOAD_DIST=1 XUM_E2E_SKIP_BUILD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 bun x playwright test --project=electron tests/e2e/scenarios/perf.tapeReplay.spec.ts --workers 1 $(PLAYWRIGHT_ARGS)

## Distribution
dist: build ## Build distributable packages
@bun x electron-builder --publish never
Expand Down
23 changes: 23 additions & 0 deletions scripts/perf/generateTapeReplayFixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Regenerate the synthetic session tape fixture of the `perf.tapeReplay` e2e scenario:
* bun scripts/perf/generateTapeReplayFixture.ts
*/
import { writeFileSync } from "node:fs";
import * as path from "node:path";
import {
buildTapeReplayFixtureTape,
TAPE_REPLAY_FIXTURE_FILE_NAME,
} from "../../tests/e2e/fixtures/sessionTapes/tapeReplayFixture";

const target = path.join(
import.meta.dir,
"..",
"..",
"tests",
"e2e",
"fixtures",
"sessionTapes",
TAPE_REPLAY_FIXTURE_FILE_NAME
);
writeFileSync(target, buildTapeReplayFixtureTape());
console.log(`Wrote ${path.relative(process.cwd(), target)}`);
31 changes: 31 additions & 0 deletions scripts/perf/tapeReplayFixture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import * as path from "node:path";
import { hashSessionTapeWorkspaceId } from "@/node/services/sessionTapes/sessionTapeRecorder";
import { readSessionTapeFile } from "@/node/services/sessionTapes/sessionTapeFile";
import {
TAPE_REPLAY_FIXTURE_FILE_NAME,
TAPE_REPLAY_FIXTURE_WORKSPACE_ID,
} from "../../tests/e2e/fixtures/sessionTapes/tapeReplayFixture";

const FIXTURE_PATH = path.join(
import.meta.dir,
"..",
"..",
"tests",
"e2e",
"fixtures",
"sessionTapes",
TAPE_REPLAY_FIXTURE_FILE_NAME
);

describe("perf.tapeReplay fixture tape", () => {
test("loads as a complete tape recorded for the scenario's workspace id", async () => {
const result = await readSessionTapeFile(FIXTURE_PATH);
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
// The replay source refuses a tape whose hash does not match the mapped workspace.
expect(result.header.workspaceIdHash).toBe(
hashSessionTapeWorkspaceId(TAPE_REPLAY_FIXTURE_WORKSPACE_ID)
);
});
});
15 changes: 13 additions & 2 deletions src/browser/components/StartHereModal/StartHereModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { Button } from "@/browser/components/Button/Button";
import { stopKeyboardPropagation } from "@/browser/utils/events";
import { isEditableElement, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds";
import { getErrorMessage } from "@/common/utils/errors";

interface StartHereModalProps {
isOpen: boolean;
Expand All @@ -30,21 +31,26 @@ export const StartHereModal: React.FC<StartHereModalProps> = ({
confirmDisabled = false,
}) => {
const [isExecuting, setIsExecuting] = useState(false);
// Why the last confirmation was refused; shown until the next attempt or cancel.
const [error, setError] = useState<string | undefined>(undefined);

const handleCancel = useCallback(() => {
if (!isExecuting) {
setError(undefined);
onClose();
}
}, [isExecuting, onClose]);

const handleConfirm = useCallback(async () => {
if (isExecuting || confirmDisabled) return;
setIsExecuting(true);
setError(undefined);
try {
await onConfirm();
onClose();
} catch (error) {
console.error("Start Here error:", error);
} catch (confirmError) {
setError(getErrorMessage(confirmError));
} finally {
setIsExecuting(false);
}
}, [isExecuting, confirmDisabled, onConfirm, onClose]);
Expand Down Expand Up @@ -88,6 +94,11 @@ export const StartHereModal: React.FC<StartHereModalProps> = ({
This will start a new context from this message and preserve earlier chat history.
</DialogDescription>
</DialogHeader>
{error && (
<div role="alert" className="bg-error-bg text-error rounded p-2 px-3 text-[13px]">
{error}
</div>
)}
<DialogFooter className="justify-center">
<Button variant="secondary" onClick={handleCancel} disabled={isExecuting}>
Cancel
Expand Down
14 changes: 14 additions & 0 deletions src/browser/features/Messages/StreamErrorMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ const StreamErrorMessageBase: React.FC<StreamErrorMessageBaseProps> = (props) =>
</Tooltip>
);

// Perf harness (XUM_REPLAY_TAPES): the backend refused to replay this workspace's tape. No
// model or stream was involved, so no error pill and no LLM request debug action.
if (message.errorType === "session_tape_replay") {
return (
<div className={cn("bg-error-bg border border-error rounded px-5 py-4 my-3", className)}>
<div className="font-primary text-error mb-2 flex items-center gap-2 text-[13px] font-semibold">
<AlertTriangle aria-hidden="true" className="h-4 w-4" />
<span>Session tape replay refused</span>
</div>
<div className="text-foreground/80 text-[13px] leading-relaxed">{message.error}</div>
</div>
);
}

// Runtime unavailable gets a distinct, friendlier presentation.
// This is a permanent failure (container/runtime doesn't exist), not a transient stream error.
// The backend sends "Container unavailable..." for Docker or "Runtime unavailable..." for others.
Expand Down
6 changes: 3 additions & 3 deletions src/browser/hooks/useStartHere.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,11 @@ export function useStartHere(
deletePlanFile: options?.deletePlanFile,
});

// Thrown, not logged: the modal keeps itself open and shows the reason (for example a
// workspace replaying a session tape refuses history changes).
if (!result.success) {
console.error("Failed to start here:", result.error);
throw new Error(String(result.error));
}
} catch (err) {
console.error("Start here error:", err);
} finally {
setIsStartingHere(false);
}
Expand Down
99 changes: 99 additions & 0 deletions src/browser/stores/WorkspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { createAgentSessionHarness } from "@/node/services/agentSession.testHarn
import { createWorkspaceServiceForTest } from "@/node/services/workspaceService.testHarness";
// eslint-disable-next-line local/no-cross-boundary-imports -- exercise the actual IPC replay boundary in this store fixture
import { subscribeWorkspaceChat } from "@/node/orpc/routerSubscriptions";
import { markSessionTapeReplayEgressBlocked } from "@/common/utils/sessionTapes/sessionTapeReplay";
import type { ORPCContext } from "@/node/orpc/context";
import { CUSTOM_EVENTS } from "@/common/constants/events";
import { MUX_GATEWAY_SESSION_EXPIRED_MESSAGE } from "@/common/constants/muxGatewayOAuth";
import { getInterruptionContext } from "@/common/utils/messages/retryEligibility";
import type { TurnCoordinator, OperationId } from "@/node/services/turnCoordinator";
import { Ok } from "@/common/types/result";
import { GlobalWindow } from "happy-dom";
Expand Down Expand Up @@ -879,6 +883,101 @@ describe("WorkspaceStore", () => {
store.dispose();
});

it("shows a refused session tape replay as an error and does not retry it", async () => {
// A mapped tape the backend refuses (here: missing) must end the skeleton with a visible
// reason, keep sends closed and not resubscribe: a retry can only be refused again. Served
// by the backend's real onChat entry point, with no services behind it.
const workspaceId = "tape-refused-workspace";
const savedEnv = {
XUM_REPLAY_TAPES: process.env.XUM_REPLAY_TAPES,
XUM_E2E: process.env.XUM_E2E,
XUM_REPLAY_HARNESS: process.env.XUM_REPLAY_HARNESS,
};
process.env.XUM_REPLAY_TAPES = JSON.stringify({ [workspaceId]: "/nonexistent/missing.jsonl" });
// The perf harness markers: outside them the backend refuses before reading the tape.
process.env.XUM_E2E = "1";
process.env.XUM_REPLAY_HARNESS = "1";
markSessionTapeReplayEgressBlocked();
const context = {} as unknown as ORPCContext;
mockOnChat.mockImplementation(async function* (input, options) {
yield* subscribeWorkspaceChat(context, { workspaceId: input!.workspaceId }, options?.signal, {
validateOutput: true,
});
});
try {
createAndAddWorkspace(store, workspaceId);
const showsRefusal = () =>
store
.getWorkspaceState(workspaceId)
.messages.some(
(message) => message.type === "stream-error" && message.error.includes("unreadable")
);
expect(await waitUntil(showsRefusal)).toBe(true);
// Past the first retry backoff (SUBSCRIPTION_RETRY_BASE_MS): a retry would subscribe again.
await tick(400);
expect(mockOnChat).toHaveBeenCalledTimes(1);
const state = store.getWorkspaceState(workspaceId);
expect(state.isHydratingTranscript).toBe(false);
expect(state.loading).toBe(false);
expect(state.isTranscriptCaughtUp).toBe(false);
// Not a model stream failure: no "Stream interrupted" barrier and no Retry/auto-retry.
expect(state.messages.at(-1)).toMatchObject({ errorType: "session_tape_replay" });
const interruption = getInterruptionContext(state.messages);
expect(interruption.hasInterruptedStream).toBe(false);
expect(interruption.isEligibleForAutoRetry).toBe(false);
} finally {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});

it.each([false, true])(
"suppresses recorded side effects when the renderer replays a session tape (%p)",
async (replaying) => {
// A replayed tape's events never ran here: no gateway dialog, no git/review refresh.
const workspaceId = `tape-side-effects-${String(replaying)}`;
const api = global.window.api as { isSessionTapeReplay?: boolean };
api.isSessionTapeReplay = replaying;
const dispatchEvent = global.window.dispatchEvent as Mock<(event: Event) => boolean>;
dispatchEvent.mockClear();
try {
const send = openChat(workspaceId);
createAndAddWorkspace(store, workspaceId);
const messageId = "tape-stream";
await send(
caughtUpEvent(),
{
type: "stream-start",
workspaceId,
messageId,
historySequence: 1,
model: TEST_MODEL,
startTime: 1,
},
toolCallEndEvent(workspaceId, "tool-1", "bash", { output: "ok" }, { messageId }),
{
type: "stream-error",
messageId,
error: MUX_GATEWAY_SESSION_EXPIRED_MESSAGE,
errorType: "authentication",
}
);
expect(
await waitUntil(() => store.getWorkspaceState(workspaceId).messages.length > 0)
).toBe(true);
const gatewayDialogs = dispatchEvent.mock.calls.filter(
([event]) => event.type === CUSTOM_EVENTS.MUX_GATEWAY_SESSION_EXPIRED
);
expect(gatewayDialogs.length).toBe(replaying ? 0 : 1);
expect(store.getFileModifyingToolMs(workspaceId) === undefined).toBe(replaying);
} finally {
delete api.isSessionTapeReplay;
}
}
);

it.each([
["stream-abort", "full"],
["error", "full"],
Expand Down
37 changes: 35 additions & 2 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import type { TodoItem } from "@/common/types/tools";
import type { AssistedReviewHunk } from "@/common/types/review";
import type { WorkflowRunRecord } from "@/common/types/workflow";
import type { TimelineEvent, TimelineSubscriptionEvent } from "@/common/orpc/schemas/timeline";
import { applyWorkspaceChatEventToAggregator } from "@/browser/utils/messages/applyWorkspaceChatEventToAggregator";
import {
applyWorkspaceChatEventToAggregator,
isSessionTapeReplayRenderer,
} from "@/browser/utils/messages/applyWorkspaceChatEventToAggregator";
import {
StreamingMessageAggregator,
type LoadedSkill,
Expand All @@ -37,6 +40,7 @@ import {
type ResponseCompleteHandler,
} from "@/browser/utils/messages/responseCompletionMetadata";
import { isAbortError } from "@/browser/utils/isAbortError";
import { isSessionTapeReplayRefusal } from "@/common/utils/sessionTapes/sessionTapeReplay";
import {
SUBSCRIPTION_RETRY_BASE_MS,
calculateSubscriptionBackoffMs,
Expand Down Expand Up @@ -1176,8 +1180,10 @@ export class WorkspaceStore {
this.consumerManager.scheduleCalculation(workspaceId, aggregator);

// Track file-modifying tools for ReviewPanel diff refresh.
// Not for a replayed session tape (perf harness): its tools never ran here.
const shouldTriggerReviewPanelRefresh =
toolCallEnd.toolName.startsWith("file_edit_") || toolCallEnd.toolName === "bash";
(toolCallEnd.toolName.startsWith("file_edit_") || toolCallEnd.toolName === "bash") &&
!isSessionTapeReplayRenderer();

if (shouldTriggerReviewPanelRefresh) {
this.fileModifyingToolMs.set(workspaceId, Date.now());
Expand Down Expand Up @@ -4465,6 +4471,12 @@ export class WorkspaceStore {
console.warn(
"[WorkspaceStore] onChat subscription aborted for " + workspaceId + "; retrying..."
);
} else if (isSessionTapeReplayRefusal(error)) {
// Perf harness (XUM_REPLAY_TAPES): this workspace is mapped to a tape the backend
// cannot serve. Retrying cannot help and there is no live fallback: show the refusal
// and stop the loop.
this.showSessionTapeReplayRefusal(workspaceId, error.message);
return true;
} else if (isIteratorValidationFailed(error)) {
if (!this.isWorkspaceRegistered(workspaceId)) return true;
console.error(
Expand Down Expand Up @@ -4518,6 +4530,27 @@ export class WorkspaceStore {
}
}

/**
* End hydration for a workspace whose session tape replay was refused and show the reason as
* an error row. The onChat loop stops (no attempt-finished hook runs), so this also clears the
* attempt's buffers and opens the replay gate. History stays unverified, so the send barrier
* stays closed.
*/
private showSessionTapeReplayRefusal(workspaceId: string, message: string): void {
if (!this.isWorkspaceRegistered(workspaceId)) return;
this.clearReplayBuffers(workspaceId);
this.chatReplayPendingWorkspaces.delete(workspaceId);
const transient = this.chatTransientState.get(workspaceId);
if (transient) transient.isHydratingTranscript = false;
this.assertGet(workspaceId).handleStreamError({
type: "stream-error",
messageId: "session-tape-replay-refused",
error: message,
errorType: "session_tape_replay",
});
this.states.bump(workspaceId);
}

/**
* Register a workspace and initialize local state.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ export interface WorkspaceChatEventAggregator {
clearTokenState(messageId: string): void;
}

/**
* Desktop perf harness (XUM_REPLAY_TAPES, exposed by preload): chat events come from a recorded
* session tape, so they must not trigger app side effects (gateway dialogs, skill refreshes,
* child-goal toasts, git/review refreshes).
*/
export function isSessionTapeReplayRenderer(): boolean {
return typeof window !== "undefined" && window.api?.isSessionTapeReplay === true;
}

function dispatchSkillsRefreshRequested(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.SKILLS_REFRESH_REQUESTED));
Expand Down Expand Up @@ -120,7 +129,7 @@ export function applyWorkspaceChatEventToAggregator(
"applyWorkspaceChatEventToAggregator requires event object"
);

const allowSideEffects = options?.allowSideEffects !== false;
const allowSideEffects = options?.allowSideEffects !== false && !isSessionTapeReplayRenderer();
Comment thread
ThomasK33 marked this conversation as resolved.

if (isStreamStart(event)) {
aggregator.handleStreamStart(event);
Expand Down
3 changes: 3 additions & 0 deletions src/common/orpc/schemas/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export const SendMessageErrorSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("history-changed") }),
/** A direct edit targeted authentic plan-review feedback; nothing was changed. */
z.object({ type: z.literal("plan_review_feedback_edit_blocked"), message: z.string() }),
// Session tape replay mode (XUM_REPLAY_TAPES) refuses turns and model creation; deterministic
z.object({ type: z.literal("session_tape_replay"), message: z.string() }),
z.object({ type: z.literal("unknown"), raw: z.string() }),
]);

Expand Down Expand Up @@ -79,6 +81,7 @@ export const StreamErrorTypeSchema = z.enum([
"model_refusal", // Provider declined to answer (refusal/content-filter); retrying the same request will refuse again
"agent_resolution", // Strict explicit-agent contract failure (agent missing/hidden/disabled/provenance changed); deterministic, retrying reproduces it
"reasoning_rejected", // Provider rejected replayed reasoning (OpenAI rs_ item / encrypted_content, Anthropic thinking signature) after the in-stream repair; deterministic
"session_tape_replay", // Perf harness (XUM_REPLAY_TAPES) refused to replay this workspace's tape; renderer-only, no model involved
"unknown", // Catch-all
]);

Expand Down
3 changes: 3 additions & 0 deletions src/common/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ declare global {
enableTelemetryInDev?: boolean;
// E2E test mode flag - used to adjust UI behavior (e.g., longer toast durations)
isE2E?: boolean;
// Session tape replay mode (XUM_REPLAY_TAPES, perf harness): chat events are recorded, so
// the renderer suppresses their side effects.
isSessionTapeReplay?: boolean;
// Enables in-app React render capture for dev profiling and automated perf tests.
enableReactPerfProfile?: boolean;
// Sandbox launchers default tutorials off unless explicitly re-enabled by env.
Expand Down
Loading
Loading