Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 38 additions & 0 deletions apps/desktop/e2e/task-to-notes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,44 @@ test("a turn's footer lists the files it touched, on demand, as the CLI reported
await expect(context).toContainText("As the CLI reported them");
});

test("a footer whose files a shell command may have changed says so and lists the command, never a guessed file", async () => {
// CF-7 close-out observations 1 and 2: Claude rewrote a file it had read through a redirect, and Codex
// edited one with `perl -pi`, which it parsed as an unknown action. Neither CLI named the file written.
const cwd = repo;
const origin = (turn: string, item: string, backend: string) => ({ thread: threadId, turn, attempt: `${turn}:A1`, backend, native: { runtime: "R", item } });
const tool = (turn: string, backend: string, id: string, extra: Record<string, unknown>) => ({ type: "item", threadId, item: { id, kind: "tool", args: {}, status: "done", outcome: "succeeded", ok: true, origin: origin(turn, id, backend), at: at(), ...extra } });
const redirect = "printf 'hello, live\\n' > greeting.txt && cat greeting.txt";
const perl = "/bin/zsh -lc 'perl -pi -e s/hello/hi/ greeting.txt'";
const before = await tid(page, "task-context-toggle").count();
await app.evaluate(({ BrowserWindow }, list) => {
for (const event of list) BrowserWindow.getAllWindows()[0]!.webContents.send("thread:event", event);
}, [
{ type: "item", threadId, item: { id: "u-redirect", kind: "user", text: "Say hello, live", at: at() } },
tool("u-redirect", "claude", "rr1", { name: "Read", title: "read greeting.txt", args: { file_path: `${cwd}/greeting.txt` } }),
tool("u-redirect", "claude", "rb1", { name: "Bash", title: `$ ${redirect}`, args: { command: redirect }, exec: { command: redirect } }),
{ type: "item", threadId, item: { id: "a-redirect", kind: "assistant", text: "Done.", phase: "final", outcome: "succeeded", origin: origin("u-redirect", "a-redirect", "claude"), at: at() } },
{ type: "item", threadId, item: { id: "u-perl", kind: "user", text: "Say hi", at: at() } },
tool("u-perl", "codex", "pp1", { name: "shell", title: `$ ${perl}`, exec: { command: perl, actions: [] }, exitCode: 0 }),
tool("u-perl", "codex", "pp2", { name: "shell", title: `$ ${perl}`, exec: { command: perl, actions: [] }, exitCode: 0 }),
{ type: "item", threadId, item: { id: "a-perl", kind: "assistant", text: "Done.", phase: "final", outcome: "succeeded", origin: origin("u-perl", "a-perl", "codex"), at: at() } },
{ type: "status", threadId, status: "idle" },
]);
await expect(tid(page, "task-context-toggle")).toHaveCount(before + 2);
const claude = tid(page, "task-context").filter({ hasText: "Files touched · 1, maybe more" });
await expect(claude).toHaveCount(1);
await tid(claude, "task-context-toggle").click();
await expect(tid(claude, "task-context-file")).toHaveText(["greeting.txt read"]);
await expect(claude).toContainText("These commands may have changed files that neither the CLI nor Modex named:");
await expect(tid(claude, "task-context-unlisted")).toHaveText([redirect]);
const codex = tid(page, "task-context").filter({ hasText: "Files touched · not listed" });
await expect(codex).toHaveCount(1);
await tid(codex, "task-context-toggle").click();
await expect(tid(codex, "task-context-file")).toHaveCount(0);
// Shown as the script its shell ran, with the exact command on hover, and once although it ran twice.
await expect(tid(codex, "task-context-unlisted")).toHaveText(["perl -pi -e s/hello/hi/ greeting.txt"]);
await expect(tid(codex, "task-context-unlisted").locator("code")).toHaveAttribute("title", perl);
});

test("a successful shell receipt cannot verify masked failures or writes after a check", async () => {
const commands = ["pnpm test || true", "pnpm test && printf broken > app.ts", "pnpm test | cat", "pnpm build && pnpm test"];
const before = await tid(page, "task-verification").count();
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/src/main/engine/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { parseRecoveryJSON, recoveryJSON } from "./attachment-recovery-file.js";
import type { DraftRemovalScope } from "../../shared/native-drafts.js";
import { boundOutput } from "../../shared/evidence.js";
import { taskMarkdown, taskRecord, type TaskNoteView } from "../../shared/task-context.js";
import { openingTitle } from "./titles.js";
import { actionDigest, emptyRequests, publicAnswers, register as registerRequest, resolve as resolveRequest, settle as settleRequest, validateAnswers, withdraw as withdrawRequests, type Question, type RequestBinding, type RequestsState, type Resolution } from "../../shared/human-requests.js";
import { CONVERSATION_SCHEMA, applyConversationEvent, initialConversation, okFor, toolItemId, type AttemptMark, type ConversationEvent, type ConversationState, type Observation, type SettledOutcome, type TurnRef } from "../../shared/conversation-events.js";

Expand Down Expand Up @@ -711,10 +712,13 @@ export class ThreadRunner {
let files: Attachment[];
try { files = this.attachments.claim(threadId, attachments, draft ? { draft } : {}); }
finally { if (draft) this.attachments.revokeDraft(draft); }
const derivedTitle = (text.replace(/\s+/g, " ").trim() || files.map((f) => f.name).join(", ")).slice(0, 60) || "New thread";
// Name the thread after its first completed turn, including a retried first turn that still carries the opening-message title.
const opening = text.replace(/\s+/g, " ").trim() || files.map((f) => f.name).join(", ");
const derivedTitle = openingTitle(opening) || "New thread";
// Name the thread after its first completed turn, including a retried first turn that still carries the opening-message title
// (or the plain 60-character cut it had before titles were cut at a word break).
const firstTurn = !l.items.some((item) => item.kind === "user" && item.id !== reuse);
const shouldName = firstTurn && thread.titleSource !== "manual" && (thread.title === "New thread" || (retry && thread.title === derivedTitle));
const openingTitled = thread.title === derivedTitle || thread.title === opening.slice(0, 60);
const shouldName = firstTurn && thread.titleSource !== "manual" && (thread.title === "New thread" || (retry && openingTitled));
l.followUp?.abort.abort();
l.followUp = undefined;
// The Modex turn is the user message it answers: Retry runs the same turn again as a new attempt.
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/main/engine/titles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ function normalizeTitle(output: string): string | null {
return /[\p{L}\p{N}]/u.test(title) && [...characters.segment(title)].length <= 60 ? title : null;
}

/** The most characters (graphemes) a thread title shows. */
export const TITLE_LENGTH = 60;

/**
* A thread's first name, from its opening message, until a better one arrives. Whitespace collapses. A
* longer message is cut at the last word break that keeps at least half the length, else at a grapheme,
* and ends in "…", so neither a word nor a character is cut in two.
*/
export function openingTitle(text: string): string {
const flat = text.replace(/\s+/g, " ").trim();
const graphemes = [...characters.segment(flat)].map((part) => part.segment);
if (graphemes.length <= TITLE_LENGTH) return flat;
const head = graphemes.slice(0, TITLE_LENGTH - 1);
const space = head.lastIndexOf(" ");
const kept = (graphemes[TITLE_LENGTH - 1] === " " ? head : space >= TITLE_LENGTH / 2 ? head.slice(0, space) : head).join("").trimEnd();
return `${kept.replace(/[\s,;:.\-–—(\[{]+$/u, "")}…`;
}

/** A separate, non-resumed CLI conversation; its output never enters the coding transcript. */
export async function generateTitle(backend: Backend, opts: TitleOptions, text: string, signal: AbortSignal): Promise<string | null> {
if (signal.aborted) return null;
Expand Down
23 changes: 19 additions & 4 deletions apps/desktop/src/renderer/components/transcript/TaskPlanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ThreadItem } from "../../../shared/types";
import type { PlanStep } from "../../../shared/conversation-events";
import { bridge } from "../../bridge";
import { ErrorNotice } from "../ui/ErrorNotice";
import { CONTEXT_SHOWN, displayCommand, noPlan, noPlanText, taskContext, verification, type TaskCheck, type TaskVerification } from "../../../shared/task-context";
import { CONTEXT_SHOWN, UNLISTED_WORDS, contextCount, displayCommand, noPlan, noPlanText, taskContext, verification, type TaskCheck, type TaskVerification } from "../../../shared/task-context";

export const PLAN_STATUS: Record<PlanStep["status"], string> = { pending: "Not started", "in-progress": "In progress", completed: "Reported done", unknown: "Status unknown" };
export const PLAN_FROM = { "codex-plan": "Codex's plan", "claude-todo": "Claude's todo list", "claude-tasks": "Claude's task list", "acp-plan": "The agent's plan" } as const;
Expand Down Expand Up @@ -99,17 +99,20 @@ export function VerificationText({ v }: { v: TaskVerification }) {
/**
* The project files a finished turn touched (docs/task-context.md#context-map), shown when asked: what
* its tools changed, read and searched, as the CLI reported them. Nothing is indexed or inferred.
* At most 20 are listed, then how many more; paths outside the project are only counted.
* At most 20 are listed, then how many more; paths outside the project are only counted. Commands that
* may have changed files no CLI named are listed after them, so the list never reads as complete when it
* might not be.
*/
export function TaskContextLine({ items, cwd }: { items: readonly ThreadItem[]; cwd: string }) {
const context = useMemo(() => taskContext(items, cwd), [items, cwd]);
const [open, setOpen] = useState(false);
const listId = useId();
if (!context.files.length && !context.outside) return null;
const unlisted = context.unlisted ?? [];
if (!context.files.length && !context.outside && !unlisted.length) return null;
return (
<div className="task-context" data-testid="task-context">
<button type="button" className="task-context-toggle" data-testid="task-context-toggle" aria-expanded={open} aria-controls={listId} onClick={() => setOpen((v) => !v)}>
Files touched · {context.files.length}
Files touched · {contextCount(context)}
</button>
{open && <div id={listId}>
<p className="task-context-note">As the CLI reported them. Modex did not index the project.</p>
Expand All @@ -120,7 +123,19 @@ export function TaskContextLine({ items, cwd }: { items: readonly ThreadItem[];
{context.files.length > CONTEXT_SHOWN && <li data-testid="task-context-more">and {context.files.length - CONTEXT_SHOWN} more</li>}
{context.outside > 0 && <li data-testid="task-context-outside">{context.outside} outside the project, not named</li>}
</ul>
{unlisted.length > 0 && <UnlistedCommands commands={unlisted} testId="task-context-unlisted" />}
</div>}
</div>
);
}

/** Commands that may have changed files no CLI named, shown as their scripts with the exact command on hover. */
export function UnlistedCommands({ commands, testId }: { commands: readonly string[]; testId: string }) {
return <>
<p className="task-context-note">{UNLISTED_WORDS}</p>
<ul className="task-context-files">
{commands.slice(0, CONTEXT_SHOWN).map((command) => <li key={command} data-testid={testId}><code title={command}>{displayCommand(command)}</code></li>)}
{commands.length > CONTEXT_SHOWN && <li>and {commands.length - CONTEXT_SHOWN} more</li>}
</ul>
</>;
}
9 changes: 5 additions & 4 deletions apps/desktop/src/renderer/space/TaskNoteView.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import type { SpacePage } from "../../shared/space";
import { CONTEXT_SHOWN, PROPOSED_WORDS, clarificationLine, type TaskNoteView as View } from "../../shared/task-context";
import { CONTEXT_SHOWN, PROPOSED_WORDS, clarificationLine, contextCount, type TaskNoteView as View } from "../../shared/task-context";
import { bridge } from "../bridge";
import { PLAN_FROM, PLAN_STATUS, VerificationText } from "../components/transcript/TaskPlanCard";
import { PLAN_FROM, PLAN_STATUS, UnlistedCommands, VerificationText } from "../components/transcript/TaskPlanCard";

const EDITED_BY: Record<NonNullable<View["edited"]>["by"], string> = { you: "You", outside: "Outside Modex", agent: "an agent", other: "an unknown change" };

Expand Down Expand Up @@ -63,13 +63,14 @@ export function TaskNoteView({ view, onOpenThread, onClose }: { view: View; onOp
{!record.plan && record.proposedPlan && <><h3>Plan</h3>
<p className="task-view-note">{PROPOSED_WORDS}</p>
<pre className="task-view-proposed" data-testid="task-view-proposed-plan">{record.proposedPlan}</pre></>}
{context && (context.files.length > 0 || context.outside > 0) && <><h3>Files touched · {context.files.length}</h3>
{context && (context.files.length > 0 || context.outside > 0 || (context.unlisted?.length ?? 0) > 0) && <><h3>Files touched · {contextCount(context)}</h3>
<p className="task-view-note">As the CLI reported them. Modex did not index the project.</p>
<ul className="task-context-files">
{context.files.slice(0, CONTEXT_SHOWN).map((file) => <li key={file.path} data-testid="task-view-file"><code>{file.path}</code> {file.relations.join(", ")}</li>)}
{context.files.length > CONTEXT_SHOWN && <li>and {context.files.length - CONTEXT_SHOWN} more</li>}
{context.outside > 0 && <li>{context.outside} outside the project, not named</li>}
</ul></>}
</ul>
{context.unlisted?.length ? <UnlistedCommands commands={context.unlisted} testId="task-view-unlisted" /> : null}</>}
{verification && (verification.checks.length > 0 || verification.edits > 0) && <><h3>Verification</h3>
<div className="task-verification" data-testid="task-view-verification" data-state={verification.state}><VerificationText v={verification} /></div></>}
{record.answer && <><h3>Answer</h3><p className="task-view-answer">{record.answer}</p></>}
Expand Down
Loading
Loading