Compare commits

...
2 Commits
Author SHA1 Message Date
ducoterra cb8767512a phase: 159_edit_and_resend_message
Build and Push Containers / build-and-push-app (push) Successful in 1m39s
Build and Push Containers / build-and-push-db (push) Successful in 15s
All completion criteria verified. Final report:

**Phase 159 — Edit the last question and re-ask it: VERIFIED COMPLETE** (all 4 tasks already in `complete/`; this pass verified end-to-end)

**Verified (implementation landed + tests green):**
- `app.js`: `EDIT_ICON`, `lastUserWrap`, `appendEditButton`/`markLastEditable` (single-target discipline, in-flight guard), `openEditor` (labelled textarea, `maxlength=4000`, Escape/Ctrl+⌘+Enter, empty guard), save path: replace → splice tail → DOM removal → `lastBrainWrap=null` → `saveConversation`+`persistConversation` → `runTurn(text,{reask:true})` — matches README exactly
- `styles.css`: `.edit-btn`/`.edit-form`/`.edit-error`/`.is-editing` + mobile 44px; unit pins 40, E2E 7, integration `test_chats_api.py` extended

**Test/lint results (all green):**
- `uv run pytest` — 3,661 tests, exit 0
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90%)
- `uv run ruff check . && uv run pyright` — all checks passed; 0 errors
- `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` (DB up) — **7/7 passed**
- Regressions in isolation: `test_retry_answer.py` 4/4, `test_failed_turn_retry.py` 4/4, `test_chat_persistence.py` 4/4

**Completion criteria:** all 7 ✓ — Edit on last bubble + prefill + Escape/Cancel restore (E2E #3); replace+truncate+re-ask with no duplicated question (E2E #1); refresh survival (E2E #4); failed/“question too long” turn editable + trim re-asks (E2E #5); earlier bubbles un-editable + byte-identical Retry/Stop/persistence (E2E #2 + regressions + unit pins); suite/coverage/lint ✓; E2E + regressions isolated ✓. Commit + phase-dir move: harness's job (no git ops by me, per protocol).

**Documentation:** README.md — full review; phase 159 section cross-checked against `app.js` source (cap, keys, truncate, image survival) — consistent, no changes needed; all 30 `.env.example` vars documented. AGENTS.md — full review; all rules/quick-ref commands valid — verified consistent, no changes needed.

**Notable:** none — no defects found in prior work; no live-infrastructure changes required.

**Next pending phase:** `160_tool_budget_telemetry`
2026-10-05 19:07:33 -04:00
ducoterra 4d7d0f6297 chore(agent): phase roadmap from TODO.md, 1 phase (161 reasoning bubble)
LLM-Generated: true
2026-10-05 13:59:47 -04:00
42 changed files with 4921 additions and 22 deletions
@@ -0,0 +1,422 @@
# Phase 161 — One live-open, condensed reasoning bubble
**Source:** `TODO.md` L3 — "The reasoning process should be one collapsible
section that **stays open** during the reasoning process and shows all
thinking and tool calls in order. Right now the thinking, tool calling,
thinking, tool calling blocks take up a ton of space and obscure the answer.
We need to condense this while still letting the user watch the thinking
process happen so the latency doesn't feel super low. This new thinking/tool
calling bubble should only collapse once the final response has been
returned and the session has stopped."
**Story:** n/a (owner TODO — 2026-10-05)
**Context:** the DIRECT follow-up to completed `158_thinking_timeline`
(the per-run `details.thinking` / `details.tool-calls-disclosure`
stack, open-live/fold-at-rest) and `117_tool_call_compact` (the
disclosure convention) + `17_thinking_display` (the 320px live-tail
window + pinned-follow pattern). `frontend/assets/app.js` —
`ensureThinkingRun` (~L1103, `body.insertBefore(block,
body.querySelector(".bubble"))` — the per-run anchor),
`ensureToolRun` (~L1122, the same anchor), `closeCurrentRun` (~L1140, the
delta fold), `closeAllRuns` (~L1149, the turn's terminal settle),
`segRuns` (~L1464, module scope, reset in `runTurn` ~L3304), the thinking
frame branch (~L3381: `ensureThinkingRun` + the per-block
`isThinkingNearBottom` pinned follow), the tool frame branch (~L3501:
`ensureToolRun` + `appendToolLine` + `armToolLineClock`), the `delta`
branch (~L3553: `closeCurrentRun()`), the `done` branch (~L3570:
`closeAllRuns(wrap)`), `finalizeFailedTurn` (~L3218: `closeAllRuns(wrap)`),
the STOP path (~L3752: `closeAllRuns(wrap)`); `restoreTimeline`
(~L2195, the collapsed per-run restore, `insertBefore(block, bubble)`),
the `renderStoredMessage` dispatch (~L2292: segments vs the byte-identical
legacy path); `frontend/assets/shared.js` — `addSegments` (~L269, the
collapsed parity copy), the record dispatch (~L584);
`frontend/assets/styles.css` — the `details.thinking` block (L633, the
320px window + the phase-17 pinned-follow convention), the
`.tool-calls-disclosure` (L689), the reduced-motion still block (L1499);
`tests/e2e/mock_llm.py` (`THINK_TOOLS_TRIGGER` L1067 +
`_thinking_tools_flow` L1983 + `_tool_call_stream` L3956 — the sequential
flow, extended by task 04 with the mid-round-delta flow);
`tests/e2e/test_thinking_timeline.py` (the six phase-158 pins — the
"direct children before the bubble" pins update in task 05);
`tests/unit/test_thinking_timeline.py` (the source pins — the
insertion-anchor pins update in task 05; the new container pins are
task 01's).
## Owner decisions (chat, 2026-10-05 — confirmed 2026-10-05 — recorded per AGENTS.md rule 3)
- **D1 — One wrapper, runs unchanged.** ONE `details.reasoning` container
per brain message wraps the ENTIRE per-run timeline (the phase-158
blocks keep their structure, order, and open/fold behavior). Created on
the turn's FIRST thinking/tool frame, inserted before the bubble,
`open = true` from creation until the turn settles. A turn with no
thinking and no tools creates NO container (byte-identical DOM —
`BOR_STREAM_THINKING=0` and plain-answer turns are untouched).
- **D2 — Stays open through mid-round deltas.** The container folds ONLY
at the existing settle points — `done`, stop/abort, failed-turn
finalize — alongside `closeAllRuns`. A mid-round `delta` (the React
style reply before the next round) still folds the ACTIVE RUN
(`closeCurrentRun`, phase 158 D5) but the CONTAINER STAYS OPEN
(the TODO's "only collapse once the final response has been returned
and the session has stopped").
- **D3 — Condensed while open (A2).** The container's `.reasoning-body`
is ONE bounded scrollable live window — `max-height: 320px` (the
phase-17 window convention) + `overflow-y: auto` — with
pinned-tail autoscroll (the phase-17 `isThinkingNearBottom` pattern
reused, extended to the container window; the inner per-run blocks are
de-bordered inside the container and their OWN 320px scroll windows
are disabled — `max-height: none` — so there is ONE scroll surface,
not nested boxes; the user watches the live tail move without the
timeline pushing the answer off-screen).
- **D4 — Zero persistence change (A4).** The `segments` record, the
`ChatMessage` schema, the SSE contract, and the history wire are ALL
untouched (the container is a pure client render decision — the run
order already persists through phase 158's `segments`). Records
WITHOUT `segments` restore byte-identically through the untouched
legacy path (no container, no new keys). No migration.
- **D5 — Collapsed at rest (A5).** The restore path and the shared page
render the SAME container COLLAPSED around the per-run timeline, with
the at-rest step-count summary (D6). The live open state is
exclusively the live turn's.
- **D6 — The summary literals (A1).** The summary is the static literal
`Reasoning` + a `span.reasoning-status`: live — `Thinking…` while a
thinking frame streams, `Calling <name>…` on a tool frame (the
display name the tool line already shows — the phase-70 legacy-name
mapping); settled — `N steps` (N = the turn's RUN count —
`segRuns.length`; restore/shared — the restored run count, the SAME
semantics: consecutive thinking entries + one per contiguous tool
group).
## Design (shared by all tasks — the executor reads this, not the chat)
### The container's STATIC structure (task 01's builder)
```
details.reasoning[open] (live) / details.reasoning (rest)
summary.reasoning-summary
(CSS chevron ::before — the family treatment, task 03)
Reasoning
span.reasoning-status ("Thinking…" / "Calling <name>…" / "N steps")
div.reasoning-body (the bounded live window, task 03)
details.thinking … (the phase-158 per-run blocks — UNCHANGED
details.tool-calls-disclosure … inside, arrival order preserved)
```
- `createReasoningWrap()` — createElement only (innerHTML ABSENT — the
house rule): `details` (class `reasoning`) + `summary` (class
`reasoning-summary`, `textContent = "Reasoning"` — D6, the ONLY new
static literal; the span is empty until a frame sets it) + `div`
(class `reasoning-body`). The caller owns open/fold + insertion.
- `ensureReasoningWrap(wrap)` — get-or-create per turn (module scope
`reasoningWrapEl`, reset with `segRuns` in `runTurn` ~L3304 — the
pagehide partial reads the LIVE container, so the module scope is
readable there like `segRuns`): on the turn's first thinking/tool
frame — `createReasoningWrap()`, `open = true` (D1), inserted
`before` `body.querySelector(".bubble")` (the container is the ONE
new anchor; the per-run blocks move from `insertBefore(bubble)` to
`reasoningWrapEl.querySelector(".reasoning-body").appendChild(…)` —
arrival order preserved: a new run is appended after the previous
run's block).
- `ensureThinkingRun` / `ensureToolRun` — the insertion line ONLY
changes (append into the container's body); everything else (the
per-run create, `open = true`, `segRuns` push, `currentRun`) is
UNCHANGED.
- `settleReasoning(wrap)` — the container fold:
`wrap?.querySelector("details.reasoning")?.removeAttribute("open")`
(idempotent; a no-op without a container — the plain-answer turn
settles byte-identically). Called alongside `closeAllRuns(wrap)` at
the THREE settle sites (the `done` branch ~L3570, the STOP path
~L3752, `finalizeFailedTurn` ~L3218). The `delta` branch UNCHANGED
(`closeCurrentRun()` — the active run folds; the container stays
open — D2).
- The live status (D6): the thinking frame branch sets
`reasoningWrapEl`'s `.reasoning-status` `textContent = "Thinking…"`;
the tool frame branch sets `"Calling <name>…"` (the `name` the
`appendToolLine` call already receives); the settle sites set
`"${segRuns.length} steps"`. The restore/shared builders set
`"${N} steps"` directly (D5/D6 — no live state there).
- The live follow (D3): the NEW small helpers — `isReasoningPinned(el)`
(the pre-update capture — the phase-17 pre-measure convention:
`el.scrollTop + el.clientHeight >= el.scrollHeight -
REASONING_NEAR_BOTTOM_PX`, the same 32px band — a new constant
`REASONING_NEAR_BOTTOM_PX = 32`) and `scrollReasoningToBottom(el)`
(the post-append apply: `el.scrollTop = el.scrollHeight`). Called
from BOTH the thinking frame branch (pre: before the block
re-render; post: after it) and the tool frame branch (pre: before
`appendToolLine`; post: after it) — `el` =
`reasoningWrapEl?.querySelector(".reasoning-body")` (a no-op when
absent). The EXISTING per-block `isThinkingNearBottom(textEl)`
follow stays (harmless — the inner `.thinking-text` is no longer
scrollable inside the container (task 03's `max-height: none`), so
it's a no-op there; it keeps the legacy no-container path's
behavior conceptually intact and costs nothing). The phase-87
`armToolLineClock` / `settleToolLine` wrap-scoped queries are
DESCENDANT-scoped — they work unchanged with the runs nested in
the container (no change).
- `closeAllRuns`'s selector (`details.thinking,
details.tool-calls-disclosure`) is descendant-scoped to the wrap —
the nested runs still match. UNCHANGED.
### `frontend/assets/app.js` — the restore path (task 02)
- `restoreTimeline`: BEFORE the loop, get-or-create the container —
`createReasoningWrap()`, `open = false` (D5 — the restore renders
collapsed), the summary's `.reasoning-status` = `"${runs} steps"`
(D6 — computed INCREMENTALLY: each `thinking` entry starts a run
(count+1 when the previous is not a thinking entry — the contiguous
semantics matching `segRuns.length`); each CONTIGUOUS tool group is
ONE run (count+1 at the group's first entry)); the container is
inserted `before` the bubble ONCE. Every per-run block appends into
the container's `.reasoning-body` (the `insertBefore(block, bubble)`
lines become appends — the rest of the loop — the malformed-entry
skips, the tOffset/tLimit/tPath guards, the
`appendTruncatedNote` call — is UNCHANGED).
- The `renderStoredMessage` dispatch (~L2292) is UNCHANGED: a record
WITHOUT `segments` never reaches `restoreTimeline` — the legacy path
is byte-identical (no container — D4).
- `frontend/assets/shared.js` — `addSegments`: the SAME wrapper parity
copy (created CLOSED — the pure-render convention; the SAME
incremental step count; the line bytes MIRROR `addToolLines` —
unchanged). The record dispatch (~L584) is UNCHANGED (no `segments`
→ the legacy `addThinkingBlock` + `addToolLines` path, byte-identical).
### `frontend/assets/styles.css` (task 03)
- `details.reasoning` — the family treatment of `details.thinking`
(L633): `background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--brand-soft); border-radius:
var(--radius-sm); margin: 0 0 0.5rem; overflow: hidden;` — a
distinct comment block citing this phase.
- `summary.reasoning-summary` — `display: flex; align-items: center;
gap: 0.5rem; padding: 0.5rem 0.75rem; min-height: 44px;` (the
WCAG ≥44px target); `color: var(--brand-ink)` (8.7:1 on
`--surface` — AA, the token the `details.thinking` summary uses);
`font-size: 0.9rem; cursor: pointer; list-style: none;`; the
webkit marker `display: none`; the CSS chevron `::before` `▸`
rotating 90° on `[open]` (the `details.thinking` convention; the
transition stills in the reduced-motion block — task 03 appends it
to the L1499 still list); `:focus-visible` → the house 3px
`var(--brand)` ring + `outline-offset: 2px`.
- `.reasoning-status` — `color: var(--ink-soft)` (6.9:1 on
`--surface` — AA); `font-size: 0.8rem; line-height: 1.4; margin-left:
0.25rem;` — text + color, never color alone (B5).
- `.reasoning-body` — `padding: 0 0.75rem 0.75rem; max-height: 320px;
overflow-y: auto;` (D3 — the ONE bounded live window; a
user-scrollable window — the phase-17 convention, the comment
cites this phase + D3).
- The inner-run compacting (D3): `details.reasoning details.thinking`
→ `border: 0; border-left: 2px solid var(--line);
border-radius: 0; margin: 0.25rem 0; background: transparent;`
(deboxed — the nested-border weight is removed);
`details.reasoning details.thinking .thinking-text` → `max-height:
none; overflow-y: visible;` (the inner scroll window is disabled —
the container's window is the single scroll surface);
`details.reasoning .tool-calls-disclosure` → `margin: 0.25rem 0;`
(already compact — the rule documents the intent, no visual change).
- The reduced-motion block (L1499) gains the `details.reasoning
summary::before` / `.reasoning-summary::before` still (the
chevron rotation stills — parity with the existing stills).
- All colors are EXISTING AA-verified tokens (no new literal colors —
the B5 token discipline; contrast ≥4.5:1 by construction).
### `tests/e2e/mock_llm.py` (task 04)
- `REASONING_MIDROUND_TRIGGER = "think out loud, answer, then check
your work"` — checked in the streaming branch BEFORE the
`_thinking_tools_flow` dispatch (the more-specific-phrase-wins
convention — the phrase CONTAINS `think out loud` + `check your
work`… the executor MUST verify: (a) no existing trigger phrase is a
substring of the new one AND no new phrase is a substring of an
existing one in a way that steals an existing question's dispatch
(the existing trigger is `think out loud and use your tools` — the
new phrase shares only `think out loud`, and NEITHER is a substring
of the other — verified: the existing continues `…and use your
tools`, the new continues `…answer, then check…`), and (b) no
existing E2E question carries the new phrase — verify both before
wiring).
- `_reasoning_midround_flow(body)` — stateless, shaped like
`_thinking_tools_flow` (gated on the `tools` parameter + the
`<tools>` section, like the other marker flows):
- request 1 (tools offered, no tool results yet): `thinking` =
`"Step 1: I think I know the answer — let me check the
documents."` → a MID-ROUND `delta` = `"The reference server is
ref-server-1."` (content stream — BEFORE the tool call — the
React-style reply) → the `_tool_call_stream("ls", {}, "call_0",
thinking="")` (the tool call AFTER the delta — the exact
mid-round ordering the TODO describes).
- request 2 (a `tool`-role result): `thinking` = `"Step 2: the
listing confirms it."` → a `delta` = `" — confirmed by the
listing."` → the `finish` (the `done` convention — the flow ends
with a content finish; NO further tool call).
- The mock stays mock-only by design (`E2E_REAL_LLM` note unchanged);
the docstring gains the new trigger entry (the phase-161 flow).
### `tests/e2e/test_reasoning_bubble.py` (task 04 — dedicated suite, run in isolation)
The conftest `app_server`/`page`/`seeded_kb` fixtures + the phase-37
admin login + the mid-round trigger question (the KB must be seeded so
the `ls` result exists — mirror `test_thinking_timeline.py`). Six
tests:
1. **`test_container_open_during_the_turn`** — during the turn (an
`expect.poll` predicate over the mock's 0.1 s pacing window, before
the answer bubble is complete): a `details.reasoning` exists, is
`open`, contains ≥1 `details.thinking` or
`details.tool-calls-disclosure` INSIDE its `.reasoning-body` (the
per-run blocks are NESTED — the phase-158 direct-children layout is
superseded), and its `.reasoning-status` carries a live suffix
(`Thinking…` or `Calling …`).
2. **`test_container_stays_open_past_a_mid_round_delta`** — the
mid-round trigger: while the answer bubble is NON-EMPTY (the
mid-round delta rendered) AND the turn is not `done`, the container
is STILL `open` with a run visible (the D2 contract — the TODO's
"only collapse once the final response has been returned" — the
phase-158 first-delta-fold is superseded at the container level).
3. **`test_container_folds_at_done`** — after `done`: the container
has NO `open` attribute; its summary is visible; EVERY inner run
has no `open` attribute (the D2/D5 settle — the phase-158
fold-at-rest contract preserved inside the fold).
4. **`test_restored_container_collapsed_with_step_count`** — RELOAD
(same context): the restored message renders ONE `details.reasoning`
(NO `open` attribute) containing the same per-run timeline, and its
`.reasoning-status` is `3 steps` (the mid-round flow's run count —
the D6 at-rest count: the flow has thinking → delta → tool →
thinking → delta: the runs are `thinking (Step 1)`, `tool (ls)`,
`thinking (Step 2)` = 3 contiguous runs — matching
`segRuns.length`; the semantics is what's pinned: contiguous-run
count).
5. **`test_shared_page_container_collapsed`** — the phase-51 save +
share flow, a FRESH context on `/shared/<token>`: ONE
`details.reasoning` (collapsed) with the same timeline + step count
(the `shared.js` parity — `addSegments`).
6. **`test_no_reasoning_turn_has_no_container`** — a plain answer
question: ZERO `details.reasoning` in the brain wrap (D1/D4 — the
byte-identical DOM); and a plain `use your tools` question (NO
thinking trigger): the container EXISTS (the tool frames create it),
wraps the ONE phase-117 disclosure, is `open` live, folds at `done`
(the no-thinking turn's contract — the phase-117 single-disclosure
behavior preserved, now wrapped).
## Tasks
1. `01_live_container.md` — the container's static builder + the
get-or-create live wrapper (D1) + the re-anchored per-run inserts +
the `settleReasoning` fold at the three settle sites (D2) + the
summary live status (D6) + the live follow (D3) + the JS source
unit pins.
2. `02_restore_and_shared.md` — the collapsed restore wrapper (D5/D6,
the incremental step count) + the `shared.js` `addSegments` parity +
the unit pins.
3. `03_container_css.md` — the `details.reasoning` styling family, the
bounded live window, the inner-run compacting, the reduced-motion
stills.
4. `04_mock_and_e2e.md` — the mock `REASONING_MIDROUND_TRIGGER` flow +
the dedicated `test_reasoning_bubble.py` story suite, run in
isolation.
5. `05_regression_update_and_commit.md` — the phase-158 suite pin
updates (the "direct children before the bubble" pins → the
container-nested contract), the remaining unit-pin updates, the full
gate, the documentation pass, the atomic commit, the phase move.
## Testing & Quality
- Unit (new, task 01): `tests/unit/test_reasoning_bubble.py` — the
source-level pins: `createReasoningWrap`'s createElement shape
(innerHTML ABSENT), `ensureReasoningWrap`'s get-or-create + reset in
`runTurn`, `open = true` at creation, the per-run inserts append
into `.reasoning-body` (the `insertBefore(bubble)` per-run anchor is
GONE from `ensureThinkingRun`/`ensureToolRun`), `settleReasoning`
defined + called at the THREE settle sites (done / stop /
finalizeFailedTurn) and NOT at the `delta` branch (D2 — the
`closeCurrentRun` there is unchanged), the status literals
(`Reasoning` / `Thinking…` / `Calling ` / ` steps`), the
`followReasoningBody` pin + the `REASONING_NEAR_BOTTOM_PX` constant,
and the `innerHTML` ABSENT from the wrapper builder.
- Unit (task 02): `tests/unit/test_reasoning_bubble.py` (extended) — the
restore wrapper (created CLOSED, the incremental step count — a
thinking entry + a contiguous tool group = the run semantics; the
legacy no-segments path is byte-identical — `restoreTimeline`
unreachable without segments), the `shared.js` `addSegments` parity
(the collapsed wrapper + the step count + the unchanged line bytes),
and `innerHTML` ABSENT from the restore/shared wrappers.
- E2E: `tests/e2e/test_reasoning_bubble.py` (new — the six tests
above), green **in isolation** (`uv run pytest
tests/e2e/test_reasoning_bubble.py -v --no-cov`), mock-only.
- Regression E2E (task 05, in isolation): `test_thinking_timeline.py`
(UPDATED in task 05 — the direct-children pins → the container-nested
contract; the open/fold + the per-run distinctness contracts
unchanged), `test_thinking_display.py`, `test_tool_call_compact.py`
(the no-thinking single-disclosure contract — now inside the
container), `test_big_read_progress.py` (the phase-87 clock — the
wrap-scoped descendant queries are unchanged — must stay green
UNCHANGED), `test_turn_progress_loader.py`, `test_share_chat.py`,
`test_chat_persistence.py`, `test_smoke.py`.
- Existing unit suites: every pin NOT broken by the wrapper (the
tool-line label literals, the phase-117 deboxed CSS, the phase-87
`.tool-elapsed` behavior, the phase-139/146 suffix pins, the emoji
guard) stays green; the phase-158 insertion-anchor pins update in
task 05 (the documented change — the per-run anchor moved into the
container).
- Coverage: `uv run pytest --cov=app --cov-report=term-missing`
**>90%** on `app/` (this phase is FRONTEND-ONLY — `app/` is
untouched; the gate stays green by the suite).
- Lint/types: `uv run ruff check . && uv run pyright` clean.
- The phase is "Complete" only when the **entire** project test suite
passes 100% (every test in the project, not just this phase's).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against
the project as it stands after this phase — fixing every
inconsistency found, **including pre-existing ones not caused by the
phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own
changes are user-visible (the reasoning bubble is a visible UI
change — name it where the chat thinking/tool-call display is
described, if anywhere).
## Completion Criteria
- [ ] A live reasoning turn renders ONE `details.reasoning` (open)
wrapping the ordered per-run timeline, condensed in the bounded
320px live window with pinned-tail follow (E2E tests 1 + 6 —
task 04); it STAYS OPEN through a mid-round delta (test 2 — the
TODO's core contract) and folds at `done` (test 3).
- [ ] A reload restores the collapsed container with the step-count
summary (test 4); a fresh context on `/shared/<token>` renders
the same (test 5).
- [ ] A plain answer turn creates NO container (test 6) — the
byte-identical DOM; a pre-phase saved record (no `segments`)
restores byte-identically through the untouched legacy path (the
regression suites + the unit pin).
- [ ] `test_thinking_timeline.py` (updated pins) +
`test_thinking_display.py` + `test_tool_call_compact.py` +
`test_big_read_progress.py` + `test_turn_progress_loader.py`
green in isolation.
- [ ] `uv run pytest` green (100% of the project suite);
`uv run pytest --cov=app --cov-report=term-missing` > 90%;
`tests/e2e/test_reasoning_bubble.py` green in isolation;
`uv run ruff check . && uv run pyright` clean.
- [ ] `git diff --stat` limited to `frontend/assets/app.js`,
`frontend/assets/shared.js`, `frontend/assets/styles.css`,
`tests/`, and the phase files (NO `app/` change — D4).
- [ ] README.md and AGENTS.md are accurate and consistent with the
whole project (verified in the final pass).
- [ ] ONE owner-directed exception to "no behavior change in completed
phases": the phase-158 per-run blocks' direct-children layout is
SUPERSeded by the container nesting (owner TODO direction
2026-10-05 — the `test_thinking_timeline.py` pins update in task
05); the per-run open/fold behavior itself is UNCHANGED.
- [ ] One atomic `--no-gpg-sign` commit (below); the phase dir moved
to `.agents/phases/complete/`.
## Commit
```bash
git add -A .agents/ frontend/ tests/ README.md && git commit --no-gpg-sign -m "feat(ui): condense the reasoning timeline into one live-open collapsible bubble (folds at turn end, collapsed on restore/shared)"
```
## Locked decisions
- **A15 UNCHANGED — the SSE contract is untouched.** No new event type,
no frame change; the container is pure client grouping/render.
`app/` is untouched (D4).
- **D1–D6 (owner, 2026-10-05 — confirmed 2026-10-05)** — the one
wrapper with unchanged per-run blocks, the stays-open-past-mid-round
deltas, the condensed live window (320px, one scroll surface,
pinned-tail follow), the zero persistence change, the collapsed-at
rest, and the summary literals.
- **A13 UNCHANGED** — no migration (the record is opaque JSONB; no new
key — the `segments` field already carries the run order).
- **A16 honoured** — one new story E2E suite + the task-05 regression
updates; mock-only by design. **A17 honoured** — one atomic
`--no-gpg-sign` commit.
- **The phase-158 `closeAllRuns` / `closeCurrentRun` / the phase-87
clock are UNCHANGED** (descendant-scoped queries — they work with
the nesting); the ONLY superseded anchor is the per-run
`insertBefore(bubble)` → the container's `.reasoning-body` append.
No other anchor changed.
@@ -0,0 +1,99 @@
# Task 01 — The live reasoning container
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "The reasoning process should be one collapsible section that **stays open** during the reasoning process and shows all thinking and tool calls in order. Right now the thinking, tool calling, thinking, tool calling blocks take up a ton of space and obscure the answer. We need to condense this while still letting the user watch the thinking process happen so the latency doesn't feel super low. This new thinking/tool calling bubble should only collapse once the final response has been returned and the session has stopped."`
**Story:** n/a
## Objective
One `details.reasoning` container per brain message wraps the phase-158
per-run timeline live: created on the turn's first thinking/tool frame,
`open` until the turn settles (done / stop / failed-finalize), condensed
in a bounded 320px live window with pinned-tail follow, the summary
carrying the D6 status literals; the per-run blocks are re-anchored into
the container (their structure and open/fold behavior are unchanged).
## Work
1. `frontend/assets/app.js` — `createReasoningWrap()` (beside
`createThinkingBlock` ~L1068): createElement only (innerHTML ABSENT)
— `details` (class `reasoning`) + `summary` (class
`reasoning-summary`, `textContent = "Reasoning"`, the ONLY new static
literal — D6) + `span` (class `reasoning-status`, empty until a frame
sets it) + `div` (class `reasoning-body`); return the `details`
element. The docstring cites this phase + D1/D6.
2. `frontend/assets/app.js` — module scope `reasoningWrapEl = null`
(beside `segRuns`/`currentRun` ~L1464–1465) + `ensureReasoningWrap
(wrap)` (get-or-create: on the turn's first thinking/tool frame —
`createReasoningWrap()`, `open = true` (D1), inserted `before`
`wrap.querySelector(".msg-body").querySelector(".bubble")`, stored in
`reasoningWrapEl`); reset in `runTurn` alongside `segRuns = []`
(~L3304 — the pagehide partial reads the live container, so the
module scope is readable there like `segRuns`).
3. `frontend/assets/app.js` — re-anchor the per-run inserts: in
`ensureThinkingRun` (~L1103) and `ensureToolRun` (~L1122), the
`body.insertBefore(block, body.querySelector(".bubble"))` line becomes
`ensureReasoningWrap(wrap).querySelector(".reasoning-body")
.appendChild(block)` (the `wrap` param — both functions already
receive it). EVERYTHING ELSE in both functions is UNCHANGED (the
`closeCurrentRun()` call, the `open = true`, the `segRuns.push`,
`currentRun`). The container is created by the FIRST run's
`ensureReasoningWrap` call — no other call site.
4. `frontend/assets/app.js` — `settleReasoning(wrap)` (beside
`closeAllRuns` ~L1149):
`wrap?.querySelector("details.reasoning")?.removeAttribute("open")`
(idempotent; a no-op without a container — the plain-answer turn
settles byte-identically). Called alongside `closeAllRuns(wrap)` at
the THREE settle sites: the `done` branch (~L3570), the STOP path
(~L3752), `finalizeFailedTurn` (~L3218). The `delta` branch
(~L3553) is UNCHANGED — `closeCurrentRun()` folds the active run;
the container STAYS OPEN (D2 — the TODO's core contract).
5. `frontend/assets/app.js` — the D6 status: in the thinking frame
branch (~L3381, after `ensureThinkingRun`), set
`reasoningWrapEl?.querySelector(".reasoning-status")?.textContent =
"Thinking…"`; in the tool frame branch (~L3501, after
`ensureToolRun`), set `"Calling " + name + "…"` (the `name` the
`appendToolLine` call already receives — the phase-70 legacy-name
mapping already produced it); at the three settle sites (alongside
`settleReasoning`), set `"${segRuns.length} steps"` (the live `N` —
D6; the settle sites read `segRuns` — module scope).
6. `frontend/assets/app.js` — the D3 live follow: the
`REASONING_NEAR_BOTTOM_PX = 32` constant (beside the phase-17
`THINKING_NEAR_BOTTOM_PX`) + the follow helpers —
`isReasoningPinned(el)` (the pre-update capture — the phase-17
pre-measure convention: `el.scrollTop + el.clientHeight >=
el.scrollHeight - REASONING_NEAR_BOTTOM_PX`) and
`scrollReasoningToBottom(el)` (the post-append apply: `el.scrollTop
= el.scrollHeight`). Called from BOTH the thinking frame branch (pre:
after the existing `isThinkingNearBottom` capture; post: after the
block re-render) and the tool frame branch (pre: before
`appendToolLine`; post: after it) — `el` =
`reasoningWrapEl?.querySelector(".reasoning-body")` (a no-op when
absent — the plain-answer turn never creates one). The EXISTING
per-block `isThinkingNearBottom(textEl)` follow is UNCHANGED
(harmless — task 03 makes the inner `.thinking-text`
non-scrollable inside the container, so it no-ops there). The
phase-87 `armToolLineClock`/`settleToolLine` wrap-scoped queries are
DESCENDANT-scoped — UNCHANGED (they match the nested runs).
7. `tests/unit/test_reasoning_bubble.py` (new) — the source-level pins:
`createReasoningWrap`'s createElement shape (the `reasoning` /
`reasoning-summary` / `reasoning-status` / `reasoning-body` classes,
the `Reasoning` literal, innerHTML ABSENT), `ensureReasoningWrap`'s
get-or-create + `open = true` + the reset in `runTurn`, the per-run
inserts append into `.reasoning-body` (the `insertBefore(…bubble)`
per-run anchor is GONE from `ensureThinkingRun`/`ensureToolRun`),
`settleReasoning` defined + called at the THREE settle sites and NOT
at the `delta` branch, the status literals (`Thinking…` / `Calling `
/ ` steps`), the `REASONING_NEAR_BOTTOM_PX = 32` constant + the
pre/post split, and the pagehide partial's readability (the module
scope reset pattern).
## Testing & Quality
- Unit: `tests/unit/test_reasoning_bubble.py` (above) — all pins green.
- Coverage: **>90%** on this task's new/modified code (frontend — the
`app/` gate is untouched; held by the suite).
## Completion Criteria
- [ ] `tests/unit/test_reasoning_bubble.py` green (`uv run pytest
tests/unit/test_reasoning_bubble.py -v --no-cov`)
- [ ] the full test suite green (`uv run pytest`)
- [ ] no behavior change in completed work beyond the documented
anchor supersession (the per-run blocks' structure + open/fold
behavior is byte-identical; only their parent changed)
@@ -0,0 +1,89 @@
# Task 02 — The collapsed restore + shared-page container
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "The reasoning process should be one collapsible section that **stays open** during the reasoning process and shows all thinking and tool calls in order. Right now the thinking, tool calling, thinking, tool calling blocks take up a ton of space and obscure the answer. We need to condense this while still letting the user watch the thinking process happen so the latency doesn't feel super low. This new thinking/tool calling bubble should only collapse once the final response has been returned and the session has stopped."`
**Story:** n/a
## Objective
The restore path (`renderStoredMessage` → `restoreTimeline`) and the
shared page (`shared.js` → `addSegments`) render the SAME `details
.reasoning` container COLLAPSED around the per-run timeline, with the
D6 at-rest step-count summary; records without `segments` keep the
byte-identical legacy path (no container — D4).
## Work
1. `frontend/assets/app.js` — `restoreTimeline` (~L2195): BEFORE the
loop, create the container — `createReasoningWrap()` (the task-01
builder — the shared one), `open = false` (D5 — the restore renders
collapsed; explicit for the pin), the summary's
`.reasoning-status` filled with the at-rest count (step 2 below),
inserted ONCE `before` `body.querySelector(".bubble")`. Every
per-run block's `body.insertBefore(block, bubble)` /
`body.insertBefore(toolDisc, bubble)` line becomes
`container.querySelector(".reasoning-body").appendChild(…)` — the
rest of the loop is UNCHANGED (the malformed-entry skips, the
tOffset/tLimit/tPath well-formedness guards, the
`appendTruncatedNote` call, the thinking text's escape-first
`renderMarkdown` write, the UNKNOWN-`kind` skip).
2. `frontend/assets/app.js` — the D6 at-rest step count, computed
INCREMENTALLY inside the loop (matching the live `segRuns.length`
semantics — contiguous runs, NOT entry count): `runs = 0`; a
`thinking` entry: `if (the previous run was not a thinking run)
runs += 1` (track the previous entry's kind); a `tool` entry: the
first entry of a NEW contiguous group (i.e. `toolDisc` was null
before this entry created it) → `runs += 1`. After the loop, set
`.reasoning-status` `textContent = "${runs} steps"`. (A record with
only one run of one kind → `1 steps` — the D6 literal is UNIFORM:
no singular/plural special-case — the executor pins the literal
EXACTLY as `${runs} steps`.)
3. `frontend/assets/app.js` — the `renderStoredMessage` dispatch
(~L2292) is UNCHANGED: a record WITHOUT `segments` never reaches
`restoreTimeline` — the legacy path is byte-identical (no
container — D4). A comment update on the dispatch cites the
container (the segments path now wraps in the collapsed container).
4. `frontend/assets/shared.js` — `addSegments` (~L269): the SAME
wrapper parity copy — create the container (createElement only —
innerHTML ABSENT; the shared.js file has no `createReasoningWrap`
— a LOCAL parity builder mirroring the app.js one: the same
`reasoning` / `reasoning-summary` / `reasoning-status` /
`reasoning-body` classes, the `Reasoning` literal, the summary's
`textContent = "Reasoning"` + the span), created CLOSED (the
pure-render convention — D5), the SAME incremental step count (the
same two-line semantics as step 2 — a parity copy, not an import),
inserted ONCE before the bubble (the shared page's existing
insertion convention — mirror the existing `addSegments`/
`addToolLines` anchor), and every per-run block appends into the
container's `.reasoning-body` (the existing
`wrap.querySelector(".msg-body").appendChild(…)` lines of the loop
— verify the actual anchor in the file and mirror it EXACTLY). The
line bytes MIRROR `addToolLines` — UNCHANGED (the four label
literals, the phase-95 truncation note, the phase-139/146
suffixes). The record dispatch (~L584) is UNCHANGED (no
`segments` → the legacy `addThinkingBlock` + `addToolLines` path,
byte-identical).
5. `tests/unit/test_reasoning_bubble.py` (extended) — the restore
wrapper: created CLOSED (the `open = false` pin), the per-run
blocks append into `.reasoning-body` (the `insertBefore(…bubble)`
per-run anchor is GONE from `restoreTimeline`), the container
inserted ONCE (a single `details.reasoning` per message — the
get-or-create shape), the incremental step count (a thinking entry
after a tool group starts a NEW run; two adjacent thinking entries
are TWO runs; a tool group of N entries is ONE run — the
`segRuns.length` semantics), the legacy no-segments path is
untouched (`renderStoredMessage`'s dispatch unchanged — the source
pin), the `shared.js` `addSegments` parity (the collapsed wrapper +
the step count + the unchanged line bytes + the local builder's
classes/literal), and `innerHTML` ABSENT from BOTH wrappers.
## Testing & Quality
- Unit: `tests/unit/test_reasoning_bubble.py` (extended — above) — all
pins green.
- Coverage: **>90%** on this task's new/modified code (frontend — the
`app/` gate is untouched; held by the suite).
## Completion Criteria
- [ ] `tests/unit/test_reasoning_bubble.py` green (`uv run pytest
tests/unit/test_reasoning_bubble.py -v --no-cov`)
- [ ] the full test suite green (`uv run pytest`)
- [ ] no behavior change in completed work beyond the documented
restore/shared wrapper (a pre-phase record — no `segments` —
renders byte-identically; the per-run block bytes are unchanged)
@@ -0,0 +1,98 @@
# Task 03 — The container's CSS
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "The reasoning process should be one collapsible section that **stays open** during the reasoning process and shows all thinking and tool calls in order. Right now the thinking, tool calling, thinking, tool calling blocks take up a ton of space and obscure the answer. We need to condense this while still letting the user watch the thinking process happen so the latency doesn't feel super low. This new thinking/tool calling bubble should only collapse once the final response has been returned and the session has stopped."`
**Story:** n/a
## Objective
The `details.reasoning` container's styling: the native
`details`/`summary` treatment of the house family (the
`details.thinking` convention — L633), the bounded 320px live window
(D3), the inner-run compacting (de-bordered, one scroll surface), the
reduced-motion stills — all AA-contrast, WCAG 2.1 AA basics
(focusable summary ≥44px, `:focus-visible`, chevron).
## Work
1. `frontend/assets/styles.css` — the `details.reasoning` block (a
NEW distinct comment block citing this phase + D3; placed
ADJACENT to the `details.thinking` family — after the
`details.thinking .thinking-text` margin rule ~L684, before the
phase-117 `.tool-calls-disclosure` comment ~L689 — verify the
actual layout in the file and keep the family blocks adjacent):
`background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--brand-soft); border-radius:
var(--radius-sm); margin: 0 0 0.5rem; overflow: hidden;` — the
family treatment of `details.thinking` (the same surface/line/
brand-soft/radius tokens; the comment documents the parity + the
D3 window).
2. `frontend/assets/styles.css` — `summary.reasoning-summary`:
`display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem
0.75rem; min-height: 44px;` (the WCAG ≥44px target — parity with
the `details.thinking` summary); `color: var(--brand-ink)` (8.7:1
on `--surface` — AA — the token the `details.thinking` summary
uses); `font-size: 0.9rem; cursor: pointer; list-style: none;`;
`summary.reasoning-summary::-webkit-details-marker { display: none
}`; the CSS chevron `summary.reasoning-summary::before { content:
"▸"; display: inline-block; transition: transform 0.15s ease; }` +
`details.reasoning[open] summary.reasoning-summary::before {
transform: rotate(90deg); }` (the `details.thinking` convention);
`summary.reasoning-summary:focus-visible { outline: 3px solid
var(--brand); outline-offset: 2px; }` (the house 3px ring).
3. `frontend/assets/styles.css` — `.reasoning-status`: `color:
var(--ink-soft)` (6.9:1 on `--surface` — AA); `font-size: 0.8rem;
line-height: 1.4; margin-left: 0.25rem;` (D6 — text + color, never
color alone — B5; the status is a span INSIDE the summary — the
flex row lays it out after the literal).
4. `frontend/assets/styles.css` — `.reasoning-body`: `padding: 0 0.75rem
0.75rem; max-height: 320px; overflow-y: auto;` (D3 — the ONE
bounded live window; a user-scrollable window — the phase-17
convention; the comment cites this phase + D3 + the pinned-tail
follow in app.js).
5. `frontend/assets/styles.css` — the inner-run compacting (D3 — the
nested-border weight removed; ONE scroll surface):
`details.reasoning details.thinking { border: 0; border-left: 2px
solid var(--line); border-radius: 0; margin: 0.25rem 0;
background: transparent; }` + `details.reasoning details.thinking
.thinking-text { max-height: none; overflow-y: visible; }` (the
inner 320px window disabled — the container's window is the single
scroll surface; the phase-17 per-block follow in app.js no-ops
accordingly — the task-01 comment) + `details.reasoning
.tool-calls-disclosure { margin: 0.25rem 0; }` (already compact —
the rule documents the intent, no visual change).
6. `frontend/assets/styles.css` — the reduced-motion block (L1499,
the existing `details.thinking summary::before { transition: none;
}` stills list): append `summary.reasoning-summary::before {
transition: none; }` (the chevron rotation stills — parity with
the existing stills).
7. No JS change in this task (the selectors the task-01/02 JS
creates — `reasoning` / `reasoning-summary` / `reasoning-status` /
`reasoning-body` — are the CSS contract).
## Testing & Quality
- Unit: `tests/unit/test_reasoning_bubble.py` (extended) — the source
pins: the `details.reasoning` rule exists with the family tokens +
`overflow: hidden`; the `summary.reasoning-summary` rule with
`min-height: 44px` + the `:focus-visible` ring + the webkit-marker
suppression + the chevron `::before` + the `[open]` rotation; the
`.reasoning-status` rule with the AA token; the `.reasoning-body`
rule with `max-height: 320px` + `overflow-y: auto`; the
inner-run compacting rules (the de-border + the
`max-height: none` on the inner `.thinking-text`); the
reduced-motion still appended.
- E2E (verified in task 04's suite): the computed-style pins —
`.reasoning-body`'s `max-height` ≤ 320px + `overflow-y: auto`
(the D3 contract — test 6 of `test_reasoning_bubble.py` per the
phase overview).
- Coverage: **>90%** on this task's new/modified code (frontend — the
`app/` gate is untouched; held by the suite).
## Completion Criteria
- [ ] `tests/unit/test_reasoning_bubble.py` green (`uv run pytest
tests/unit/test_reasoning_bubble.py -v --no-cov`)
- [ ] `uv run ruff check . && uv run pyright` clean (the CSS is
verified by the unit source pins — no CSS lint gate)
- [ ] the full test suite green (`uv run pytest`)
- [ ] no behavior change in completed work beyond the documented
inner-run compacting (the `details.thinking` /
`.tool-calls-disclosure` rules OUTSIDE the container are
byte-identical — the legacy no-container path renders
unchanged)
@@ -0,0 +1,117 @@
# Task 04 — The mock mid-round flow + the story E2E suite
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "The reasoning process should be one collapsible section that **stays open** during the reasoning process and shows all thinking and tool calls in order. Right now the thinking, tool calling, thinking, tool calling blocks take up a ton of space and obscure the answer. We need to condense this while still letting the user watch the thinking process happen so the latency doesn't feel super low. This new thinking/tool calling bubble should only collapse once the final response has been returned and the session has stopped."`
**Story:** n/a
## Objective
The mock `REASONING_MIDROUND_TRIGGER` flow (a mid-round `delta` BEFORE
the tool call — the React-style reply the TODO's stays-open contract
needs) + the dedicated `test_reasoning_bubble.py` suite, green in
isolation (mock-only).
## Work
1. `tests/e2e/mock_llm.py` — `REASONING_MIDROUND_TRIGGER = "think out
loud, answer, then check your work"` (beside
`THINK_TOOLS_TRIGGER` ~L1067). BEFORE wiring, verify BOTH (the
phase-overview convention): (a) no existing trigger phrase is a
substring of the new one AND the new one is not a substring of an
existing one in a way that steals an existing question's dispatch
— the existing trigger is `think out loud and use your tools` (they
share only `think out loud`; NEITHER is a substring of the other —
verified, re-verify at execution); and (b) no existing E2E
question carries the new phrase (grep the suite). Checked in the
streaming branch BEFORE the `_thinking_tools_flow` dispatch
(the more-specific-phrase-wins convention — the phase-158
precedent).
2. `tests/e2e/mock_llm.py` — `_reasoning_midround_flow(body)`
(stateless, shaped like `_thinking_tools_flow` ~L1983 — gated on
the `tools` parameter + the `<tools>` section, like the other
marker flows):
- request 1 (tools offered, no tool results yet): `thinking` =
`"Step 1: I think I know the answer — let me check the
documents."` → a MID-ROUND `delta` = `"The reference server is
ref-server-1."` (the content stream — BEFORE the tool call — the
exact mid-round ordering the TODO describes) → the
`_tool_call_stream("ls", {}, "call_0", thinking="")` (the tool
call AFTER the delta).
- request 2 (a `tool`-role result): `thinking` = `"Step 2: the
listing confirms it."` → a `delta` = `" — confirmed by the
listing."` → the content `finish` (the flow ends with a content
finish; NO further tool call — the `done` convention).
3. `tests/e2e/mock_llm.py` — dispatch: in `chat_completions`
(the phase-158 site ~L4631, beside the `think_tools =
_thinking_tools_flow(body)` check), the `REASONING_MIDROUND_TRIGGER`
check BEFORE the phase-158 check; the docstring gains the new
trigger entry (the phase-161 flow). The mock stays mock-only by
design (`E2E_REAL_LLM` note unchanged).
4. `tests/e2e/test_reasoning_bubble.py` (new — the dedicated story
suite, run in isolation) — the conftest `app_server`/`page`/
`seeded_kb` fixtures + the phase-37 admin login + the mid-round
trigger question (the KB must be seeded so the `ls` result exists —
mirror `test_thinking_timeline.py`'s setup). The six tests (per
the phase overview):
1. `test_container_open_during_the_turn` — during the turn (an
`expect.poll` predicate over the mock's 0.1 s pacing window,
before the turn settles): a `details.reasoning` exists, is
`open`, contains ≥1 `details.thinking` or
`details.tool-calls-disclosure` INSIDE its `.reasoning-body`
(the per-run blocks are NESTED — the phase-158
direct-children layout is superseded), and its
`.reasoning-status` carries a live suffix (`Thinking…` or
`Calling …`).
2. `test_container_stays_open_past_a_mid_round_delta` — the
mid-round trigger: while the answer bubble is NON-EMPTY (the
mid-round delta rendered) AND the turn is not settled, the
container is STILL `open` with a run visible (the D2 contract —
the phase-158 first-delta-fold is superseded at the container
level; the TODO's core: "only collapse once the final response
has been returned and the session has stopped").
3. `test_container_folds_at_done` — after `done`: the container has
NO `open` attribute; its summary is visible; EVERY inner run has
no `open` attribute (the D2/D5 settle — the phase-158
fold-at-rest contract preserved inside the fold); the
`.reasoning-status` is `3 steps` (the flow's contiguous-run
count — `thinking (Step 1)`, `tool (ls)`, `thinking (Step 2)` —
the D6 live settle literal).
4. `test_restored_container_collapsed_with_step_count` — RELOAD
(same context): the restored message renders ONE
`details.reasoning` (NO `open` attribute) containing the same
per-run timeline, and its `.reasoning-status` is `3 steps` (the
D6 at-rest count — the restore's incremental count matches the
live `segRuns.length` semantics).
5. `test_shared_page_container_collapsed` — the phase-51 save +
share flow, a FRESH context on `/shared/<token>`: ONE
`details.reasoning` (collapsed) with the same timeline + step
count (the `shared.js` parity — `addSegments`).
6. `test_no_reasoning_turn_has_no_container` — a plain answer
question: ZERO `details.reasoning` in the brain wrap (D1/D4 —
the byte-identical DOM); and a plain `use your tools` question
(NO thinking trigger): the container EXISTS (the tool frames
create it), wraps the ONE phase-117 disclosure, is `open` live
(an `expect.poll` window), folds at `done` — and its
`.reasoning-status` is `1 steps` (the D6 uniform literal — the
no-thinking turn's single tool run; the phase-117
single-disclosure behavior preserved, now wrapped).
+ the D3 computed-style pin (the phase overview's test 6 — fold
into test 1 or a dedicated assertion there): the
`.reasoning-body`'s computed `max-height` ≤ 320px +
`overflow-y: auto` (the bounded-window contract — the CSS
verified in the browser, not just in source).
## Testing & Quality
- E2E: `tests/e2e/test_reasoning_bubble.py` green **in isolation**
(`uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov`),
mock-only.
- Coverage: **>90%** on this task's new/modified code (the mock is
test-only — the `app/` gate is untouched; held by the suite).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov`
green (all six tests)
- [ ] the full test suite green (`uv run pytest`) — the phase-158
suite's direct-children pins may be RED at this point (they
update in task 05 — the documented change); every OTHER suite
must stay green
- [ ] no behavior change in completed work beyond the documented
mock addition (the existing triggers dispatch unchanged — the
more-specific-phrase-wins verification)
@@ -0,0 +1,87 @@
# Task 05 — Regression pins, the full gate, docs, commit
**Phase:** `161_reasoning_bubble` · **Source:** `TODO.md:3 — "The reasoning process should be one collapsible section that **stays open** during the reasoning process and shows all thinking and tool calls in order. Right now the thinking, tool calling, thinking, tool calling blocks take up a ton of space and obscure the answer. We need to condense this while still letting the user watch the thinking process happen so the latency doesn't feel super low. This new thinking/tool calling bubble should only collapse once the final response has been returned and the session has stopped."`
**Story:** n/a
## Objective
The documented pin updates (the phase-158 "direct children before the
bubble" contract → the container-nested contract — the ONE
owner-directed exception), the remaining unit-pin updates, the full
gate (unit + integration, E2E in isolation, coverage >90%, lint/
types), the README/AGENTS documentation pass, the atomic commit, the
phase move.
## Work
1. `tests/e2e/test_thinking_timeline.py` — the pin updates (the
documented change — the per-run blocks are now NESTED in
`details.reasoning`): every assertion that the per-run blocks are
DIRECT children of `.msg-body` before the bubble → the blocks are
children of `.msg-body > details.reasoning .reasoning-body`
(arrival order preserved — the seven-element order pin
UNCHANGED in shape, the container is the one new parent);
`test_live_runs_open_while_streaming` — the inner runs'
open-while-streaming contract UNCHANGED (the container is also
open — no pin change); `test_no_thinking_turn_single_disclosure` —
the single-disclosure selector now scoped inside the container (the
phase-117 contract UNCHANGED — one disclosure, now wrapped).
2. `tests/unit/test_thinking_timeline.py` — the insertion-anchor pin
updates: `ensureThinkingRun`/`ensureToolRun`'s
"insert before the bubble" pins → "append into
`details.reasoning .reasoning-body`" (the phase-161 anchor — the
documented supersession); every OTHER pin (the per-run create, the
phase-17 template, the clock, `buildSegments`, the save-payload
sites, the restore guards) stays green UNCHANGED.
3. The remaining E2E regression suite — run each in isolation; fix
ONLY the pins broken by the nesting (descendant selectors like
`.msg-body details.thinking` still match — they are UNCHANGED;
`:first-child`/direct-children pins may break):
`test_thinking_display.py`, `test_tool_call_compact.py`,
`test_big_read_progress.py` (the phase-87 clock — the wrap-scoped
descendant queries are unchanged — must stay green UNCHANGED if no
pin breaks), `test_turn_progress_loader.py`, `test_share_chat.py`,
`test_chat_persistence.py`, `test_smoke.py`.
4. The full gate — `uv run pytest` (100% of the project suite
green); `uv run pytest --cov=app --cov-report=term-missing`
(>90% on `app/` — the phase is frontend-only, the gate stays
green by the suite); `uv run pytest
tests/e2e/test_reasoning_bubble.py -v --no-cov` (green in
isolation); `uv run ruff check . && uv run pyright` (clean).
5. The documentation pass — `README.md` + `AGENTS.md` reviewed in
full against the project as it stands after this phase; fix every
inconsistency found (including pre-existing ones). The
reasoning bubble is a visible UI change — name it where the
chat thinking/tool-call display is described, if anywhere (the
README's chat description, if it names the phase-158
per-run timeline; the AGENTS.md quick-reference is unchanged —
no new commands).
6. The atomic commit + the phase move:
`git add -A .agents/ frontend/ tests/ README.md && git commit
--no-gpg-sign -m "feat(ui): condense the reasoning timeline into one live-open collapsible bubble (folds at turn end, collapsed on restore/shared)"`
— then move `.agents/phases/todo/161_reasoning_bubble/` →
`.agents/phases/complete/161_reasoning_bubble/` (the phase is
Complete per the overview's criteria).
## Testing & Quality
- The full gate (step 4) — every check green before the commit.
- Coverage: **>90%** on `app/` (the gate — frontend-only phase, held
by the suite).
## Completion Criteria
- [ ] `uv run pytest` green (100% of the project suite)
- [ ] `uv run pytest --cov=app --cov-report=term-missing` > 90%
- [ ] `uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov`
green in isolation
- [ ] `uv run ruff check . && uv run pyright` clean
- [ ] `git diff --stat` (before the commit) limited to
`frontend/assets/app.js`, `frontend/assets/shared.js`,
`frontend/assets/styles.css`, `tests/`, and the phase files —
NO `app/` change (D4)
- [ ] README.md + AGENTS.md accurate and consistent (the
documentation pass)
- [ ] ONE owner-directed exception to "no behavior change in
completed phases": the phase-158 direct-children layout is
superseded by the container nesting (owner TODO direction
2026-10-05 — recorded in the overview); the per-run
open/fold behavior itself is UNCHANGED
- [ ] one atomic `--no-gpg-sign` commit; the phase dir moved to
`.agents/phases/complete/`
@@ -0,0 +1,22 @@
All completion criteria verified. Final report:
**Phase 159 — Edit the last question and re-ask it: VERIFIED COMPLETE** (all 4 tasks already in `complete/`; this pass verified end-to-end)
**Verified (implementation landed + tests green):**
- `app.js`: `EDIT_ICON`, `lastUserWrap`, `appendEditButton`/`markLastEditable` (single-target discipline, in-flight guard), `openEditor` (labelled textarea, `maxlength=4000`, Escape/Ctrl+⌘+Enter, empty guard), save path: replace → splice tail → DOM removal → `lastBrainWrap=null` → `saveConversation`+`persistConversation` → `runTurn(text,{reask:true})` — matches README exactly
- `styles.css`: `.edit-btn`/`.edit-form`/`.edit-error`/`.is-editing` + mobile 44px; unit pins 40, E2E 7, integration `test_chats_api.py` extended
**Test/lint results (all green):**
- `uv run pytest` — 3,661 tests, exit 0
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90%)
- `uv run ruff check . && uv run pyright` — all checks passed; 0 errors
- `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` (DB up) — **7/7 passed**
- Regressions in isolation: `test_retry_answer.py` 4/4, `test_failed_turn_retry.py` 4/4, `test_chat_persistence.py` 4/4
**Completion criteria:** all 7 ✓ — Edit on last bubble + prefill + Escape/Cancel restore (E2E #3); replace+truncate+re-ask with no duplicated question (E2E #1); refresh survival (E2E #4); failed/“question too long” turn editable + trim re-asks (E2E #5); earlier bubbles un-editable + byte-identical Retry/Stop/persistence (E2E #2 + regressions + unit pins); suite/coverage/lint ✓; E2E + regressions isolated ✓. Commit + phase-dir move: harness's job (no git ops by me, per protocol).
**Documentation:** README.md — full review; phase 159 section cross-checked against `app.js` source (cap, keys, truncate, image survival) — consistent, no changes needed; all 30 `.env.example` vars documented. AGENTS.md — full review; all rules/quick-ref commands valid — verified consistent, no changes needed.
**Notable:** none — no defects found in prior work; no live-infrastructure changes required.
**Next pending phase:** `160_tool_budget_telemetry`
@@ -0,0 +1,122 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
............................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 466 0 100%
--------------------------------------------------
TOTAL 5742 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,22 @@
**Task 159.01 complete — report**
**Implemented**
- `EDIT_ICON` (aria-hidden pencil, RETRY_ICON stroke family) + `appendEditButton` (joins `.msg-actions` via `getActionsRow`, listitem, "Edit" label, not admin-gated)
- `lastUserWrap` (mirror of `lastBrainWrap`: runTurn fresh-send, restore user branch, cleared by `startNewChat`) + `lastUserRecordIndex()` newest-first scan + `markLastEditable()` (remove-then-append single target, in-flight guard, record/wrap bails) — paired at all six `markLastRetryable()` sites
- `openEditor` (openTuneForm family): labelled `textarea[maxlength=MAX_QUESTION_CHARS]` rows=3 prefilled via `.value`, composer placeholder, Save/Cancel, `role=alert` `.edit-error`; `.is-editing` hides the bubble; Escape/Cancel restore + refocus; Ctrl/Cmd+Enter submits; in-flight save no-op; empty-trim rejected in-form; save = replace-in-place + `saveConversation()` + `renderMarkdown` re-render (attachment survives); task-02 seam marked at the close
- CSS: `.edit-btn` pill family (+ hover + mobile 44px), `.edit-form` card family, `.edit-save/.edit-cancel`, `.edit-error` (–err-* tokens), `.msg.user .bubble.is-editing`; global `:focus-visible` verified unscoped
- Verified E2E-style (scratch Playwright, since deleted): Edit at rest, Cancel/Escape byte-identical, empty guard, Ctrl+Enter save, localStorage update, reload restore, button moves on new send
**Gates**
- `uv run pytest --cov=app --cov-report=term-missing` → **3641 passed, 0 failed, TOTAL 99%** (>90%)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_scratch_edit_manual_check.py --no-cov` (scratch, deleted) → green
**Docs** — README: new "Edit the newest question" paragraph in the Chat section. AGENTS.md: no impact (no new conventions).
**Notable decisions**
- `markLastEditable()`'s in-flight guard no-ops at the four mid-turn paired sites (uiState still streaming/thinking there) — added the one sanctioned 7th call in `runTurn`'s `finally` after the idle settle, so the button lands at rest (documented in-code; task 03 verifies the error-path pairs end-to-end)
- Stale-click guard `wrap !== lastUserWrap` in `openEditor` (phase-49 discipline) — a superseded button is inert
- Updated two stale prior-phase tail-pins (phase 120/130 `finalizeFailedTurn` now ends `markLastRetryable()` + `markLastEditable()`); scoped two over-broad assertions (`bubble.innerHTML` count → typing-clock functions; 900-char finally window → brace-matched); renamed local `saveBtn`→`editSaveBtn` (house `tuneSaveBtn` precedent); per-file E501 ignore for the 32KB `runTurn` byte-pin file (pyproject, commented)
**Next pending task:** `02_resend_in_place.md` (phase 159)
@@ -0,0 +1,122 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
......................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 466 0 100%
--------------------------------------------------
TOTAL 5742 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,8 @@
**Task 02 complete — report**
- **Implemented (frontend-only, no `app/` change):** `openEditor`'s submit handler now does Save = replace + truncate + re-ask: `conversation.splice(editIdx + 1)` drops the whole tail after the in-place record replace; DOM removal walks `lastUserWrap.nextSibling` onward; `lastBrainWrap = null` + `leavePartialIndex` reset to -1 (gated on `> editIdx`); `saveConversation()` + fire-and-forget `persistConversation()` persist the truncated shape; `markLastRetryable()` re-targets (clears); then `closeEditor()`, the double in-flight check on one synchronous stretch (no `await`), and `return runTurn(text, { reask: true })` — no image (A7 text-only; the record/bubble keep the attachment). Guards in order: in-flight no-op, empty trim. Composer untouched.
- **Tests:** `tests/unit/test_frontend_edit_message.py` — replaced the task-01 seam pin with a full-flow-order pin + 7 new pins (splice, DOM walk, bookkeeping resets, save+persist-before-reask, retry re-target, exact `runTurn(text, { reask: true })` no-image, synchronous-stretch double-check, composer untouched); `test_frontend_hidden_tab.py` — the phase-73 `leavePartialIndex` pin updated for the sanctioned third gated -1 write; `tests/integration/test_chats_api.py` — 2 new pins (edited + truncated tail PUT round-trips byte-identically through body/raw JSONB/GET; the `image` key survives the in-place edit).
- **Results:** `uv run pytest --cov=app` → **3651 passed, TOTAL 99%**; `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors; `node --check app.js` → OK; regression E2E in isolation: `test_retry_answer.py` 4 passed, `test_failed_turn_retry.py` 4 passed, `test_chat_persistence.py` 4 passed. `retryLastTurn` / `runTurn` byte-pins unchanged (task 02 touches neither).
- **Docs:** README's phase-159 paragraph updated (save now drops the tail and re-asks — a fresh answer streams in the old answer's place, no duplication, attachment survives); no AGENTS.md impact.
- **Decisions:** the pre-re-ask in-flight re-check is explicit (the stretch is synchronous, so it's defensive documentation of the invariant, per the task's double-check wording); `editIdx` reuses task 01's `lastUserRecordIndex()` scan in the closure (identical value).
- **Next pending task:** `03_editable_error_turn.md`.
@@ -0,0 +1,122 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 466 0 100%
--------------------------------------------------
TOTAL 5742 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
All gates verified. Final report:
**Task 03 complete — the driver case: a failed or rejected question stays editable**
**Implemented:**
- Verified all 5 pre-existing `markLastEditable()` pairings (finalizeFailedTurn funnel — the 422 + SSE error-frame path, zero-frame fallback, stop finalize, done settle) + both restore pairs; the 300s-guard `aborted` catch branch settled with **no** mark pair → added `markLastRetryable(); markLastEditable();` there (timed-out turn no longer strands the question; finally idle-settle re-run stays harmless via remove-then-append)
- Verified the guard pins: `markLastEditable` bails only on `thinking`/`streaming` (never `error`/`idle`); the save handler's double guard is byte-identical
- Confirmed the failed `{failed: true}` record is dropped (single-arg splice + DOM walk + `lastBrainWrap = null` before re-ask) and added the one-line truncation-ordering comment (new turn's `rememberBrainTurn` sees the post-splice array)
- Confirmed `?chat=` admin restore is an editable working copy (mirrors localStorage, links `currentChatId`) — **no read-only render path exists; the assumption holds, no gate added** (finding recorded)
- `showErrorBanner` untouched — its `opts.retryable && lastBrainWrap` condition is byte-pinned via the shared phase-120 pin
**Tests:** extended `tests/unit/test_frontend_edit_message.py` — 7 new task-03 tests (guard states, save-guard mirror, banner byte-pin, failed-record-dropped, ordering-note pin, aborted-settle pair, restore paths) + updated the 8-call/7-paired counts and `PINNED_RUN_TURN`
**Results:** `uv run pytest` → exit 0, 100% dots, zero failures · `--cov=app` → TOTAL 99% (>90% gate) · `uv run ruff check .` → All checks passed · `uv run pyright` → 0/0/0 · manual driver check (mock LLM + Playwright): error-frame turn → Edit+Retry, trim+Save streams a **grounded** answer; 422 >4000-char turn → same; refresh restores with working Edit — all PASS
**Docs:** no doc impact (README's phase-159 paragraph already covers the "failed turn fixed by trimming in the editor" behavior)
**Next pending task:** `.agents/phases/todo/159_edit_and_resend_message/04_edit_resend_tests.md`
@@ -0,0 +1,122 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 466 0 100%
--------------------------------------------------
TOTAL 5742 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
Docs are consistent — the README's phase-159 paragraph (L103–114) already covers the task-03 driver-case behavior, and AGENTS.md needs no phase-159 changes (the Edit button joins the existing `.msg-actions` row; no new conventions). Final report:
**Task 03 complete — the driver case: a failed or rejected question stays editable**
**Verified/implemented (this pass re-runs all gates after the process-rejection):**
- All 5 non-`done` settle pairings + both restore pairs verified (422 non-OK-HTTP + SSE `error`-frame path → `finalizeFailedTurn` funnel with `markLastEditable()` as the very next statement; zero-frame fallback; stop finalize; done settle)
- The 300s-guard `aborted` catch branch settled with no mark pair → added `markLastRetryable(); markLastEditable();` there (a timed-out turn no longer strands the question)
- Guard pins verified: `markLastEditable` bails only on `thinking`/`streaming` (never `error`/`idle`); the save handler's double guard is byte-identical
- Failed `{failed: true}` record is dropped by the single-arg splice + DOM walk; `lastBrainWrap = null` precedes the re-ask; added the one-line truncation-ordering comment (new turn's `rememberBrainTurn` sees the post-splice array)
- `?chat=` admin restore confirmed an editable working copy — no read-only path exists, assumption holds, no gate added; `showErrorBanner` byte-unchanged (shared phase-120 pin)
- Extended `tests/unit/test_frontend_edit_message.py` with 7 new task-03 tests; updated the 8-call/7-paired counts and `PINNED_RUN_TURN`
**Results:** `uv run pytest` → exit 0, zero failures (all-dots progress) · `--cov=app` → TOTAL 99% (>90% gate) · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors/0 warnings · manual driver check (mock LLM + Playwright): error-frame + 422 turns → Edit + trim + Save streams a grounded answer; refresh restores with a working Edit — all PASS
**Docs:** README/AGENTS.md consistency check re-run — both accurate, no changes needed
**Next pending task:** `.agents/phases/todo/159_edit_and_resend_message/04_edit_resend_tests.md`
@@ -0,0 +1,122 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 466 0 100%
--------------------------------------------------
TOTAL 5742 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
**Report — phase 159, task 04 (edit-and-resend tests)**
**Implemented**
- Unit: `tests/unit/test_frontend_edit_message.py` — added the task-04 regression pins `PINNED_HANDLE_SEND` + `PINNED_REMEMBER_BRAIN_TURN` (byte-unchanged, "NOT touched" contract) with two tests; docstring extended to tasks 01–04 (file now 40 tests; tasks 01–03 had already pinned the icon/button/markLastEditable/openEditor/save-flow/CSS clusters and the runTurn@phase-159 state)
- Integration: `tests/integration/test_chats_api.py` — added `test_shared_serve_carries_the_edited_truncated_shape` (case b: post-edit shape on a shared chat serves byte-identically through `SharedChatOut.messages`; case a already existed from task 02)
- E2E: new `tests/e2e/test_edit_and_resend.py` — 7 mock-only tests (re-ask in place + row poll, single-target discipline, Escape no-op with focus return + byte-identical localStorage, refresh survival, the `always fail` driver case with the real-endpoint-only "Question too long" docstring note, in-flight inertness, composer draft survival) on the phase-120 template (TRUNCATE-then-seed, `auth_helpers.login`, `homelab/kubernetes.md` seed)
**Results (all green)**
- `uv run pytest` (unit + integration): 3661 passed
- `uv run pytest --cov=app --cov-report=term-missing`: TOTAL 99% (>90%)
- `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov`: 7 passed in isolation
- Regressions in isolation: `test_retry_answer` 4, `test_failed_turn_retry` 4, `test_chat_persistence` 4, `test_stop_generation` 3, `test_smoke` 3 — all passed
- `uv run ruff check . && uv run pyright`: clean (0 errors, 0 warnings)
**Docs** — no doc impact from this task: README's phase-159 Edit paragraph was already added by earlier tasks; AGENTS.md unchanged.
**Decisions** — the saved-chat row is compared to localStorage key-for-key (`_shape_matches`), not raw-equal: the DB stores the schema-normalized shape (explicit `None` keys, the `_expect` convention) while localStorage keeps raw client records.
**Next pending task** — none in phase 159 (04 was the last); phase final validation (regression gate + atomic commit + dir move) is the harness's.
@@ -0,0 +1,122 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
............................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 466 0 100%
--------------------------------------------------
TOTAL 5742 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+14
View File
@@ -100,6 +100,20 @@ origin the browser rejects the clipboard, so the click instead offers the
full path in an inline fallback field that selects itself on focus —
click or Tab, then copy (phase 153).
**Edit** the newest question (phase 159): the latest user bubble carries
an **Edit** button that swaps the bubble for an inline textarea
prefilled with the question (the 4,000-character cap is enforced with
`maxlength`; **Ctrl/⌘+Enter** submits, **Esc** / **Cancel** restore the
original byte-identical, and a whitespace-only save is rejected inline).
Saving rewrites the question in the chat and the saved chat in place,
drops every turn that followed it, and re-asks the trimmed question —
a fresh answer streams in the old answer's place (the question is
never duplicated; an attachment on the question survives the edit and
the re-ask stays text-only). A turn that failed with "Question too
long — trim it and re-ask" (or a mistyped question) is fixed by
trimming it in the editor instead of retyping it. Earlier bubbles
never carry the button (last-question-only scope, owner-locked).
### RAG / Knowledge base (`/sources.html`)
The indexed document list. The *Path* column opens each document in the same
+317 -1
View File
@@ -600,12 +600,33 @@ function appendFailedNote(wrap, detail) {
* key off it. */
let lastBrainWrap = null;
/* Phase 159 (task 01, TODO.md L3): the rendered wrap of the CURRENT
* last USER record — the exact mirror of lastBrainWrap (one `who`
* over). Assigned exactly where a user bubble becomes the newest
* question (runTurn's fresh-send addMessage — the reask path keeps
* the existing bubble, so no assignment there — and the phase-14
* restore's user branch, where the LAST restored user bubble wins),
* and cleared by startNewChat. Both markLastEditable's targeting and
* openEditor's stale-click guard key off it (the phase-49
* single-target discipline). Task 02 reuses the variable for the tail
* truncation. */
let lastUserWrap = null;
/* The Retry button's redo glyph (aria-hidden decoration — the "Retry"
* text carries the accessible name), currentColor so the CSS themes the
* stroke (ink-soft → ink on hover, the phase-08 palette). */
const RETRY_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
/* Phase 159 (task 01, TODO.md L3): the Edit button's pencil glyph
* (aria-hidden decoration — the visible "Edit" label carries the
* accessible name). Same stroke family as RETRY_ICON: fill="none",
* currentColor stroke (the CSS themes it — ink-soft → ink on hover,
* the phase-08 palette), 24×24 viewBox, stroke-width 1.7. No icon
* font, no CDN (A11). */
const EDIT_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
/* "Retry" button in the last brain bubble's actions row — the redo
* action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house
* pattern through getActionsRow (phase 130 task 02 — the role=list
@@ -627,6 +648,30 @@ function appendRetryButton(wrap) {
meta.appendChild(btn);
}
/* "Edit" button in the last USER bubble's actions row (phase 159,
* task 01, TODO.md L3 — the trim-and-re-ask remedy: a "Question too
* long — trim it and re-ask" turn, or any mistyped / half-formed
* question, is fixed by editing, never by retyping). The house
* pattern through getActionsRow (phase 130 task 02 — the role=list
* row joins the button as a listitem so ARIA stays valid); one
* button per bubble. The visible "Edit" label carries the accessible
* name (no ellipsis clipping needed at this width). Click →
* openEditor(wrap, btn). NOT admin-gated, unlike appendTuneButton —
* chat is public, so every visitor who can chat can edit the last
* question (the phase-49 Retry precedent). */
function appendEditButton(wrap) {
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".edit-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "edit-btn";
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = EDIT_ICON + "<span>Edit</span>";
btn.addEventListener("click", () => openEditor(wrap, btn));
meta.appendChild(btn);
}
/* Last-bubble-only management (owner-locked 2026-08-29): the Retry
* button lives on exactly ONE bubble — the last brain answer. Remove
* every rendered .retry-btn FIRST (an earlier bubble's button is stale
@@ -662,6 +707,44 @@ function markLastRetryable() {
appendRetryButton(lastBrainWrap);
}
/* Phase 159 (task 01, TODO.md L3): the Edit button's single-target
* management — the sibling of markLastRetryable (the phase-49
* discipline, one `who` over): exactly ONE editable bubble, the
* NEWEST question (owner-locked 2026-10-05; earlier bubbles are
* never editable, so no mid-conversation branch exists and the
* "what happens to the turns after it" question never arises for an
* old message). Remove every rendered .edit-btn FIRST (an older
* bubble's button is stale the moment a newer question lands), then
* bail while a turn is in flight (the retryLastTurn guard — the
* button never appears or moves mid-turn; runTurn's idle settle
* re-runs this at rest), then scan `conversation` NEWEST-FIRST for
* `who === "user"` through lastUserRecordIndex() (ASSUMPTION: the
* index comes from the record scan — the markLastRetryable
* last-brain-index rule, one `who` over — NOT from a DOM↔record
* index map: the DOM and the record list are parallel today, but
* re-deriving the index from the record keeps the editor correct
* after the task-02 truncation), and bail on -1 or a missing
* lastUserWrap — the wrap is assigned exactly where a user bubble
* is created (runTurn's fresh send, the restore's user branch) and
* cleared by startNewChat, so a stale wrap can never carry the
* button. startNewChat needs no call: its list reset removes the
* buttons along with the list (the phase-49 reasoning). Task 02
* reuses lastUserRecordIndex() for the truncation. */
function lastUserRecordIndex() {
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "user") return i;
}
return -1;
}
function markLastEditable() {
messagesEl.querySelectorAll(".edit-btn").forEach((b) => b.remove());
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
if (lastUserRecordIndex() === -1) return; // no user record — nothing to edit
if (!lastUserWrap) return; // no tracked bubble — a stale wrap never carries the button
appendEditButton(lastUserWrap);
}
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right
* "Save as doc" action of EVERY completed brain bubble (deflected
* included — same scope as Tune; a stopped partial is a note, not an
@@ -940,6 +1023,212 @@ function openTuneForm(wrap, toggleBtn) {
form.querySelector("textarea").focus();
}
/* Phase 159 (task 01, TODO.md L3): the INLINE editor in the bubble —
* the openTuneForm family (owner-locked 2026-10-05: editing happens
* IN the bubble, not the composer — the composer keeps its own draft
* untouched by editing). A labeled textarea (rows 3, maxlength =
* MAX_QUESTION_CHARS — the 4000 cap stays single-sourced) prefilled
* from the record's text (through .value — never an HTML string, the
* house no-injection rule), Save (submit) + Cancel, and a
* role=alert .edit-error line. While open the rendered .bubble is
* hidden (.is-editing — the question appears ONCE); Cancel / Escape
* restore it and return focus to the toggle button (the openTuneForm
* cancel rule); Ctrl/Cmd+Enter submits. One editor at a time.
* Stale-click guard (the phase-49 discipline): the click's wrap must
* still be lastUserWrap — a superseded bubble's button is inert.
*
* Save (task 02 — the resend): guards first, in order — the
* in-flight no-op (a save can never start a second turn) and the
* empty trim (the .edit-error line, the form stays open —
* ChatRequest.message / ChatMessage.text both require min_length=1;
* the >4000 case is unreachable: the textarea carries the
* maxlength). Then: the trimmed text REPLACES the record in place
* (conversation[editIdx].text); the TAIL TRUNCATION drops every
* record the OLD question produced (conversation.splice(editIdx + 1)
* — stale by construction, never rewritten — the phase-114
* whole-turn rule), the matching DOM bubbles are removed (every
* .msg after lastUserWrap), and the turn bookkeeping resets
* (lastBrainWrap = null; leavePartialIndex cleared to -1 when it
* pointed past editIdx — a partial saved from the dropped turn
* must not be re-persisted into the new shape). The bubble text
* re-renders through renderMarkdown (the addMessage renderer — the
* markdown rules stay in one place); the attachment (if the
* question carried one) survives the re-render (the img is
* re-prepended — phase 123's one bubble-image position). The
* truncated shape persists — saveConversation() (localStorage,
* trimToBudget rides it unchanged) + persistConversation() (the
* phase-55 auto-save — fire-and-forget, never blocks the turn; a
* shortened messages array is already legal — no new field, no
* migration, A10 stays stateless). markLastRetryable() re-targets:
* no brain record exists after the drop, so the call simply clears
* every .retry-btn (the re-ask's settle re-appends it on the fresh
* bubble). The editor closes, the in-flight guard re-checks (the
* handler is ONE synchronous stretch — no await between the guard
* and the call), and runTurn(text, { reask: true }) re-asks: reask
* skips the user append + save point 1 (the edited question is the
* LAST record, so history = conversation.slice(0, -1) is exactly
* the prior turns and the question is never duplicated), and NO
* image is passed (the phase-49/123 locked A7 rule: a re-ask sends
* text only — the record keeps its image key, the bubble keeps the
* attachment). The promise is returned (the retryLastTurn
* convention) — runTurn never rejects; a failure lands in the
* phase-120 failed-turn path. The composer is never touched (the
* unsent draft survives the edit — input.value / autoGrow /
* updateCharCount stay untouched). The visible re-render is the
* feedback — no new live region (the #send-status announcer stays
* the sole a11y announcer, the phase-55 convention). */
let editSeq = 0; // unique ids for one open edit form's inputs
function openEditor(wrap, toggleBtn) {
if (wrap !== lastUserWrap) return; // stale click — the button moved on
document.querySelectorAll(".edit-form").forEach((f) => f.remove()); // one at a time
const body = wrap.querySelector(".msg-body");
if (!body) return;
const editIdx = lastUserRecordIndex();
if (editIdx === -1) return;
editSeq += 1;
const inputId = `edit-input-${editSeq}`;
const form = document.createElement("form");
form.className = "edit-form";
form.noValidate = true;
const label = document.createElement("label");
label.setAttribute("for", inputId);
label.textContent = "Edit your question";
const ta = document.createElement("textarea");
ta.id = inputId;
ta.name = "question";
ta.setAttribute("rows", "3");
ta.setAttribute("maxlength", String(MAX_QUESTION_CHARS));
ta.value = conversation[editIdx].text; // prefill through .value — never an HTML string
if (input && input.placeholder) ta.placeholder = input.placeholder; // the composer's placeholder, reused
form.append(label, ta);
const actions = document.createElement("div");
actions.className = "edit-form-actions";
const editSaveBtn = document.createElement("button");
editSaveBtn.type = "submit";
editSaveBtn.className = "edit-save";
editSaveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "edit-cancel";
cancelBtn.textContent = "Cancel";
actions.append(editSaveBtn, cancelBtn);
form.appendChild(actions);
const status = document.createElement("p");
status.className = "edit-error";
status.setAttribute("role", "alert");
status.hidden = true;
form.appendChild(status);
const bubble = wrap.querySelector(".bubble");
function closeEditor() {
form.remove();
if (bubble) bubble.classList.remove("is-editing");
toggleBtn.focus();
}
ta.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
closeEditor(); // cancel — restore the bubble, return focus to the toggle
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
form.requestSubmit(); // Ctrl/Cmd+Enter submits
}
});
cancelBtn.addEventListener("click", closeEditor);
form.addEventListener("submit", (e) => {
e.preventDefault();
// In-flight re-check (the in-flight discipline): a save can never
// start a second turn — no-op while a turn is in flight (the
// form stays open; the user can Cancel or wait for the settle).
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
const text = ta.value.trim();
if (!text) {
// Empty guard: the question can't be empty (the schema's
// min_length=1, both sides) — refused in-form, the form stays
// open (the note is never lost, the openTuneForm failure way).
status.textContent = "Ask something first — the question can't be empty.";
status.hidden = false;
return;
}
// Replace in place (LOCKED): the edited text IS the record from
// this point on.
conversation[editIdx].text = text;
// Truncate the tail (LOCKED — the phase-49 redo-in-place rule,
// one level up: the question is REPLACED instead of kept
// verbatim): every record the OLD question produced is stale by
// construction and is dropped outright (never rewritten — the
// phase-114 whole-turn rule). The edited question is now the
// LAST record — runTurn's history = conversation.slice(0, -1)
// invariant holds unchanged, and the question is never
// duplicated into the history.
// Phase 159 (task 03): this truncation happens BEFORE the re-ask's turn starts, so the new turn's rememberBrainTurn index bookkeeping (the replaceIndex/leavePartialIndex correlation — reset to -1 when it points past editIdx, and again at runTurn's start; trimToBudget only drops HEAD-most records) sees the post-splice array and can never be confused by the drop.
conversation.splice(editIdx + 1);
// The matching DOM bubbles are removed: every .msg after the
// question's bubble (#messages' direct children are the bubbles,
// in order — the empty-state div is a leading child, so the
// trailing siblings are exactly the dropped tail).
let n = lastUserWrap.nextSibling;
while (n) {
const next = n.nextSibling;
n.remove();
n = next;
}
// Turn bookkeeping resets with the drop: the removed answer is
// no longer a retry target, and the phase-73 pagehide partial
// pointer must not survive the truncation — a partial saved from
// the dropped turn must not be re-persisted into the new shape
// (the settle's identity guard would fall back to an append).
// -1 = none (the safe value; the next runTurn also resets it
// turn-locally).
lastBrainWrap = null;
if (leavePartialIndex > editIdx) leavePartialIndex = -1;
// The bubble text re-renders through renderMarkdown (the
// addMessage renderer — the markdown rules stay in one place);
// the attachment (if any) survives the re-render (re-prepended —
// phase 123's one bubble-image position).
if (bubble) {
const img = bubble.querySelector(".msg-image");
bubble.innerHTML = renderMarkdown(text);
if (img) bubble.prepend(img);
}
// The truncated shape persists NOW (a crash never resurrects the
// pre-edit text or the stale tail): saveConversation() writes
// localStorage (trimToBudget rides it unchanged) +
// persistConversation() auto-saves the row (the phase-55 path —
// fire-and-forget, never blocks the turn; a shortened messages
// array is already legal — no new field, no migration, A10
// stays stateless).
saveConversation();
persistConversation();
// The Retry button re-targets the (now removed) tail: no brain
// record exists after the drop, so the call simply clears every
// .retry-btn (the re-ask's settle re-appends it on the fresh
// bubble).
markLastRetryable();
closeEditor();
// In-flight re-check (the double-check — earned by the handler
// being ONE synchronous stretch from the guard above to this
// call: there is no await in between, so uiState cannot change;
// the re-check keeps the invariant explicit): a save can never
// start a second turn.
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
// Re-ask (LOCKED): reask skips the user-bubble append + the
// persistence save point 1 (the question is already in the DOM
// and in the record — it is never duplicated), and NO image is
// passed (the phase-49/123 locked A7 rule: a re-ask sends text
// only — the record keeps its image key, the bubble keeps the
// attachment). The promise is returned (the retryLastTurn
// convention) — runTurn never rejects; a failure lands in the
// phase-120 failed-turn path. The composer was never touched
// (the unsent draft survives the edit).
return runTurn(text, { reask: true });
});
if (bubble) bubble.classList.add("is-editing");
body.appendChild(form);
ta.focus();
}
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
* Inline SVG as string constants so the message renderer and the typing
* indicator share exactly the same marks. currentColor lets the CSS theme
@@ -2260,6 +2549,7 @@ function renderStoredMessage(m) {
// instead of the old forced "auto" — noted per the phase-42 task.
if (m.who === "user") {
const wrap = addMessage("user", renderMarkdown(m.text), true);
lastUserWrap = wrap; // phase 159: the LAST restored user bubble wins (the mirror of the brain line below)
// Phase 123 (task 03, TODO L6): the restored record may carry the
// question's attached image — `m.image`, the STORED PATH (A5:
// never base64; a pre-phase / text-only record has no key at all,
@@ -2413,6 +2703,7 @@ function restoreConversation() {
currentChatId = record ? record.chatId : null; // phase 55: the link survives reloads
for (const m of conversation) renderStoredMessage(m);
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
markLastEditable(); // phase 159: the restored last user bubble is editable
}
/* ---------- saved-chats row link (phase 50; auto-saved since phase 55) ----------
@@ -2480,6 +2771,7 @@ async function restoreSavedChatFromUrl() {
conversation = messages; // REPLACES the local conversation (owner-locked)
for (const m of conversation) renderStoredMessage(m);
markLastRetryable(); // parity with the local restore: Retry on the last brain bubble
markLastEditable(); // phase 159: parity with the local restore: Edit on the last user bubble
currentChatId = chatId; // linked: a subsequent Save updates THIS row
saveConversation(); // mirror to localStorage — a plain refresh returns here
// Phase 53 (task 05): the `stale` flag is server-computed (task 03 —
@@ -2887,6 +3179,7 @@ function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = [];
currentChatId = null; // phase 55: unlinked — a fresh row on its first message
lastUserWrap = null; // phase 159: the editable wrap resets with the conversation (buttons die with the list reset below)
if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation
clearStoredConversation();
removeTyping();
@@ -3245,6 +3538,7 @@ function finalizeFailedTurn(
lastBrainWrap = fwrap;
}
markLastRetryable(); // the in-bubble Retry lands on the failed bubble
markLastEditable(); // phase 159: the failed turn's question stays editable (the trim-and-re-ask remedy — the settle re-runs at idle)
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the chat turn —
@@ -3266,12 +3560,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// text-only send, every re-ask — A7: a redo re-sends the text
// only) leaves the bubble and the record byte-identical to
// pre-phase.
addMessage(
const userWrap = addMessage(
"user",
renderMarkdown(text),
true, // reveal my message (owner-kept)
image ? { src: image.src || image.path, alt: image.alt } : null
);
lastUserWrap = userWrap; // phase 159: the newest question bubble is the editable one
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it. The `image`
// key (the stored path) joins the `bor.chat.v1` record only when an
@@ -3647,6 +3942,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
appendRelated(wrap, ev.related); // phase 113 — above the actions row
appendTuneButton(wrap); // every completed brain bubble is tunable
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
markLastEditable(); // phase 159: the done turn's question is editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
// Phase 59: the RAW persisted markdown (never the rendered
// HTML) — exactly the string rememberBrainTurn stores above,
// so a reload (the restore path) offers the identical draft.
@@ -3732,12 +4028,24 @@ async function runTurn(text, { reask = false, image = null } = {}) {
}, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
markLastEditable(); // phase 159: the failed turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
}
} catch (err) {
if (aborted) {
// The 300s guard already took the turn to the error state — its
// own abort surfaces here, and it is never read as a user stop.
// Phase 159 (task 03): the aborted settle must not strand the
// question — the same mark pair as the other non-`done` settles.
// markLastRetryable re-targets (a timed-out turn persists NO
// brain record — the phase-120 exclusion — so the pair re-lands
// the previous turn's target and clears stale buttons), and
// markLastEditable lands Edit on the question NOW (the guard
// bails only on thinking/streaming — the error state passes;
// the finally idle-settle re-runs it — the remove-then-append
// keeps the double pass harmless).
markLastRetryable();
markLastEditable();
} else if (stoppedByUser || err?.name === "AbortError") {
// Phase 48 (owner-locked): the STOP path — no error banner. When
// answer text streamed, keep the partial on screen and persist it
@@ -3767,6 +4075,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
}, leavePartialIndex);
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
markLastRetryable(); // phase 49: Retry on the stopped partial
markLastEditable(); // phase 159: the stopped turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
}
// Pre-token / thinking-only stop: persist NOTHING brain-side
// (phase-20 convention — the question is already saved on send).
@@ -3817,6 +4126,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
cancelStream(res); // the reader lock is released — no unhandled rejection
turnAbort = null; // phase 48: the turn's abort owner is spent
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
// Phase 159 (task 01): the terminal settle (idle) is the ONE mark
// pass that lands the Edit button at rest — the mid-turn paired
// call sites (done / zero-frame / stop / failed funnel) no-op on
// the in-flight guard, so this re-run at idle is what actually
// appends (a New-chat-mid-turn clears the records + the wrap, so
// this is a no-op there by construction).
markLastEditable();
if (stoppedByUser) sendStatus.textContent = "Answer stopped.";
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
// coarse pointer (a phone/tablet) focusing the composer pops the
+110
View File
@@ -1027,6 +1027,37 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.retry-btn svg { width: 14px; height: 14px; display: block; }
.retry-btn:hover { background: var(--brand-soft); color: var(--ink); }
/* Phase 159 (task 01, TODO L3): the "Edit" button — the
* trim-and-re-ask action in the .msg-meta.msg-actions row (phase 130)
* of the NEWEST user bubble (markLastEditable — the phase-49
* single-target discipline, one `who` over). The exact visual family
* of .retry-btn (same pill size/spacing, the global :focus-visible
* ring, >=44px via min-height) so the meta actions read as a set —
* but neutral: ink-soft -> ink on hover (Tune keeps the brand pair).
* The pencil glyph rides currentColor. No per-button auto margin
* (phase 130 — the row's justify right-aligns the cluster). Contrast:
* ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft
* ~15.7:1 — all AA. */
.edit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.edit-btn svg { width: 14px; height: 14px; display: block; }
.edit-btn:hover { background: var(--brand-soft); color: var(--ink); }
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the "Save as doc"
button — the bottom-right action of every completed brain bubble's
meta row (the JS gates: admin + a configured docs repo; this rule
@@ -1193,6 +1224,84 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
font-weight: 600;
}
/* Phase 159 (task 01, TODO L3): the inline EDIT form in the newest
* user bubble — the .tune-form card family (labeled textarea +
* Save/Cancel, both ≥44px; the textarea's maxlength = the 4000 cap,
* single-sourced from MAX_QUESTION_CHARS in app.js). While open the
* rendered .bubble is hidden (.is-editing) so the question appears
* ONCE; Cancel / Escape restore it and return focus to the toggle.
* Save rejection (role=alert) keeps the form open (the note is never
* lost — the openTuneForm failure way). */
.msg.user .bubble.is-editing { display: none; }
.edit-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius-sm);
padding: 0.75rem 0.85rem;
}
.edit-form label {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-soft);
}
.edit-form textarea {
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
resize: vertical;
min-height: 2.6rem;
}
.edit-form-actions { display: flex; gap: 0.5rem; }
.edit-save {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.edit-save:hover:not(:disabled) { background: var(--brand-hover); }
.edit-save:disabled { opacity: 0.6; cursor: wait; }
.edit-cancel {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.edit-cancel:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Inline save rejection (role=alert): err pair ≈9.1:1 — form is kept. */
.edit-error {
margin: 0 0 0 0.25rem;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
/* Header panel above the messages: notes newest-first, per-note delete,
designed empty state. */
.steering-panel {
@@ -5707,6 +5816,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
.tune-btn { min-height: 44px; }
.retry-btn { min-height: 44px; }
.edit-btn { min-height: 44px; }
/* Phase 27: the tuning page squeezes like the other cards — the row
padding tightens; the icon+label actions keep their 44px floor and
the note text ellipsizes (min-width: 0). */
+8
View File
@@ -50,6 +50,14 @@ target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
# The phase-159 task-01 test embeds the FULL byte-pin of app.js's
# ``runTurn`` (32KB, 584 lines — the house byte-pin convention for the
# phase's "NOT touched" contract). The pin must stay byte-identical to
# the JS source, so its long JS comment lines cannot be wrapped — E501
# is ignored for that file only (the test code itself is wrapped).
[tool.ruff.lint.per-file-ignores]
"tests/unit/test_frontend_edit_message.py" = ["E501"]
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "standard"
+622
View File
@@ -0,0 +1,622 @@
"""Phase 159 E2E (Playwright): edit the last question and re-ask it.
Source: ``TODO.md`` L3 — "Need to be able to edit and resend a message.
Got 'Question too long — trim it and re-ask' but I can't trim it
because I can't edit it." Run in isolation (DB must be up:
``podman compose up -d db``):
uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the failure
shape is the deterministic injection in ``tests/e2e/mock_llm.py``
(the phase-67 ``always fail`` trigger). The phase-114 "Question too
long — trim it and re-ask." frame takes the IDENTICAL settle path
(this suite's driver case rides it through the mock's
``ALWAYS_FAIL_TRIGGER`` 500s → retry-budget exhaustion → the terminal
``error`` frame → ``finalizeFailedTurn``): the EXACT detail string is
real-endpoint-only (it is the server's embed-input-too-large
short-circuit — ``app/api/chat.py`` — which the mock's embeddings
endpoint never raises), so the mock's 500 exhaustion (the phase-120
zero-frame shape) is the driver case's stand-in.
The contract under test (phase 159, owner-locked 2026-10-05):
* EXACTLY ONE editable bubble — the most recent user message (the
``markLastRetryable`` single-target discipline, one ``who`` over);
earlier bubbles are never editable.
* The Edit opens an INLINE editor in the bubble (the ``.tune-form``
family — the textarea prefilled with the question,
``maxlength`` = the 4000 cap, Save/Cancel, a ``role=alert`` error
line); Cancel/``Escape`` restore the bubble and return focus to the
toggle.
* Save = REPLACE + TRUNCATE + RE-ASK (the phase-49 redo-in-place rule,
one level up): the edited text replaces the record in place, every
record after it is dropped (DOM + ``bor.chat.v1`` + the saved chat
row), and a fresh answer streams from the edited text with
``runTurn(text, { reask: true })`` — the question is never
duplicated, the composer's unsent draft survives.
* In-flight discipline: no Edit button while a turn runs; the button
lands at the settle.
KB seed (the phase-120 ``test_failed_turn_retry.py`` direct-seed
pattern, the task's ``docs/homelab/kubernetes.md``): ONE fixture
document — the "How is my Kubernetes cluster set up?" questions
FTS-hit it → HIGH → the grounded path (the re-asked turns stream the
grounded ``MOCK_ANSWER_MARKER`` answer).
Test → contract mapping:
1. ``test_edit_last_question_reasks_in_place`` — send, grounded answer
(ONE old brain bubble), the user bubble carries Edit; the editor
prefills the EXACT question; append " in one line" + Save: exactly
ONE user bubble (the edited text), the old answer bubble is gone,
the new answer streams with the marker, ``bor.chat.v1`` holds
``[user(edited), brain]`` with NO duplicate question, and the
auto-saved ``saved_chats`` row matches the localStorage shape key-
for-key (polled — the fire-and-forget PUTs land in order, the
truncated shape is the final state; the DB stores the
schema-normalized shape, the integration ``_expect`` convention).
2. ``test_only_the_last_question_is_editable`` — two turns: exactly
ONE ``.edit-btn`` exists, on the SECOND user bubble; the first has
none (the single-target discipline, owner-locked).
3. ``test_edit_cancel_is_a_no_op`` — open the editor, ``Escape``: the
bubble text, the record, and the answer are unchanged (the
``localStorage`` payload is byte-identical before/after), the
``.edit-form`` is gone, and focus RETURNS to the Edit toggle
(``document.activeElement`` is the ``.edit-btn``).
4. ``test_edit_drops_a_stale_tail_and_survives_refresh`` — after an
edit-save, ``page.reload()``: the edited question + the new answer
restore, the DROPPED answer never reappears (one brain bubble, the
fresh one — the old text is absent), and Edit is back on the
question (the restore's mark pair).
5. ``test_failed_turn_question_is_editable_and_trim_reasks`` — the
driver case: the ``always fail`` trigger settles the turn with the
terminal ``error`` frame + the failed bubble (the phase-120
zero-frame shape — the phase-114 "question too long" frame takes
this identical settle path, its exact detail string is
real-endpoint-only, see the module docstring); Edit exists on the
question BESIDE the failed bubble's Retry; trimming (the trigger
stripped) + Save streams the grounded answer (the failed record is
part of the dropped tail — ``failed: true`` never survives).
6. ``test_edit_is_inert_while_the_turn_runs`` — the ``write a long
answer`` trigger (~8 s): NO ``.edit-btn`` while the turn runs
(the in-flight guard — the button never appears or moves
mid-turn); at the ``done`` settle the button lands.
7. ``test_composer_draft_survives_an_edit`` — negative: a typed,
UNSSENT composer draft survives the edit-save (the save handler
never touches ``input.value`` / ``autoGrow`` /
``updateCharCount`` — the composer keeps its own draft).
"""
from __future__ import annotations
import hashlib
import json
import re
import time
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from pathlib import Path
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document
from e2e.auth_helpers import login
from tests.e2e.mock_llm import (
ALWAYS_FAIL_TRIGGER,
LONG_ANSWER_TRIGGER,
embed_text,
)
REPO = Path(__file__).resolve().parents[2]
# --------------------------------------------------------------------------
# Seed + questions (see the module docstring for the gate notes)
# --------------------------------------------------------------------------
SEED_SOURCE = "docs"
SEED_PATH = "homelab/kubernetes.md"
KUB_CONTENT = (REPO / "tests" / "fixtures" / "docs" / SEED_PATH).read_text()
#: The grounded question (the phase-120 suite's — FTS-hits the seed).
Q = "How is my Kubernetes cluster set up?"
#: Test 2's second turn (the seed's workloads section — FTS-hits too).
Q2 = "Which workloads does my Kubernetes cluster run?"
#: Test 4's edit — the original tokens plus one (grounding preserved —
#: a superset of the seed's hit tokens), DISTINCT from the original so
#: the mock's question echo makes the old vs the new answer
#: distinguishable byte-for-byte.
Q_EDITED = "How is my Kubernetes cluster set up, briefly?"
#: Test 1's edit — append the task's " in one line" to the question.
Q_TEST1_EDITED = "How is my Kubernetes cluster set up? in one line"
#: Test 5's failure question (the phase-67 trigger — every streaming
#: request 500s → the retry-budget exhaustion, the phase-120 shape).
FAIL_Q = f"{Q} {ALWAYS_FAIL_TRIGGER}"
#: Test 6's long answer (the mock's ~8 s pacing — the in-flight window).
LONG_Q = f"{Q} {LONG_ANSWER_TRIGGER}"
#: Test 7's unsent composer draft.
DRAFT = "draft question about backups — do not send yet"
#: The app's terminal LLM-failure copy (app/api/chat.py's LLMError
#: frame) — the error detail the note/banner/record all carry.
ERROR_COPY = "The chat model dropped the connection — try again?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
STORAGE_KEY = "bor.chat.v1"
def _js_const(name: str) -> str:
"""A frontend string constant, read from app.js — the E2E asserts
against the SAME text the page renders (no JS/Python drift)."""
js = (REPO / "frontend" / "assets" / "app.js").read_text(encoding="utf-8")
m = re.search(rf'const {name}\s*=\s*\n?\s*"([^"]*)"', js)
assert m, f"const {name} not found in app.js"
return m.group(1)
FAILED_TURN_TEXT = _js_const("FAILED_TURN_TEXT")
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, the phase-120 pattern)
# --------------------------------------------------------------------------
def _seed(db: Session) -> None:
"""The single fixture document (see the module docstring)."""
md = Document(
source=SEED_SOURCE,
path=SEED_PATH,
full_path=f"/tmp/{SEED_PATH}",
title="Kubernetes",
content=KUB_CONTENT,
content_hash=hashlib.sha256(KUB_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
)
db.add(md)
db.flush()
# One chunk carrying the mock's own embedding → genuine token overlap
# for the grounded questions (the FTS path carries them to HIGH).
db.add(
Chunk(
document_id=md.id,
position=0,
content=KUB_CONTENT,
embedding=embed_text(KUB_CONTENT),
)
)
def _reset_db() -> None:
"""Truncate the KB (plus the prompt-shaping tables), then re-seed.
``steering_notes`` / ``kb_overview`` are truncated too, so the
prompts are byte-stable regardless of leftovers from other suites.
"""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
_seed(db)
db.commit()
@pytest.fixture(autouse=True)
def clean_chats() -> Iterator[None]:
"""The send auto-saves a ``saved_chats`` row per turn (phase 55) —
truncate around every test so the suite starts from (and leaves)
an empty deployment (the phase-80/103 isolation pattern)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
# The auto-save is fire-and-forget (the browser never awaits the
# PUT): a short margin lets the last turn's PUT land server-side
# before the TRUNCATE, so the teardown never 500s an in-flight
# upsert (the "Could not refresh instance" race). This runs AFTER
# the page closes (LIFO finalization), so the PUT is either done
# or aborted — never in flight.
time.sleep(0.75)
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# --------------------------------------------------------------------------
# localStorage reads + the auto-saved row poll + the turn flow
# --------------------------------------------------------------------------
def _stored_raw(page: Page) -> str:
"""The raw ``bor.chat.v1`` payload (the byte-identity check's
unit — a string, not a parsed dict)."""
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
assert raw is not None, "the conversation key must exist in localStorage"
return raw
def _stored(page: Page) -> dict:
"""The persisted ``bor.chat.v1`` conversation (localStorage — the
phase-14 store the restore reads on refresh)."""
return json.loads(_stored_raw(page))
def _poll_saved_messages(
title: str, want: Callable[[list[dict]], bool]
) -> list[dict]:
"""Wait for the auto-saved row's ``messages`` to match *want* (the
phase-55 upserts are fire-and-forget — there is no status line to
wait on; the ``#send-label`` settle only proves the LAST save
point fired, not that its PUT landed). The row's title keeps the
FIRST question on re-Save (title-keep), so the edit's row is the
same row, polled by the original title."""
deadline = time.monotonic() + 15
last: list[dict] | None = None
while time.monotonic() < deadline:
with SessionLocal() as db:
row = db.execute(
text("SELECT messages FROM saved_chats WHERE title = :t"),
{"t": title},
).scalar_one_or_none()
if row is not None:
last = row
if want(row):
return row
time.sleep(0.2)
raise AssertionError(
f"the saved_chats row for {title!r} never settled (last: {last!r})"
)
def _shape_matches(row: list[dict], stored: list[dict]) -> bool:
"""The row's shape matches the localStorage shape key-for-key.
The DB stores the schema-NORMALIZED shape (``ChatMessage``
re-validation — every optional key present as an explicit
``None``, the integration suite's ``_expect`` convention), while
localStorage keeps the RAW client records (a key subset). The
match: every localStorage key equals the row's value, and the row
carries no NON-NULL key the record lacks (no stale metadata,
no missing content)."""
if len(row) != len(stored):
return False
for r, s in zip(row, stored, strict=True):
for k, v in s.items():
if r.get(k) != v:
return False
for k, v in r.items():
if k not in s and v is not None:
return False
return True
def _submit(page: Page, question: str) -> None:
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _assert_settled(page: Page) -> None:
"""The turn has FULLY settled: the ``done`` frame is processed
(save point 2 — the answer record persisted through
``rememberBrainTurn`` — synchronously in the frame handler) BEFORE
the finally's idle state flips the label to Send. The label is
the settle signal, so a localStorage / row read after it is
race-free for the record's shape (the marker can be visible a few
frames before ``done`` — the last deltas carry it)."""
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
def _assert_grounded_settled(page: Page) -> str:
"""The grounded answer streamed + the turn settled: returns the
brain bubble's text (the phase-120 ``_assert_redo_succeeded``
shape, minus the redo's failed-marker checks)."""
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
_assert_settled(page)
return bubble.inner_text()
def _open_editor(page: Page, expected_prefill: str) -> None:
"""Click the Edit toggle (it must exist on the last user bubble)
and pin the textarea's prefill (the record's text, exact)."""
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
page.click(".msg.user .edit-btn")
ta = page.locator(".edit-form textarea")
expect(ta).to_be_visible(timeout=10_000)
expect(ta).to_have_value(expected_prefill)
def _save_editor(page: Page, new_text: str) -> None:
page.fill(".edit-form textarea", new_text)
page.click(".edit-form .edit-save")
# --------------------------------------------------------------------------
# 1 — the flow: send → Edit (prefilled) → trim + Save → the old answer
# is gone, the new one streams, the record + the row are truncated
# --------------------------------------------------------------------------
def test_edit_last_question_reasks_in_place(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_submit(page, Q)
old_answer = _assert_grounded_settled(page)
# The old answer is the ONLY brain bubble (the pre-edit shape).
expect(page.locator(".msg.brain")).to_have_count(1)
_open_editor(page, Q)
_save_editor(page, Q_TEST1_EDITED)
# The old answer bubble is GONE (the truncation removed it); the
# question appears ONCE, edited; the fresh answer streams from the
# edited text.
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble")).to_have_text(Q_TEST1_EDITED)
new_answer = _assert_grounded_settled(page)
expect(page.locator(".msg.brain")).to_have_count(1)
assert new_answer != old_answer, "the old answer is gone — the new one streams"
# The record (localStorage): [user(edited), brain] — the edited
# text replaces the record in place, the tail is dropped, and the
# question is never DUPLICATED (no record carries the old text;
# the new answer merely echoes the edited question).
stored = _stored(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][0]["text"] == Q_TEST1_EDITED
assert Q not in [m["text"] for m in stored["messages"]], (
"no duplicate question — the old text is gone from the record"
)
assert MOCK_ANSWER_MARKER in stored["messages"][1]["text"]
# The auto-saved row (polled — the title keeps the FIRST question
# on re-Save): the truncated shape [user(edited), brain], matching
# the localStorage shape key-for-key (the DB is the durable boundary).
row = _poll_saved_messages(
Q, lambda msgs: len(msgs) == 2 and msgs[0].get("text") == Q_TEST1_EDITED
)
assert _shape_matches(row, stored["messages"]), (
"the saved_chats row matches the localStorage shape — the "
"truncated conversation persisted (no stale tail, no duplicate)"
)
# --------------------------------------------------------------------------
# 2 — the single-target discipline: exactly ONE editable bubble, the
# NEWEST question (owner-locked 2026-10-05)
# --------------------------------------------------------------------------
def test_only_the_last_question_is_editable(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_submit(page, Q)
_assert_grounded_settled(page)
_submit(page, Q2)
_assert_grounded_settled(page)
# Exactly ONE .edit-btn in the whole DOM, on the SECOND user
# bubble; the first (the older question) carries none — the
# remove-then-append single target, one `who` over.
expect(page.locator(".edit-btn")).to_have_count(1)
expect(page.locator(".msg.user").nth(1).locator(".edit-btn")).to_have_count(1)
expect(page.locator(".msg.user").nth(0).locator(".edit-btn")).to_have_count(0)
# --------------------------------------------------------------------------
# 3 — negative: Cancel (Escape) is a no-op — the bubble, the record,
# and the answer are unchanged; focus returns to the toggle
# --------------------------------------------------------------------------
def test_edit_cancel_is_a_no_op(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_submit(page, Q)
answer = _assert_grounded_settled(page)
question_text = page.locator(".msg.user .bubble").inner_text()
before = _stored_raw(page)
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
page.click(".msg.user .edit-btn")
ta = page.locator(".edit-form textarea")
expect(ta).to_be_visible(timeout=10_000)
# A real edit attempt (so the no-op is meaningful): the textarea is
# changed, then Escape cancels.
page.fill(".edit-form textarea", "something entirely different")
ta.press("Escape")
# The form is gone; the bubble + the answer are byte-identical; the
# localStorage payload is byte-identical (nothing persisted —
# cancel never touches the record).
expect(page.locator(".edit-form")).to_have_count(0)
expect(page.locator(".msg.user .bubble")).to_have_text(question_text)
assert page.locator(".msg.brain .bubble").last.inner_text() == answer
assert _stored_raw(page) == before, (
"cancel is a no-op — the localStorage payload is byte-identical"
)
# Focus RETURNED to the Edit toggle (the openTuneForm cancel rule).
is_toggle = page.evaluate(
"() => !!(document.activeElement && document.activeElement.classList"
" && document.activeElement.classList.contains('edit-btn'))"
)
assert is_toggle, "focus must return to the Edit toggle on Escape"
# --------------------------------------------------------------------------
# 4 — the truncated shape survives a refresh: the edited question + the
# new answer restore; the dropped answer never reappears; Edit is
# back on the question
# --------------------------------------------------------------------------
def test_edit_drops_a_stale_tail_and_survives_refresh(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_submit(page, Q)
old_answer = _assert_grounded_settled(page)
_open_editor(page, Q)
_save_editor(page, Q_EDITED)
new_answer = _assert_grounded_settled(page)
assert new_answer != old_answer
# The refresh: the restore reads the truncated localStorage shape —
# the edited question + the fresh answer.
page.reload()
expect(page.locator("#messages > .msg")).to_have_count(2)
expect(page.locator(".msg.user .bubble")).to_have_text(Q_EDITED)
restored = page.locator(".msg.brain .bubble").last
expect(restored).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
assert page.locator(".msg.brain .bubble").last.inner_text() != old_answer, (
"the DROPPED answer never reappears — the restore shows the "
"fresh one (no stale tail resurrected)"
)
# Edit is back on the restored question (the restore's mark pair —
# the mirror of the live settle).
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
# The restored record is the truncated shape (the restore is
# lossless: the edited text, the fresh answer, no duplicate).
stored = _stored(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][0]["text"] == Q_EDITED
assert Q not in [m["text"] for m in stored["messages"]]
# --------------------------------------------------------------------------
# 5 — the driver case (TODO.md L3): a failed turn's question is
# editable; trimming + Save re-asks successfully (the phase-114
# "Question too long" frame takes this identical settle path — its
# exact detail string is real-endpoint-only, see the module
# docstring)
# --------------------------------------------------------------------------
def test_failed_turn_question_is_editable_and_trim_reasks(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_submit(page, FAIL_Q)
# The ``always fail`` trigger: every streaming request 500s → the
# retry-budget exhaustion → the terminal error frame — the
# phase-120 zero-frame shape (banner + the failed bubble).
banner = page.locator("#kb-banner")
expect(banner).to_have_attribute("role", "alert", timeout=60_000)
expect(banner).to_contain_text(ERROR_COPY)
expect(page.locator("#banner-retry")).to_be_visible(timeout=10_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
brain = page.locator(".msg.brain")
expect(brain).to_have_count(1)
expect(brain.locator(".bubble")).to_have_text(FAILED_TURN_TEXT)
note = brain.locator(".failed-note")
expect(note).to_have_count(1)
expect(note).to_contain_text(ERROR_COPY)
expect(brain.locator(".retry-btn")).to_have_count(1)
# THE remedy: Edit exists on the question BESIDE the failed
# bubble's Retry (the guard bails on thinking/streaming only — the
# error state passes; the failed record is the stale tail the
# edit-save truncates).
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
_open_editor(page, FAIL_Q)
_save_editor(page, Q) # the trigger stripped out
# The trimmed question re-asks successfully: the grounded answer
# streams in place of the failed bubble (the failed record is
# dropped — never kept, never rewritten).
_assert_grounded_settled(page)
expect(page.locator(".msg.user .bubble")).to_have_text(Q)
expect(page.locator(".msg.brain")).to_have_count(1)
expect(page.locator(".failed-note")).to_have_count(0)
stored = _stored(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][0]["text"] == Q
assert stored["messages"][-1].get("failed") in (None, False), (
"the failed record is part of the dropped tail — it never "
"survives the edit"
)
assert FAILED_TURN_TEXT not in stored["messages"][-1]["text"]
# --------------------------------------------------------------------------
# 6 — in-flight discipline: no Edit button while a turn runs; the
# button lands at the settle
# --------------------------------------------------------------------------
def test_edit_is_inert_while_the_turn_runs(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# The ~8 s long answer — a comfortable in-flight window (the
# mock's 12-char / 0.02 s pacing).
_submit(page, LONG_Q)
# The turn is in flight (the button IS the Stop control —
# phase 48). While it runs the Edit button never appears (the
# in-flight guard — the button never appears or moves mid-turn).
expect(page.locator("#send-label")).to_have_text("Stop", timeout=30_000)
expect(page.locator(".edit-btn")).to_have_count(0)
# At the ``done`` settle the button lands (the idle-settle mark
# pass — the at-rest landing).
expect(page.locator("#send-label")).to_have_text("Send", timeout=60_000)
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
# --------------------------------------------------------------------------
# 7 — negative: the composer's unsent draft survives an edit (the save
# handler never touches the composer — task 02's work item)
# --------------------------------------------------------------------------
def test_composer_draft_survives_an_edit(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_submit(page, Q)
_assert_grounded_settled(page)
# The unsent draft (typed AFTER the settle — the composer keeps its
# own draft, untouched by the edit below).
page.fill("#message-input", DRAFT)
_open_editor(page, Q)
_save_editor(page, Q_EDITED)
_assert_grounded_settled(page)
# The draft is STILL in the composer (the save handler never writes
# input.value / autoGrow / updateCharCount — the edit is the
# in-bubble editor's job, the composer's is separate).
assert page.locator("#message-input").input_value() == DRAFT
+164
View File
@@ -1036,6 +1036,170 @@ def test_shared_serve_includes_the_brain_answer_image_ref(
assert "answer_image" not in messages[0]
# ---------------------------------------------------------------------------
# Phase 159 (task 02): edit-and-resend — the SAVE shape. The client
# edits the last question: the record is replaced in place, EVERY
# record after it is dropped, and the edited question becomes the LAST
# record. The auto-save then PUTs that shortened list. This pins the
# claim "no schema change is needed": a shortened ``messages`` array
# with the edited last user record is ALREADY legal on the existing
# ``SavedChatUpdate`` path (no new field, no migration — A10 stays
# stateless; the ≤4000 / ≤32 000 caps already cover the edited text).
# ---------------------------------------------------------------------------
#: The driver case's question (the phase-114 "Question too long" turn)
#: — long, near the 4 000-char ``ChatRequest.message`` cap, so the
#: edit trims it down to a short re-askable question.
LONG_QUESTION = "How do I prune deleted docs " + "and rotate the certificates " * 90
TRIMMED_QUESTION = "How do I prune deleted docs?"
def test_put_edited_truncated_tail_round_trips_byte_identical(
admin_client: TestClient, db: Session
) -> None:
"""The phase-159 client shape round-trips byte-identically through
the EXISTING PUT path: the conversation was
[user Q1, brain A1, user Q2-long, brain A2]; the owner trims Q2
→ Q2', and the auto-save PUTs [user Q1, brain A1, user Q2'] —
the tail (A2) is dropped, the last record is the EDITED user
record, and the array is SHORTER. The PUT is a plain re-Save
(no new field): 200 + ``message_count`` drops to 3, the stored
JSONB + the admin GET + the 200 body all round-trip the edited
record + the truncated list byte-identically (``_expect``'s full
bor.chat.v1 shape), and the title keeps the FIRST question
(title-keep on re-Save — the edited second question never
re-titles the row)."""
q1 = "What is my Traefik port?"
a1 = {"who": "brain", "text": "8080."}
created = admin_client.post(
"/api/chats",
json={
"messages": [
_user(q1),
a1,
_user(LONG_QUESTION),
{"who": "brain", "text": "A long answer."},
]
},
).json()
assert created["message_count"] == 4
edited = [_user(q1), a1, _user(TRIMMED_QUESTION)]
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited})
assert r.status_code == 200, r.text # a shortened array is already legal
body = r.json()
assert body["message_count"] == 3, "the tail (A2) is dropped — 2 + the edit"
assert body["title"] == q1 # title-keep: the edit re-titles nothing
assert body["messages"] == _expect(edited)
# The RAW stored JSONB (the DB is the durable boundary — the
# phase-123/124 precedent): the edited last user record + the
# truncated list, byte-for-byte, explicit nulls included.
raw = db.execute(
text("SELECT messages FROM saved_chats WHERE id = :id"),
{"id": created["id"]},
).scalar_one()
assert raw == _expect(edited)
# The admin GET round-trips the same shape (the restore path reads
# from this surface — a refresh shows the edited question and no
# stale tail).
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["messages"] == _expect(edited)
def test_put_edited_record_keeps_the_user_image_key(
admin_client: TestClient,
) -> None:
"""The image survives the edit (the phase-49/123 A7 rule's record
side): the record KEEPS its ``image`` key when the question is
replaced in place + the tail dropped — the re-ask sends text
only, but the saved shape (and the bubble's attachment) carry the
attachment through the round-trip. The key is present-and-carrying
(the phase-123 shape — ``_expect`` covers the base keys, the
optional ``image`` key is asserted on top, the phase-123
precedent)."""
edited = [
_user_with_image("What is in this image?"), # edited text, image kept
# (the tail — a brain answer — is dropped by the edit)
]
created = admin_client.post(
"/api/chats",
json={
"messages": [
_user_with_image("What is in this picture?"),
{"who": "brain", "text": "A cat, by the look of it."},
]
},
).json()
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited})
assert r.status_code == 200, r.text
msgs = r.json()["messages"]
assert len(msgs) == 1, "the tail is dropped — one record (the edit)"
expected = _expect(edited)[0]
expected["image"] = IMAGE_PATH
assert msgs[0] == expected, (
"the attachment key survives the in-place edit (text-only "
"re-ask, attachment kept)"
)
def test_shared_serve_carries_the_edited_truncated_shape(
admin_client: TestClient,
) -> None:
"""The phase-159 claim pinned on the PUBLIC surface: the
edited/truncated conversation needs NO schema change on the shared
serve either. The conversation is [user Q1, brain A1, user Q2,
brain A2], shared BEFORE the edit (the token predates the shape
change), then the owner trims Q2 → Q2' and the auto-save PUTs the
post-edit shape [user Q1, brain A1, user Q2'] (the tail dropped,
the array shorter, the last record the edited user record). The
anonymous ``GET /api/shared/<token>`` still serves exactly the
public shape (``title`` + ``messages`` — no id, no timestamps, no
token) with the CURRENT row content: the edited record + the
truncated list, byte-identical (the read is a point-in-time read
of the live row, so the pre-share token rides the post-edit shape
— the shared page renders the edited question, no stale tail, no
new field)."""
q1 = "What is my Traefik port?"
a1 = {"who": "brain", "text": "8080."}
created = admin_client.post(
"/api/chats",
json={
"messages": [
_user(q1),
a1,
_user(LONG_QUESTION),
{"who": "brain", "text": "A long answer."},
]
},
).json()
share_url = _share(admin_client, created["id"])["share_url"]
# The edit + the auto-save's PUT (the post-edit shape — the same
# shape as the admin-surface pin above, now served publicly).
edited = [_user(q1), a1, _user(TRIMMED_QUESTION)]
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited})
assert r.status_code == 200, r.text
# The public read: the token (issued BEFORE the shape change) serves
# the CURRENT row — the edited user record as the LAST message,
# the tail gone, the array shorter, the byte-identical shape.
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
got = anon.get(f"/api{share_url}")
assert got.status_code == 200, got.text
body = got.json()
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
assert body["title"] == q1 # title-keep: the edit re-titles nothing
assert len(body["messages"]) == 3, "the tail (A2) is dropped — 2 + the edit"
assert body["messages"] == _expect(edited), (
"the public surface serves the edited/truncated shape "
"byte-identically — no schema change, no stale tail"
)
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
admin_client: TestClient,
) -> None:
+12 -3
View File
@@ -66,9 +66,18 @@ def test_typing_elapsed_hint_is_built_with_createelement_and_textcontent() -> No
)
# No innerHTML on the typing bubble — the skeleton template
# (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML
# write, and it must not gain a second one.
assert js.count("bubble.innerHTML") == 0, (
"the clock must never assign innerHTML to the typing bubble"
# write of the typing indicator, and the clock must not gain a
# second one (the typing-clock context: addTyping / startThinking
# Clock / stopThinkingClock / removeTyping — the phase-159
# openEditor re-render of a USER bubble's .bubble is a different
# context and does not count).
for name in ("function addTyping", "function startThinkingClock",
"function stopThinkingClock", "function removeTyping"):
f = js.find(name)
assert f != -1, f"{name} must exist"
b = js[f : js.find("\n}\n", f)]
assert "bubble.innerHTML" not in b, (
f"{name} must never assign innerHTML to the typing bubble"
)
+11 -5
View File
@@ -365,10 +365,13 @@ def test_stop_finalize_appends_actions_last() -> None:
def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
"""The phase-120 failure funnel (finalizeFailedTurn): the Failed
note is appended and the funnel ENDS with markLastRetryable — the
note + Retry in the failed bubble's actions row (the partial
note is appended and the funnel ENDS with markLastRetryable
immediately followed by markLastEditable (phase 159 task 01 —
the failed turn's question is editable too) — the note + Retry
+ Edit in the failed bubble's actions row (the partial
keeps its streamed text; the zero-frame shape gets a plain
FAILED_TURN_TEXT bubble). Both shapes end retryable."""
FAILED_TURN_TEXT bubble). Both shapes end retryable +
editable."""
js = _js()
header = (
"function finalizeFailedTurn(\n"
@@ -382,10 +385,13 @@ def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
)
lines = [line for line in body.splitlines() if line.strip()]
assert lines[-1].strip() == "}" and lines[-2].strip().startswith(
"markLastEditable();"
) and lines[-3].strip().startswith(
"markLastRetryable();"
), (
"the funnel still ends with the markLastRetryable() call — the "
"Retry button lands on the failed bubble's actions row"
"the funnel ends with the markLastRetryable() + "
"markLastEditable() pair — the Retry + Edit buttons land on "
"the failed bubble's actions row"
)
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -175,14 +175,21 @@ def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None:
def test_finalize_failed_turn_ends_retryable() -> None:
"""The funnel ENDS with ``markLastRetryable()`` (the last statement
— a trailing comment is fine) — the in-bubble Retry button lands
on the failed bubble (the last brain wrap)."""
"""The funnel ENDS with ``markLastRetryable()`` immediately
followed by ``markLastEditable()`` (phase 159 task 01 — the
failed turn's question is editable too: the trim-and-re-ask
remedy) — the in-bubble Retry + Edit buttons land on the failed
bubble (the last brain wrap / the last user wrap)."""
js = _js()
body = _fn_body(js, "finalizeFailedTurn").rstrip()
last_line = body.splitlines()[-1].strip()
assert last_line.startswith("markLastRetryable();"), (
"finalizeFailedTurn must end with the markLastRetryable() call"
lines = [line for line in body.splitlines() if line.strip()]
assert lines[-1].strip().startswith("markLastEditable();"), (
"finalizeFailedTurn must end with the markLastEditable() call "
"(phase 159 — the failed turn's question stays editable)"
)
assert lines[-2].strip().startswith("markLastRetryable();"), (
"the markLastEditable() call must sit immediately after "
"markLastRetryable() (the paired settle)"
)
+16 -2
View File
@@ -319,8 +319,22 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
assert "stopped: true" in branch, "the owner-locked optional marker"
assert "thinking: thinkingAcc || undefined" in branch
assert "tools: toolAcc.length ? toolAcc : undefined" in branch
# The confirmation rides the single settle in the finally.
finally_block = js[finally_idx : finally_idx + 900]
# The confirmation rides the single settle in the finally —
# brace-matched from the finally's opening brace, so the
# phase-159 settle additions (the markLastEditable pass + its
# comment) cannot push the line out of a fixed window.
finally_brace = js.index("{", finally_idx)
depth = 0
finally_end = finally_brace
for k in range(finally_brace, len(js)):
if js[k] == "{":
depth += 1
elif js[k] == "}":
depth -= 1
if depth == 0:
finally_end = k
break
finally_block = js[finally_idx : finally_end + 1]
assert 'if (stoppedByUser) sendStatus.textContent = "Answer stopped."' in finally_block
+16 -4
View File
@@ -27,13 +27,25 @@ def test_leave_partial_index_is_a_module_turn_local() -> None:
"""`leavePartialIndex` is a module-scope turn local (like
persistedOnLeave): declared -1, reset at the top of runTurn, and
written ONLY by the pagehide handler (the one `= conversation.length
- 1` assignment). No other code may move the correlation."""
- 1` assignment) — plus the phase-159 (task 02) truncation reset
(the SANCTIONED third write: the edit's Save clears it to -1 WHEN
IT POINTED PAST editIdx — a partial saved from the dropped tail
must not be re-persisted into the new shape; the settle's identity
guard would otherwise fall back to an append; the next runTurn
also resets it turn-locally regardless). No other code may move
the correlation."""
js = _js()
assert "let leavePartialIndex = -1" in js, "module-scope declaration"
# The two -1 writes are the declaration and the runTurn reset.
assert js.count("leavePartialIndex = -1;") == 2, (
"only the declaration and the runTurn reset write -1"
# The -1 writes: the declaration, the runTurn reset, and the
# phase-159 truncation reset (the sanctioned third).
assert js.count("leavePartialIndex = -1;") == 3, (
"only the declaration, the runTurn reset, and the phase-159 "
"truncation reset write -1"
)
# The phase-159 (task 02) truncation reset is GATED: it fires only
# when the pointer pointed past the edited record (a partial on or
# before the edited record is untouched).
assert "if (leavePartialIndex > editIdx) leavePartialIndex = -1;" in js
# The one and only index write is the pagehide handler's.
assert js.count("leavePartialIndex = conversation.length - 1") == 1, (
"only the pagehide handler records the partial's index"