Skip to content

fix(chatgpt-review): detect completion by content, not DOM element count - #612

Merged
BorisTyshkevich merged 1 commit into
mainfrom
fix/chatgpt-review-dom-pruning
Aug 5, 2026
Merged

fix(chatgpt-review): detect completion by content, not DOM element count#612
BorisTyshkevich merged 1 commit into
mainfrom
fix/chatgpt-review-dom-pruning

Conversation

@BorisTyshkevich

Copy link
Copy Markdown
Collaborator

Summary

Found live while running /ship against a long ChatGPT review conversation (4+ rounds).

  • ChatGPT virtualizes/prunes older turns out of the DOM in long conversations. browser.mjs's completion and recovery-vs-fresh-submission decisions were both built on assistantCount(page) > before, a raw element count assuming the DOM only ever grows. Once pruning kicks in, that count plateaus (or drops), which:
    • made waitForCompletion wait out the full --timeout and throw timed_out with an empty response_text, discarding a real, complete answer that was sitting there the whole time;
    • made review()'s recovery-vs-fresh decision go the wrong way and submit a duplicate message into the conversation instead of recovering the existing one. Confirmed live: at least one redundant resubmission generated unwatched, and the same plan file got uploaded 9 times under one literal name, which is why ChatGPT's own upload UI started collision-renaming it (plan-590(9).md).

Fix

Track a SHA-256 fingerprint of the current last assistant message's text instead of a count — content identity, not DOM position, so it's immune to how many turns happen to be mounted.

  • review()'s recovery decision now compares the live tail's fingerprint against session.lastResponseFingerprint (persisted on every successful pass).
  • waitForCompletion's before is now either the pre-submission baseline text (fresh submission: accept only a tail that differs from it) or null (recovery: accept whatever is already there).
  • Each pass's upload is now named with the real pass number (plan-590-pass4.md) via a same-content temp copy, rather than re-uploading the plan/diff file's own literal path (which must never move — it's the review-session identity) every time.
  • state.mjs's session record gains lastResponseFingerprint alongside passCount.
  • Removed the now-fully-unused assistantCount().

Test plan

  • Added two tests simulating a pruned DOM (the assistant locator always returns exactly one element, with different content across submit/recovery) proving both paths detect the new response correctly regardless of mount count.
  • Updated the plan-mode upload test for the new pass-numbered filename.
  • node --test tests/*.test.mjs — 30/30 pass.
  • Verified live against the actual broken session from tonight's run: invoking the fixed script with --session <handle> correctly recovered a real, complete, previously-stuck review (status: "completed", proper trailing VERDICT: REVISE line, passCount and lastResponseFingerprint persisted correctly).

🤖 Generated with Claude Code

ChatGPT virtualizes/prunes older turns out of the DOM in long
conversations (confirmed live: only the last two rendered turns stay
mounted, e.g. conversation-turn-6 and conversation-turn-8 — earlier
ones are gone). browser.mjs's completion and recovery-vs-fresh-submit
decisions were both built on assistantCount(page) > before, a raw
element count that assumed the DOM only ever grows. Once pruning
kicks in for a sufficiently long conversation, that count plateaus
(or even drops), which silently broke two things at once:

- waitForCompletion's `count > before` check never became true, so
  it waited out the full --timeout and threw 'timed_out' with an
  EMPTY response_text — discarding a real, complete answer that was
  sitting there the whole time.
- review()'s recovery-vs-fresh-submission decision
  (`existingCount > recordedPasses`) could go the wrong way and
  submit a brand new duplicate message into the conversation instead
  of recovering the one already there. Confirmed live: at least one
  redundant resubmission went out and generated unwatched, and the
  plan file got uploaded 9 times under the same name, which is why
  ChatGPT's own upload UI started collision-renaming it
  (plan-590(9).md).

Fix: track a SHA-256 fingerprint of the current LAST assistant
message's text instead of a count.
`review()` now decides "is there an uncollected response" by
comparing the live tail's fingerprint against `session.
lastResponseFingerprint` (persisted on every successful pass) —
content identity, not position. `waitForCompletion`'s `before` is now
either the pre-submission baseline text (fresh submission: accept
only a tail that differs from it) or `null` (recovery: accept
whatever is already there). Both are immune to how many turns are
currently mounted.

Also names each pass's upload with the real pass number
(plan-590-pass4.md) instead of re-uploading the plan/diff file's own
literal path every time — that path is the review-session identity
and must never move, so a same-content, differently-named temp copy
is uploaded instead. Fixes the ChatGPT-side collision-rename
confusion above and, as a side effect, makes it easy to see in the
ChatGPT UI which upload belongs to which pass.

state.mjs's session record gains `lastResponseFingerprint` alongside
`passCount`. Removed the now-fully-unused `assistantCount()`.

Added two tests simulating a pruned DOM (assistant locator always
returns exactly one element, with different content across
submit/recovery) proving both paths detect the new response
correctly regardless of how many turns are actually mounted.
30/30 tests pass.
@BorisTyshkevich
BorisTyshkevich merged commit 2fb6263 into main Aug 5, 2026
8 checks passed
@BorisTyshkevich
BorisTyshkevich deleted the fix/chatgpt-review-dom-pruning branch August 6, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant