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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user