fix(chatgpt-review): detect completion by content, not DOM element count - #612
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Found live while running
/shipagainst a long ChatGPT review conversation (4+ rounds).browser.mjs's completion and recovery-vs-fresh-submission decisions were both built onassistantCount(page) > before, a raw element count assuming the DOM only ever grows. Once pruning kicks in, that count plateaus (or drops), which:waitForCompletionwait out the full--timeoutand throwtimed_outwith an emptyresponse_text, discarding a real, complete answer that was sitting there the whole time;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 againstsession.lastResponseFingerprint(persisted on every successful pass).waitForCompletion'sbeforeis now either the pre-submission baseline text (fresh submission: accept only a tail that differs from it) ornull(recovery: accept whatever is already there).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 gainslastResponseFingerprintalongsidepassCount.assistantCount().Test plan
node --test tests/*.test.mjs— 30/30 pass.--session <handle>correctly recovered a real, complete, previously-stuck review (status: "completed", proper trailingVERDICT: REVISEline,passCountandlastResponseFingerprintpersisted correctly).🤖 Generated with Claude Code