phase: 159_edit_and_resend_message
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`
This commit is contained in:
@@ -66,10 +66,19 @@ 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"
|
||||
)
|
||||
|
||||
|
||||
def test_typing_elapsed_hint_lives_in_start_thinking_clock() -> None:
|
||||
|
||||
@@ -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