Skip to content

Commit 45242f5

Browse files
committed
Merge main (CF-7 first part #289) into the web-search fix
Coven-Harness: Claude Code / claude-opus-5-5
2 parents a258228 + f6976df commit 45242f5

20 files changed

Lines changed: 587 additions & 11 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
* A task's plan and record (docs/task-context.md): the plan the CLI reported, in its statuses, and an
8+
* explicit Save to Notes that writes the turn's record into the project's notes and updates it when
9+
* saved again. Plan items are sent to the window as main would; the saved turn ran on the mock engine.
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+
19+
test.beforeAll(async () => {
20+
({ home, repo } = seedHome());
21+
({ app, page } = await launch(home));
22+
const script = path.join(home, "task-script.json");
23+
fs.writeFileSync(script, JSON.stringify([{ content: "Fixed the import." }]));
24+
await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
25+
await page.keyboard.press("Meta+n");
26+
await expect(tid(page, "composer-input")).toBeFocused();
27+
await tid(page, "composer-input").fill("Fix the failing import");
28+
await page.keyboard.press("Meta+Enter");
29+
await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 });
30+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
31+
threadId = (await currentRow(page).getAttribute("data-thread-id"))!;
32+
});
33+
34+
test.afterAll(async () => {
35+
await app?.close();
36+
fs.rmSync(home, { recursive: true, force: true });
37+
fs.rmSync(repo, { recursive: true, force: true });
38+
});
39+
40+
test("a plan shows each step as the CLI reported it, never as verified", async () => {
41+
const origin = { thread: threadId, turn: "u-plan", attempt: "P1", backend: "codex", native: { runtime: "R", item: "plan" } };
42+
await app.evaluate(({ BrowserWindow }, events) => {
43+
for (const event of events) BrowserWindow.getAllWindows()[0]!.webContents.send("thread:event", event);
44+
}, [
45+
{ type: "item", threadId, item: { id: "u-plan", kind: "user", text: "plan turn", at: at() } },
46+
{ type: "item", threadId, item: { id: "p", kind: "plan", from: "codex-plan", revision: 2, explanation: "Small fix", outcome: "succeeded", origin, at: at(),
47+
steps: [{ text: "Read the config", status: "completed" }, { text: "Fix the import", status: "in-progress" }, { text: "Run the tests", status: "pending" }, { text: "Ship", status: "unknown" }] } },
48+
]);
49+
const plan = items(page, "plan");
50+
await expect(tid(plan, "plan-meta")).toHaveText("From Codex's plan · revision 2");
51+
await expect(tid(plan, "plan-step")).toHaveText(["Reported done Read the config", "In progress Fix the import", "Not started Run the tests", "Status unknown Ship"]);
52+
await expect(plan).toContainText("Small fix");
53+
await expect(plan).not.toContainText(/verified/i);
54+
});
55+
56+
test("Save to Notes writes the turn's record into the project's notes, and saving again updates the same note", async () => {
57+
const save = tid(page, "save-task-note").first();
58+
await save.click();
59+
await expect(tid(page, "task-note-saved").first()).toContainText("Saved to Notes · Task:");
60+
const pages = async () => (await page.evaluate(() => window.modex!.invoke("space:list", undefined))) as { id: string; title: string; markdown: string }[];
61+
const saved = (await pages()).filter((p) => p.title.startsWith("Task:"));
62+
expect(saved).toHaveLength(1);
63+
expect(saved[0]!.markdown).toContain("> Fix the failing import");
64+
expect(saved[0]!.markdown).toContain("Fixed the import.");
65+
expect(saved[0]!.markdown).toContain(`From modex thread ${threadId}.`);
66+
expect(saved[0]!.markdown).not.toContain(repo);
67+
await expect(save).toHaveText("Update note");
68+
await save.click();
69+
await expect(tid(page, "task-note-saved").first()).toContainText("Updated in Notes · Task:");
70+
expect((await pages()).filter((p) => p.title.startsWith("Task:"))).toHaveLength(1);
71+
});

‎apps/desktop/src/main/commands.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export function registerCommands(handle: RegisterCommand, {
9090
});
9191
handle("thread:create", ({ projectId, worktree, mode, model, backend, auto }) => runner.createThread(projectId, { worktree, mode, model, backend, auto }));
9292
handle("thread:items", ({ threadId }) => runner.items(threadId));
93+
handle("task:save-note", ({ threadId, turnId }) => runner.saveTaskNote(threadId, turnId));
9394
handle("thread:followup", ({ threadId }) => runner.followUp(threadId));
9495
handle("thread:send", ({ threadId, text, attachments }) => startTurn(threadId, runner.send(threadId, text, attachments)));
9596
registerAttachmentCommands(handle, runner.attachments, { bytesOnly: true, reveal: revealAttachment ?? (() => { throw new Error("Attachment reveal is unavailable."); }) });

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { LineBuffer, shortJson, stderrTail } from "./types.js";
1111
import { claudeCall, UsageMeter, withUsage } from "./usage.js";
1212
import { cliEnvironment, cliSpawn, health, installation, probe } from "./health.js";
1313
import type { BackendHealth } from "../../../shared/types.js";
14-
import type { NativeRef, Observation } from "../../../shared/conversation-events.js";
14+
import type { NativeRef, Observation, PlanStepStatus } from "../../../shared/conversation-events.js";
1515
import { isModelOutOfCredits } from "../../../shared/failures.js";
1616

1717
/**
@@ -407,6 +407,11 @@ export class ClaudeBackend implements Backend {
407407
continue;
408408
}
409409
ctx.started.set(block.id, Date.now());
410+
// TodoWrite carries the whole todo list: Claude's plan, revised by each call.
411+
if (block.name === "TodoWrite" && Array.isArray(block.input?.todos)) {
412+
const steps = (block.input.todos as { content?: unknown; status?: unknown }[]).map((t) => ({ text: String(t.content ?? ""), status: claudeStepStatus(t.status) }));
413+
ctx.observe({ type: "plan.update", native: ctx.native("todos"), from: "claude-todo", steps });
414+
}
410415
// Bash's command is what ran; its working directory is the CLI's own and persists between calls, so none is claimed.
411416
const command = block.name === "Bash" && typeof block.input?.command === "string" ? block.input.command : undefined;
412417
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 } } : {}) } });
@@ -582,6 +587,10 @@ export function thinkingRepresentation(display: string | undefined): "summary" |
582587
return display === "summarized" ? "summary" : "provider-text";
583588
}
584589

590+
function claudeStepStatus(status: unknown): PlanStepStatus {
591+
return status === "pending" ? "pending" : status === "in_progress" ? "in-progress" : status === "completed" ? "completed" : "unknown";
592+
}
593+
585594
/** A `result` message as a turn outcome. */
586595
function resultOf(msg: ClaudeMessage): TurnResult {
587596
if (msg.is_error) return { status: "failed", error: typeof msg.result === "string" ? msg.result : msg.subtype ?? "error", detail: { resultSubtype: msg.subtype, errors: msg.errors, sessionId: msg.session_id, numTurns: msg.num_turns, durationMs: msg.duration_ms } };

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { LineBuffer, stderrTail } from "./types.js";
1111
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";
14-
import type { MessagePhase, NativeRef, SettledOutcome } from "../../../shared/conversation-events.js";
14+
import type { MessagePhase, NativeRef, PlanStepStatus, SettledOutcome } from "../../../shared/conversation-events.js";
1515
import { boundOutput, byteLength, memoryEvidence } from "../../../shared/evidence.js";
1616

1717
/**
@@ -425,6 +425,12 @@ export class CodexBackend implements Backend {
425425
case "item/reasoning/textDelta":
426426
sink.observe({ type: "reasoning.delta", native: native(p.itemId), representation: "provider-text", index: partIndex(p.contentIndex), text: String(p.delta ?? "") });
427427
break;
428+
// The plan Codex reports for the turn (codex-cli 0.163.0: pending, inProgress, completed), whole each time.
429+
case "turn/plan/updated": {
430+
const steps = Array.isArray(p.plan) ? (p.plan as { step?: unknown; status?: unknown }[]).map((s) => ({ text: String(s.step ?? ""), status: codexStepStatus(s.status) })) : [];
431+
sink.observe({ type: "plan.update", native: native("plan"), from: "codex-plan", steps, ...(typeof p.explanation === "string" && p.explanation ? { explanation: p.explanation } : {}) });
432+
break;
433+
}
428434
case "item/agentMessage/delta":
429435
sink.observe({ type: "message.delta", native: native(p.itemId), text: String(p.delta ?? "") });
430436
break;
@@ -702,6 +708,10 @@ export function codexOutcome(item: { type: string; status?: string; exitCode?: n
702708
return item.status === undefined && item.type === "webSearch" ? "succeeded" : "unknown";
703709
}
704710

711+
function codexStepStatus(status: unknown): PlanStepStatus {
712+
return status === "pending" ? "pending" : status === "inProgress" ? "in-progress" : status === "completed" ? "completed" : "unknown";
713+
}
714+
705715
function partIndex(value: unknown): number {
706716
const n = Number(value);
707717
return Number.isSafeInteger(n) && n >= 0 ? n : 0;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ export function mobileItem(item: ThreadItem): Record<string, unknown> {
389389
case "tool": return { id: item.id, kind: item.kind, title: item.status === "done" && (item.outcome ? item.outcome !== "succeeded" : item.ok === false) ? `${item.title} · ${toolStatusLabel(item)}` : item.title,
390390
status: item.status, ok: item.ok, ...(item.outcome ? { outcome: item.outcome } : {}), at: item.at };
391391
case "route": return { id: item.id, kind: item.kind, text: `${item.backend} · ${item.model || "default"}`, at: item.at };
392+
// A phone that predates plans shows a kind it does not know as its text.
393+
case "plan": return { id: item.id, kind: item.kind, text: `Plan · ${item.steps.filter((s) => s.status === "completed").length} of ${item.steps.length} reported done`, steps: item.steps, revision: item.revision, ...(item.outcome ? { outcome: item.outcome } : {}), at: item.at };
392394
}
393395
}
394396

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { copyComposerRecovery } from "./update-recovery.js";
2929
import { parseRecoveryJSON, recoveryJSON } from "./attachment-recovery-file.js";
3030
import type { DraftRemovalScope } from "../../shared/native-drafts.js";
3131
import { boundOutput } from "../../shared/evidence.js";
32+
import { taskMarkdown, taskRecord } from "../../shared/task-context.js";
3233
import { CONVERSATION_SCHEMA, applyConversationEvent, initialConversation, okFor, type AttemptMark, type ConversationEvent, type ConversationState, type Observation, type SettledOutcome, type TurnRef } from "../../shared/conversation-events.js";
3334

3435
/** Refused approvals in a routine run: the routine is read-only, and nobody is there to answer. */
@@ -301,6 +302,38 @@ export class ThreadRunner {
301302
} finally { this.unattended.delete(threadId); }
302303
}
303304

305+
/**
306+
* Saves a turn's task record (shared/task-context.ts) to the thread's project notes, at a person's
307+
* request. Saving again updates the same note, unless it was edited since: then the edit stays and
308+
* this version becomes a note of its own. Writes only through the notes store; reads only the
309+
* transcript.
310+
*/
311+
saveTaskNote(threadId: string, turnId: string): { pageId: string; title: string; revision: number; created: boolean; preserved: boolean } {
312+
this.assertNotRestarting();
313+
const thread = this.o.store.thread(threadId);
314+
if (!thread) throw new Error(`unknown thread ${threadId}`);
315+
const items = this.items(threadId);
316+
const start = items.findIndex((item) => item.kind === "user" && item.id === turnId);
317+
if (start < 0) throw new Error("That message is no longer in this thread.");
318+
const next = items.findIndex((item, i) => i > start && item.kind === "user");
319+
const turn = items.slice(start, next < 0 ? undefined : next);
320+
const record = taskRecord(turn, { threadId, title: `Task: ${thread.title}`, cwd: thread.cwd });
321+
const markdown = taskMarkdown(record);
322+
const notes = this.o.store.space;
323+
const project = notes.tree().nodes.find((node) => node.kind === "project" && node.modexProjectId === thread.projectId);
324+
if (!project) throw new Error("This project has no notes yet. Open Notes for it, then save again.");
325+
const saved = thread.taskNotes?.[turnId];
326+
const existing = saved ? notes.list().find((page) => page.id === saved.pageId && !page.trashedAt) : undefined;
327+
const change = { actor: "human", summary: "Saved a task record" };
328+
const untouched = existing && existing.revision === saved!.revision;
329+
const page = untouched
330+
? notes.save({ ...existing, title: record.title, markdown }, change)
331+
: notes.create({ title: existing ? `${record.title} (saved again)` : record.title, markdown, containerId: project.id }, change);
332+
const updated = this.o.store.updateThread(threadId, { taskNotes: { ...thread.taskNotes, [turnId]: { pageId: page.id, revision: page.revision } } });
333+
this.o.emit({ threadId, type: "thread", thread: updated });
334+
return { pageId: page.id, title: page.title, revision: page.revision, created: !untouched, preserved: Boolean(existing && !untouched) };
335+
}
336+
304337
/** A receipt in the transcript for something Modex did on the thread's behalf. */
305338
addNotice(threadId: string, level: "info" | "warn" | "error", text: string): void {
306339
this.addItem(threadId, { id: newId(), kind: "notice", level, text, at: new Date().toISOString() });

‎apps/desktop/src/main/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ handle("project:remove", async ({ projectId }) => {
778778
});
779779
handle("thread:create", ({ projectId, worktree, mode, model, backend, auto }) => runner.createThread(projectId, { worktree, mode, model, backend, auto }));
780780
handle("thread:items", ({ threadId }) => runner.items(threadId));
781+
handle("task:save-note", ({ threadId, turnId }) => runner.saveTaskNote(threadId, turnId));
781782
handle("thread:followup", ({ threadId }) => runner.followUp(threadId));
782783
/**
783784
* Starts a turn without waiting for it. A rejection the runner can detect up front (busy thread,

‎apps/desktop/src/renderer/components/ThreadView.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ErrorNotice } from "./ui/ErrorNotice";
1616
import { TurnContent, groupTurns } from "./transcript/TurnContent";
1717
import { ReasoningItem } from "./transcript/ReasoningItem";
1818
import { EvidenceCard } from "./transcript/EvidenceCard";
19+
import { SaveTaskNote, TaskPlanCard } from "./transcript/TaskPlanCard";
1920
import { OUTPUT_PREVIEW_BYTES } from "../../shared/evidence";
2021

2122
export { formatElapsed } from "./transcript/TurnContent";
@@ -170,6 +171,7 @@ export function ThreadView({ thread, project, items, text, onText, attachments,
170171
)}
171172
{turns.map((turn, i) => (
172173
<TurnContent key={turn.user?.id ?? `pre-${i}`} turn={turn} status={i === lastUser && busy ? thread.status : undefined}
174+
footer={(t) => <SaveTaskNote threadId={thread.id} turnId={t.user!.id} saved={Boolean(thread.taskNotes?.[t.user!.id])} />}
173175
render={(item, live) => <Item key={item.id} item={item} live={live} onAnswer={onAnswer} onPolicy={(approvals) => onUpdate({ approvals })} onFix={onFix} onRetry={!thread.retired && !busy && item.id === retryId ? onRetry : undefined} />} />
174176
))}
175177
</div>
@@ -247,6 +249,8 @@ function Item({ item, live, onAnswer, onPolicy, onFix, onRetry }: { item: Thread
247249
return <ReasoningItem item={item} />;
248250
case "route":
249251
return <RouteItem item={item} />;
252+
case "plan":
253+
return <TaskPlanCard item={item} />;
250254
}
251255
}
252256

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useState } from "react";
2+
import type { ThreadItem } from "../../../shared/types";
3+
import type { PlanStep } from "../../../shared/conversation-events";
4+
import { bridge } from "../../bridge";
5+
import { ErrorNotice } from "../ui/ErrorNotice";
6+
7+
const STATUS: Record<PlanStep["status"], string> = { pending: "Not started", "in-progress": "In progress", completed: "Reported done", unknown: "Status unknown" };
8+
const FROM = { "codex-plan": "Codex's plan", "claude-todo": "Claude's todo list" } as const;
9+
10+
/**
11+
* The plan a CLI reported for a turn (docs/task-context.md), in its own statuses. A step the CLI
12+
* marked completed reads "Reported done": what it said, not a check.
13+
*/
14+
export function TaskPlanCard({ item }: { item: Extract<ThreadItem, { kind: "plan" }> }) {
15+
return (
16+
<div className="plan-card" data-testid="item" data-item-kind="plan">
17+
<div className="plan-head">
18+
<span className="plan-title">Plan</span>
19+
<span className="plan-meta" data-testid="plan-meta">From {FROM[item.from]} · revision {item.revision}</span>
20+
</div>
21+
{item.explanation && <p className="plan-explanation">{item.explanation}</p>}
22+
<ol className="plan-steps">
23+
{item.steps.map((step, i) => (
24+
<li key={i} className={`plan-step ${step.status}`} data-testid="plan-step" data-status={step.status}>
25+
<span className="plan-status">{STATUS[step.status]}</span> {step.text}
26+
</li>
27+
))}
28+
</ol>
29+
</div>
30+
);
31+
}
32+
33+
/**
34+
* Saves one turn's task record to its project's notes, only when asked. Saving again updates the
35+
* same note, unless it was edited since; then the edit stays and this version becomes a new note.
36+
*/
37+
export function SaveTaskNote({ threadId, turnId, saved }: { threadId: string; turnId: string; saved: boolean }) {
38+
const [state, setState] = useState<{ busy: boolean; result?: { pageId: string; title: string; created: boolean; preserved: boolean }; error?: string }>({ busy: false });
39+
const save = async () => {
40+
setState((s) => ({ ...s, busy: true, error: undefined }));
41+
try { setState({ busy: false, result: await bridge.invoke("task:save-note", { threadId, turnId }) }); }
42+
catch (err) { setState({ busy: false, error: (err as Error).message }); }
43+
};
44+
const result = state.result;
45+
return (
46+
<div className="task-note">
47+
<button type="button" className="task-note-save" data-testid="save-task-note" disabled={state.busy} onClick={() => void save()}>{saved || result ? "Update note" : "Save to Notes"}</button>
48+
{result && <span className="task-note-saved" data-testid="task-note-saved">
49+
{result.created ? (result.preserved ? "The note was edited since; saved as a new note · " : "Saved to Notes · ") : "Updated in Notes · "}{result.title}
50+
<button type="button" className="btn small" onClick={() => window.dispatchEvent(new CustomEvent("modex:open-page", { detail: result.pageId }))}>Open</button>
51+
</span>}
52+
{state.error && <ErrorNotice testId="task-note-error" where="Notes · save task" message={state.error} onRetry={state.busy ? undefined : () => void save()} context={{ threadId, turnId }} />}
53+
</div>
54+
);
55+
}

‎apps/desktop/src/renderer/components/transcript/TurnContent.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function groupTurns(items: ThreadItem[]): Turn[] {
2323
* Keep the final answer and actionable items visible; completed work stays inspectable. Which item
2424
* answers, which are live and which may fold comes from `layoutTurn` (shared/turn-layout.ts).
2525
*/
26-
export function TurnContent({ turn, status, render }: { turn: Turn; status?: Thread["status"]; render: (item: ThreadItem, live: boolean) => ReactNode }) {
26+
export function TurnContent({ turn, status, render, footer }: { turn: Turn; status?: Thread["status"]; render: (item: ThreadItem, live: boolean) => ReactNode; footer?: (turn: Turn) => ReactNode }) {
2727
const [expanded, setExpanded] = useState(false);
2828
const historyId = useId();
2929
const live = status !== undefined;
@@ -41,6 +41,7 @@ export function TurnContent({ turn, status, render }: { turn: Turn; status?: Thr
4141
<div key={item.id} hidden={layout.work[index] && !showWork} data-final={index === layout.final ? "true" : undefined} data-live={layout.live[index] ? "true" : undefined}>{render(item, layout.live[index]!)}</div>,
4242
])}
4343
</div>
44+
{!live && turn.user && turn.rest.length > 0 && footer?.(turn)}
4445
</>;
4546
}
4647

0 commit comments

Comments
 (0)