Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
62fccb9
Reproduce Claude Stop, withdrawal and drain faults on claude -p
BunsDev Oct 18, 2026
7e78eb9
Stop Claude with its native interrupt and drain its last output
BunsDev Oct 18, 2026
feb2166
Withdraw approval cards Claude cancels and say how Stop ended
BunsDev Oct 18, 2026
62ac15c
Keep a failure that precedes Stop, and redact a failed launch's argv
BunsDev Oct 18, 2026
d11cdb6
Answer Claude's native interrupt in the title-naming fixture
BunsDev Oct 18, 2026
f0b383b
Record the Claude lifecycle evidence and move it to In flight
BunsDev Oct 18, 2026
6856bf3
Merge main: adopt CF-1's conversation-event contract (#282)
BunsDev Oct 18, 2026
172b13f
Record a tool call Stop interrupted as interrupted
BunsDev Oct 18, 2026
20a1eea
Record the final checks after merging CF-1
BunsDev Oct 18, 2026
52b159a
Merge main: CF-4 turn layout (#284)
BunsDev Oct 18, 2026
bab1e10
Add opt-in live Claude Stop acceptance and record its first pass
BunsDev Oct 18, 2026
a6ea0b0
Merge main: CF-6 first half, typed state through journal and phone (#…
BunsDev Oct 18, 2026
9fdaf40
Keep failed Claude turns owned until Stop confirms exit
BunsDev Oct 18, 2026
2597393
Require a real stop on every live step, and record a6ea0b0's checks
BunsDev Oct 18, 2026
7c9d62c
Merge main: CF-5 first part, command evidence and output bounds (#287)
BunsDev Oct 18, 2026
449f660
Record the final checks and live run at 7c9d62c
BunsDev Oct 18, 2026
13faaeb
Merge main: CF-7 first part, plans and task records (#289)
BunsDev Oct 18, 2026
3b1c1be
Record the checks and live run at 13faaeb
BunsDev Oct 18, 2026
a5dd2be
Reconcile the CF-2 repair with current main
BunsDev Oct 18, 2026
18a0d2a
Preserve the CF-2 owner's latest verification receipts
BunsDev Oct 18, 2026
4679fb5
Merge main: steward docs cycle 1 (#292)
BunsDev Oct 18, 2026
61cc65d
Merge #291: keep failed Claude turns owned until Stop confirms exit
BunsDev Oct 18, 2026
5e691d3
test: reconcile CF-2 evidence checks with current main
BunsDev Oct 18, 2026
305fca3
Record the checks at 61cc65d after merging #291
BunsDev Oct 18, 2026
31de358
Merge main: CF-8 first part, end-to-end conversation acceptance (#294)
BunsDev Oct 18, 2026
ee496e2
Record CF-8's Stop acceptance against CF-2
BunsDev Oct 18, 2026
9db9968
docs: preserve the latest CF-2 owner acceptance receipts
BunsDev Oct 18, 2026
4c850f3
docs: reconcile current main and record green CF-2 follow-up CI
BunsDev Oct 18, 2026
2154349
Merge main: CF-5's evidence-turn race fix (#297)
BunsDev Oct 18, 2026
71d905a
Merge main: CF-8 fidelity states and transcript measurement (#301)
BunsDev Oct 18, 2026
a790633
Merge main: steward docs cycle 2 (#300)
BunsDev Oct 18, 2026
80d35df
Merge branch 'main' into cf2-claude-lifecycle
BunsDev Oct 18, 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
150 changes: 150 additions & 0 deletions apps/desktop/e2e/claude-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { launch, seedHome, tid, items } from "./support";

// Stop and withdrawn permission prompts through the real ClaudeBackend, runner, IPC and UI (#275). The local
// executable below speaks Claude Code 2.1.302's stream-json the way it was captured live on 2026-10-18: an
// interrupt withdraws the pending can_use_tool (control_cancel_request), is acknowledged, closes the turn
// (error_during_execution, aborted_tools) and the CLI exits with code 1 once its input ends. It is a scripted
// fixture, not live-model evidence.

function fixture(home: string, stage: string): string {
const cli = path.join(home, "claude-lifecycle.cjs");
fs.writeFileSync(cli, `#!${process.execPath}
const fs = require('node:fs');
const path = require('node:path');
if (process.argv.includes('--version')) { console.log('2.1.302 (Claude Code)'); process.exit(0); }
if (process.argv.includes('auth')) { console.log('{"loggedIn":true,"authMethod":"claude.ai"}'); process.exit(0); }
if (path.basename(process.cwd()).startsWith('modex-title-')) process.exit(1);
const log = ${JSON.stringify(path.join(home, "claude-stdin.jsonl"))};
const record = (entry) => fs.appendFileSync(log, JSON.stringify({ pid: process.pid, ...entry }) + '\\n');
const emit = (o) => process.stdout.write(JSON.stringify(o) + '\\n');
let mode = '', pending = null;
process.on('SIGTERM', () => { record({ signal: 'SIGTERM' }); process.exit(143); });
const rl = require('node:readline').createInterface({ input: process.stdin });
rl.on('line', (line) => {
const msg = JSON.parse(line);
record({ msg });
if (msg.type === 'user') {
const text = typeof msg.message.content === 'string' ? msg.message.content : '';
mode = text.includes('slow') ? 'slow' : text.includes('withdraw') ? 'withdraw' : 'card';
emit({ type: 'system', subtype: 'init', session_id: 's-' + mode, claude_code_version: '2.1.302', capabilities: ['interrupt_receipt_v1'] });
if (mode === 'slow') {
// Ignores the interrupt and the end of its input; only SIGTERM ends it.
setInterval(() => {}, 1000);
emit({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Working on it' } } });
return;
}
pending = 'req-' + mode;
const input = { command: 'touch ' + mode + '.txt' };
emit({ type: 'assistant', message: { id: 'm1', content: [{ type: 'tool_use', id: 'tu-' + mode, name: 'Bash', input }] } });
emit({ type: 'control_request', request_id: pending, request: { subtype: 'can_use_tool', tool_name: 'Bash', input, tool_use_id: 'tu-' + mode } });
if (mode === 'withdraw') {
const timer = setInterval(() => {
if (!fs.existsSync(${JSON.stringify(stage)})) return;
clearInterval(timer);
emit({ type: 'control_cancel_request', request_id: pending });
pending = null;
emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu-withdraw', content: 'Permission request withdrawn', is_error: true }] } });
emit({ type: 'assistant', message: { id: 'm2', content: [{ type: 'text', text: 'Skipped the file.' }] } });
emit({ type: 'result', subtype: 'success', is_error: false, num_turns: 2, session_id: 's-withdraw' });
}, 50);
}
return;
}
if (msg.type === 'control_request' && msg.request && msg.request.subtype === 'interrupt' && mode !== 'slow') {
if (pending) { emit({ type: 'control_cancel_request', request_id: pending }); pending = null; }
emit({ type: 'control_response', response: { subtype: 'success', request_id: msg.request_id, response: { still_queued: [] } } });
emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu-' + mode, content: "The user doesn't want to proceed with this tool use.", is_error: true }] } });
emit({ type: 'user', message: { content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }] } });
emit({ type: 'result', subtype: 'error_during_execution', is_error: true, num_turns: 2, session_id: 's-' + mode, terminal_reason: 'aborted_tools', errors: ['[ede_diagnostic] result_type=user'] });
}
});
rl.on('close', () => { record({ stdin: 'end' }); if (mode !== 'slow') process.exit(1); });
`, { mode: 0o755 });
return cli;
}

type Entry = { pid: number; msg?: { type: string; request_id?: string; request?: { subtype?: string }; response?: { request_id?: string } }; signal?: string; stdin?: string };
const entries = (home: string): Entry[] => {
const file = path.join(home, "claude-stdin.jsonl");
return fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as Entry) : [];
};
const answered = (home: string, id: string) => entries(home).filter((e) => e.msg?.type === "control_response" && e.msg.response?.request_id === id);
const interrupts = (home: string) => entries(home).filter((e) => e.msg?.type === "control_request" && e.msg.request?.subtype === "interrupt");

async function send(page: Page, text: string): Promise<void> {
await page.keyboard.press("Meta+n");
await expect(tid(page, "draft-view")).toBeVisible();
await tid(page, "composer-input").fill(text);
await tid(page, "send").click();
}
const currentRow = (page: Page) => page.locator('[data-testid="thread-row"][aria-current="true"]');

test("Claude Stop and withdrawn prompts: cards close inert, Stop ends the CLI, late answers do nothing", async () => {
const { home, repo } = seedHome({ default_backend: "claude", default_mode: "agent" });
const stage = path.join(home, "withdraw-now");
const statePath = path.join(home, "app/state.json");
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
state.settings.claude_bin = fixture(home, stage);
fs.writeFileSync(statePath, JSON.stringify(state));
let app: ElectronApplication | undefined;
try {
const opened = await launch(home); app = opened.app;
const page = opened.page;

// 1. Stop while a permission card waits: one interrupt, the CLI withdraws the prompt, the card closes as stopped.
await send(page, "card: touch a file");
const card = items(page, "approval").first();
await expect(card.getByRole("button", { name: "Approve" })).toBeVisible();
await expect(currentRow(page)).toHaveAttribute("data-status", "waiting");
await tid(page, "stop").click();
await expect(tid(card, "approval-answer")).toHaveText("Not answered — the turn was stopped");
await expect(card.getByRole("button", { name: "Approve" })).toHaveCount(0);
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
await expect(items(page, "notice").last()).toHaveText(/^Stopped\.$/);
await expect(page.locator('[data-item-kind="notice"][data-level="error"]')).toHaveCount(0);
expect(interrupts(home)).toHaveLength(1);
expect(answered(home, "req-card")).toEqual([]);
// A late answer to the obsolete card (straight over IPC, as an old window or the phone could send) changes nothing.
const threadId = (await currentRow(page).getAttribute("data-thread-id"))!;
const late = await page.evaluate(async (id) => {
const list = await window.modex!.invoke("thread:items", { threadId: id }) as { id: string; kind: string; withdrawn?: string }[];
const stale = list.find((i) => i.kind === "approval")!;
await window.modex!.invoke("thread:answer", { threadId: id, itemId: stale.id, answer: "yes" });
const after = await window.modex!.invoke("thread:items", { threadId: id }) as { id: string; kind: string; answer?: string; withdrawn?: string }[];
return after.find((i) => i.id === stale.id);
}, threadId);
expect(late).toMatchObject({ answer: "no", withdrawn: "stopped" });
await expect(tid(card, "approval-answer")).toHaveText("Not answered — the turn was stopped");
expect(answered(home, "req-card")).toEqual([]);
await page.screenshot({ path: test.info().outputPath("claude-stop-card.png") });

// 2. Claude withdraws its own prompt mid-turn: the card closes as withdrawn and the turn carries on to its reply.
await send(page, "withdraw: touch a file");
const second = items(page, "approval").first();
await expect(second.getByRole("button", { name: "Approve" })).toBeVisible();
fs.writeFileSync(stage, "now");
await expect(tid(second, "approval-answer")).toHaveText("Withdrawn — the agent no longer needs an answer");
await expect(second.getByRole("button", { name: "Approve" })).toHaveCount(0);
await expect(items(page, "assistant").last()).toContainText("Skipped the file.");
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
expect(answered(home, "req-withdraw")).toEqual([]);
expect(fs.existsSync(path.join(repo, "withdraw.txt"))).toBe(false);
await page.screenshot({ path: test.info().outputPath("claude-withdrawn-card.png") });

// 3. A CLI that ignores the interrupt is terminated after the grace window, and the notice says so.
await send(page, "slow: think for a while");
await expect(items(page, "assistant").last()).toContainText("Working on it");
await tid(page, "stop").click();
await expect(items(page, "notice").last()).toHaveText("Stopped. Claude did not end the turn within 1.5 s of the interrupt, so Modex terminated it.", { timeout: 10_000 });
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
expect(entries(home).filter((e) => e.signal === "SIGTERM")).toHaveLength(1);
await page.screenshot({ path: test.info().outputPath("claude-lifecycle.png") });
} finally {
if (app) await app.close();
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
}
});
112 changes: 112 additions & 0 deletions apps/desktop/e2e/claude-live-stop.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { launch, seedHome, tid, items } from "./support";

// Live acceptance for #275: the real `claude -p` and a real model, driven through the app. Each run spends usage on
// the signed-in Claude account, so it runs only with MODEX_CLAUDE_LIVE=1. MODEX_CLAUDE_BIN picks the executable
// (default ~/.local/bin/claude) and MODEX_CLAUDE_MODEL the model (default haiku). Start it from a shell without
// CLAUDE*/ANTHROPIC* variables, so the CLI does not run as a nested session. MODEX_LIVE_REPORT names a file for the
// timings and outcomes it observed.
test.skip(process.env.MODEX_CLAUDE_LIVE !== "1", "live Claude acceptance is opt-in (MODEX_CLAUDE_LIVE=1)");

type Item = { id: string; kind: string; text?: string; level?: string; answer?: string; withdrawn?: string; status?: string; outcome?: string; failure?: unknown };
const currentRow = (page: Page) => page.locator('[data-testid="thread-row"][aria-current="true"]');
const threadItems = async (page: Page): Promise<Item[]> => {
const threadId = await currentRow(page).getAttribute("data-thread-id");
return page.evaluate((id) => window.modex!.invoke("thread:items", { threadId: id }), threadId) as Promise<Item[]>;
};
async function newChat(page: Page, text: string): Promise<void> {
await page.keyboard.press("Meta+n");
await expect(tid(page, "draft-view")).toBeVisible();
await tid(page, "composer-input").fill(text);
await tid(page, "send").click();
}
/** Stop, then how long until the thread was idle again and what the turn's last notice said. */
async function stop(page: Page): Promise<{ ms: number; notice: string | undefined; failures: number }> {
const at = Date.now();
await tid(page, "stop").click();
await expect(currentRow(page)).toHaveAttribute("data-status", "idle", { timeout: 20_000 });
const ms = Date.now() - at;
const list = await threadItems(page);
return { ms, notice: list.filter((i) => i.kind === "notice").at(-1)?.text, failures: list.filter((i) => i.kind === "notice" && i.failure).length };
}

test("live Claude: Stop with a waiting card, Stop mid-command, resume after Stop, Stop mid-stream", async () => {
test.setTimeout(420_000);
const bin = process.env.MODEX_CLAUDE_BIN ?? path.join(os.homedir(), ".local/bin/claude");
const model = process.env.MODEX_CLAUDE_MODEL ?? "haiku";
const { home, repo } = seedHome({ default_backend: "claude", default_mode: "chat", default_model: { codex: "", claude: model, mock: "mock" } });
const statePath = path.join(home, "app/state.json");
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
state.settings.claude_bin = bin;
fs.writeFileSync(statePath, JSON.stringify(state));
const report: Record<string, unknown> = { bin, version: execFileSync(bin, ["--version"]).toString().trim(), model, at: new Date().toISOString() };
let app: ElectronApplication | undefined;
try {
const opened = await launch(home); app = opened.app;
const page = opened.page;

// 1. Stop while a permission card waits: the card closes as stopped and the command never runs.
await newChat(page, "Use the Bash tool to run exactly this command and nothing else: touch live-a.txt");
const card = items(page, "approval").last();
await expect(card.getByRole("button", { name: "Approve" })).toBeVisible({ timeout: 120_000 });
const pendingStop = await stop(page);
report.pendingCard = pendingStop;
await expect(tid(card, "approval-answer")).toHaveText("Not answered — the turn was stopped");
expect(pendingStop.failures).toBe(0);
expect(pendingStop.notice).toMatch(/^Stopped\./);
expect(fs.existsSync(path.join(repo, "live-a.txt"))).toBe(false);

// 2. Approve a long command, then Stop while it runs: the call ends Stopped and its file is never written.
// (Claude Code itself refuses a long foreground `sleep`, so the wait is in Python.)
await newChat(page, `Use the Bash tool to run exactly this command and nothing else: python3 -c "import time; time.sleep(30); open('live-b.txt', 'w')"`);
const second = items(page, "approval").last();
await second.getByRole("button", { name: "Approve" }).click({ timeout: 120_000 });
await expect(tid(second, "approval-answer")).toHaveText("Approved");
await page.waitForTimeout(3000);
const runningStop = await stop(page);
const tools = (await threadItems(page)).filter((i) => i.kind === "tool");
const tool = tools.at(-1);
report.runningTool = { ...runningStop, tools: tools.map((t) => ({ title: (t as { title?: string }).title, status: t.status, outcome: t.outcome })) };
expect(runningStop.failures).toBe(0);
expect(runningStop.notice).toMatch(/^Stopped\./);
expect(tool?.status).toBe("done");
expect(tool?.outcome, "the call Stop interrupted reads interrupted").toBe("interrupted");
await page.waitForTimeout(31_000);
expect(fs.existsSync(path.join(repo, "live-b.txt")), "the stopped command never finished").toBe(false);

// 3. The stopped thread answers its next message: its session resumes cleanly after the interrupt.
const before = (await threadItems(page)).filter((i) => i.kind === "assistant").length;
await tid(page, "composer-input").fill("Reply with the single word: resumed");
await tid(page, "send").click();
await expect.poll(async () => (await threadItems(page)).filter((i) => i.kind === "assistant").length, { timeout: 120_000 }).toBeGreaterThan(before);
await expect(currentRow(page)).toHaveAttribute("data-status", "idle", { timeout: 120_000 });
const resumed = await threadItems(page);
report.resume = { reply: resumed.filter((i) => i.kind === "assistant").at(-1)?.text, failures: resumed.filter((i) => i.kind === "notice" && i.failure).length };
expect(resumed.filter((i) => i.kind === "assistant").at(-1)?.text ?? "").toMatch(/resumed/i);

// 4. Stop while the reply streams: the text so far is kept, and nothing reads as a failure.
// Long enough that Stop lands while it streams, so the turn is really stopped rather than already done.
await newChat(page, "Write a 2000-word story about a lighthouse keeper, in plain prose. Do not use any tools.");
await expect(items(page, "assistant").last()).toBeVisible({ timeout: 120_000 });
const streamingStop = await stop(page);
const story = (await threadItems(page)).filter((i) => i.kind === "assistant").at(-1);
report.streaming = { ...streamingStop, keptChars: story?.text?.length ?? 0 };
expect(streamingStop.failures).toBe(0);
expect(streamingStop.notice, "Stop ended the streaming turn").toMatch(/^Stopped\./);
expect(story?.text?.length ?? 0).toBeGreaterThan(0);
await page.screenshot({ path: test.info().outputPath("claude-live-stop.png") });
} finally {
const out = JSON.stringify(report, null, 2);
fs.writeFileSync(test.info().outputPath("live-report.json"), out);
if (process.env.MODEX_LIVE_REPORT) fs.writeFileSync(process.env.MODEX_LIVE_REPORT, out);
if (app) await app.close();
// Keep what the app recorded, for the evidence receipt.
if (fs.existsSync(path.join(home, "app/threads"))) fs.cpSync(path.join(home, "app/threads"), test.info().outputPath("threads"), { recursive: true });
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
}
});
11 changes: 10 additions & 1 deletion apps/desktop/e2e/fixtures/title-cli.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,18 @@ process.on("SIGTERM", () => {
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const message = JSON.parse(line);
if (!codex) {
if (message.type !== "user") return;
const naming = path.basename(process.cwd()).startsWith("modex-title-");
const session_id = naming ? "title-session" : "coding-session";
// Stop is Claude Code's native interrupt; 2.1.302 acknowledges it and closes the turn at once.
if (message.type === "control_request" && message.request?.subtype === "interrupt") {
clearInterval(pending.get(session_id));
pending.delete(session_id);
send({ type: "control_response", response: { subtype: "success", request_id: message.request_id, response: { still_queued: [] } } });
send({ type: "result", subtype: "error_during_execution", is_error: true, session_id, terminal_reason: "aborted_streaming" });
fs.writeFileSync(path.join(home, "title-cancelled"), "stopped");
return;
}
if (message.type !== "user") return;
send({ type: "system", subtype: "init", session_id });
run(message.message.content, process.cwd(), (text, fail) => {
send({ type: "assistant", message: { content: [{ type: "text", text }] } });
Expand Down
Loading
Loading