Skip to content

Commit c676754

Browse files
committed
Merge #299's final head (main at #310) into the retained-log slice
Coven-Harness: Claude Code / claude-opus-5-5
2 parents 1829b4b + 9321b3b commit c676754

32 files changed

Lines changed: 1187 additions & 172 deletions

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,8 @@ must be public for the rules to apply.
271271

272272
## Not (yet) here
273273

274-
Codex Cloud tasks stay out of scope. Read-only [routines](docs/routines.md) are on `main`,
275-
unreleased; routines that change code wait on the approval gate decision. Windows and Linux
274+
Codex Cloud tasks stay out of scope. Read-only [routines](docs/routines.md) ship from v0.2.0;
275+
routines that change code wait on the approval gate decision. Windows and Linux
276276
installers ship from v0.1.0, and their [acceptance gaps](docs/native-platforms.md) are still
277277
open. Modex has no HTTP-API execution mode; if a CLI is not installed or logged in, the thread
278278
says so.

‎apps/desktop/e2e/conversation-fidelity.spec.ts‎

Lines changed: 222 additions & 7 deletions
Large diffs are not rendered by default.

‎apps/desktop/e2e/human-requests.spec.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,71 @@ test("Codex questions: the card answers by its schema, the CLI hears once, the s
149149
fs.rmSync(logs, { recursive: true, force: true });
150150
}
151151
});
152+
153+
test("with the journal on, a restart shows answered and stopped cards inert, says when an answer was never confirmed, and re-sends nothing", async () => {
154+
const { home } = seedHome({ default_backend: "codex", default_mode: "agent" });
155+
const logs = fs.mkdtempSync(path.join(os.tmpdir(), "modex-e2e-codex-questions-"));
156+
const statePath = path.join(home, "app/state.json");
157+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
158+
state.settings.codex_bin = fixture(logs);
159+
fs.writeFileSync(statePath, JSON.stringify(state));
160+
const journal = process.env.MODEX_EVENT_JOURNAL;
161+
process.env.MODEX_EVENT_JOURNAL = "1";
162+
let app: ElectronApplication | undefined;
163+
try {
164+
let opened = await launch(home); app = opened.app;
165+
let page = opened.page;
166+
await send(page, "deploy it");
167+
const card = items(page, "question").first();
168+
await card.getByRole("radio", { name: "staging" }).check();
169+
await card.getByLabel("Token").fill("s3cr3t-token");
170+
await tid(card, "question-submit").click();
171+
await expect(tid(card, "question-answer")).toHaveText(["staging", "(hidden)"]);
172+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
173+
await expect(tid(card, "answer-note")).toHaveCount(0);
174+
// A follow-up in the same thread asks again; Stop closes that card unanswered.
175+
await tid(page, "composer-input").fill("deploy again");
176+
await page.keyboard.press("Meta+Enter");
177+
await expect(tid(items(page, "question").nth(1), "question-submit")).toBeVisible();
178+
await tid(page, "stop").click();
179+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
180+
const threadId = (await currentRow(page).getAttribute("data-thread-id"))!;
181+
const lifecycle = (list: { kind: string; answeredBy?: string; delivery?: string; withdrawn?: string }[]) =>
182+
list.filter((i) => i.kind === "question").map((i) => [i.answeredBy ?? null, i.delivery ?? null, i.withdrawn ?? null]);
183+
expect(lifecycle(await page.evaluate((id) => window.modex!.invoke("thread:items", { threadId: id }), threadId) as never)).toEqual([["desktop", "sent", null], [null, null, "stopped"]]);
184+
await app.close();
185+
app = undefined;
186+
expect(replies(logs)).toHaveLength(2);
187+
188+
// As if Modex had died after taking the first answer and before recording its hand-off to Codex.
189+
const file = path.join(home, "app", "threads", `${threadId}.json`);
190+
const saved = JSON.parse(fs.readFileSync(file, "utf8")) as { kind: string; delivery?: string }[];
191+
saved.find((i) => i.kind === "question")!.delivery = "sending";
192+
fs.writeFileSync(file, JSON.stringify(saved));
193+
194+
opened = await launch(home); app = opened.app;
195+
page = opened.page;
196+
await page.locator(`[data-testid="thread-row"][data-thread-id="${threadId}"] button.thread-main`).click();
197+
const [first, second] = [items(page, "question").first(), items(page, "question").nth(1)];
198+
await expect(tid(first, "question-answer")).toHaveText(["staging", "(hidden)"]);
199+
await expect(tid(first, "answer-note")).toHaveText("Modex stopped before it could confirm the agent received this answer.");
200+
await expect(tid(second, "question-closed")).toHaveText("Not answered — the turn was stopped");
201+
await expect(page.locator('[data-testid="question-submit"]')).toHaveCount(0);
202+
expect(lifecycle(await page.evaluate((id) => window.modex!.invoke("thread:items", { threadId: id }), threadId) as never)).toEqual([["desktop", "uncertain", null], [null, null, "stopped"]]);
203+
// An old window answering the uncertain card again is refused, and nothing reaches Codex.
204+
expect(await page.evaluate(async (id) => {
205+
const list = await window.modex!.invoke("thread:items", { threadId: id }) as { id: string; kind: string }[];
206+
return window.modex!.invoke("thread:answer-question", { threadId: id, itemId: list.find((i) => i.kind === "question")!.id, answers: { "q-env": ["production"], "q-token": ["other"] } });
207+
}, threadId)).toEqual({ ok: false, reason: "already-resolved" });
208+
expect(replies(logs)).toHaveLength(2);
209+
await app.close();
210+
app = undefined;
211+
// The journal recorded all of it, and no file Modex wrote holds the secret.
212+
expect(fs.existsSync(path.join(home, "app", "events.sqlite"))).toBe(true);
213+
expect(everything(path.join(home, "app")).includes("s3cr3t-token")).toBe(false);
214+
} finally {
215+
if (journal === undefined) delete process.env.MODEX_EVENT_JOURNAL; else process.env.MODEX_EVENT_JOURNAL = journal;
216+
await app?.close();
217+
fs.rmSync(logs, { recursive: true, force: true });
218+
}
219+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { test, expect } from "@playwright/test";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { currentRow, items, launch, seedHome, tid } from "./support";
6+
7+
/**
8+
* A plan clarification (docs/task-context.md, CF-7 #280): the CLI reports a plan, asks a clarifying question
9+
* through the question card, and carries on once it is answered. The answer reaches the CLI once, and Save
10+
* to Notes records it with the secret hidden. A clarification nobody answered says so and gains no answer.
11+
* The executable below is a scripted codex-cli 0.163.0 app-server (`turn/plan/updated`, then
12+
* `item/tool/requestUserInput`), not a provider.
13+
*/
14+
function fixture(dir: string): string {
15+
const cli = path.join(dir, "codex-clarify.cjs");
16+
fs.writeFileSync(cli, `#!${process.execPath}
17+
const fs = require('node:fs');
18+
if (process.argv.includes('--version')) { console.log('codex-cli 0.163.0'); process.exit(0); }
19+
if (process.argv.includes('login') || process.argv.includes('auth')) { console.log('Logged in using ChatGPT'); process.exit(0); }
20+
const log = ${JSON.stringify(path.join(dir, "codex-replies.jsonl"))};
21+
const emit = (o) => process.stdout.write(JSON.stringify(o) + '\\n');
22+
const questions = [
23+
{ id: 'q-env', header: 'Environment', question: 'Which environment should the plan target?', isOther: false, isSecret: false, options: [{ label: 'staging', description: 'Safe to try' }, { label: 'production', description: 'Live traffic' }] },
24+
{ id: 'q-token', header: 'Token', question: 'Paste the deploy token', isOther: true, isSecret: true, options: null },
25+
];
26+
let turn = 0, open = null;
27+
const cwds = new Map();
28+
const plan = (thread, id, a, b) => emit({ method: 'turn/plan/updated', params: { threadId: thread, turnId: id, explanation: null, plan: [{ step: 'Pick the environment', status: a }, { step: 'Deploy', status: b }] } });
29+
require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => {
30+
const msg = JSON.parse(line);
31+
const p = msg.params || {};
32+
if (msg.method === 'initialize') return emit({ id: msg.id, result: { userAgent: 'codex-clarify/0.163.0' } });
33+
if (msg.method === 'initialized') return;
34+
if (msg.method === 'account/read') return emit({ id: msg.id, result: { account: { type: 'chatgpt' }, requiresOpenaiAuth: true } });
35+
if (msg.method === 'model/list') return emit({ id: msg.id, result: { data: [{ id: 'fixture', model: 'fixture', displayName: 'Fixture', isDefault: true }], nextCursor: null } });
36+
if (msg.method === 'thread/start' || msg.method === 'thread/resume') {
37+
const id = p.threadId || 'thr-' + cwds.size;
38+
cwds.set(id, p.cwd || '');
39+
return emit({ id: msg.id, result: { thread: { id } } });
40+
}
41+
if (msg.method === 'turn/start' && /modex-title-/.test(cwds.get(p.threadId) || '')) {
42+
const id = 'title-' + (++turn);
43+
emit({ id: msg.id, result: { turn: { id } } });
44+
emit({ method: 'item/completed', params: { threadId: p.threadId, turnId: id, item: { id: 'name-' + turn, type: 'agentMessage', text: 'Deploy plan', phase: 'final_answer' } } });
45+
return emit({ method: 'turn/completed', params: { threadId: p.threadId, turn: { id, status: 'completed', error: null } } });
46+
}
47+
if (msg.method === 'turn/start') {
48+
const id = 'turn-' + (++turn);
49+
open = { thread: p.threadId, turn: id, request: 900 + turn, done: false };
50+
emit({ id: msg.id, result: { turn: { id } } });
51+
emit({ method: 'turn/started', params: { threadId: p.threadId, turn: { id } } });
52+
plan(p.threadId, id, 'inProgress', 'pending');
53+
emit({ id: open.request, method: 'item/tool/requestUserInput', params: { threadId: p.threadId, turnId: id, itemId: 'ask-' + turn, isBlocking: true, autoResolutionMs: null, questions } });
54+
return;
55+
}
56+
const finish = (status, text) => {
57+
if (!open || open.done) return;
58+
open.done = true;
59+
if (text) emit({ method: 'item/completed', params: { threadId: open.thread, turnId: open.turn, item: { id: 'say-' + turn, type: 'agentMessage', text, phase: 'final_answer' } } });
60+
emit({ method: 'turn/completed', params: { threadId: open.thread, turn: { id: open.turn, status, error: null } } });
61+
};
62+
if (msg.method === 'turn/interrupt') { emit({ id: msg.id, result: {} }); return finish('interrupted'); }
63+
if (open && msg.id === open.request && !msg.method) {
64+
fs.appendFileSync(log, JSON.stringify({ request: msg.id, result: msg.result, error: msg.error }) + '\\n');
65+
if (msg.result) {
66+
plan(open.thread, open.turn, 'completed', 'completed');
67+
finish('completed', 'Planned the deploy to ' + msg.result.answers['q-env'].answers[0] + '.');
68+
}
69+
return;
70+
}
71+
if (msg.id !== undefined && msg.method) emit({ id: msg.id, error: { code: -32601, message: 'fixture does not implement ' + msg.method } });
72+
});
73+
`, { mode: 0o755 });
74+
return cli;
75+
}
76+
77+
type Reply = { request: number; result?: { answers: Record<string, { answers: string[] }> }; error?: { code: number } };
78+
const replies = (dir: string): Reply[] => {
79+
const file = path.join(dir, "codex-replies.jsonl");
80+
return fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as Reply) : [];
81+
};
82+
const everything = (dir: string): string => fs.readdirSync(dir, { recursive: true, withFileTypes: true })
83+
.filter((entry) => entry.isFile()).map((entry) => fs.readFileSync(path.join(entry.parentPath, entry.name), "utf8")).join("\n");
84+
85+
test("a plan clarification is answered on its card, reaches the CLI once, and is saved to Notes with the secret hidden; an unanswered one says so", async () => {
86+
const { home, repo } = seedHome({ default_backend: "codex", default_mode: "agent" });
87+
const logs = fs.mkdtempSync(path.join(os.tmpdir(), "modex-e2e-clarify-"));
88+
const statePath = path.join(home, "app/state.json");
89+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
90+
state.settings.codex_bin = fixture(logs);
91+
fs.writeFileSync(statePath, JSON.stringify(state));
92+
const { app, page } = await launch(home);
93+
try {
94+
await page.keyboard.press("Meta+n");
95+
await tid(page, "composer-input").fill("Plan the deploy");
96+
await page.keyboard.press("Meta+Enter");
97+
// The plan as reported, then the clarification on the existing question card.
98+
await expect(tid(items(page, "plan"), "plan-step")).toHaveText(["In progress Pick the environment", "Not started Deploy"]);
99+
const card = items(page, "question").first();
100+
await expect(currentRow(page)).toHaveAttribute("data-status", "waiting");
101+
await card.getByRole("radio", { name: "staging" }).check();
102+
await card.getByLabel("Token").fill("s3cr3t-deploy-token");
103+
await tid(card, "question-submit").click();
104+
await expect(items(page, "assistant").last()).toContainText("Planned the deploy to staging.");
105+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
106+
await expect(tid(items(page, "plan"), "plan-step")).toHaveText(["Reported done Pick the environment", "Reported done Deploy"]);
107+
expect(replies(logs).map((reply) => reply.result)).toEqual([{ answers: { "q-env": { answers: ["staging"] }, "q-token": { answers: ["s3cr3t-deploy-token"] } } }]);
108+
109+
// Save to Notes: the clarification is in the record, the secret is not.
110+
await tid(page, "save-task-note").last().click();
111+
await expect(tid(page, "task-note-saved").last()).toContainText("Saved to Notes · Task:");
112+
const notes = async () => (await page.evaluate(() => window.modex!.invoke("space:list", undefined))) as { title: string; markdown: string }[];
113+
const first = (await notes()).find((note) => note.title.startsWith("Task:"))!;
114+
expect(first.markdown).toContain("## Clarifications");
115+
expect(first.markdown).toContain("- Which environment should the plan target? — staging");
116+
expect(first.markdown).toContain("- Paste the deploy token — (hidden)");
117+
expect(first.markdown).not.toContain("s3cr3t-deploy-token");
118+
119+
// A second turn asks again, and Stop closes the question unanswered: the CLI gets an error, the record no answer.
120+
await tid(page, "composer-input").fill("Plan it again");
121+
await page.keyboard.press("Meta+Enter");
122+
await expect(tid(items(page, "question").nth(1), "question-submit")).toBeVisible();
123+
await tid(page, "stop").click();
124+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
125+
await expect.poll(() => replies(logs).length).toBe(2);
126+
expect(replies(logs)[1]).toMatchObject({ error: { code: -32000 } });
127+
await tid(page, "save-task-note").last().click();
128+
await expect(tid(page, "task-note-saved").last()).toContainText("Saved to Notes · Task:");
129+
const second = (await notes()).filter((note) => note.title.startsWith("Task:")).find((note) => note.markdown.includes("Plan it again"))!;
130+
expect(second.markdown).toContain("- Which environment should the plan target? — not answered: the turn was stopped");
131+
expect(second.markdown).toMatch(/## Not settled\n[\s\S]*Paste the deploy token — not answered: the turn was stopped/);
132+
expect(second.markdown).not.toContain("— staging");
133+
expect(replies(logs)).toHaveLength(2);
134+
expect(everything(home).includes("s3cr3t-deploy-token")).toBe(false);
135+
} finally {
136+
await app.close();
137+
for (const dir of [home, repo, logs]) fs.rmSync(dir, { recursive: true, force: true });
138+
}
139+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const POLICY = "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src
1717
const escape = (text: string) => text.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
1818
const inline = (tokens: InlineToken[]): string => tokens.map(t => t.type === "text" ? escape(t.text) : t.type === "code" ? `<code>${escape(t.text)}</code>` : t.type === "bold" ? `<strong>${inline(t.children)}</strong>` : `<a href="${escape(t.href)}" rel="noreferrer">${inline(t.children)}</a>`).join("");
1919

20-
/** The same small Markdown subset as transcript previews; source HTML is always text. */
20+
/** A small Markdown subset: headings, fenced code, inline code, bold, links, lists, paragraphs. Source HTML is always text. */
2121
function markdown(text: string): string {
2222
const lines = text.replace(/\r\n/g, "\n").split("\n");
2323
const blocks: string[] = [];

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,9 @@ export class CompanionServer {
308308
this.reply(res, 201, { thread: mobileThread(thread, this.source.status(thread.id), this.source.pullRequest?.(thread.id)) });
309309
return;
310310
}
311-
const match = /^\/v1\/threads\/([a-f0-9]{8})\/(send|answer|policy|archive)$/.exec(url.pathname);
311+
// Any id the store accepts: 8 hex characters for threads made here or by older builds, the full UUID a
312+
// Mac draft reserved (#181). The thread must still exist.
313+
const match = /^\/v1\/threads\/([A-Za-z0-9_-]{1,128})\/(send|answer|policy|archive)$/.exec(url.pathname);
312314
if (req.method !== "POST" || !match) { this.reply(res, 404, { error: "Not found." }); return; }
313315
const threadId = match[1]!;
314316
const action = match[2]!;
@@ -385,7 +387,9 @@ export function mobileItem(item: ThreadItem): Record<string, unknown> {
385387
case "assistant": return { id: item.id, kind: item.kind, text: item.text, at: item.at, ...(item.phase ? { phase: item.phase } : {}), ...(item.outcome ? { outcome: item.outcome } : {}) };
386388
case "thinking": return { id: item.id, kind: item.kind, text: item.text, at: item.at, ...(item.reasoning ? { reasoning: item.reasoning } : {}), ...(item.outcome ? { outcome: item.outcome } : {}) };
387389
case "user": case "notice": return { id: item.id, kind: item.kind, text: item.text, at: item.at, ...(item.kind === "notice" ? { level: item.level } : {}) };
388-
case "approval": return { id: item.id, kind: item.kind, question: item.question, detail: item.detail, answer: item.answer, ...(item.decidedBy?.source === "policy" ? { auto: item.decidedBy.ruleId.slice("policy:".length) } : item.decidedBy && item.decidedBy.decision !== "ask" ? { auto: "rule" } : {}), at: item.at };
390+
// Who answered, whether it reached the CLI and why an unanswered card closed ride along the same way.
391+
case "approval": return { id: item.id, kind: item.kind, question: item.question, detail: item.detail, answer: item.answer, ...(item.decidedBy?.source === "policy" ? { auto: item.decidedBy.ruleId.slice("policy:".length) } : item.decidedBy && item.decidedBy.decision !== "ask" ? { auto: "rule" } : {}),
392+
...(item.answeredBy ? { answeredBy: item.answeredBy } : {}), ...(item.delivery ? { delivery: item.delivery } : {}), ...(item.withdrawn ? { withdrawn: item.withdrawn } : {}), at: item.at };
389393
// A phone that reads only title and status shows every finished tool as done, so a finished tool
390394
// that did not succeed says how it ended in its title.
391395
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,
@@ -395,7 +399,7 @@ export function mobileItem(item: ThreadItem): Record<string, unknown> {
395399
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 };
396400
// Phones cannot answer questions yet: a notice every client can show, with the questions and never the answers.
397401
case "question": return { id: item.id, kind: "notice", level: "info", at: item.at,
398-
text: `The agent asked: ${item.questions.map((q) => q.text).join(" · ")}\n${item.answers ? "Answered on your Mac." : item.withdrawn ? "Not answered." : "Answer it in Modex on your Mac."}` };
402+
text: `The agent asked: ${item.questions.map((q) => q.text).join(" · ")}\n${item.answers ? (item.delivery === "uncertain" ? "Answered on your Mac; Modex can't tell whether the agent received it." : "Answered on your Mac.") : item.withdrawn ? "Not answered." : "Answer it in Modex on your Mac."}` };
399403
}
400404
}
401405

0 commit comments

Comments
 (0)