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
8 changes: 8 additions & 0 deletions apps/desktop/e2e/human-requests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ test("Codex questions: the card answers by its schema, the CLI hears once, the s
const token = card.getByLabel("Token");
await expect(token).toHaveAttribute("type", "password");
await token.fill(" s3cr3t-token ");
// A pending answer belongs to the request, even while its thread view is unmounted.
const waitingThread = (await currentRow(page).getAttribute("data-thread-id"))!;
await page.keyboard.press("Meta+n");
await expect(tid(page, "draft-view")).toBeVisible();
await page.locator(`[data-testid="thread-row"][data-thread-id="${waitingThread}"]`).click();
await expect(card.getByRole("radio", { name: "staging" })).toBeChecked();
await expect(token).toHaveValue(" s3cr3t-token ");
expect(replies(logs)).toEqual([]);
await page.screenshot({ path: test.info().outputPath("codex-question-card.png") });
await tid(card, "question-submit").click();

Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { QuestionDraftContext, type QuestionDraftCache } from "./components/transcript/QuestionCard";
import type { NativeDraftBridge, NativeDraftReadOnlyOpen, PendingSendView } from "../shared/native-drafts";
import type { ComposerRecovery } from "../shared/update-recovery";
import type { Attachment, AttachmentDraftOwner } from "../shared/attachments";
Expand Down Expand Up @@ -100,6 +101,24 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE
const [selected, setSelected] = useState<string | null>(null);
const [draftRevision, setDraftRevision] = useState(0);
const [items, setItems] = useState<Record<string, ThreadItem[]>>({});
const questionDrafts = useRef(new Map<string, QuestionDraftCache>());
useEffect(() => {
for (const [threadId, drafts] of questionDrafts.current) {
if (state && !state.threads.some(t => t.id === threadId && !t.retired)) {
questionDrafts.current.delete(threadId);
continue;
}
const snapshot = items[threadId];
if (!snapshot) continue;
const open = new Set(snapshot.filter(item => item.kind === "question" && !item.answers && !item.withdrawn && !item.delivery).map(item => item.id));
for (const id of drafts.keys()) if (!open.has(id)) drafts.delete(id);
}
}, [items, state]);
const draftsForQuestions = (threadId: string) => {
let drafts = questionDrafts.current.get(threadId);
if (!drafts) { drafts = new Map(); questionDrafts.current.set(threadId, drafts); }
return drafts;
};
// Unsent text by thread id (or `draft:<revision>:<projectId>` for a new chat). Switching threads keeps it;
// Native recovery saves the exact raw values; remote clients keep their existing local drafts.
const [unsent, setUnsent] = useState<Record<string, string>>({});
Expand Down Expand Up @@ -943,6 +962,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE
</section>)}
{surface === "chat" && thread && project && !showChanges && <RepositoryOverview project={project} changes={changes} onOpenChanges={() => setLayout({ changes: true })} onOpenFolder={() => openPath(thread.cwd)} />}
{thread && project ? (
<QuestionDraftContext.Provider value={draftsForQuestions(thread.id)}>
<ThreadView
key={thread.id}
thread={thread}
Expand All @@ -967,6 +987,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE
inputRef={inputRef}
branch={changes?.branch ?? undefined}
/>
</QuestionDraftContext.Provider>
) : draft ? (
<DraftView
key={`${draft.projectId}:${draftRevision}`}
Expand Down
31 changes: 24 additions & 7 deletions apps/desktop/src/renderer/components/transcript/QuestionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useState } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import type { AnswerResult, ThreadItem } from "../../../shared/types";
import { answerNote, type Question } from "../../../shared/human-requests";

interface QuestionDraft { choice: Record<string, string>; text: Record<string, string> }
/** Window-owned memory only; question answers must never enter composer recovery or storage. */
export const QuestionDraftContext = createContext<Map<string, QuestionDraft> | null>(null);
export type QuestionDraftCache = Map<string, QuestionDraft>;

type QuestionEntry = Extract<ThreadItem, { kind: "question" }>;

/** Why a card closed without an answer, in the approval card's words. */
Expand All @@ -28,11 +33,23 @@ const OTHER = "\u0000other";
* answers against the questions before anything reaches the CLI, and the card says why when it refuses.
*/
export function QuestionCard({ item, onAnswer }: { item: QuestionEntry; onAnswer: (itemId: string, answers: Record<string, string[]>) => Promise<AnswerResult | undefined> }) {
const [choice, setChoice] = useState<Record<string, string>>({});
const [text, setText] = useState<Record<string, string>>({});
const drafts = useContext(QuestionDraftContext);
const [draft, setDraft] = useState<QuestionDraft>(() => drafts?.get(item.id) ?? { choice: {}, text: {} });
const { choice, text } = draft;
const edit = (field: "choice" | "text", id: string, value: string) => {
const next = { ...draft, [field]: { ...draft[field], [id]: value } };
drafts?.set(item.id, next);
setDraft(next);
};
const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const open = !item.answers && !item.withdrawn;
const open = !item.answers && !item.withdrawn && !item.delivery;
useEffect(() => {
if (!open) {
drafts?.delete(item.id);
setDraft({ choice: {}, text: {} });
}
}, [open, drafts, item.id]);
const typed = (q: Question) => !q.options || choice[q.id] === OTHER;
const value = (q: Question) => (typed(q) ? text[q.id] ?? "" : choice[q.id] ?? "");
const submit = async () => {
Expand Down Expand Up @@ -63,14 +80,14 @@ export function QuestionCard({ item, onAnswer }: { item: QuestionEntry; onAnswer
<div className="question-options" role="radiogroup" aria-label={q.header || q.text}>
{q.options.map((o) => (
<label key={o.label} className="question-option">
<input type="radio" name={`${item.id}:${q.id}`} checked={choice[q.id] === o.label} disabled={sending} onChange={() => setChoice((c) => ({ ...c, [q.id]: o.label }))} />
<input type="radio" name={`${item.id}:${q.id}`} checked={choice[q.id] === o.label} disabled={sending} onChange={() => edit("choice", q.id, o.label)} />
<span>{o.label}</span>
{o.description && <span className="question-hint">{o.description}</span>}
</label>
))}
{q.allowOther && (
<label className="question-option">
<input type="radio" name={`${item.id}:${q.id}`} checked={choice[q.id] === OTHER} disabled={sending} onChange={() => setChoice((c) => ({ ...c, [q.id]: OTHER }))} />
<input type="radio" name={`${item.id}:${q.id}`} checked={choice[q.id] === OTHER} disabled={sending} onChange={() => edit("choice", q.id, OTHER)} />
<span>Other</span>
</label>
)}
Expand All @@ -79,7 +96,7 @@ export function QuestionCard({ item, onAnswer }: { item: QuestionEntry; onAnswer
{typed(q) && (
<input className="question-input" data-testid="question-input" type={q.secret ? "password" : "text"} autoComplete="off" spellCheck={!q.secret}
aria-label={q.options ? `${q.header || q.text}: other` : q.header || q.text} value={text[q.id] ?? ""} disabled={sending}
onChange={(e) => setText((t) => ({ ...t, [q.id]: e.target.value }))} onKeyDown={(e) => { if (e.key === "Enter") void submit(); }} />
onChange={(e) => edit("text", q.id, e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void submit(); }} />
)}
</>
)}
Expand Down
2 changes: 1 addition & 1 deletion docs/conversation-fidelity.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ replays or re-sends anything by being shown, reloaded or reconnected.
| Exit code | Reported | Not reported |
| Citations | Memory citations | None; links in the reply are labelled as the model's |
| Plan | `turn/plan/updated` | `TodoWrite` |
| Questions | A card for each `item/tool/requestUserInput` (experimental); its answers in the task record | None: the CLI sends Modex no structured questions |
| Questions | A card for each `item/tool/requestUserInput` (experimental); unsent answers survive thread navigation in window memory; non-secret answers in the task record | None: the CLI sends Modex no structured questions |
| Approvals | Answered once, from the Mac or the phone; the card says who answered and whether the CLI received it | The same |
| Save to Notes | Yes | Yes |
| Stop | Native interrupt | Process stop (CF-2, [#275](https://github.com/TypeSafeAI/modex/issues/275), refines this) |
Expand Down
16 changes: 13 additions & 3 deletions docs/human-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ cannot write two answers for one native ID. This is a local guard, not an exactl
- a free-text question gets a text field;
- a secret question gets a password field.

Unsent question choices and text survive switching threads or opening a new chat. They live
only in window memory, keyed by thread and request item, and are discarded when the request
closes (including a background answer or withdrawal), the thread is removed/retired, or the
window closes. Secret drafts never enter composer recovery or persistent storage.

Sending and uncertain cards show their delivery state and have no submit control. An uncertain
card does not read as answered. Secret input is sent with its original whitespace; validation only
checks that it is not empty.
Expand Down Expand Up @@ -247,8 +252,13 @@ restart rendering. `sent` is always a local write receipt. The offline backend a
its scripted decision through the same callback boundary.

Installed CLI versions checked on 2026-10-18: `codex-cli 0.163.0` and Claude Code `2.1.289`.
The schemas and scripted fixtures below are source/protocol evidence. They are not a live native
question acceptance run or human keyboard/VoiceOver acceptance. Those gates remain open in #276.
The schemas and scripted fixtures below are source/protocol evidence. A subsequent
[2026-10-20 live check](reviews/2026-10-20-cf3-drafts.md#native-evidence-and-remaining-gates)
passed through the real app with codex-cli 0.163.0 and the opt-in
`default_mode_request_user_input` flag: the card sent Blue and the model replied Blue.
Without that flag, a separate run used `request_user_input_async`, emitted as an async
agent message with questions; that distinct form is not answered by this broker.
Human keyboard/VoiceOver acceptance remains open in #276.
The preview approval gate remains off by default; Never rules, escalation rules, routine refusal,
Ask/Always/YOLO, and permission-profile validation keep their existing decisions.

Expand Down Expand Up @@ -282,7 +292,7 @@ Ask/Always/YOLO, and permission-profile validation keep their existing decisions
- `e2e/human-requests.spec.ts` runs a fixture Codex app-server through the real UI:
- the turn header while a question waits;
- an incomplete answer is refused in place;
- the password field;
- the password field and unsent choices/text retained across thread navigation;
- one reply carrying the answers;
- a refused second answer;
- Stop gives an error reply;
Expand Down
116 changes: 116 additions & 0 deletions docs/reviews/2026-10-20-cf3-drafts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# CF-3 question draft follow-up

Tracker [#273](https://github.com/TypeSafeAI/modex/issues/273), child
[#276](https://github.com/TypeSafeAI/modex/issues/276). Worktree `cf3-audit`, initially based on
`43d5b9c8053303b2a3c060efcd205229d88394c4`, then rebased onto
`ac7f743735d7ce5db2e0f1fad2fe291ab54c96db`. The user subsequently authorized taking
the PR and necessary dependencies through merge to main. No software release is authorized.

## Reconciliation

CF-1 (#282, contract #274), CF-2 (#286, contract #275), CF-3 (#293), native delivery repair
(#335), save rollback (#336), journal/reconnect and older-client fallbacks are already landed.
This follow-up retains that broker and the existing permission-profile validation, turn scope,
Ask/Always/YOLO/routine policies and disabled preview gate. Native action fingerprints and
request generation checks remain separate from display text and Jev's digest.

The remaining reproducible defect was a question draft disappearing when its thread view
unmounted. Choices and text now live in a window-owned cache scoped by thread and request ID.
Closing a request clears its draft even while another thread is selected; removing or retiring
its thread clears that thread's cache. Secrets remain in memory and never enter composer
recovery, storage, events, telemetry or exports. Closing the window intentionally discards them.

## Verification

Pinned pnpm: `10.34.5`.

Initial-base verification:

- RED: `pnpm --filter @modex/desktop exec playwright test e2e/human-requests.spec.ts --grep 'Codex questions:'`
failed at the new assertion after opening New chat and returning: `staging` was unchecked.
- GREEN: all three `e2e/human-requests.spec.ts` cases passed. The navigation case retains the
exact password whitespace, sends one native response and scans persisted app files for the
secret. The other cases cover journal/restart and uncertain delivery.
- `pnpm build`: passed.
- `pnpm typecheck`: passed.
- `pnpm test`: passed, 1,890 passed and 16 skipped across all six test invocations. Includes
broker races, Stop, wrong identity, changed actions, permissions, companion and older-client tests.
- First full `pnpm test:e2e`: desktop finished with 361 passed, 6 skipped, 1 failed and 1 not run
(12.8 minutes). `browser-attachments.spec.ts:38` failed in browser setup because the expected
Chromium executable was absent; its second test did not run. After
`pnpm --filter @modex/desktop exec playwright install chromium`, the entire browser-attachments
spec passed both tests on unchanged source. The initial full command still exited 1; it is not
reported as a clean full run.
- Separate `pnpm --filter @modex/site test:e2e`: all 7 passed. The first root E2E's site stage
had been prevented by the desktop setup failure.
- Final full `pnpm test:e2e`, with Chromium installed, exited **0**: desktop **363 passed,
6 skipped** (12.2 minutes), followed by site **7 passed** (6.4 seconds). No failures.
The source hashes were checked after completion and still match the staged implementation.
- Independent source review found no blocking issue. It confirmed background event cleanup and
existing full native method/params fingerprinting before broker delivery.

After rebasing onto `ac7f743`, the full command
`pnpm build && pnpm typecheck && pnpm test && pnpm test:e2e` exited **0**:
build and typecheck passed; unit tests **1,894 passed, 16 skipped**; desktop E2E
**369 passed, 6 skipped** (13.6 minutes); site E2E **7 passed** (6.6 seconds).
All three human-request cases and all five keyboard-answer cases passed.
Only this evidence document and status prose changed after the run.

## Native evidence and remaining gates

Fresh installed versions: `codex-cli 0.163.0`, Claude Code `2.1.304`. Codex schema generation
still exposes `ToolRequestUserInputParams`, `ToolRequestUserInputResponse` and
`ServerRequestResolvedNotification`. Schema presence is not evidence that a live model uses it.
Live probe at `2026-10-20T11:06:00Z`, through the real app and signed-in Codex CLI, passed
with `--enable default_mode_request_user_input`. It displayed one structured question,
accepted Blue through the desktop card, recorded `delivery: "sent"`, and the real model's
final response was `Blue`. This flag is listed by the installed CLI as under development
and off by default; the probe enabled it only in a disposable wrapper. Product defaults
and the preview approval gate were unchanged.

The default-mode probe at `2026-10-20T11:02:11Z` did not produce the supported RPC. Its
native rollout recorded `request_user_input_async`, an `AgentMessage` with `delivery: "async"`
and structured `questions`, followed by sleeps. Modex displayed the question text; the turn
remained running until the probe stopped it after 120 seconds. This is a distinct native
form, not a fabricated synchronous request or proof of universal question support. No
Markdown answer parsing or automatic answer was added. The first probe also had a report
writer error, which was fixed before the default-mode observation was repeated.

Human keyboard-only/VoiceOver acceptance and support for the distinct async native form
remain unclaimed. The opt-in synchronous run is actual native evidence, separate from fixtures.

The initial environment could not sign commits (`gpg` was absent and the SSH agent had no
identities). On resumption, GitHub CLI authentication and an SSH identity were available.
Implementation commits are signed locally with that existing SSH key, using per-command
Git options rather than changing other sessions' configuration. With explicit user approval,
the public key was registered on GitHub as signing key `1259375`; its public-key readback matches
fingerprint `SHA256:YprOIgLzN6V1HRC23ZBFfarWcT8LC3y45AMN4QDe9rs`.
The earlier GraphQL quota exhaustion has reset and CLI authentication is working.

## Evidence locations and delivery

The durable checkpoint is `/Users/buns/.codex/task-evidence/cf3-audit-2026-10-20/`:
full command logs, `changes.patch`, the new review document, `pr-body.md`, installed-schema
hashes and JSON files, `tested-source-sha256.txt`, the disposable native probe, and its
default/opt-in reports. `full-e2e-rerun.json` records the final successful command and counts. The source
probe and generated schemas also remain in `apps/desktop/.probes/cf3-audit/` (ignored).
`current-main-verification.json` and the `current-*.log` files record the successful rebased run
and signing-key registration.

The two signed commits on `cf3-audit` are prepared for a PR linked to #276 and #273.
The broader async-question and human accessibility
limitations above remain explicit; this follow-up does not close those acceptance gates.

## Merge follow-up

PR #348's first CI attempt could not acquire a hosted macOS runner. Its second attempt
passed the iPhone job and unit coverage, but desktop E2E ended with 371 passed, 3 skipped
and one failure: the known keyboard Jump to latest race at `keyboard-answers.spec.ts:104`.
The same race is fixed by landed PR #352 (`9e4197e`), with regressions for composer growth
and an unfinished Page Up animation. Current main was merged into this branch with a signed
merge commit to consume that fix; no second implementation or weakened assertion was added.

On the combined branch, `pnpm build && pnpm typecheck && pnpm test && pnpm test:e2e`
exited 0: 1,900 unit tests passed (16 skipped), 372 desktop E2E tests passed (6 skipped,
14.1 minutes), and all 7 site tests passed (8.2 seconds). All eight keyboard-answer
tests and all three human-request tests passed. Only documentation changed after this run.
4 changes: 4 additions & 0 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ and [landing verification](reviews/2026-10-11-landing-clarity.md).
CLI, instead of the request reading answered while the CLI never hears it.
[Across replay and reconnect](human-requests.md#across-replay-and-reconnect).

- **Native question drafts survive thread navigation** (CF-3 #276, PR #348). Unsent choices
and text stay in window memory and clear when their request closes or thread is removed.
Sensitive answers stay out of persisted recovery. [Verification and native capability limits](reviews/2026-10-20-cf3-drafts.md).

## In flight

- **v0.3.0 after publication.** v0.3.0 was published on 2026-10-20 from the signed tag on
Expand Down
Loading