Skip to content

Commit 74833f7

Browse files
committed
Merge main (CF-5 first part #287) into the CF-4 parser slice
Coven-Harness: Claude Code / claude-opus-5-5
2 parents 046df0e + d3711c6 commit 74833f7

15 files changed

Lines changed: 511 additions & 32 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { currentRow, items, launch, seedHome, tid } from "./support";
5+
6+
/**
7+
* What a turn's evidence shows (docs/conversation-evidence.md): the command that actually ran and how
8+
* it ended, output bounded with a visible note, a patch labelled as this turn's, a native memory
9+
* citation, and model-written links labelled as such. Items are sent to the window as main would.
10+
*/
11+
let app: ElectronApplication;
12+
let page: Page;
13+
let home: string;
14+
let repo: string;
15+
let threadId: string;
16+
17+
const at = () => new Date().toISOString();
18+
const origin = (item: string) => ({ thread: threadId, turn: "u-evidence", attempt: "E1", backend: "codex", native: { runtime: "R", thread: "nt", turn: "nu", item } });
19+
async function send(events: Record<string, unknown>[]): Promise<void> {
20+
await app.evaluate(({ BrowserWindow }, list) => {
21+
for (const event of list) BrowserWindow.getAllWindows()[0]!.webContents.send("thread:event", event);
22+
}, events);
23+
}
24+
const item = (event: Record<string, unknown>) => ({ type: "item", threadId, item: event });
25+
26+
test.beforeAll(async () => {
27+
({ home, repo } = seedHome());
28+
({ app, page } = await launch(home));
29+
const script = path.join(home, "evidence-script.json");
30+
fs.writeFileSync(script, JSON.stringify([{ content: "Ready." }]));
31+
await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
32+
await page.keyboard.press("Meta+n");
33+
await expect(tid(page, "composer-input")).toBeFocused();
34+
await tid(page, "composer-input").fill("start");
35+
await page.keyboard.press("Meta+Enter");
36+
await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 });
37+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
38+
threadId = (await currentRow(page).getAttribute("data-thread-id"))!;
39+
const tool = (id: string, title: string, extra: Record<string, unknown>) => item({ id, kind: "tool", name: "shell", title, args: {}, status: "done", durationMs: 1200, origin: origin(id), at: at(), ...extra });
40+
await send([
41+
item({ id: "u-evidence", kind: "user", text: "evidence turn", at: at() }),
42+
tool("t-fail", "$ npm test", { exec: { command: "/bin/zsh -lc 'npm test -- --grep \"a b\"'", cwd: "/repo/pkg" }, exitCode: 2, outcome: "failed", ok: false,
43+
output: "…last lines\n1 failing\n", outputBytes: 3 * 1024 * 1024, outputTruncated: true }),
44+
tool("t-denied", "$ rm -rf build", { exec: { command: "/bin/zsh -lc 'rm -rf build'", cwd: "/repo" }, exitCode: null, outcome: "denied", ok: false }),
45+
tool("t-claude", "$ ls", { exec: { command: "ls" }, outcome: "succeeded", ok: true, output: "src\n" }),
46+
item({ id: "t-patch", kind: "tool", name: "apply_patch", title: "edit NOTE.md", args: { patch: "*** Begin Patch\n*** Add File: NOTE.md\n+hi\n*** End Patch" }, status: "done", outcome: "succeeded", ok: true, origin: origin("t-patch"), at: at() }),
47+
item({ id: "a-final", kind: "assistant", text: "Use the build flags from memory.\n\nSources:\n- [Node.js](https://nodejs.org/)\n- [npm docs](https://docs.npmjs.com/)", phase: "final", outcome: "succeeded", origin: origin("a-final"), at: at(),
48+
evidence: [{ kind: "memory", source: "codex", path: "notes/build.md", lineStart: 3, lineEnd: 5, note: "Build flags 🛠" }] }),
49+
{ type: "status", threadId, status: "idle" },
50+
]);
51+
await tid(page, "turn-label").last().click();
52+
});
53+
54+
test.afterAll(async () => {
55+
await app?.close();
56+
fs.rmSync(home, { recursive: true, force: true });
57+
fs.rmSync(repo, { recursive: true, force: true });
58+
});
59+
60+
test("a command shows what actually ran, where, and how it ended, apart from its title", async () => {
61+
const failed = items(page, "tool").filter({ hasText: "$ npm test" });
62+
await tid(failed, "item-toggle").click();
63+
await expect(tid(failed, "tool-command")).toHaveText("/bin/zsh -lc 'npm test -- --grep \"a b\"'");
64+
await expect(tid(failed, "tool-cwd")).toHaveText("in /repo/pkg");
65+
await expect(tid(failed, "tool-exit")).toHaveText("Exit code 2");
66+
const denied = items(page, "tool").filter({ hasText: "$ rm -rf build" });
67+
await tid(denied, "item-toggle").click();
68+
await expect(tid(denied, "tool-exit")).toHaveText("No exit code");
69+
const claude = items(page, "tool").filter({ hasText: "$ ls" });
70+
await tid(claude, "item-toggle").click();
71+
await expect(tid(claude, "tool-exit")).toHaveText("Exit code not reported");
72+
await expect(tid(claude, "tool-cwd")).toHaveCount(0);
73+
});
74+
75+
test("output past the preview says how much is shown", async () => {
76+
const failed = items(page, "tool").filter({ hasText: "$ npm test" });
77+
await expect(tid(failed, "tool-truncated")).toHaveText("Showing the last 256 KB of 3.0 MB.");
78+
await expect(tid(failed, "tool-output")).toContainText("1 failing");
79+
});
80+
81+
test("a patch is labelled as this turn's, apart from the working tree now", async () => {
82+
const patch = items(page, "tool").filter({ hasText: "edit NOTE.md" });
83+
await tid(patch, "item-toggle").click();
84+
await expect(tid(patch, "tool-patch-note")).toHaveText("The patch as this turn applied it. The working tree may have changed since; Changes shows it now.");
85+
});
86+
87+
test("a native memory citation is shown as one; links the model wrote are labelled as its own", async () => {
88+
const answer = items(page, "assistant").filter({ hasText: "Use the build flags" });
89+
const memory = tid(answer, "evidence-memory");
90+
await expect(memory).toContainText("Codex memory");
91+
await expect(memory).toContainText("notes/build.md · lines 3–5");
92+
await expect(memory).toContainText("Build flags 🛠");
93+
const sources = tid(answer, "sources");
94+
await expect(sources).toHaveAttribute("data-provenance", "reply-text");
95+
await expect(sources).toContainText("Links in the reply · 2");
96+
await expect(sources).toHaveAttribute("title", "Links the model wrote in its reply. Modex has not checked them.");
97+
});

‎apps/desktop/e2e/sources.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ test("links in an answer are links, and its Sources list becomes numbered cards"
5252
await expect(answer.getByRole("link", { name: "https://example.com/pricing", exact: true })).toHaveAttribute("href", "https://example.com/pricing");
5353

5454
const sources = tid(answer, "sources");
55-
await expect(sources).toContainText("Sources · 2");
55+
await expect(sources).toContainText("Links in the reply · 2");
5656
const cards = tid(sources, "source-link");
5757
await expect(cards).toHaveCount(2);
5858
await expect(cards.nth(0)).toHaveAttribute("href", ANNOUNCEMENT);

‎apps/desktop/src/main/engine/backends/claude.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,9 @@ export class ClaudeBackend implements Backend {
407407
continue;
408408
}
409409
ctx.started.set(block.id, Date.now());
410-
ctx.observe({ type: "tool.start", native: ctx.native(block.id), tool: { name: block.name ?? "tool", title: toolTitle(block.name ?? "tool", block.input ?? {}), args: block.input ?? {} } });
410+
// Bash's command is what ran; its working directory is the CLI's own and persists between calls, so none is claimed.
411+
const command = block.name === "Bash" && typeof block.input?.command === "string" ? block.input.command : undefined;
412+
ctx.observe({ type: "tool.start", native: ctx.native(block.id), tool: { name: block.name ?? "tool", title: toolTitle(block.name ?? "tool", block.input ?? {}), args: block.input ?? {}, ...(command ? { exec: { command } } : {}) } });
411413
}
412414
}
413415
return;

‎apps/desktop/src/main/engine/backends/codex.ts‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { codexCall, UsageMeter, withUsage } from "./usage.js";
1212
import { cliEnvironment, cliSpawn, health, installation } from "./health.js";
1313
import type { AgentActivity, BackendHealth } from "../../../shared/types.js";
1414
import type { MessagePhase, NativeRef, SettledOutcome } from "../../../shared/conversation-events.js";
15+
import { boundOutput, byteLength, memoryEvidence } from "../../../shared/evidence.js";
1516

1617
/**
1718
* Codex's own wording when the sign-in it loaded at startup no longer matches ~/.codex/auth.json
@@ -283,7 +284,8 @@ export class CodexBackend implements Backend {
283284
const runtime = this.generation;
284285
const agents = new AgentTracker(sink, runtime);
285286
const activities = new Set<string>();
286-
const tools = new Map<string, { started: number; output: string }>();
287+
// Output is kept bounded as it streams (its last 256 KiB), with the total seen.
288+
const tools = new Map<string, { started: number; output: string; bytes: number }>();
287289
let turnId: string | null = null;
288290
// Codex announces a fatal error as an `error` notification before `turn/completed` reports the
289291
// failure. The notification carries the better message and an error code; it is held here and
@@ -401,15 +403,16 @@ export class CodexBackend implements Backend {
401403
} else if (item.type === "reasoning") {
402404
sink.observe({ type: "reasoning.start", native: native(item.id) });
403405
} else if (item.type === "commandExecution") {
404-
tools.set(item.id, { started: Date.now(), output: "" });
405-
sink.observe({ type: "tool.start", native: native(item.id), tool: { name: "shell", title: `$ ${stripShell(item.command ?? "")}`, args: { command: item.command, cwd: item.cwd } } });
406+
tools.set(item.id, { started: Date.now(), output: "", bytes: 0 });
407+
sink.observe({ type: "tool.start", native: native(item.id), tool: { name: "shell", title: `$ ${stripShell(item.command ?? "")}`, args: { command: item.command, cwd: item.cwd },
408+
...(item.command ? { exec: { command: item.command, ...(item.cwd ? { cwd: item.cwd } : {}) } } : {}) } });
406409
} else if (item.type === "fileChange") {
407-
tools.set(item.id, { started: Date.now(), output: "" });
410+
tools.set(item.id, { started: Date.now(), output: "", bytes: 0 });
408411
const files = (item.changes ?? []).map((c) => c.path);
409412
this.changePaths.set(`${tid}:${item.id}`, files);
410413
sink.observe({ type: "tool.start", native: native(item.id), tool: { name: "apply_patch", title: `edit ${files.join(", ")}`, args: { patch: (item.changes ?? []).map((c) => c.diff).join("\n") } } });
411414
} else if (item.type === "mcpToolCall" || item.type === "dynamicToolCall" || item.type === "webSearch") {
412-
tools.set(item.id, { started: Date.now(), output: "" });
415+
tools.set(item.id, { started: Date.now(), output: "", bytes: 0 });
413416
const label = item.type === "webSearch" ? `search ${item.query ?? ""}` : `${item.server ? item.server + "." : ""}${item.tool ?? item.type}`;
414417
sink.observe({ type: "tool.start", native: native(item.id), tool: { name: item.type, title: label, args: (item.arguments as Record<string, unknown>) ?? {} } });
415418
}
@@ -428,8 +431,10 @@ export class CodexBackend implements Backend {
428431
case "item/commandExecution/outputDelta": {
429432
const t = tools.get(String(p.itemId));
430433
if (t) {
431-
t.output += String(p.delta ?? "");
432-
sink.observe({ type: "tool.update", native: native(p.itemId), progress: { output: t.output } });
434+
const delta = String(p.delta ?? "");
435+
t.bytes += byteLength(delta);
436+
t.output = boundOutput(t.output + delta).text;
437+
sink.observe({ type: "tool.update", native: native(p.itemId), progress: { output: t.output, ...(t.bytes > byteLength(t.output) ? { outputBytes: t.bytes, outputTruncated: true } : {}) } });
433438
}
434439
break;
435440
}
@@ -438,11 +443,18 @@ export class CodexBackend implements Backend {
438443
if (item.type === "fileChange") this.changePaths.delete(`${tid}:${item.id}`);
439444
if (item.type === "agentMessage") {
440445
// A message that completes without streaming is kept, commentary included: its phase says what it is.
441-
sink.observe({ type: "message.done", native: native(item.id), ...(typeof item.text === "string" ? { text: item.text } : {}), phase: codexPhase(item.phase), ...(item.phase ? { nativePhase: item.phase } : {}) });
446+
const evidence = memoryEvidence(item.memoryCitation);
447+
sink.observe({ type: "message.done", native: native(item.id), ...(typeof item.text === "string" ? { text: item.text } : {}), phase: codexPhase(item.phase), ...(item.phase ? { nativePhase: item.phase } : {}), ...(evidence.length ? { evidence } : {}) });
442448
} else if (tools.has(item.id)) {
443449
const t = tools.get(item.id)!;
444450
const output = item.type === "commandExecution" ? (item.aggregatedOutput ?? t.output) : item.type === "fileChange" ? (item.changes ?? []).map((c) => `${c.kind?.type ?? "update"} ${c.path}\n${c.diff}`).join("\n") : JSON.stringify(item.result ?? item.error ?? "", null, 2);
445-
sink.observe({ type: "tool.done", native: native(item.id), outcome: codexOutcome(item), progress: { ...(output ? { output } : {}), durationMs: item.durationMs ?? Date.now() - t.started } });
451+
const total = Math.max(t.bytes, byteLength(output));
452+
const shown = boundOutput(output).text;
453+
sink.observe({ type: "tool.done", native: native(item.id), outcome: codexOutcome(item), progress: {
454+
...(shown ? { output: shown } : {}), ...(total > byteLength(shown) ? { outputBytes: total, outputTruncated: true } : {}),
455+
...(item.type === "commandExecution" && "exitCode" in item ? { exitCode: item.exitCode ?? null } : {}),
456+
durationMs: item.durationMs ?? Date.now() - t.started,
457+
} });
446458
} else if (item.type === "reasoning") {
447459
sink.observe({ type: "reasoning.done", native: native(item.id), summary: item.summary ?? [], text: item.content ?? [] });
448460
}
@@ -623,6 +635,7 @@ interface CodexItem {
623635
agentThreadId?: string;
624636
agentPath?: string;
625637
kind?: string;
638+
memoryCitation?: unknown;
626639
}
627640

628641
/** Cancel setup locally without killing the shared server used by other threads. */

‎apps/desktop/src/main/engine/runner.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type { PendingCreation, PendingSendRecord } from "./pending-sends.js";
2828
import { copyComposerRecovery } from "./update-recovery.js";
2929
import { parseRecoveryJSON, recoveryJSON } from "./attachment-recovery-file.js";
3030
import type { DraftRemovalScope } from "../../shared/native-drafts.js";
31+
import { boundOutput } from "../../shared/evidence.js";
3132
import { CONVERSATION_SCHEMA, applyConversationEvent, initialConversation, okFor, type AttemptMark, type ConversationEvent, type ConversationState, type Observation, type SettledOutcome, type TurnRef } from "../../shared/conversation-events.js";
3233

3334
/** Refused approvals in a routine run: the routine is read-only, and nobody is there to answer. */
@@ -1165,10 +1166,18 @@ export class ThreadRunner {
11651166
/** How a backend's turn result ends its attempt. */
11661167
const ATTEMPT_OUTCOME: Record<TurnResult["status"], SettledOutcome> = { completed: "succeeded", interrupted: "interrupted", failed: "failed" };
11671168

1168-
/** Tool args at the size the UI keeps. */
1169+
/** Tool args and output at the size the transcript keeps: output is its last 256 KiB, with the total. */
11691170
function redactObservation(o: Observation): Observation {
11701171
if (o.type === "tool.start") return { ...o, tool: { ...o.tool, args: redact(o.tool.args) } };
1171-
if ((o.type === "tool.update" || o.type === "tool.done") && o.progress?.args) return { ...o, progress: { ...o.progress, args: redact(o.progress.args) } };
1172+
if ((o.type === "tool.update" || o.type === "tool.done") && o.progress) {
1173+
const progress = { ...o.progress, ...(o.progress.args ? { args: redact(o.progress.args) } : {}) };
1174+
if (progress.output !== undefined) {
1175+
const bounded = boundOutput(progress.output);
1176+
progress.output = bounded.text;
1177+
if (bounded.truncated) Object.assign(progress, { outputBytes: Math.max(progress.outputBytes ?? 0, bounded.truncated.totalBytes), outputTruncated: true });
1178+
}
1179+
return { ...o, progress } as Observation;
1180+
}
11721181
return o;
11731182
}
11741183

0 commit comments

Comments
 (0)