Skip to content

Fix attachment uploads: accept-aware input selection, upload-settle wait, scoped stop detection, reply-baseline wait - #55

Open
adossey wants to merge 4 commits into
agentify-sh:mainfrom
adossey:fix/attachment-uploads
Open

Fix attachment uploads: accept-aware input selection, upload-settle wait, scoped stop detection, reply-baseline wait#55
adossey wants to merge 4 commits into
agentify-sh:mainfrom
adossey:fix/attachment-uploads

Conversation

@adossey

@adossey adossey commented Jul 22, 2026

Copy link
Copy Markdown

Summary

Attachment uploads to chatgpt.com hung forever at 0% (file tile renders, spinner never moves, no network request), and long-thinking models returned empty text. Debugging against a live session via CDP surfaced five compounding bugs; this PR fixes all of them.

1. Documents were fed to the image-only file input (the 0%-spinner root cause)

setFileInputFiles tried file inputs in reverse DOM order. chatgpt.com ships three inputs — #upload-files (the composer), #upload-photos (accept="image/*"), #upload-camera (accept="image/*") — so reverse order picks #upload-camera. A document dropped into an image-only input enters the site's image pipeline, whose decode rejects uncaught inside uploadFile (verified via Runtime.exceptionThrown): the tile renders in Uploading state and no upload request is ever issued. Both backends (Chrome CDP + Electron) now read each input's accept attribute and prefer accept-compatible inputs; image-only inputs are used only when every file is an image.

2. Send raced in-flight uploads

query() clicked send immediately after attaching. Providers disable send while uploads are in flight, so every send strategy failed within seconds (send_not_triggered) while tiles were legitimately still uploading. New #waitForUploadsSettled() runs between typing and sending: polls for a visible enabled send button with no in-flight upload indicator in the composer, held stable across polls; byte-scaled timeout (2min base + 1min/MB, 15min cap); surfaces attachment_upload_failed / attachment_upload_timeout with data instead of a misleading send error. Emits the existing uploading_files progress phase.

3. Stop-button detection matched sidebar history ("…Cancellations")

The stop selector includes button[aria-label*="cancel" i], which matched sidebar history rows for a conversation titled "JetBlue West Coast Cancellations" — every send failed with already_generating. Stop lookups (#clickSend guard, #clickVisibleStop, #waitForAssistantStable) are now scoped out of nav/aside/[role=navigation]/history containers. Additionally, when a provider replaces the send button with the stop button while responding, the absence of a send button no longer counts as "send enabled" — so generating is correctly detected during long thinking phases.

4. Empty replies from reasoning models

#waitForAssistantStable accepted pre-existing page text as "the reply" after 2.5s of stability — models that think silently for minutes (e.g. "Pro thinking") returned success with empty/garbage text. Completion now requires a new assistant message beyond the count captured when waiting began, with escape hatches for selector-less providers (45s fallback grace) and a baseline-glitch guard (2min of stable, non-generating quiet).

5. Closed tabs were never pruned (silent 10-minute hangs)

A Target.targetDestroyed listener was registered, but Target.setDiscoverTargets was never enabled — Chrome never emits those events without it. Closing a tab left a zombie record whose dead session made every evaluate throw; waitForPromptVisible swallowed the errors and spun silently until full timeout. Discovery is now enabled, and waitForPromptVisible surfaces tab_target_lost after repeated consecutive evaluate failures so callers can recreate the tab.

Verification

  • Live against chatgpt.com (Chrome CDP backend): attach → upload completes (POST /backend-api/files → CDN PUT → process_upload_stream) → send → waits through an extended "Pro thinking" phase → returns the exact reply text. Previously: tile frozen at 0% and send_not_triggered / already_generating / empty-text results.
  • npm test: 163/163 pass.
  • Diagnosis method, for the curious: attached to the live session over CDP, confirmed change/input events fire with correct File+mime, zero upload fetches from the page, captured the uncaught rejection in the site's uploadFile, and bisected the three file inputs to find only #upload-files triggers the real upload chain.

adossey added 2 commits July 22, 2026 15:22
…ait, scoped stop detection, reply-baseline wait

Four compounding bugs made attachments hang at 0% and queries fail or return
empty text:

1. setFileInputFiles picked file inputs in reverse DOM order, which on
   chatgpt.com selects #upload-camera (accept="image/*"). Documents fed to an
   image-only input enter the site's image pipeline, whose decode rejects
   uncaught — tile stuck at 0%, no network request ever made. Both backends
   now read each input's accept attribute and prefer accept-compatible inputs
   (image-only inputs are used only when every file is an image).

2. query() sent immediately after attaching; providers disable send while
   uploads are in flight, so every send path failed (send_not_triggered)
   while tiles were still uploading. New #waitForUploadsSettled polls until
   an enabled send button with no in-flight upload indicator holds stable,
   with a byte-scaled timeout, and surfaces attachment_upload_failed/timeout.

3. Stop-button detection matched sidebar history rows (aria-label*="cancel"
   vs a conversation titled "…Cancellations"), failing sends with
   already_generating. Stop lookups are now scoped out of nav/aside/history.
   Also: when a provider replaces send with stop while responding, "no send
   button" no longer counts as send-enabled, so generating is detected during
   long thinking phases.

4. waitForAssistantStable accepted pre-existing page text as the reply after
   2.5s — reasoning models that think silently for minutes returned empty
   text. A reply now requires a NEW assistant message beyond the baseline
   count (with long-grace escape hatches for selector-less providers).

Also: enable Target.setDiscoverTargets so Target.targetDestroyed actually
fires — closed tabs were never pruned and later queries hung on dead sessions
for their full timeout; waitForPromptVisible now surfaces tab_target_lost
after repeated evaluate failures instead of silently spinning.

Verified live against chatgpt.com: attach -> upload (backend-api/files ->
CDN PUT -> process_upload_stream) -> send -> wait through "Pro thinking" ->
correct reply text returned. Test suite: 163/163 pass.
The send-confirmation poll used the raw stop selector, so out-of-scope
matches (e.g. sidebar history rows matching aria-label*="cancel") made it
report "sent" instantly even when the click never landed — the query then
waited on a reply that was never requested and returned page chrome as text.
Same scoping as the other three stop-detection sites.
adossey added 2 commits July 22, 2026 16:17
…min response cap, auth-only liveness

Three layers silently capped long queries far below what reasoning models
need (they can think for an hour before responding):

- requestJson used Node's built-in fetch, whose undici transport enforces a
  ~5-minute headers timeout — long-blocking /query calls died with 'fetch
  failed' at exactly 300s. Default transport is now node:http with an
  optional client timeout; a custom fetchImpl (tests) keeps the fetch path.
- /query clamped timeoutMs to 30 minutes; the ceiling is now 2 hours.
- chatgpt-controller.query() capped the response wait at 8 minutes
  regardless of the requested timeout; it now honors the full budget.

Also: validateConn treated any non-ok /status as 'not running', but /status
carries tab-level errors (tab_not_found before a default tab exists) while
the server is healthy — only auth failures invalidate a connection now.
Human-paced typing at 12-45ms/char meant a 29KB prompt took ~35 minutes to
type — during which a stray send could fire with a PARTIAL message (the
assistant then answers incomplete input) and the eventual clickSend fails
with already_generating. Prompts over 1500 chars are now inserted in bulk
via Input.insertText (paste-like, no key events), with a short human-typed
tail; short prompts keep the existing pacing.
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