Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions src/chatgpt_web2api/cdp_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1576,13 +1576,17 @@ async def _read_assistant_count_baseline(self) -> int:
# Unreachable (the loop either returns or raises).
raise SendReadinessError("send_baseline: exhausted retries unexpectedly")

async def _verify_send_acknowledged(self) -> bool | None:
async def _verify_send_acknowledged(self, *, fresh_chat: bool = False) -> bool | None:
"""P0 send acknowledgment (ChatGPT review, conv 6a52f0f3).

After click_send dispatches synthetic mouse events, verify the message
was actually accepted by React — not just that the JS event loop ran.

Composite condition: user-message count increased AND composer cleared.
On a fresh chat only, a transition to a valid ``/c/{uuid}`` URL with
an empty composer is also positive acknowledgement. ChatGPT creates
that conversation URL only after accepting the first submission, and
its user-message DOM node can hydrate after the SPA navigation.
Uses the pre-send user count baseline (self._pre_send_user_count) to
detect the delta, not just "userCount > 0" (which is always true on
existing conversations).
Expand Down Expand Up @@ -1615,7 +1619,7 @@ async def _verify_send_acknowledged(self) -> bool | None:
f" || document.querySelector('{COMPOSER_FALLBACK_SELECTOR}');"
" var composerPresent = !!composer;"
" var composerEmpty = composer ? !(composer.innerText || composer.value || '').trim() : false;"
" return JSON.stringify({userCount: userMsgs, composerPresent: composerPresent, composerEmpty: composerEmpty});"
" return JSON.stringify({userCount: userMsgs, composerPresent: composerPresent, composerEmpty: composerEmpty, href: location.href});"
"})()"
)
if not result or not result.strip().startswith("{"):
Expand All @@ -1634,6 +1638,20 @@ async def _verify_send_acknowledged(self) -> bool | None:
current_count = state.get("userCount", 0)
if current_count > pre_send_count and state.get("composerEmpty"):
return True
if fresh_chat and state.get("composerEmpty"):
# Fresh-chat /c/{uuid} navigation can precede user DOM hydration.
import re as _re

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] URL validation is looser than a canonical UUID check

The regex [0-9a-fA-F-]{36} accepts any 36 characters of hex digits and hyphens, and uuid.UUID() strips hyphens before validating, so it accepts non-canonical hyphen placement too. For example, /c/0123456789abcdef0123456789abcdef-abc (36 chars, 32 hex digits) passes both checks despite not being a canonical UUID form. Practical impact is minimal since real ChatGPT conversation URLs are canonical 8-4-4-4-12, but if the intent is 'valid /c/{uuid}', a canonical regex such as [0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12} would make the gate exact and let you drop the redundant uuid.UUID round-trip.

import uuid as _uuid
match = _re.search(
r"/c/([0-9a-fA-F-]{36})(?:[/?#]|$)",
str(state.get("href") or ""),
)
if match:
try:
_uuid.UUID(match.group(1))
return True
except ValueError:
pass
except Exception:
pass
await asyncio.sleep(0.5)
Expand Down Expand Up @@ -1791,7 +1809,9 @@ async def send_and_stream(
# could prevent sends in edge cases we haven't seen.
if not captured_uuid:
try:
acknowledged = await self._verify_send_acknowledged()
acknowledged = await self._verify_send_acknowledged(
fresh_chat=(fallback_anchor.mode == "fresh_chat"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] No regression test for the caller-side fresh_chat wiring

The crux of the fix in production is the call site fresh_chat=(fallback_anchor.mode == "fresh_chat") in send_and_stream, but all three new tests invoke _verify_send_acknowledged directly. If the kwarg stops being forwarded (e.g., a refactor renames the mode string or drops the argument), the original false negative silently returns with no failing test. Consider a test that exercises the send_and_stream path (or asserts kwarg propagation with a stubbed anchor) so the integration point is pinned, not just the helper's logic.

)
if acknowledged is False: # explicitly False, not None
raise SendReadinessError(
"Send not acknowledged — click dispatched but no user "
Expand Down
50 changes: 50 additions & 0 deletions tests/test_send_acknowledgment.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,53 @@ async def fake_js_strict(expr, timeout=15):
assert result is None, (
f"Missing composer should return None (inconclusive), got {result!r}"
)

@pytest.mark.asyncio
async def test_fresh_chat_conversation_url_acknowledges_before_dom_hydrates(monkeypatch):
driver = _make_driver()
driver._pre_send_user_count = 0

async def fake_js_strict(expr, timeout=15):
if "userCount" in expr and "composerEmpty" in expr:
return json.dumps({
"userCount": 0, "composerPresent": True, "composerEmpty": True,
"href": "https://chatgpt.com/g/project-id/c/6ab8dc2b-5fdc-83eb-af4f-4865b11faecf",
})
return "0"

driver._js_strict = fake_js_strict
assert await driver._verify_send_acknowledged(fresh_chat=True) is True


@pytest.mark.asyncio
async def test_existing_conversation_url_does_not_relax_ack_gate(monkeypatch):
driver = _make_driver()
driver._pre_send_user_count = 2

async def fake_js_strict(expr, timeout=15):
if "userCount" in expr and "composerEmpty" in expr:
return json.dumps({
"userCount": 2, "composerPresent": True, "composerEmpty": True,
"href": "https://chatgpt.com/c/6ab8dc2b-5fdc-83eb-af4f-4865b11faecf",
})
return "0"

driver._js_strict = fake_js_strict
assert await driver._verify_send_acknowledged(fresh_chat=False) is False


@pytest.mark.asyncio
async def test_fresh_chat_invalid_conversation_url_does_not_ack(monkeypatch):
driver = _make_driver()
driver._pre_send_user_count = 0

async def fake_js_strict(expr, timeout=15):
if "userCount" in expr and "composerEmpty" in expr:
return json.dumps({
"userCount": 0, "composerPresent": True, "composerEmpty": True,
"href": "https://chatgpt.com/g/project-id/c/not-a-conversation-id",
})
return "0"

driver._js_strict = fake_js_strict
assert await driver._verify_send_acknowledged(fresh_chat=True) is False