phase: 159_edit_and_resend_message
Build and Push Containers / build-and-push-app (push) Successful in 1m39s
Build and Push Containers / build-and-push-db (push) Successful in 15s

All completion criteria verified. Final report:

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

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

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

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

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

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

**Next pending phase:** `160_tool_budget_telemetry`
This commit is contained in:
2026-10-05 19:07:33 -04:00
parent 4d7d0f6297
commit cb8767512a
36 changed files with 4009 additions and 22 deletions
+164
View File
@@ -1036,6 +1036,170 @@ def test_shared_serve_includes_the_brain_answer_image_ref(
assert "answer_image" not in messages[0]
# ---------------------------------------------------------------------------
# Phase 159 (task 02): edit-and-resend — the SAVE shape. The client
# edits the last question: the record is replaced in place, EVERY
# record after it is dropped, and the edited question becomes the LAST
# record. The auto-save then PUTs that shortened list. This pins the
# claim "no schema change is needed": a shortened ``messages`` array
# with the edited last user record is ALREADY legal on the existing
# ``SavedChatUpdate`` path (no new field, no migration — A10 stays
# stateless; the ≤4000 / ≤32 000 caps already cover the edited text).
# ---------------------------------------------------------------------------
#: The driver case's question (the phase-114 "Question too long" turn)
#: — long, near the 4 000-char ``ChatRequest.message`` cap, so the
#: edit trims it down to a short re-askable question.
LONG_QUESTION = "How do I prune deleted docs " + "and rotate the certificates " * 90
TRIMMED_QUESTION = "How do I prune deleted docs?"
def test_put_edited_truncated_tail_round_trips_byte_identical(
admin_client: TestClient, db: Session
) -> None:
"""The phase-159 client shape round-trips byte-identically through
the EXISTING PUT path: the conversation was
[user Q1, brain A1, user Q2-long, brain A2]; the owner trims Q2
→ Q2', and the auto-save PUTs [user Q1, brain A1, user Q2'] —
the tail (A2) is dropped, the last record is the EDITED user
record, and the array is SHORTER. The PUT is a plain re-Save
(no new field): 200 + ``message_count`` drops to 3, the stored
JSONB + the admin GET + the 200 body all round-trip the edited
record + the truncated list byte-identically (``_expect``'s full
bor.chat.v1 shape), and the title keeps the FIRST question
(title-keep on re-Save — the edited second question never
re-titles the row)."""
q1 = "What is my Traefik port?"
a1 = {"who": "brain", "text": "8080."}
created = admin_client.post(
"/api/chats",
json={
"messages": [
_user(q1),
a1,
_user(LONG_QUESTION),
{"who": "brain", "text": "A long answer."},
]
},
).json()
assert created["message_count"] == 4
edited = [_user(q1), a1, _user(TRIMMED_QUESTION)]
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited})
assert r.status_code == 200, r.text # a shortened array is already legal
body = r.json()
assert body["message_count"] == 3, "the tail (A2) is dropped — 2 + the edit"
assert body["title"] == q1 # title-keep: the edit re-titles nothing
assert body["messages"] == _expect(edited)
# The RAW stored JSONB (the DB is the durable boundary — the
# phase-123/124 precedent): the edited last user record + the
# truncated list, byte-for-byte, explicit nulls included.
raw = db.execute(
text("SELECT messages FROM saved_chats WHERE id = :id"),
{"id": created["id"]},
).scalar_one()
assert raw == _expect(edited)
# The admin GET round-trips the same shape (the restore path reads
# from this surface — a refresh shows the edited question and no
# stale tail).
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["messages"] == _expect(edited)
def test_put_edited_record_keeps_the_user_image_key(
admin_client: TestClient,
) -> None:
"""The image survives the edit (the phase-49/123 A7 rule's record
side): the record KEEPS its ``image`` key when the question is
replaced in place + the tail dropped — the re-ask sends text
only, but the saved shape (and the bubble's attachment) carry the
attachment through the round-trip. The key is present-and-carrying
(the phase-123 shape — ``_expect`` covers the base keys, the
optional ``image`` key is asserted on top, the phase-123
precedent)."""
edited = [
_user_with_image("What is in this image?"), # edited text, image kept
# (the tail — a brain answer — is dropped by the edit)
]
created = admin_client.post(
"/api/chats",
json={
"messages": [
_user_with_image("What is in this picture?"),
{"who": "brain", "text": "A cat, by the look of it."},
]
},
).json()
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited})
assert r.status_code == 200, r.text
msgs = r.json()["messages"]
assert len(msgs) == 1, "the tail is dropped — one record (the edit)"
expected = _expect(edited)[0]
expected["image"] = IMAGE_PATH
assert msgs[0] == expected, (
"the attachment key survives the in-place edit (text-only "
"re-ask, attachment kept)"
)
def test_shared_serve_carries_the_edited_truncated_shape(
admin_client: TestClient,
) -> None:
"""The phase-159 claim pinned on the PUBLIC surface: the
edited/truncated conversation needs NO schema change on the shared
serve either. The conversation is [user Q1, brain A1, user Q2,
brain A2], shared BEFORE the edit (the token predates the shape
change), then the owner trims Q2 → Q2' and the auto-save PUTs the
post-edit shape [user Q1, brain A1, user Q2'] (the tail dropped,
the array shorter, the last record the edited user record). The
anonymous ``GET /api/shared/<token>`` still serves exactly the
public shape (``title`` + ``messages`` — no id, no timestamps, no
token) with the CURRENT row content: the edited record + the
truncated list, byte-identical (the read is a point-in-time read
of the live row, so the pre-share token rides the post-edit shape
— the shared page renders the edited question, no stale tail, no
new field)."""
q1 = "What is my Traefik port?"
a1 = {"who": "brain", "text": "8080."}
created = admin_client.post(
"/api/chats",
json={
"messages": [
_user(q1),
a1,
_user(LONG_QUESTION),
{"who": "brain", "text": "A long answer."},
]
},
).json()
share_url = _share(admin_client, created["id"])["share_url"]
# The edit + the auto-save's PUT (the post-edit shape — the same
# shape as the admin-surface pin above, now served publicly).
edited = [_user(q1), a1, _user(TRIMMED_QUESTION)]
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited})
assert r.status_code == 200, r.text
# The public read: the token (issued BEFORE the shape change) serves
# the CURRENT row — the edited user record as the LAST message,
# the tail gone, the array shorter, the byte-identical shape.
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
got = anon.get(f"/api{share_url}")
assert got.status_code == 200, got.text
body = got.json()
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
assert body["title"] == q1 # title-keep: the edit re-titles nothing
assert len(body["messages"]) == 3, "the tail (A2) is dropped — 2 + the edit"
assert body["messages"] == _expect(edited), (
"the public surface serves the edited/truncated shape "
"byte-identically — no schema change, no stale tail"
)
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
admin_client: TestClient,
) -> None: