Findings captured from live DOM/API probing during the Phase-2 and
composer-redesign work (Jun 2026). These describe ChatGPT's internal
structure as observed at that time; ChatGPT changes frequently, so treat
these as a starting map, not a contract. The doctor command
(W2A_DIAGNOSE=1) is the supported way to re-derive this kind of
evidence going forward — this document captures the knowledge the
one-off probes produced, not the probes themselves.
A reasoning-model turn moves through distinct DOM states. Polled at 0.5s granularity from send-click to settle:
| Signal | During reasoning | During answer stream | After settle |
|---|---|---|---|
.result-thinking on msg |
present | present (lingers) | present (collapsed "Thought process") |
.result-streaming cursor |
absent | present | absent |
.markdown textContent |
empty | empty (lags) | fills at settle |
innerText |
"Thinking..." / reasoning text | answer text + label | full answer + "Thought for N seconds" |
action button ([data-testid*="copy"] etc.) |
absent | absent | appears |
child_count / html_len |
grows | grows | stable |
Two traps these findings exposed (both now fixed in cdp_driver.py):
is_thinkingpinned forever. The naive checks!!last.querySelector('.result-thinking') || /thinking/i.test(innerText)stay true after completion:.result-thinkinglingers as a collapsed section, and/thinking/imatches the persistent "Thought for N seconds" summary label. The fix:is_thinking = !has_action && !!last.querySelector('.result-thinking')— the action button marks a finished turn.- Empty
.markdownduring streaming..markdowntextContent is empty until the turn settles; reading it as the stream source produces empty responses. The fix: stream frominnerText(populated during streaming, with the reasoning label stripped), preferring.markdownonly when non-empty.
Earlier heuristics each failed:
!stopBtn && hasContent— Stop button absent at first poll buthtml_len > 50(wrapper) → false "done" immediately.generation_started && !is_generating— Stop button flickers off between token batches → premature truncation.- text stability alone —
.markdownempty during streaming → never "stable" → stall fires.
The robust signal: the per-turn action row (data-testid containing
copy, turn-action, or response-turn) renders only on a completed
message. It is immune to Stop-button flicker and the empty-.markdown
quirk. This is what send_and_stream Phase-2 now keys on.
GET /backend-api/conversation/{id} returns a tree under mapping,
keyed by node id. Each node carries a message with:
author.role:"user"/"assistant"/"system"content.content_type:"text"(the answer),"reasoning_recap"(the reasoning summary — empty text),"code","tether_browsing"content.parts: array;parts[0]is the text (string) or a structured object (for non-text content types)status:"in_progress"→"finished_successfully"end_turn: boolean — the reliable completion markercreate_time: float, monotonic within a conversation
Key gotcha: the newest-by-create_time assistant node is often a
reasoning_recap with empty text. The real answer is a sibling node
with content_type: "text". Code that grabs "newest assistant" blindly
gets empty text. _fetch_text filters for content.
offset/limit params on the conversation endpoint do not change which message versions are returned — same mapping either way.
ChatGPT replaced the <textarea id="prompt-textarea"> with a
contenteditable ProseMirror div. The old #prompt-textarea survives
as a hidden fallback overlay (class wcDTda_fallbackTextarea) that
only works with JS off — typing into it does not reach the composer.
| Element | New selector | Legacy fallback |
|---|---|---|
| Input | div[role="textbox"]#prompt-textarea, div[role="textbox"].ProseMirror |
textarea#prompt-textarea |
| Send | button[aria-label*="Send" i]:not([data-testid="stop-button"]) |
button[data-testid="send-button"] |
Typing into the ProseMirror div requires document.execCommand('insertText', …)
(or an InputEvent dispatch); setting .value does nothing (it's not a
form control).
An earlier experiment (removed scripts/curl_cffi_test.py) tried sending
messages by POSTing directly to /backend-api/f/conversation with the
sentinel prepare/finalize tokens. Even with a fully-correct body, headers,
PoW, and a Chrome-impersonated TLS fingerprint (via curl_cffi), the
direct send 403'd with "Unusual activity". The sentinel flow
(prepare → finalize → token) succeeds via plain urllib, but the message
send itself is gated behind TLS/behavioral fingerprinting that the
in-page fetch() (the bridge's approach) passes naturally because it
runs in the real browser context. This is why the driver types into the
composer and polls the DOM rather than calling the conversation API
directly for sends — the reads (GET) work over plain HTTP, but the
write path requires the browser's full fingerprint.