Compare commits
2
Commits
aa297ea9b1
...
cb8767512a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb8767512a | ||
|
|
4d7d0f6297 |
@@ -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/`
|
||||
+22
@@ -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`
|
||||
+122
@@ -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
|
||||
+22
@@ -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)
|
||||
+122
@@ -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
|
||||
+8
@@ -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`.
|
||||
+122
@@ -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
|
||||
+18
@@ -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`
|
||||
+122
@@ -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
|
||||
+17
@@ -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`
|
||||
+122
@@ -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
|
||||
+19
@@ -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.
|
||||
+122
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user