From 0735b6a31bf8ac870d78751171a627ab7e38cf28 Mon Sep 17 00:00:00 2001 From: btipling Date: Sat, 22 Aug 2026 00:52:30 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(harness):=20Stop/Esc=20never=20drains?= =?UTF-8?q?=20submit=20queue;=20idle=20=E2=96=B6=20promotes=20(plan=20#760?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop and Esc (Busy) today look like a finished turn: the host aborts then sets Lifecycle.Ready, Wasm sees busy->ready and pops the queue head. This makes Stop consume nothing: the queue stays exactly as-is, and only an explicit idle ▶ / Ctrl+Enter (empty composer + non-empty queue) starts the next item. Successful turns keep auto-promoting exactly as before. - bridge.zig: protocol v19 scalar `inv_set_queue_promote_allowed` (default true for legacy host); reset() and inv_clear_messages both re-arm it; build.zig export whitelist entry so the wasm-linker roots it. - harnessBridge.ts: `inv_set_queue_promote_allowed` REQUIRED export + setter, HARNESS_PROTOCOL_VERSION 18->19. - harnessChat.ts: completeTurn(bridge, promoteAllowed) wraps scalar+lifecycle on every terminal — true on success Ready, false on Stop/error/timeout/validation. - ui.zig: gate the terminal-promote block on hasQueuePromoteAllowed(); idle ▶ and Ctrl+Enter fall back to tryPromoteQueued with an empty composer + non-empty queue (composer_chrome on_promote action). - Tests: bridge.test.zig (10/11 scalar reset/clear), composer_layout dispatch (goals 2/3/4), harnessChat + harnessBridge host arming, wasm-int protocol-v19 round-trip. Docs: harness-limits.md (removes 'drains after Ready' defect), feature-divide.md, AGENTS.md v19 row. --- AGENTS.md | 2 +- docs/feature-divide.md | 4 +- docs/harness-limits.md | 10 +- lib/harnessBridge.test.ts | 37 ++++++- lib/harnessBridge.ts | 24 +++- lib/harnessChat.test.ts | 68 ++++++++++++ lib/harnessChat.ts | 26 ++++- lib/harnessChat.wasm-int.test.ts | 15 +++ lib/harnessHostModelPersist.test.ts | 1 + native/harness/build.zig | 23 ++++ native/harness/src/bridge.test.zig | 42 +++++++ native/harness/src/bridge.zig | 42 ++++++- native/harness/src/composer_layout.test.zig | 117 ++++++++++++++++++++ native/harness/src/ui.zig | 27 ++++- native/harness/src/ui/composer_chrome.zig | 11 ++ 15 files changed, 426 insertions(+), 23 deletions(-) create mode 100644 native/harness/src/bridge.test.zig diff --git a/AGENTS.md b/AGENTS.md index 043f2cd5..99051cc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,7 +295,7 @@ invincible/ | Logical agent cwd + workspace-root↔abs canonicalization (`change_dir` / session / default env; **`sandbox_info`** is the bind/cwd/caps/env introspector — do not `exec env`; `canonicalizePath(R, p)` / `workspaceAbsToRel(R, abs)` / `resolvePathForTool(R, cwd, p)` / `rewriteExecRootToRel(R, text)` in `lib/agent/workPath.ts`) + **`search`** (read-grant-only code-grep via `rg`; `lib/agent/tools.ts`) | `lib/agent/workPath.ts`, `lib/agent/tools.ts`, `lib/agent/runAgent.ts`, `lib/agent/agentBody.ts`, `lib/sandbox/config.ts`, `lib/sessionStore.ts`, `lib/harnessChat.ts`, `lib/agentApi.ts`, `lib/sessionCloudCaps.ts` (shared client-safe `sanitizeSessionCwd` + Redis-safe opaque id predicate), [docs/sandbox.md](docs/sandbox.md), [docs/session-model.md](docs/session-model.md), [docs/agent-stream.md](docs/agent-stream.md). Tool paths accept **in-jail absolute paths** on all FS tools + `change_dir` + `exec` cwd: an absolute under the per-binding jail root R (`resolved.value.workspaceRoot` → `RunAgentParams.workspaceRoot` → `createAgentTools`) is canonicalized to the same workspace-relative freshness key as its relative form (BYO + Vercel parity); out-of-jail absolutes and `..`/symlink escapes fail closed. Absolute paths under `R` that **appear in `exec` stdout/stderr** are likewise rewritten to workspace-relative (`rewriteExecRootToRel` in `lib/agent/workPath.ts`, applied to `result.stdout`/`result.stderr` separately) so `exec pwd` ≡ `pwd`/annotations; when `R` is unresolvable the exec output passes through byte-for-byte (fail-open), and rewrites are capped and never throw. When R is unresolvable (BYO daemon down/pre-v2 — `workspaceRoot === null`) absolute is rejected (“root unavailable — use workspace-relative”) while relative + cwd still work. Initial request/session `cwd` stays relative-only; `.` is the workspace-root default session start (there is no `SANDBOX_DEFAULT_CWD` env knob), `..` walks up toward the workspace root and errors only past it, and an **exact ancestor** of cwd re-roots cleanly (`change_dir invincible` from `cwd=invincible/docs` → `invincible`, not the phantom `invincible/docs/invincible`) while a name-prefix sibling is never re-rooted. P1/GAP-1 (#452/#330): `cwd` + `activeSandboxId` are **session-owned** and ride the Redis record (`meta.{logicalCwd,activeSandboxId}`). `activeSandboxId` is now **server-resolved** (routing override via `lib/tenancy/resolveSandbox.ts` `requestedSandboxId`), not carry-only. A **confirmed successful `change_dir`** is persisted as the session cwd even when the turn later cancels / times out / hard-errors (`lib/harnessChat.ts` host-side `liveCwd`); the success path still prefers the authoritative `agentResult.cwd`, and only a confirmed `change_dir` (never an errored one) is stored on a failed/aborted turn | | Cloud multi-device harness session (Redis multi-session, `/api/sessions*`, hybrid local+cloud; **phase 0 #515 envelope + Blob transcript carrier**) | `app/api/sessions/*` (+ `app/api/sessions/[id]/envelope/*`, `[id]/transcript/*`), `lib/sessionRepository.ts`, `lib/sessionCloudCaps.ts`, `lib/sessions/*` (+ `lib/sessions/blobStore.ts`, `blobStores.ts`), `lib/tenancy/harnessSessionsRedis.ts`, `lib/tenancy/harnessSessions.ts` (archive read + shared validator), `lib/di/index.ts` (root), `app/harness/HarnessHost.tsx`, `middleware.ts`, [docs/session-model.md](docs/session-model.md), [docs/bring-your-own.md](docs/bring-your-own.md), [SECURITY.md](SECURITY.md) — one-shot Postgres→Redis backfill: GHA **`sessions-redis-backfill`** (idempotent per-user marker); Postgres `harness_sessions` is a read-only archive. P1/GAP-1 (#452): session-carrier `meta.{logicalCwd,activeSandboxId}` folds into the PUT body and restores on pull/adopt; **plan #616 (source #610)** adds the reserved `meta.selectedModel` session carrier for the selected model pick (restore by id after the model catalog push; server **drops a poisoned value to unset**, never a 400). **Phase 0 (#515):** the transcript lives in **Vercel Blob** (`BLOB_READ_WRITE_TOKEN` / BYO S3-R2 seam) pointed to by `meta.transcriptPointer` on the small Redis envelope (`harness:envelope:*`); server mints short-lived scoped upload URLs for **client→Blob** uploads; legacy full-record GET stays for roll-forward while old blobs stay small. Envelope upsert/read: `PUT`/`GET /api/sessions/:id/envelope`; mint/read: `POST`/`GET /api/sessions/:id/transcript` | -| Harness stream chrome (Thinking collapse/caps, live tools) | `lib/harnessChat.ts`, `native/harness/src/ui.zig` (facade + `frame`; transcript band owner), `native/harness/src/ui/thinking.zig` (Thinking kind), `native/harness/src/transcript_split.zig` (collapsible left rail + session list in the transcript band), `native/harness/src/session_catalog.zig` (v17 catalog + pending switch), protocol **v18** in `lib/harnessBridge.ts` (Stop cancel v9; Thinking kind v8; tool-run kind 6 v10→live paint v11; skill-attach kind 7 v12; **status-slot store v13**; **turn-clock feed v14**; **v14 addendum** `inv_set_busy_tick`; **v16** model persist; **v17** session-rail catalog + pending switch; **v18** `inv_queued_count` submit-queue depth) | +| Harness stream chrome (Thinking collapse/caps, live tools) | `lib/harnessChat.ts`, `native/harness/src/ui.zig` (facade + `frame`; transcript band owner), `native/harness/src/ui/thinking.zig` (Thinking kind), `native/harness/src/transcript_split.zig` (collapsible left rail + session list in the transcript band), `native/harness/src/session_catalog.zig` (v17 catalog + pending switch), protocol **v19** in `lib/harnessBridge.ts` (Stop cancel v9; Thinking kind v8; tool-run kind 6 v10→live paint v11; skill-attach kind 7 v12; **status-slot store v13**; **turn-clock feed v14**; **v14 addendum** `inv_set_busy_tick`; **v16** model persist; **v17** session-rail catalog + pending switch; **v18** `inv_queued_count` submit-queue depth; **v19** `inv_set_queue_promote_allowed` — host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready **never drains the queue**; only idle ▶ / Ctrl+Enter with an empty composer + non-empty queue promotes, plan #760) | | Keyboard shortcuts (keymap, leader, help overlay) | `native/harness/src/keymap.zig` (single chord table + reserved-browser deny-list + leader machine; **NEW caps** `KEYMAP_MAX`=64, `LEADER_WINDOW_MS`=800), `native/harness/src/ui/keymap_dispatch.zig` (one per-frame walk of `dvui.events()`, handled-marking, leader dvui-timer arm/expiry), `native/harness/src/ui/help_overlay.zig` (in-canvas TEAL panel), wired in `native/harness/src/ui.zig` (dispatch before textEntry; overlay paint) + `ui/queue_band.zig` (scan removed; `queue_save`/`cancel_queue_edit` routed via dispatcher). **DOM adds no keyboard UI / `window` keydown / React cheatsheet** | | Workspace status bar (protocol v13 status-slot store; bridge overall **v14** — plan #538/#541 + Phase 2 git #540 + Phase 3 context/usage #539, **two-line bottom status bar under the composer — #554/#555/#570**) | `native/harness/src/{bridge,ui,model_picker,model_catalog}.zig` (status-slot store + two-line 64 px bar directly **below the composer**: **line 1** = identity (spinner · `h:{build-id}` · model menu) relocated from the deleted header band, **line 2** = `paintStatusSlots` right-aligned slot pack — header merged by plan #570; each line has explicit 32 px height so the model picker (`PICKER_TRIGGER_H`=32) fits and slots never clip; sandbox/cwd/git + context/usage slots — context painted generically via `STATUS_SLOT_DROP_ORDER`), `lib/harnessBridge.ts` (`StatusSlot`, `setStatusSlot`/`getStatusSlot`/`clearStatusSlot`/`clearStatusSlots`, `STATUS_SLOT_MAX_BYTES` mirror), `lib/harnessChat.ts` (`foldStatusSlots` — folds `activeSandboxId` + `cwd` + **context/usage** (`formatUsageSummary`, re-sanitized on read) after hydrate, after **every** agent turn — success **and** fail (403-clear / committed `change_dir` repaint the pack — PR #543), and **live mid-turn on tool results** (Phase 2 #627 / #625: a confirmed `change_dir` or successful `meta_sandbox_switch` repaints sandbox/cwd immediately, plus the host persists via `onSessionPatch`); context default **hidden** on missing usage, abort/cancel carries the prior honest value forward; host-ellipsized to the byte cap before the wire; `refreshGitStatusSlot` — host polls the read-only `GET /api/harness/status` probe on a ~10 s cadence **and** on-demand after a successful `exec` or `meta_sandbox_switch` mid-turn (not only the cadence), fail-soft keeps the last git value on transient error/429), `app/harness/HarnessHost.tsx` (Clear/New clears the pack; wires the git cadence + `onSessionPatch` → persist), `app/api/harness/status/route.ts` (read-only git probe: envelope-authoritative bind (`meta.activeSandboxId` wins over Redis-safe `?sandboxId=` carry), `resolveSandbox` → bounded argv-only read-only git at the bind workspace root via `lib/agent/statusProbe.ts`, per-instance rate cap `STATUS_PROBE_MIN_INTERVAL_MS`; middleware matcher + in-route `requireSessionUser` dual gate; never mutates a session/envelope — no Production write), `lib/agent/statusProbe.ts` (`STATUS_GIT_PROBE_OUT_MAX_BYTES`=512, fail-soft `{}`), `lib/sessionCloudCaps.ts` (`STATUS_SLOT_MAX_BYTES` = 96 + `STATUS_PROBE_MIN_INTERVAL_MS` = 2000 — client-safe single sources), **context/usage carrier:** `lib/agent/usageSummary.ts` (bounded provider-usage mapper `mapProviderUsage` / read-side `sanitizeUsageSummary` / host `formatUsageSummary`, `USAGE_SUMMARY_MAX_BYTES` = 96 — NEW cap), emitted **live mid-stream** from `finish` parts (aggregate only — never `finish-step` per-step counts) in `lib/agent/agentStream.ts` (SSE `usage` event), reconciled at the final `done.usage` / JSON result / chat result in `lib/agent/runAgent.ts` (+ `app/api/chat/route.ts`), parsed by `lib/agentApi.ts` / `lib/chatApi.ts`, mirrored on `SessionSnapshot.usage` (`lib/sessionStore.ts`; reserved cloud `meta.usage` JSON string, drop-to-unset on poison), docs: [docs/feature-divide.md](docs/feature-divide.md), [docs/harness-limits.md](docs/harness-limits.md), [docs/agent-stream.md](docs/agent-stream.md), [docs/session-model.md](docs/session-model.md) | | | Tool-run aggregation + expandable transcript control (#325) | `lib/agent/agentStream.ts` (backend `tool_result.preview` — bounded/redacted L2 detail), `lib/toolRun.ts` (encode/decode, host aggregation, `meaningfulDetail` preview→`detail`, `mergeToolRunPayloads`/`encodeToolRunPayload` hydrate coalesce), `lib/harnessChat.ts` (stream/JSON aggregation → kind 6 `tool_run`, **live-painted**: a tool event opens/grows ONE card immediately via `update_last` — grouping keys off the host's `lastRingRowIsToolRun` flag, the only ring writer: grow iff the last ring row is a tool-run, else a NEW card at `1`; a thinking/assistant/user/error row last is a separator; commit-once is removed; reload coalescing of consecutive `tool_run` rows via `coalesceToolRunMessages` in `pushSessionToBridge`), `lib/sessionStore.ts` role `tool_run`, `native/harness/src/rich/toolrun.zig` (decode), `native/harness/src/ui/toolrun.zig` (`paintToolRun` — **headerless**: no `tools` kind band; 📋 copy on the header row; status glyphs as the single channel from embedded faces, `✓`/`✗` DejaVu symbols + `…` Noto; L2 preview in Vera Sans Mono for command/output tools **or any multi-line detail**, body otherwise; short single-line results → static label, no blank expander), `native/harness/src/bridge.zig` + `lib/harnessBridge.ts` (protocol **v11**; additive test-only ring readback `inv_message_*_at`), protocol **v11**; expand state + stick-to-bottom reuse dvui `reorder_tree.zig` / `scrolling.zig` idioms | diff --git a/docs/feature-divide.md b/docs/feature-divide.md index 28ba8bf9..5369f02d 100644 --- a/docs/feature-divide.md +++ b/docs/feature-divide.md @@ -130,7 +130,7 @@ re-resolved each turn. | Concern | Path | |---------|------| | Host shell | `app/harness/HarnessHost.tsx` | -| Bridge TS (protocol **v18**) | `lib/harnessBridge.ts` | +| Bridge TS (protocol **v19**) | `lib/harnessBridge.ts` | | Image fetch/decode | `lib/harnessImages.ts` | | Model catalog API | `app/api/models/route.ts` | | Admin inference keys | `app/admin/inference/*` | @@ -143,7 +143,7 @@ re-resolved each turn. | Theme | `native/harness/src/palette.zig` ↔ `lib/palette.ts` | | Export whitelist | `native/harness/build.zig` | -Host `HARNESS_PROTOCOL_VERSION` must equal Wasm `PROTOCOL_VERSION` (currently **18** — 13 added the additive status-slot store; 14 the scalar turn-clock feed `inv_set_turn_elapsed`; 15 added the busy-tick `inv_set_busy_tick`; 16 added model-selection persistence `inv_set_selected_model` + pending-model-change; 17 added the session-rail catalog + pending switch; **18** adds `inv_queued_count` for the in-canvas submit queue). +Host `HARNESS_PROTOCOL_VERSION` must equal Wasm `PROTOCOL_VERSION` (currently **19** — 13 added the additive status-slot store; 14 the scalar turn-clock feed `inv_set_turn_elapsed`; 15 added the busy-tick `inv_set_busy_tick`; 16 added model-selection persistence `inv_set_selected_model` + pending-model-change; 17 added the session-rail catalog + pending switch; **18** adds `inv_queued_count` for the in-canvas submit queue; **19** adds `inv_set_queue_promote_allowed` — the host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready never drains the queue, plan #760). Mismatch → load error; rebuild both sides. Image **bytes** enter only via bridge put; never dual DOM `` product surface. ## Related diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 135b6e7c..93a9b758 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -55,10 +55,10 @@ closed). | Chord | Action | |-------|--------| | **Enter** (composer focused) | Insert a newline (composer is multi-line) | -| **Ctrl+Enter** / **Cmd+Enter** (composer focused) | Send prompt when idle; **enqueue** a follow-up when Busy | +| **Ctrl+Enter** / **Cmd+Enter** (composer focused) | Send prompt when idle (an empty composer + non-empty queue **promotes the head** instead); **enqueue** a follow-up when Busy | | **Ctrl+Enter** / **Cmd+Enter** (queue-row editor) | **Save** the queued-item edit | | **↑** / **↓** (composer focused) | **↑** on an empty composer loads the newest user message; further ↑ walk older user rows. **↓** walks forward; ↓ past the newest restores the in-progress draft. Only user messages appear (assistant/thinking/tool/system/error/skill rows never load). Works while Busy. Ring window only (Load earlier for prompts that have aged out) | -| **Escape** | Cancel the in-progress turn when Busy (same as ■ Stop); **dismisses a queue-row editor first** (does not abort the turn); **closes the help overlay** (wins over busy cancel); **disarms the leader**. Idle: no-op (textEntry / dvui menus keep it) | +| **Escape** | Cancel the in-progress turn when Busy (same as ■ Stop); **dismisses a queue-row editor first** (does not abort the turn); **closes the help overlay** (wins over busy cancel); **disarms the leader**. A Busy cancel **consumes nothing** — the submit queue stays exactly as-is (no promote on the Stop terminal; only an explicit idle ▶ / Ctrl+Enter with an empty composer + non-empty queue starts the next item). Idle: no-op (textEntry / dvui menus keep it) | | **Ctrl+**/**Cmd+/** | Toggle the in-canvas **help overlay** (TEAL panel over the transcript band) | | **Ctrl+Shift+Space** | Arm the **leader** prefix (800 ms). Within the window press **`?`** to toggle help; **`t`** toggles **thinking default-collapsed** (collapse thinking by default, including the active Busy turn; in-memory only, resets to on on reload/New/Clear); **Escape** cancels; an unmatched key swallows (never lands in the prompt); a reserved browser chord still yields to the browser | | Tab / Ctrl+Left / Ctrl+Right | DOM nav / text caret (not harness chords; Ctrl+Left/Right are word-jumps, left to the textEntry) | @@ -87,7 +87,7 @@ Vertical bands inside the Wasm root (not a DOM panel): | Composer visibility | Fully on-canvas while the harness is ready; not optional | | Height budget | Every frame: absolute-rect bands — the transcript band is **`[left rail \| scrollArea]`** from the canvas top to the queue band (or the composer when the queue is empty). Height = viewport − dynamic composer `composer_h` − optional queue band − status bar `STATUS_BAR_H`; no inter-band gap. The rail is a sibling `Options.rect` of the scroller (closed 40 px, open 220 px TEAL session-list column); the scroller’s `Options.rect.x` is the rail width so both share `scroll_h`. Composer sits above the status bar with dynamic height from previous-frame measurement (idle ~44 px, max 124 px), full canvas width. The queue band sits **above** the composer and does **not** move it. Status bar absolute-rect flush to the canvas bottom, full width. Transcript scroller **and** composer use `Options.rect` so neither participates in root flex; the scrollArea's `.auto` bar cannot publish virtual content height as min-size (dvui `ScrollContainerWidget.deinit` overwrites `min_size.h` with full content). Tall content cannot push chrome off-canvas | | Wrap / grow | Field is **`break_lines`** + grows **vertically** with wrapped lines up to `COMPOSER_INPUT_MAX_H` (120 px) then scrolls **inside** the entry. Composer chrome box uses a dynamic absolute rect from previous-frame measurement (`composer_last_h`): idle = `COMPOSER_IDLE_CHROME_H` (44 px = TOUCH_H + 2×HUG_PAD), max = `COMPOSER_MAX_CHROME_H` (124 px). Glyphs sit `COMPOSER_TE_PAD` (5 px) inside the field stroke — the field's `min_size_content` / `max_size_content` are passed minus 2×pad on the **height axis only** (`TOUCH_H − 2×pad`, `COMPOSER_INPUT_MAX_H − 2×pad`) because `TextEntryWidget.init` bakes the padding in; the **width** is passed raw (`min 120`, `max field_w`) — it is already the leftover after the reserved strip, so it must **not** be pad-shortened or the wrap would shrink 10 px inside the reserve. The textEntry hugs one line when idle, grows up when multi-line (one-frame settle lag), and scrolls internally past 120 px. `scroll_horizontal = false` on the textEntry so the inner `ScrollContainerWidget` gives the child a finite viewport width (`.none` → child gets `viewport.w`, not the unwrapped `min_size.w`), letting `break_lines` fire at the field width instead of h-scrolling. **`#734` width bound (plan #737):** the field's `max_size_content.w` is `field_w = avail_w − (TOUCH_H×n + 8)` — the leftover after a **reserved** trailing icon pack (n = 1 idle / 2 busy; the 8 is the field's right margin) — and the textEntry sits on an explicit trailing-reserved sub-rect, so a long unbreakable line's natural min width can never squeeze the ▶/■ icons off-canvas. The reserved field wrapper reports `min=max=field_w` to the box, so the icons always land post-reserve; the field wraps at `field_w` (or clips inside via `scroll_horizontal = false`). The Send/Stop icons at `gravity_y = 1.0` stay bottom-pinned on the field baseline (adversarial review #584 Round 2 Major L1+L9). Never a horizontal gutter (repo no-h-scroll policy, #344/#457/#579) | -| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. The icons sit in a **trailing-reserved strip** `TOUCH_H×n + 8` px (plan #737/#734): the field is width-bounded to the leftover after that reserve, so the icons always keep their full `TOUCH_H` square and are never squeezed off-canvas by a long unbreakable line — the field **yields** to the reserve, never the icon. Idle = one ▶ Send (submit when non-empty). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) | +| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. The icons sit in a **trailing-reserved strip** `TOUCH_H×n + 8` px (plan #737/#734): the field is width-bounded to the leftover after that reserve, so the icons always keep their full `TOUCH_H` square and are never squeezed off-canvas by a long unbreakable line — the field **yields** to the reserve, never the icon. Idle = one ▶ Send (submit when non-empty; **an empty composer + non-empty queue promotes the head** — explicit Play). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) | | Turn clock | Whole-turn **`mm:ss`** is painted **in-canvas** by the Wasm busy row (`Waiting for model… · 0:42`), protocol **v14** (`inv_set_turn_elapsed`). The **DOM host** owns the only reliable wall-clock (no WASI clock in Wasm): its ~1 Hz Busy effect pushes the elapsed seconds to the bridge (`HarnessBridge.setTurnElapsed` → `inv_set_turn_elapsed`), reset to 0 on Ready/Stop/error so no bare `0:00` lingers. The clock is client wall-time from turn start — **not** provider `usage` duration. See [feature-divide.md](feature-divide.md) | | Busy spinner | Two instances of a **2×4 rectangle grid**: (a) **transcript busy row** — a **WARM rectangle grid** paints **left of** `Waiting for model…` on a **full-width `teal_bg` bar**; while Busy the waiting copy runs a **left-to-right cyclic WARM color wave** (same 10 Hz phase as the spinner) via `text_wave.zig` — a **localized directed comet** behind the traveling head: the head scalar is `warm_accent`, the next 2 scalars behind it are `warm_muted`, the next 1 is `warm_border`, and the **rest of the line stays `warm_accent`** (readable; `warm_surface` #1a120c is ~1:1 on `teal_bg` and reserved for rect_spinner off-cells). The wave doubles its speed via `SPEED=2` — **~2.7 s full cycle** at 10 Hz (`N*STEPS/SPEED` ticks). The wave is painted inside the same textLayout as the ` · mm:ss` clock (no separate clock textLayout). (b) **status-bar line 1** — during **Busy** the same WARM pulse runs; during **idle** (Ready / boot / error) all 8 cells paint `teal_muted` as a static, subtle grid. The grid is **4×4 px cells** with **2 px sibling gaps** (inner 10×22), centered by equal pad (**1.5 / 3.5**) inside a reserved **13×29 slot**; the slot and the **10 px `TRAIL`** before the waiting copy do not move. The pulse is a **clockwise loop**: left column **bottom→top**, right column **top→bottom**. The **DOM host** feeds the pulse phase at **`HARNESS_BUSY_TICK_HZ` = 10 Hz while Busy** (`HarnessBridge.setBusyTick` → additive `inv_set_busy_tick`). **NEW cap:** 10 Hz while Busy, **0 otherwise** — well below the dvui 60 fps ceiling, host-local `setInterval` (no transport), turns transient. Pulse is pure Wasm LUT paint (`native/harness/src/busy_spinner.zig` / `rect_spinner.zig` / `text_wave.zig`), **no I/O / alloc in the frame path**. Each `setBusyTick` triggers a full dvui `refresh()` (re-layout + repaint) at up to 10 Hz while Busy vs 1 Hz today — the 10 Hz bound is the lock, not a cached-redraw claim. **Reduced motion:** host skips tick pushes → `busy_tick` stays 0 (reserved sentinel). Both grids static (spinner head **bottom-left**; wave all `warm_accent` — the phase-0 fast-path in `text_wave.zig` paints the whole string at `ramp[0]`). The bridge stores the host's monotonic `u32` tick as-is; `0` is reserved for idle / reduced motion / busy-start. The comet wraps only on `N*STEPS` (visible loop), not on a u8 fold. The live `mm:ss` **clock feed keeps running** (solid `warm_accent`, never waves). Old host + new Wasm: `busy_tick` stays 0 → static grids + static wave (graceful). New host + old Wasm: `inv_set_busy_tick` is in `REQUIRED_FNS`, so a stale build fails closed at load | | Short canvas | Transcript shrinks / scrolls first — chrome keeps touch-sized targets (~40px). `SCROLL_FLOOR_H` (32 px) prevents the transcript from collapsing to zero on absurdly short canvases | @@ -104,8 +104,8 @@ While a turn is **Busy**, follow-up prompts go into an in-canvas FIFO **above** | Enqueue | Busy Ctrl/Cmd+Enter or the extra ▶ icon appends the composer text. Idle ▶ still starts a turn immediately. A **successful** enqueue scrolls the queue list to the newest row and the transcript to the bottom (same “I sent something” follow as idle send). Blank enqueue clears the composer (same as idle empty-send). Full enqueue does not scroll — composer text stays; `· full` is the signal | | Visibility | Compact TEAL list (header + up to 3 rows, extra rows scroll). Each row: first-line preview (100 bytes, UTF-8 safe, slash commands kept) + edit + remove. Enqueue follows the newest row into view when `n > 3` | | Edit / cancel | Edit in place (Ctrl+Enter / blur saves; Escape reverts). ✕ removes one item. **Clear** empties the queue. The in-flight turn is untouched | -| When it runs | After the current turn reaches Ready or error, the head submits as a normal user turn (same `pending_submit` path). Promote is held while a row is being edited | -| Stop | Cancels **only** the in-flight turn. The queue stays and drains after Ready | +| When it runs | After a **successful** turn reaches Ready, the head **automatically** submits as a normal user turn (same `pending_submit` path; protocol v19 promote gate armed true). A **Stop / Esc / error / timeout / validation** terminal never promotes (gate armed false). The queue head starts ONLY on an explicit **idle ▶** or **Ctrl/Cmd+Enter** with an empty composer + non-empty queue (plan #760). Promote is held while a row is being edited | +| Stop | Cancels **only** the in-flight turn and **consumes nothing** — the queue stays exactly as-is (the old "drains after Ready" behavior is removed, plan #760). Only an explicit idle ▶ / Ctrl+Enter (empty composer, non-empty queue) starts the next item | | Lifetime | Wasm-ephemeral. Refresh, New session, Clear, and session switch wipe it. Not stored on the cloud session | | Full (16) | Further enqueues are rejected; the composer text stays so nothing is silently dropped | | Auto-continue | A later host auto-continue (session TODO drain) must not start while the queue is non-empty or a pending submit is unacked | diff --git a/lib/harnessBridge.test.ts b/lib/harnessBridge.test.ts index fa1926b6..0a1ca3ef 100644 --- a/lib/harnessBridge.test.ts +++ b/lib/harnessBridge.test.ts @@ -22,6 +22,7 @@ type MockExtras = { __busyTick: () => number; __setModelPending: (on: boolean) => void; __modelPending: () => boolean; + __promoteAllowed: () => boolean; }; function makeMockExports(overrides?: Partial): HarnessBridgeExports & MockExtras { @@ -49,6 +50,8 @@ function makeMockExports(overrides?: Partial): HarnessBrid const statusSlots: (string | undefined)[] = new Array(8).fill(undefined); let turnElapsedSec = 0; let busyTickPhase = 0; + // Protocol v19 (plan #760) — same default-true scalar the Wasm holds. + let promoteAllowed = true; const gpa_u8 = (len: number) => { if (len <= 0) return 0; @@ -133,6 +136,9 @@ function makeMockExports(overrides?: Partial): HarnessBrid pending = null; }, inv_queued_count: () => 0, + inv_set_queue_promote_allowed: (v: number) => { + promoteAllowed = v !== 0; + }, inv_set_can_load_earlier: (v: number) => { canLoad = v ? 1 : 0; if (!canLoad) loadEarlier = false; @@ -278,6 +284,7 @@ function makeMockExports(overrides?: Partial): HarnessBrid modelPending = !!on; }, __modelPending: () => modelPending, + __promoteAllowed: () => promoteAllowed, }; return { @@ -292,6 +299,7 @@ function makeMockExports(overrides?: Partial): HarnessBrid __busyTick: base.__busyTick, __setModelPending: base.__setModelPending, __modelPending: base.__modelPending, + __promoteAllowed: base.__promoteAllowed, }; } @@ -808,7 +816,7 @@ describe('skill_attached kind (protocol v12)', () => { // Distinct from the protocol version (13) — a hardcoded kind 13 would be an // unknown kind to the Wasm painter. expect(MessageKind.SkillAttached).not.toBe(HARNESS_PROTOCOL_VERSION); - expect(HARNESS_PROTOCOL_VERSION).toBe(18); + expect(HARNESS_PROTOCOL_VERSION).toBe(19); }); it('push/readback round-trips a skill_attached row', () => { @@ -837,8 +845,8 @@ describe('setTurnElapsed (protocol v14)', () => { expect(exp.__turnElapsed()).toBe(0); }); - it('version bumped to 18 and the export is REQUIRED (fail-closed when missing)', () => { - expect(HARNESS_PROTOCOL_VERSION).toBe(18); + it('version bumped to 19 and the export is REQUIRED (fail-closed when missing)', () => { + expect(HARNESS_PROTOCOL_VERSION).toBe(19); const exp = makeMockExports() as unknown as WebAssembly.Exports; expect(isHarnessBridgeExports(exp)).toBe(true); // A rebuilt Wasm that omits inv_set_turn_elapsed fails bridge-load closed, @@ -907,7 +915,7 @@ describe('status-slot pack (protocol v13)', () => { describe('queuedCount (protocol v18)', () => { it('reads inv_queued_count and fails closed when the export is missing', () => { - expect(HARNESS_PROTOCOL_VERSION).toBe(18); + expect(HARNESS_PROTOCOL_VERSION).toBe(19); const exp = makeMockExports(); const bridge = new HarnessBridge(exp); expect(bridge.queuedCount()).toBe(0); @@ -916,3 +924,24 @@ describe('queuedCount (protocol v18)', () => { expect(isHarnessBridgeExports(record as unknown as WebAssembly.Exports)).toBe(false); }); }); + +describe('setQueuePromoteAllowed (protocol v19, plan #760)', () => { + it('arms the one-shot scalar; default true mirrors legacy auto-promote', () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + expect(exp.__promoteAllowed()).toBe(true); // legacy default + bridge.setQueuePromoteAllowed(false); // Stop / Esc / error / timeout Ready + expect(exp.__promoteAllowed()).toBe(false); + bridge.setQueuePromoteAllowed(true); // successful Ready + expect(exp.__promoteAllowed()).toBe(true); + }); + + it('export is REQUIRED (fail-closed when missing from the wasm)', () => { + expect(HARNESS_PROTOCOL_VERSION).toBe(19); + const exp = makeMockExports() as unknown as WebAssembly.Exports; + expect(isHarnessBridgeExports(exp)).toBe(true); + const record = exp as unknown as Record; + delete record.inv_set_queue_promote_allowed; + expect(isHarnessBridgeExports(record as WebAssembly.Exports)).toBe(false); + }); +}); diff --git a/lib/harnessBridge.ts b/lib/harnessBridge.ts index eddc5e65..7fb58865 100644 --- a/lib/harnessBridge.ts +++ b/lib/harnessBridge.ts @@ -31,7 +31,11 @@ import { // `inv_push_session_catalog_entry`, `inv_set_current_session`, // `inv_has_pending_session_switch` / len / copy / ack. Additive, now REQUIRED. // v18: submit-queue count — `inv_queued_count` (Wasm-ephemeral FIFO). Additive, now REQUIRED. -export const HARNESS_PROTOCOL_VERSION = 18 as const; +// v19 (plan #760): promote gate — `inv_set_queue_promote_allowed` (host arms a +// one-shot per-terminal scalar so a Stop / Esc / error / timeout Ready never +// drains the queue; only idle ▶ / Ctrl+Enter with an empty composer + non-empty +// queue promotes). Additive, now REQUIRED. +export const HARNESS_PROTOCOL_VERSION = 19 as const; /** XOR constant used by `inv_ping` on the Wasm side. */ export const INV_PING_XOR = 0xa5a5 as const; @@ -146,6 +150,11 @@ export type HarnessBridgeExports = { inv_pending_submit_copy: (outPtr: number, maxLen: number) => number; inv_ack_pending_submit: () => void; inv_queued_count: () => number; + // Protocol v19 (plan #760) — host arms the one-shot promote gate: true on a + // successful turn's Ready (auto-promote stays), false on Stop / Esc / error / + // timeout / validation Ready so the Wasm terminal-promote block never drains + // a queued head after a non-success. + inv_set_queue_promote_allowed: (v: number) => void; inv_set_can_load_earlier: (v: number) => void; inv_has_pending_load_earlier: () => number; inv_ack_pending_load_earlier: () => void; @@ -229,6 +238,7 @@ const REQUIRED_FNS: Exclude[] = [ 'inv_pending_submit_copy', 'inv_ack_pending_submit', 'inv_queued_count', + 'inv_set_queue_promote_allowed', 'inv_set_can_load_earlier', 'inv_has_pending_load_earlier', 'inv_ack_pending_load_earlier', @@ -566,6 +576,18 @@ export class HarnessBridge { return this.exports.inv_queued_count(); } + /** + * Protocol v19 (plan #760) — arm the one-shot promote gate for the NEXT + * terminal. Host sets true on a SUCCESSFUL turn's Ready (auto-promote stays, + * unchanged) and false on a Stop / Esc / error / timeout / validation Ready + * so the Wasm terminal-promote block cannot drain a queued head after a + * non-success. The idle ▶ / Ctrl+Enter explicit-Play path is unchanged (it + * promotes regardless of this scalar). + */ + setQueuePromoteAllowed(allowed: boolean): void { + this.exports.inv_set_queue_promote_allowed(allowed ? 1 : 0); + } + /** Read + ack pending Wasm→JS submit, or null if none. */ takePendingSubmit(): string | null { if (!this.hasPendingSubmit()) return null; diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 80ecc653..17fc1795 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -56,6 +56,7 @@ function makeMockExports(): HarnessBridgeExports & { __lifecycle: () => Lifecycle; __canLoadEarlier: () => number; __statusSlots: (string | undefined)[]; + __promoteAllowed: () => boolean; } { let buf = new ArrayBuffer(64 * 1024); const memory = { @@ -66,6 +67,9 @@ function makeMockExports(): HarnessBridgeExports & { let nextPtr = 1024; let lifecycle = Lifecycle.Boot; let canLoadEarlier = 0; + // Protocol v19 (plan #760) — mirrors the Wasm scalar: default true (legacy + // auto-promote) until the host arms it false on a Stop/error Ready. + let promoteAllowed = true; const messages: { kind: number; text: string }[] = []; const statusSlots: (string | undefined)[] = new Array(8).fill(undefined); @@ -128,6 +132,9 @@ function makeMockExports(): HarnessBridgeExports & { inv_pending_submit_copy: () => 0, inv_ack_pending_submit: () => {}, inv_queued_count: () => 0, + inv_set_queue_promote_allowed: (v: number) => { + promoteAllowed = v !== 0; + }, inv_set_can_load_earlier: (v: number) => { canLoadEarlier = v ? 1 : 0; }, @@ -182,6 +189,7 @@ function makeMockExports(): HarnessBridgeExports & { __lifecycle: () => lifecycle, __canLoadEarlier: () => canLoadEarlier, __statusSlots: statusSlots, + __promoteAllowed: () => promoteAllowed, }; } @@ -298,6 +306,66 @@ describe('runHarnessChat', () => { }); }); +describe('protocol v19 promote gate arming (plan #760)', () => { + it('runHarnessChat success arms promote_allowed=true then Ready', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const send = vi.fn(async (): Promise => ({ ok: true, text: 'PONG' })); + await runHarnessChat(bridge, 'hi', { send }); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(exp.__promoteAllowed()).toBe(true); // success → auto-promote stays + }); + + it('runHarnessChat failure (agent error) arms promote_allowed=false then Ready', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const send = vi.fn(async (): Promise => ({ + ok: false, + error: 'AI_GATEWAY_API_KEY is not configured.', + status: 503, + })); + await runHarnessChat(bridge, 'hello', { send }); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(exp.__promoteAllowed()).toBe(false); // failure / stop → never drain + }); + + it('runHarnessTurn agent success arms promote_allowed=true then Ready', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const sendAgent = vi.fn(async (): Promise => ({ ok: true, text: 'PONG' })); + await runHarnessTurn(bridge, createEmptySession(), 'hi', { sendAgent }); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(exp.__promoteAllowed()).toBe(true); // success → auto-promote stays + }); + + it('runHarnessTurn Stop (Request cancelled.) arms promote_allowed=false then Ready — no queue drain', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const sendAgent = vi.fn(async (): Promise => ({ + ok: false, + error: 'Request cancelled.', + status: 499, + })); + const { result } = await runHarnessTurn(bridge, createEmptySession(), 'x', { + sendAgent, + }); + expect(result.ok).toBe(false); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + // A user Stop must never drain a queued head — the Wasm terminal-promote + // block is gated on this scalar. + expect(exp.__promoteAllowed()).toBe(false); + }); + + it('runHarnessTurn validation arms promote_allowed=false then Ready', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const result = await runHarnessChat(bridge, ' '); + expect(result.ok).toBe(false); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(exp.__promoteAllowed()).toBe(false); + }); +}); + describe('runHarnessTurn', () => { it('appends user + assistant to session (agent path)', async () => { const exp = makeMockExports(); diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 3c03d055..3f472019 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -489,6 +489,20 @@ export async function refreshGitStatusSlot( } } +/** + * Protocol v19 (plan #760) — host terminal: arm the one-shot promote gate then + * set Ready. The host is the SOLE lifecycle writer and the only observer of the + * outcome (`classifyTurnFailure`), so every Ready path must go through this + * helper or a Stop/error Ready could silently drain the queue. `promoteAllowed` + * is true on a SUCCESSFUL turn (auto-promote stays, unchanged) and false on + * Stop / Esc / error / timeout / validation (the Wasm terminal-promote block + * never drains after a non-success; only idle ▶ / Ctrl+Enter explicit Play does). + */ +function completeTurn(bridge: HarnessBridge, promoteAllowed: boolean): void { + bridge.setQueuePromoteAllowed(promoteAllowed); + bridge.setLifecycle(Lifecycle.Ready); +} + /** * Run one prompt → Gateway → transcript update. * Sets lifecycle busy → ready (soft API errors leave ready for retry). @@ -501,7 +515,7 @@ export async function runHarnessChat( const validation = validatePrompt(rawPrompt); if (validation) { bridge.pushMessage(MessageKind.Error, validation); - bridge.setLifecycle(Lifecycle.Ready); + completeTurn(bridge, false); // validation — no auto-promote return { ok: false, error: validation }; } @@ -546,7 +560,7 @@ export async function runHarnessChat( ); } bridge.pushMessage(MessageKind.System, describeTurnEnd('chat')); - bridge.setLifecycle(Lifecycle.Ready); + completeTurn(bridge, true); // success — auto-promote allowed return result; } @@ -555,7 +569,7 @@ export async function runHarnessChat( fail.kind === 'stop' ? MessageKind.System : MessageKind.Error, describeTurnEnd(fail.kind, fail.detail), ); - bridge.setLifecycle(Lifecycle.Ready); + completeTurn(bridge, false); // stop / error / timeout / empty — no auto-promote return result; } @@ -810,7 +824,7 @@ export async function runHarnessTurn( if (validation) { bridge.pushMessage(MessageKind.Error, describeTurnEnd('validation', validation)); const next = appendMessage(session, 'error', describeTurnEnd('validation', validation)); - bridge.setLifecycle(Lifecycle.Ready); + completeTurn(bridge, false); // validation — no auto-promote return { result: { ok: false, error: validation }, session: next }; } @@ -1382,7 +1396,7 @@ export async function runHarnessTurn( // slot right after the fold instead of waiting for the cadence tick. // Fail-soft; server rate-limited; never blocks the turn return. void refreshGitStatusSlot(bridge, next, opts?.signal); - bridge.setLifecycle(Lifecycle.Ready); + completeTurn(bridge, true); // success — auto-promote allowed return { result: { ok: true, text: agentResult.text || assistantAcc }, session: next, @@ -1489,7 +1503,7 @@ export async function runHarnessTurn( // repaint on cancel). The unscoped fetch is bounded by the fail-soft // catch; it only repaints one slot once. void refreshGitStatusSlot(bridge, failedSession); - bridge.setLifecycle(Lifecycle.Ready); + completeTurn(bridge, false); // stop / error / timeout / empty — no auto-promote return { result: { ok: false, diff --git a/lib/harnessChat.wasm-int.test.ts b/lib/harnessChat.wasm-int.test.ts index 6e9245aa..a6974929 100644 --- a/lib/harnessChat.wasm-int.test.ts +++ b/lib/harnessChat.wasm-int.test.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { runHarnessTurn } from './harnessChat'; import { + HARNESS_PROTOCOL_VERSION, HarnessBridge, Lifecycle, MessageKind, @@ -113,6 +114,20 @@ describe('real-Wasm live tool increment (implements #433)', () => { expect(typeof bridge.exports.inv_message_kind_at).toBe('function'); }); + it('real Wasm is protocol v19 and rides the promote-gate export (plan #760)', async () => { + const bridge = await loadBridge(); + // Protocol v19 parity — Wasm `PROTOCOL_VERSION` must equal the host + // `HARNESS_PROTOCOL_VERSION` (assertRoundTrip also checks it; pin it here). + expect(bridge.protocolVersion()).toBe(HARNESS_PROTOCOL_VERSION); + // REQUIRED_FNS already proves `inv_set_queue_promote_allowed` reached the + // real Wasm (a stale artifact without it fails loadBridge → this suite + // fails, never it.skip). Round-trip the scalar — arm false (Stop/error + // Ready: no auto-promote) then true (successful Ready): no throw. + bridge.setQueuePromoteAllowed(false); + bridge.setQueuePromoteAllowed(true); + expect(bridge.protocolVersion()).toBe(HARNESS_PROTOCOL_VERSION); + }); + it('real Wasm carries the additive 2×4 spinner export (plan #574) and accepts tick pushes', async () => { const bridge = await loadBridge(); // REQUIRED_FNS already proves `inv_set_busy_tick` exists (a stale artifact diff --git a/lib/harnessHostModelPersist.test.ts b/lib/harnessHostModelPersist.test.ts index ba8491c7..e1147dea 100644 --- a/lib/harnessHostModelPersist.test.ts +++ b/lib/harnessHostModelPersist.test.ts @@ -110,6 +110,7 @@ function makeMockExports(overrides?: Partial): HarnessBrid inv_pending_submit_copy: () => 0, inv_ack_pending_submit: () => {}, inv_queued_count: () => 0, + inv_set_queue_promote_allowed: () => {}, inv_set_can_load_earlier: () => {}, inv_has_pending_load_earlier: () => 0, inv_ack_pending_load_earlier: () => {}, diff --git a/native/harness/build.zig b/native/harness/build.zig index 539d1c6d..f2a07f97 100644 --- a/native/harness/build.zig +++ b/native/harness/build.zig @@ -88,6 +88,7 @@ pub fn build(b: *std.Build) void { "inv_pending_submit_copy", "inv_ack_pending_submit", "inv_queued_count", + "inv_set_queue_promote_allowed", "inv_set_can_load_earlier", "inv_has_pending_load_earlier", "inv_ack_pending_load_earlier", @@ -256,6 +257,28 @@ pub fn build(b: *std.Build) void { test_rich.dependOn(&b.addRunArtifact(chip_preview_tests).step); } + // Host unit tests for bridge.zig protocol-v19 promote gate (plan #760): + // default-true legacy auto-promote, host set/read round-trip, and reset() / + // inv_clear_messages re-arming on fresh surfaces. Imports bridge.zig which + // needs the web-backend stub (mirrors queue_band_tests wiring). + { + const bridge_tests = b.addTest(.{ + .name = "bridge", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/bridge.test.zig"), + .target = host_target, + .optimize = optimize, + }), + }); + bridge_tests.root_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + bridge_tests.root_module.addImport("web-backend", b.createModule(.{ + .root_source_file = b.path("src/test_web_backend_stub.zig"), + .target = host_target, + .optimize = optimize, + })); + test_rich.dependOn(&b.addRunArtifact(bridge_tests).step); + } + // Host unit tests for submit_queue.zig + queue_preview.zig (plan #664). { const submit_queue_tests = b.addTest(.{ diff --git a/native/harness/src/bridge.test.zig b/native/harness/src/bridge.test.zig new file mode 100644 index 00000000..fb9fe4cb --- /dev/null +++ b/native/harness/src/bridge.test.zig @@ -0,0 +1,42 @@ +//! Unit tests for `bridge.zig` protocol-v19 promote gate (plan #760). The gate +//! is a host-armed one-shot scalar (`queue_promote_allowed`): default **true** +//! so a legacy host keeps today's success auto-promote; the host arms it false +//! on a Stop / Esc / error / timeout / validation Ready so the Wasm +//! terminal-promote block can never drain the queue after a non-success. +//! +//! Testable without a dvui frame: these call the pub scalar seam +//! (`hasQueuePromoteAllowed`, `inv_set_queue_promote_allowed`, `reset`, +//! `inv_clear_messages`) directly, mirroring `queue_band.test.zig` wiring +//! (bridge.zig → web-backend stub). +const std = @import("std"); +const t = std.testing; +const bridge = @import("bridge.zig"); + +test "promote gate defaults true (legacy-host auto-promote preserved)" { + bridge.reset(); + try t.expect(bridge.hasQueuePromoteAllowed()); +} + +test "inv_set_queue_promote_allowed arms false, then true (set/read round-trip)" { + bridge.reset(); + bridge.inv_set_queue_promote_allowed(0); + try t.expect(!bridge.hasQueuePromoteAllowed()); + bridge.inv_set_queue_promote_allowed(1); + try t.expect(bridge.hasQueuePromoteAllowed()); +} + +test "reset() restores promote gate to true after a Stop armed it false" { + bridge.reset(); + bridge.inv_set_queue_promote_allowed(0); // host Stop terminal armed it false + try t.expect(!bridge.hasQueuePromoteAllowed()); + bridge.reset(); // New / session (re-)init = fresh surface + try t.expect(bridge.hasQueuePromoteAllowed()); +} + +test "inv_clear_messages restores promote gate to true (Clear / New surface)" { + bridge.reset(); + bridge.inv_set_queue_promote_allowed(0); // host Stop armed it false + try t.expect(!bridge.hasQueuePromoteAllowed()); + bridge.inv_clear_messages(); // Clear does NOT call reset() — must re-arm here + try t.expect(bridge.hasQueuePromoteAllowed()); +} diff --git a/native/harness/src/bridge.zig b/native/harness/src/bridge.zig index 69f73b77..cb5fef53 100644 --- a/native/harness/src/bridge.zig +++ b/native/harness/src/bridge.zig @@ -34,7 +34,11 @@ const submit_queue = @import("submit_queue.zig"); /// `inv_has_pending_session_switch` / len / copy / ack. Additive, now REQUIRED. /// v18: submit-queue count — `inv_queued_count` (Wasm-ephemeral FIFO; host /// auto-continue must wait for 0). Additive, now REQUIRED. -pub const PROTOCOL_VERSION: u32 = 18; +/// v19: promote gate — `inv_set_queue_promote_allowed` (host arms a one-shot +/// per-terminal scalar so a Stop / Esc / error / timeout Ready never drains the +/// queue; only idle ▶ / Ctrl+Enter with an empty composer + non-empty queue does). +/// Additive, now REQUIRED. +pub const PROTOCOL_VERSION: u32 = 19; pub const Lifecycle = enum(u8) { boot = 0, @@ -127,6 +131,15 @@ var pending_submit_len: u32 = 0; var has_pending_submit: bool = false; /// Wasm-ephemeral operator follow-up FIFO (protocol v18). var queue: submit_queue.Q = .{}; +/// Protocol v19 — promote gate (plan #760). The host arms a one-shot per- +/// terminal scalar: **true** on a successful turn's Ready (auto-promote stays, +/// unchanged), **false** on Stop / Esc / error / timeout / validation Ready so +/// the Wasm terminal-promote block can NEVER drain the queue after a non-success. +/// Default **true** keeps a legacy host (that never writes it) on today's +/// success auto-promote. Reset on `reset()` and `inv_clear_messages` (fresh +/// surfaces re-arm the default). Wasm only READS this on the terminal edge; the +/// host rewrites it every terminal. +var queue_promote_allowed: bool = true; /// Host sets when SessionStore has messages older than the current ring window. var can_load_earlier: bool = false; var has_pending_load_earlier: bool = false; @@ -242,6 +255,14 @@ pub fn enqueueFromUi(text: []const u8) error{ Blank, Full }!void { refresh(); } +/// Protocol v19 — current promote gate (plan #760). Wasm reads this on the +/// terminal edge (busy→ready) so a Stop / error terminal cannot drain the queue +/// when the host has armed it false. It is NOT consumed here — the host rewrites +/// it on every terminal, and `reset()`/`inv_clear_messages` restore the default. +pub fn hasQueuePromoteAllowed() bool { + return queue_promote_allowed; +} + /// Promote the queue head into `queueSubmitFromUi` when the turn is terminal /// and the operator is not mid-edit. Peek → submit → pop only if accepted. pub fn tryPromoteQueued(editing: bool) bool { @@ -305,6 +326,7 @@ pub fn reset() void { has_pending_load_earlier = false; has_pending_cancel = false; has_pending_model_change = false; + queue_promote_allowed = true; // fresh surface re-arms the legacy default (plan #760) session_catalog.reset(); suppress_refresh = false; catalog_count = 0; @@ -521,7 +543,7 @@ export fn inv_update_last_message(kind: u8, ptr: [*]const u8, len: usize) u8 { return 1; } -export fn inv_clear_messages() void { +pub export fn inv_clear_messages() void { msg_head = 0; msg_count = 0; has_pending_cancel = false; @@ -529,6 +551,8 @@ export fn inv_clear_messages() void { has_pending_submit = false; pending_submit_len = 0; submit_queue.clear(&queue); + // Clear / New also re-arm the promote gate (fresh surface, plan #760). + queue_promote_allowed = true; image_cache.clear(); math_cache.clear(); refresh(); @@ -577,6 +601,20 @@ export fn inv_queued_count() u32 { return submit_queue.count(&queue); } +/// Protocol v19 — host arms the one-shot promote gate for the NEXT terminal +/// (plan #760). `v != 0` re-enables auto-promote for the terminal that follows; +/// `0` makes a Stop / error / timeout Ready drain nothing. Takes effect on the +/// next Wasm terminal edge (busy→ready / edit-close), not retroactively on the +/// current transition. `refresh()` so the canvas re-paints. +/// `pub` so the host-target unit test (`bridge.test.zig`) can call it directly +/// on the compiled module; the Wasm ABI export still roots via build.zig. +pub export fn inv_set_queue_promote_allowed(v: u8) void { + const next = v != 0; + if (queue_promote_allowed == next) return; + queue_promote_allowed = next; + refresh(); +} + export fn inv_set_can_load_earlier(v: u8) void { const next = v != 0; if (can_load_earlier == next) return; diff --git a/native/harness/src/composer_layout.test.zig b/native/harness/src/composer_layout.test.zig index a970ef41..bc2923f1 100644 --- a/native/harness/src/composer_layout.test.zig +++ b/native/harness/src/composer_layout.test.zig @@ -36,6 +36,14 @@ var T_buf: [512]u8 = [_]u8{0} ** 512; var T_busy: bool = false; var T_want_focus: bool = true; var T_last_res: composer_chrome.Result = .{}; +// Staged action callbacks (Zig has no closures into test fns — same pattern as +// T_buf / T_busy). Set per test before `paintAndClickSend`. +var T_actions: composer_chrome.Actions = .{}; +// Plan #760 — idle ▶ promote-fallback dispatch capture. The test injects +// callbacks and records which one the paint dispatched (or neither). +var T_promote_count: usize = 0; +var T_send_count: usize = 0; +var T_send_text: []const u8 = "unset"; const FieldRects = struct { wrap: dvui.Rect.Physical, @@ -76,6 +84,42 @@ fn resetBuf() void { @memset(&T_buf, 0); } +/// Reset the plan-#760 dispatch captures for one test. +fn resetDispatch() void { + T_promote_count = 0; + T_send_count = 0; + T_send_text = "unset"; +} + +/// Paint TWO frames passing the STAGED `T_actions` callbacks (plan #760 idle-▶ +/// dispatch test), then move the mouse onto the send ▶ and click it. Two +/// frames are needed so the tag rect exists before the click lands. +fn paintAndClickSend(expect_click: bool) !void { + resetDispatch(); + const frame = struct { + fn paint() !dvui.App.Result { + T_last_res = composer_chrome.paintComposerChrome(.{ + .busy = T_busy, + .avail_w = WIN_LW, + .y = 0, + .h = metrics.COMPOSER_IDLE_CHROME_H, + .prompt_buf = &T_buf, + .want_focus = &T_want_focus, + .actions = T_actions, + }); + return .ok; + } + }.paint; + + _ = dvui.testing.step(frame) catch @panic("step 1 failed"); + _ = dvui.testing.step(frame) catch @panic("step 2 failed"); + if (expect_click) { + try dvui.testing.moveTo("composer-send"); + try dvui.testing.click(.left); + _ = dvui.testing.step(frame) catch @panic("step 3 failed"); + } +} + test "idle: field on reserved sub-rect, ▶ lands post-reserve, field right ≤ ▶ left" { var tr = try dvui.testing.init(.{}); defer tr.deinit(); @@ -206,3 +250,76 @@ test "multi-line prompt: measured outer height grows (dynamic hug intact through // build includes freetype, so wrapped textLayout heights are real.) try t.expect(multi_h > single_h + EPS); } + +// ── Plan #760 — idle ▶ promote-fallback dispatch (goals 2/3/4) ───────────── + +test "idle ▶ + empty composer → on_promote fires (explicit Play of the head)" { + var tr = try dvui.testing.init(.{}); + defer tr.deinit(); + resetBuf(); // empty field + T_busy = false; + T_want_focus = true; + const promote_cb = struct { + fn run() void { + T_promote_count += 1; + } + }.run; + const send_cb = struct { + fn run(_: []const u8) void { + T_send_count += 1; + } + }.run; + T_actions = .{ .on_promote = &promote_cb, .on_send = &send_cb }; + try paintAndClickSend(true); + // Goal 2: idle ▶ with an EMPTY field promotes — on_promote, never on_send. + try t.expectEqual(@as(usize, 1), T_promote_count); + try t.expectEqual(@as(usize, 0), T_send_count); +} + +test "idle ▶ + typed composer → on_send fires with the text, never on_promote" { + var tr = try dvui.testing.init(.{}); + defer tr.deinit(); + resetBuf(); + const text = "hello"; + @memcpy(T_buf[0..text.len], text); + T_buf[text.len] = 0; + T_busy = false; + T_want_focus = true; + const promote_cb = struct { + fn run() void { + T_promote_count += 1; + } + }.run; + const send_cb = struct { + fn run(txt: []const u8) void { + T_send_count += 1; + T_send_text = txt; + } + }.run; + T_actions = .{ .on_promote = &promote_cb, .on_send = &send_cb }; + try paintAndClickSend(true); + // Goal 3: idle ▶ with TEXT sends that text (queue untouched here); the head + // is not promoted. + try t.expectEqual(@as(usize, 0), T_promote_count); + try t.expectEqual(@as(usize, 1), T_send_count); + try t.expect(std.mem.eql(u8, "hello", T_send_text)); +} + +test "idle ▶ + empty composer + empty queue (no on_promote) → no-op" { + var tr = try dvui.testing.init(.{}); + defer tr.deinit(); + resetBuf(); // empty field + T_busy = false; + T_want_focus = true; + // Caller binds on_promote only when it has a queue head to promote; when + // the queue is empty it is null (goal 4) → the ▶ click must be a no-op. + const send_cb = struct { + fn run(_: []const u8) void { + T_send_count += 1; + } + }.run; + T_actions = .{ .on_send = &send_cb }; + try paintAndClickSend(true); + try t.expectEqual(@as(usize, 0), T_promote_count); + try t.expectEqual(@as(usize, 0), T_send_count); +} diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index 121340f0..3865349c 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -229,7 +229,12 @@ pub fn frame() !void { const trigger_b = state.queue_closed_edit and terminal; state.queue_closed_edit = false; const editing = state.queue_editing_index != null; - if ((trigger_a or trigger_b) and !editing) { + // Protocol v19 promote gate (plan #760): the host arms a one-shot scalar + // on every terminal — true on a SUCCESSFUL Ready (auto-promote stays, + // unchanged), false on Stop / Esc / error / timeout / validation Ready. + // When false, this block can never drain a queued head; only an explicit + // idle ▶ / Ctrl+Enter with an empty composer + non-empty queue promotes. + if ((trigger_a or trigger_b) and !editing and bridge.hasQueuePromoteAllowed()) { if (bridge.tryPromoteQueued(false)) { busy = true; } @@ -641,6 +646,16 @@ pub fn frame() !void { bridge.queueCancelFromUi(); } }.run; + // Plan #760 — idle ▶ with an EMPTY composer + non-empty queue promotes the + // head (goal 2). Same seam as the terminal gate (`tryPromoteQueued`), so a + // promoted head is a normal user send (pending submit → host starts a turn). + // `busy` becomes true on the next frame once the host polls the pending + // submit (mirrors on_send, which also doesn't mutate this frame's busy). + const on_promote = struct { + fn run() void { + _ = bridge.tryPromoteQueued(false); + } + }.run; var typed: []const u8 = state.prompt_buf[0..0]; { @@ -669,7 +684,7 @@ pub fn frame() !void { .h = composer_h, .prompt_buf = &state.prompt_buf, .want_focus = &state.want_composer_focus, - .actions = .{ .on_send = &on_send, .on_stop = &on_stop }, + .actions = .{ .on_send = &on_send, .on_stop = &on_stop, .on_promote = &on_promote }, }); typed = res.typed; @@ -695,6 +710,14 @@ pub fn frame() !void { composer.submitOrEnqueue(typed); typed = state.prompt_buf[0..0]; state.want_composer_focus = true; + } else if (bridge.queuedCount() > 0) { + // Plan #760 — Ctrl/Cmd+Enter with an EMPTY composer + non-empty + // queue promotes the head (goal 2, mirroring the idle ▶ click). + // tryPromoteQueued no-ops when the queue is empty, so goal 4 + // (empty+empty → no-op) needs no extra guard. + if (bridge.tryPromoteQueued(false)) { + busy = true; + } } } } diff --git a/native/harness/src/ui/composer_chrome.zig b/native/harness/src/ui/composer_chrome.zig index 2b65d3ff..067ff0c8 100644 --- a/native/harness/src/ui/composer_chrome.zig +++ b/native/harness/src/ui/composer_chrome.zig @@ -51,6 +51,12 @@ pub const Actions = struct { on_send: ?*const fn ([]const u8) void = null, /// Cancel the in-flight turn on the ■ Stop click (busy only). on_stop: ?*const fn () void = null, + /// Plan #760 — idle ▶ clicked with an EMPTY field + non-empty queue: + /// promote the queue head (explicit Play). Invoked only when the field is + /// empty on the idle ▶ (the busy row's ▶ keeps enqueuing typed text). No-op + /// in the caller when the queue is empty (goal 4). Bridge-free: the caller + /// binds it to `tryPromoteQueued`. + on_promote: ?*const fn () void = null, }; /// What the chrome observed this frame, handed back so `frame()` can keep the @@ -213,6 +219,11 @@ pub fn paintComposerChrome(opts: struct { })) { if (res.typed.len > 0) { if (opts.actions.on_send) |cb| cb(res.typed); + } else if (opts.actions.on_promote) |cb| { + // Plan #760 — idle ▶ with an EMPTY field falls back to an + // explicit Play of the queue head (goal 2). The caller no-ops + // when the queue is empty (goal 4) — no blank row is created. + cb(); } } } From df4780ea8c37eb9ba02b2389da90c0bc41fcc4fe Mon Sep 17 00:00:00 2001 From: btipling Date: Sat, 22 Aug 2026 02:37:16 +0000 Subject: [PATCH 2/3] feat(harness): honor edit lock on explicit Play + host-testable promote gate (PR #763 review round 1) --- lib/harnessChat.test.ts | 8 +- native/harness/src/bridge.test.zig | 89 +++++++++++++++++++++ native/harness/src/bridge.zig | 15 ++++ native/harness/src/composer_layout.test.zig | 22 +++-- native/harness/src/ui.zig | 35 ++++++-- 5 files changed, 155 insertions(+), 14 deletions(-) diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 17fc1795..768a743c 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -359,8 +359,14 @@ describe('protocol v19 promote gate arming (plan #760)', () => { it('runHarnessTurn validation arms promote_allowed=false then Ready', async () => { const exp = makeMockExports(); const bridge = new HarnessBridge(exp); - const result = await runHarnessChat(bridge, ' '); + // Actually route through runHarnessTurn (adversarial #763 Nit: the old test + // called runHarnessChat, so the runHarnessTurn validatePrompt branch was + // untested under its own name). A blank prompt fails validation BEFORE any + // agent/chat send, armor false, and land on Ready. + const sendAgent = vi.fn(async (): Promise => ({ ok: true, text: 'NOPE' })); + const { result } = await runHarnessTurn(bridge, createEmptySession(), ' ', { sendAgent }); expect(result.ok).toBe(false); + expect(sendAgent).not.toHaveBeenCalled(); // validatePrompt short-circuits before any send expect(exp.__lifecycle()).toBe(Lifecycle.Ready); expect(exp.__promoteAllowed()).toBe(false); }); diff --git a/native/harness/src/bridge.test.zig b/native/harness/src/bridge.test.zig index fb9fe4cb..dd588c2f 100644 --- a/native/harness/src/bridge.test.zig +++ b/native/harness/src/bridge.test.zig @@ -40,3 +40,92 @@ test "inv_clear_messages restores promote gate to true (Clear / New surface)" { bridge.inv_clear_messages(); // Clear does NOT call reset() — must re-arm here try t.expect(bridge.hasQueuePromoteAllowed()); } + +// ── shouldAutoPromote gate predicate (adversarial #763 L6) ───────────────── +// The pure, host-testable seam the ui.zig terminal-promote block folds through, +// so goal 1 (a Stop / Esc / error / timeout Ready never drains the queue) is a +// real failing-before / passing-after test — not just host-arming coverage. + +test "shouldAutoPromote: Stop / error terminal never auto-promotes (allowed=false)" { + bridge.reset(); + // Busy → ready with host armed false (Stop / timeout / validation Ready). + try t.expect(!bridge.shouldAutoPromote(.busy, .ready, false, false)); + // Busy → err terminal likewise never drains. + try t.expect(!bridge.shouldAutoPromote(.busy, .err, false, false)); +} + +test "shouldAutoPromote: successful ready auto-promotes only when not mid-edit" { + bridge.reset(); + // Successful turn: busy → ready, host armed true, operator not mid-edit. + try t.expect(bridge.shouldAutoPromote(.busy, .ready, false, true)); + // Even a successful ready must NOT promote while the operator is mid-edit. + try t.expect(!bridge.shouldAutoPromote(.busy, .ready, true, true)); +} + +test "shouldAutoPromote: non-terminal transition never promotes" { + bridge.reset(); + try t.expect(!bridge.shouldAutoPromote(.busy, .busy, false, true)); // still busy + try t.expect(!bridge.shouldAutoPromote(.ready, .ready, false, true)); // idle→idle + try t.expect(!bridge.shouldAutoPromote(.boot, .ready, false, true)); // boot→ready +} + +// ── Gate end-to-end over the real FIFO (adversarial #763 L6) ─────────────── +// None of the PR's tests executed `if (… and hasQueuePromoteAllowed()) tryPromoteQueued`. +// These do: they model the ui.zig block — Stop + non-empty queue must keep the +// head; a successful Ready armed true must pop it — through actual queue depth. + +test "gate e2e: Stop (allowed=false) + non-empty queue keeps depth unchanged" { + bridge.reset(); + _ = try bridge.enqueueFromUi("one"); + _ = try bridge.enqueueFromUi("two"); + try t.expectEqual(@as(u32, 2), bridge.queuedCount()); + // Host Stop terminal armed the scalar false. + bridge.inv_set_queue_promote_allowed(0); + // The ui.zig gate: turn-ended && !editing && hasQueuePromoteAllowed(). + const prev: bridge.Lifecycle = .busy; + const cur: bridge.Lifecycle = .ready; + const editing = false; + if (bridge.shouldAutoPromote(prev, cur, editing, bridge.hasQueuePromoteAllowed())) { + _ = bridge.tryPromoteQueued(editing); + } + // Depth unchanged — the gate refused, so nothing was promoted. + try t.expectEqual(@as(u32, 2), bridge.queuedCount()); +} + +test "gate e2e: success (allowed=true) + non-empty queue pops the head" { + bridge.reset(); + _ = try bridge.enqueueFromUi("one"); + _ = try bridge.enqueueFromUi("two"); + // Successful Ready armed the scalar true. + bridge.inv_set_queue_promote_allowed(1); + const prev: bridge.Lifecycle = .busy; + const cur: bridge.Lifecycle = .ready; + const editing = false; + if (bridge.shouldAutoPromote(prev, cur, editing, bridge.hasQueuePromoteAllowed())) { + _ = bridge.tryPromoteQueued(editing); + } + // Head promoted + popped; one remains. (tryPromoteQueued no-ops on empty, + // so success auto-promote exactly mirrors the terminal gate.) + try t.expectEqual(@as(u32, 1), bridge.queuedCount()); +} + +test "gate e2e: Play-while-editing never pops (adversarial #763 L1)" { + bridge.reset(); + _ = try bridge.enqueueFromUi("one"); + bridge.inv_set_queue_promote_allowed(1); // host armed true (last turn succeeded) + // Explicit Play with the row editor open: `tryPromoteQueued(editing=true)` + // must refuse via canPromote even though the queue is non-empty + allowed. + const promoted = bridge.tryPromoteQueued(true); + try t.expect(!promoted); + try t.expectEqual(@as(u32, 1), bridge.queuedCount()); // nothing drained mid-edit +} + +test "gate e2e: empty FIFO — Play with allowed=true still stays depth 0 (no-op, goal 4)" { + bridge.reset(); + bridge.inv_set_queue_promote_allowed(1); // host armed true (last turn succeeded) + // Explicit Play with an EMPTY queue: tryPromoteQueued must no-op (goal 4) — + // no head to promote, so the depth stays 0 regardless of the scalar. + const promoted = bridge.tryPromoteQueued(false); + try t.expect(!promoted); + try t.expectEqual(@as(u32, 0), bridge.queuedCount()); +} diff --git a/native/harness/src/bridge.zig b/native/harness/src/bridge.zig index cb5fef53..47cd93bd 100644 --- a/native/harness/src/bridge.zig +++ b/native/harness/src/bridge.zig @@ -263,6 +263,21 @@ pub fn hasQueuePromoteAllowed() bool { return queue_promote_allowed; } +/// Plan #760 promote-gate predicate (adversarial #763 L6). Whether a busy→terminal +/// lifecycle transition (a turn just ended: `prev` busy → `cur` ready/err) MAY +/// auto-promote the queue head. Folds the turn-ended trigger with the two gate +/// guards — not mid-edit (`editing`) AND the host-armed scalar (`allowed`) — into +/// one pure, host-unit-testable seam (no dvui frame / no ui state needed), so +/// goal 1 (a Stop / Esc / error / timeout Ready does not drain) is a real +/// failing-before / passing-after test, not just host-arming / chrome-dispatch +/// coverage. The queue-edit-close trigger is folded in separately at the call +/// site (it needs Wasm ui state) under the SAME `!editing && allowed` guards. +pub fn shouldAutoPromote(prev: Lifecycle, cur: Lifecycle, editing: bool, allowed: bool) bool { + const terminal = cur == .ready or cur == .err; + const turn_ended = prev == .busy and terminal; + return turn_ended and !editing and allowed; +} + /// Promote the queue head into `queueSubmitFromUi` when the turn is terminal /// and the operator is not mid-edit. Peek → submit → pop only if accepted. pub fn tryPromoteQueued(editing: bool) bool { diff --git a/native/harness/src/composer_layout.test.zig b/native/harness/src/composer_layout.test.zig index bc2923f1..37a71676 100644 --- a/native/harness/src/composer_layout.test.zig +++ b/native/harness/src/composer_layout.test.zig @@ -305,21 +305,33 @@ test "idle ▶ + typed composer → on_send fires with the text, never on_promot try t.expect(std.mem.eql(u8, "hello", T_send_text)); } -test "idle ▶ + empty composer + empty queue (no on_promote) → no-op" { +test "idle ▶ + empty composer + empty queue → on_promote fires; no-op is tryPromoteQueued's" { var tr = try dvui.testing.init(.{}); defer tr.deinit(); resetBuf(); // empty field T_busy = false; T_want_focus = true; - // Caller binds on_promote only when it has a queue head to promote; when - // the queue is empty it is null (goal 4) → the ▶ click must be a no-op. + // Adversarial #763 Nit: production `ui.zig` ALWAYS binds on_promote (it + // does not know the queue depth here) and relies on + // `bridge.tryPromoteQueued` no-oping when the FIFO is empty. This chrome + // test must mirror that prod wiring — bind on_promote and confirm the ▶ + // click dispatches it (never on_send); the EMPTY-QUEUE refusal is the + // bridge's job, asserted in `bridge.test.zig` (gate e2e: empty queue keeps + // depth 0), not a null-callback fixture here. + const promote_cb = struct { + fn run() void { + T_promote_count += 1; + } + }.run; const send_cb = struct { fn run(_: []const u8) void { T_send_count += 1; } }.run; - T_actions = .{ .on_send = &send_cb }; + T_actions = .{ .on_promote = &promote_cb, .on_send = &send_cb }; try paintAndClickSend(true); - try t.expectEqual(@as(usize, 0), T_promote_count); + // Empty field → on_promote fires (never on_send). With an empty FIFO the + // tryPromoteQueued call is a no-op at the bridge (goal 4). + try t.expectEqual(@as(usize, 1), T_promote_count); try t.expectEqual(@as(usize, 0), T_send_count); } diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index 3865349c..bab48caa 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -225,17 +225,29 @@ pub fn frame() !void { state.thinking_collapse_state.onLifecycleTransition(prev_lc, cur_lc, bridge.messageHead()); state.prev_lifecycle = cur_lc; const terminal = cur_lc == .ready or cur_lc == .err; - const trigger_a = prev_lc == .busy and terminal; + // Trigger B: a queue edit just closed this frame on a terminal lifecycle. + // Folded here (needs Wasm ui state) but under the SAME `!editing && + // allowed` guards as the turn-ended trigger, so it can never bypass the gate. const trigger_b = state.queue_closed_edit and terminal; state.queue_closed_edit = false; const editing = state.queue_editing_index != null; // Protocol v19 promote gate (plan #760): the host arms a one-shot scalar // on every terminal — true on a SUCCESSFUL Ready (auto-promote stays, // unchanged), false on Stop / Esc / error / timeout / validation Ready. - // When false, this block can never drain a queued head; only an explicit - // idle ▶ / Ctrl+Enter with an empty composer + non-empty queue promotes. - if ((trigger_a or trigger_b) and !editing and bridge.hasQueuePromoteAllowed()) { - if (bridge.tryPromoteQueued(false)) { + // `bridge.shouldAutoPromote` is the host-unit-testable turn-ended gate + // (goal 1: a Stop / error Ready can never drain the queue); trigger B ORs + // in under identical guards. When the gate is false this block can never + // pop a queued head; only an explicit idle ▶ / Ctrl+Enter with an empty + // composer + non-empty queue promotes. Both triggers pass the LIVE edit + // lock into `tryPromoteQueued` so nothing drains mid-edit. + const auto_promote = bridge.shouldAutoPromote( + @enumFromInt(@intFromEnum(prev_lc)), + @enumFromInt(@intFromEnum(cur_lc)), + editing, + bridge.hasQueuePromoteAllowed(), + ) or (trigger_b and !editing and bridge.hasQueuePromoteAllowed()); + if (auto_promote) { + if (bridge.tryPromoteQueued(editing)) { busy = true; } } @@ -651,9 +663,13 @@ pub fn frame() !void { // promoted head is a normal user send (pending submit → host starts a turn). // `busy` becomes true on the next frame once the host polls the pending // submit (mirrors on_send, which also doesn't mutate this frame's busy). + // Adversarial #763 L1: pass the LIVE queue-edit lock so a mouse ▶ click while + // a row's editor is open (queue_editing_index != null) cannot punch through + // the edit lock and discard the unsaved buffer. `tryPromoteQueued(editing=true)` + // → `canPromote` returns false, so Play is held exactly like the terminal gate. const on_promote = struct { fn run() void { - _ = bridge.tryPromoteQueued(false); + _ = bridge.tryPromoteQueued(state.queue_editing_index != null); } }.run; @@ -714,8 +730,11 @@ pub fn frame() !void { // Plan #760 — Ctrl/Cmd+Enter with an EMPTY composer + non-empty // queue promotes the head (goal 2, mirroring the idle ▶ click). // tryPromoteQueued no-ops when the queue is empty, so goal 4 - // (empty+empty → no-op) needs no extra guard. - if (bridge.tryPromoteQueued(false)) { + // (empty+empty → no-op) needs no extra guard. Adversarial #763 L1: + // honor the live queue-edit lock too — pass + // `state.queue_editing_index != null` so an open row editor can + // never be discarded by this chord. + if (bridge.tryPromoteQueued(state.queue_editing_index != null)) { busy = true; } } From d6b795acee9df363fcb7107d1c89bb8bc37c9746 Mon Sep 17 00:00:00 2001 From: btipling Date: Sat, 22 Aug 2026 03:22:02 +0000 Subject: [PATCH 3/3] feat(harness): fold trigger B into promote gate + err non-terminable (PR #763 review round 2) --- native/harness/src/bridge.test.zig | 88 ++++++++++++++++++++++++++---- native/harness/src/bridge.zig | 32 +++++++---- native/harness/src/ui.zig | 30 +++++----- 3 files changed, 113 insertions(+), 37 deletions(-) diff --git a/native/harness/src/bridge.test.zig b/native/harness/src/bridge.test.zig index dd588c2f..b4910a23 100644 --- a/native/harness/src/bridge.test.zig +++ b/native/harness/src/bridge.test.zig @@ -46,27 +46,50 @@ test "inv_clear_messages restores promote gate to true (Clear / New surface)" { // so goal 1 (a Stop / Esc / error / timeout Ready never drains the queue) is a // real failing-before / passing-after test — not just host-arming coverage. -test "shouldAutoPromote: Stop / error terminal never auto-promotes (allowed=false)" { +test "shouldAutoPromote: Stop (allowed=false) never auto-promotes" { bridge.reset(); // Busy → ready with host armed false (Stop / timeout / validation Ready). - try t.expect(!bridge.shouldAutoPromote(.busy, .ready, false, false)); - // Busy → err terminal likewise never drains. - try t.expect(!bridge.shouldAutoPromote(.busy, .err, false, false)); + try t.expect(!bridge.shouldAutoPromote(.busy, .ready, false, false, false)); +} + +test "shouldAutoPromote: error terminal never auto-promotes even when allowed=true (round-2 Nit L1)" { + bridge.reset(); + // err is NOT a success terminal for promotion: a failed turn must never + // drain the queue regardless of the host-armed scalar. (The round-1 test + // only passed because it passed allowed=false — this pins the real reason, + // aligning with sibling #774's err-is-not-terminal-for-promotion intent.) + try t.expect(!bridge.shouldAutoPromote(.busy, .err, false, true, false)); + try t.expect(!bridge.shouldAutoPromote(.busy, .err, false, true, true)); } test "shouldAutoPromote: successful ready auto-promotes only when not mid-edit" { bridge.reset(); // Successful turn: busy → ready, host armed true, operator not mid-edit. - try t.expect(bridge.shouldAutoPromote(.busy, .ready, false, true)); + try t.expect(bridge.shouldAutoPromote(.busy, .ready, false, true, false)); // Even a successful ready must NOT promote while the operator is mid-edit. - try t.expect(!bridge.shouldAutoPromote(.busy, .ready, true, true)); + try t.expect(!bridge.shouldAutoPromote(.busy, .ready, true, true, false)); +} + +test "shouldAutoPromote: trigger B (edit-closed) promotes only on ready + not mid-edit + allowed (round-2 Nit L6)" { + bridge.reset(); + // Edit-close on a ready terminal, host armed true, not mid-edit → promote. + try t.expect(bridge.shouldAutoPromote(.ready, .ready, false, true, true)); + // ...but never while the operator is still mid-edit. + try t.expect(!bridge.shouldAutoPromote(.ready, .ready, true, true, true)); + // ...and never after Stop (allowed=false) — the plan #760 named drain. + try t.expect(!bridge.shouldAutoPromote(.ready, .ready, false, false, true)); + // ...and never on an err terminal (still not a success). + try t.expect(!bridge.shouldAutoPromote(.ready, .err, false, true, true)); + // No edit closed this frame → trigger B does not fire (idle→idle stays). + try t.expect(!bridge.shouldAutoPromote(.ready, .ready, false, true, false)); } test "shouldAutoPromote: non-terminal transition never promotes" { bridge.reset(); - try t.expect(!bridge.shouldAutoPromote(.busy, .busy, false, true)); // still busy - try t.expect(!bridge.shouldAutoPromote(.ready, .ready, false, true)); // idle→idle - try t.expect(!bridge.shouldAutoPromote(.boot, .ready, false, true)); // boot→ready + try t.expect(!bridge.shouldAutoPromote(.busy, .busy, false, true, false)); // still busy + try t.expect(!bridge.shouldAutoPromote(.boot, .ready, false, true, false)); // boot→ready + // trigger B alone cannot turn a still-busy turn into a promotion. + try t.expect(!bridge.shouldAutoPromote(.busy, .busy, false, true, true)); } // ── Gate end-to-end over the real FIFO (adversarial #763 L6) ─────────────── @@ -81,11 +104,12 @@ test "gate e2e: Stop (allowed=false) + non-empty queue keeps depth unchanged" { try t.expectEqual(@as(u32, 2), bridge.queuedCount()); // Host Stop terminal armed the scalar false. bridge.inv_set_queue_promote_allowed(0); - // The ui.zig gate: turn-ended && !editing && hasQueuePromoteAllowed(). + // The ui.zig gate: (turn-ended || edit-closed) && !editing && allowed. const prev: bridge.Lifecycle = .busy; const cur: bridge.Lifecycle = .ready; const editing = false; - if (bridge.shouldAutoPromote(prev, cur, editing, bridge.hasQueuePromoteAllowed())) { + const edit_closed = false; + if (bridge.shouldAutoPromote(prev, cur, editing, bridge.hasQueuePromoteAllowed(), edit_closed)) { _ = bridge.tryPromoteQueued(editing); } // Depth unchanged — the gate refused, so nothing was promoted. @@ -101,7 +125,8 @@ test "gate e2e: success (allowed=true) + non-empty queue pops the head" { const prev: bridge.Lifecycle = .busy; const cur: bridge.Lifecycle = .ready; const editing = false; - if (bridge.shouldAutoPromote(prev, cur, editing, bridge.hasQueuePromoteAllowed())) { + const edit_closed = false; + if (bridge.shouldAutoPromote(prev, cur, editing, bridge.hasQueuePromoteAllowed(), edit_closed)) { _ = bridge.tryPromoteQueued(editing); } // Head promoted + popped; one remains. (tryPromoteQueued no-ops on empty, @@ -109,6 +134,45 @@ test "gate e2e: success (allowed=true) + non-empty queue pops the head" { try t.expectEqual(@as(u32, 1), bridge.queuedCount()); } +test "gate e2e: trigger B after Stop (allowed=false) never pops (round-2 Nit L6)" { + bridge.reset(); + _ = try bridge.enqueueFromUi("one"); + _ = try bridge.enqueueFromUi("two"); + try t.expectEqual(@as(u32, 2), bridge.queuedCount()); + // Host Stop armed the scalar false. + bridge.inv_set_queue_promote_allowed(0); + // Simulate trigger B: a queue edit just closed this frame on a ready + // terminal. The ui.zig gate folds it in under the SAME `!editing && allowed` + // guards — with allowed=false it must refuse and the head stays. + const prev: bridge.Lifecycle = .ready; + const cur: bridge.Lifecycle = .ready; + const editing = false; + const edit_closed = true; + if (bridge.shouldAutoPromote(prev, cur, editing, bridge.hasQueuePromoteAllowed(), edit_closed)) { + _ = bridge.tryPromoteQueued(editing); + } + // Depth unchanged — the guard OR inside the predicate refused the pop. + try t.expectEqual(@as(u32, 2), bridge.queuedCount()); +} + +test "gate e2e: trigger B on success (allowed=true) pops the head (round-2 Nit L6)" { + bridge.reset(); + _ = try bridge.enqueueFromUi("one"); + _ = try bridge.enqueueFromUi("two"); + // Successful Ready armed the scalar true. + bridge.inv_set_queue_promote_allowed(1); + // A queue edit closing this frame on a ready terminal is a promote trigger. + const prev: bridge.Lifecycle = .ready; + const cur: bridge.Lifecycle = .ready; + const editing = false; + const edit_closed = true; + if (bridge.shouldAutoPromote(prev, cur, editing, bridge.hasQueuePromoteAllowed(), edit_closed)) { + _ = bridge.tryPromoteQueued(editing); + } + // Head promoted + popped; one remains. + try t.expectEqual(@as(u32, 1), bridge.queuedCount()); +} + test "gate e2e: Play-while-editing never pops (adversarial #763 L1)" { bridge.reset(); _ = try bridge.enqueueFromUi("one"); diff --git a/native/harness/src/bridge.zig b/native/harness/src/bridge.zig index 47cd93bd..b098b16e 100644 --- a/native/harness/src/bridge.zig +++ b/native/harness/src/bridge.zig @@ -263,19 +263,27 @@ pub fn hasQueuePromoteAllowed() bool { return queue_promote_allowed; } -/// Plan #760 promote-gate predicate (adversarial #763 L6). Whether a busy→terminal -/// lifecycle transition (a turn just ended: `prev` busy → `cur` ready/err) MAY -/// auto-promote the queue head. Folds the turn-ended trigger with the two gate -/// guards — not mid-edit (`editing`) AND the host-armed scalar (`allowed`) — into -/// one pure, host-unit-testable seam (no dvui frame / no ui state needed), so -/// goal 1 (a Stop / Esc / error / timeout Ready does not drain) is a real -/// failing-before / passing-after test, not just host-arming / chrome-dispatch -/// coverage. The queue-edit-close trigger is folded in separately at the call -/// site (it needs Wasm ui state) under the SAME `!editing && allowed` guards. -pub fn shouldAutoPromote(prev: Lifecycle, cur: Lifecycle, editing: bool, allowed: bool) bool { - const terminal = cur == .ready or cur == .err; +/// Plan #760 promote-gate predicate (adversarial #763 L6). Whether a turn edge +/// MAY auto-promote the queue head. Folds BOTH wasm triggers — the turn-ended +/// edge (`prev` busy → `cur` ready) AND the queue-edit-closed edge (`edit_closed` +/// on a ready terminal, the "trigger B" the pre-round-2 call site ORed in under +/// duplicate guards) — together with the two gate guards (not mid-edit +/// `editing`, and the host-armed scalar `allowed`) into ONE pure, +/// host-unit-testable seam (no dvui frame / no ui state needed). Goal 1 (a Stop +/// / Esc / error / timeout Ready never drains) is a real failing-before / +/// passing-after test with FIFO e2e coverage, and the gate-guard OR can no longer +/// be dropped at the call site. +/// +/// Only a SUCCESSFUL terminal (`cur == .ready`) can auto-promote: `err` is a +/// terminal for lifecycle/collapse purposes but NOT a success — a failed turn +/// must never drain the queue regardless of `allowed` (the round-1 `.busy→.err` +/// test only passed because it passed `allowed=false`; sibling #774 likewise +/// treats err as non-terminal for promotion). +pub fn shouldAutoPromote(prev: Lifecycle, cur: Lifecycle, editing: bool, allowed: bool, edit_closed: bool) bool { + const terminal = cur == .ready; const turn_ended = prev == .busy and terminal; - return turn_ended and !editing and allowed; + const edit_closed_terminal = edit_closed and terminal; + return (turn_ended or edit_closed_terminal) and !editing and allowed; } /// Promote the queue head into `queueSubmitFromUi` when the turn is terminal diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index bab48caa..3d85e0aa 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -224,28 +224,32 @@ pub fn frame() !void { const prev_lc = state.prev_lifecycle; state.thinking_collapse_state.onLifecycleTransition(prev_lc, cur_lc, bridge.messageHead()); state.prev_lifecycle = cur_lc; - const terminal = cur_lc == .ready or cur_lc == .err; - // Trigger B: a queue edit just closed this frame on a terminal lifecycle. - // Folded here (needs Wasm ui state) but under the SAME `!editing && - // allowed` guards as the turn-ended trigger, so it can never bypass the gate. - const trigger_b = state.queue_closed_edit and terminal; - state.queue_closed_edit = false; const editing = state.queue_editing_index != null; + // Trigger B: a queue edit just closed this frame. The predicate needs + // Wasm ui state only to KNOW it closed; capture the raw latch BEFORE + // clearing, then fold it into the single host-testable seam. + const edit_closed = state.queue_closed_edit; + state.queue_closed_edit = false; // Protocol v19 promote gate (plan #760): the host arms a one-shot scalar // on every terminal — true on a SUCCESSFUL Ready (auto-promote stays, // unchanged), false on Stop / Esc / error / timeout / validation Ready. - // `bridge.shouldAutoPromote` is the host-unit-testable turn-ended gate - // (goal 1: a Stop / error Ready can never drain the queue); trigger B ORs - // in under identical guards. When the gate is false this block can never - // pop a queued head; only an explicit idle ▶ / Ctrl+Enter with an empty - // composer + non-empty queue promotes. Both triggers pass the LIVE edit - // lock into `tryPromoteQueued` so nothing drains mid-edit. + // `bridge.shouldAutoPromote` is the host-unit-testable seam that folds + // BOTH triggers — the turn-ended edge (prev busy → cur ready) and the + // edit-closed trigger B — under the same `!editing && allowed` guards + // (adversarial #763 L6 round 2: the old call site ORed trigger B in as + // `trigger_b and !editing and allowed`, a duplicated-guard OR that this + // predicate now owns). `err` is NOT a promotable terminal, so a failed + // turn can never drain the queue. When the gate is false this block can + // never pop a queued head; only an explicit idle ▶ / Ctrl+Enter with an + // empty composer + non-empty queue promotes. Both triggers pass the LIVE + // edit lock into `tryPromoteQueued` so nothing drains mid-edit. const auto_promote = bridge.shouldAutoPromote( @enumFromInt(@intFromEnum(prev_lc)), @enumFromInt(@intFromEnum(cur_lc)), editing, bridge.hasQueuePromoteAllowed(), - ) or (trigger_b and !editing and bridge.hasQueuePromoteAllowed()); + edit_closed, + ); if (auto_promote) { if (bridge.tryPromoteQueued(editing)) { busy = true;