Open bug report

AI Threads census attaches the wrong Codex conversation id to a tab (and a send could land in the wrong thread)

AdityaAngajala · 10d ago

What happened

On Adom Hydrogen v1.0.601 (DESKTOP-7UQJT50, Adom Bridge 2.1.174 embedded), a remote AI drove two Codex threads through hd_api → /ai-threads/*. Later, GET /ai-threads attached the wrong conversation id to a tab. The other conversation's id disappeared from the list.

Both tabs are Codex webview tabs in editor group 0:

Tab Conversation (Codex session) Opened with
A 01a1326a-13b8-7182-af2c-549acb571465 (a one-line connectivity test) run-prompt {new:true, type:"codex", name:"Claude -> Codex test", tags:["orchestrated-by:claude-h2o"]}
B 01a132d7-7b67-7400-8b61-7d1b60c27746 (a long-running takeover thread with an active Codex /goal) run-prompt {new:true, type:"codex", name:"Playwright HP takeover", ...}, later tagged with POST /ai-threads/tag {ai:"codex", sid:"01a132d7-…", add:[...]}

About 01:20 UTC (correct):

g0t0  codex  label "Codex"                  sid null
g0t1  codex  label "Codex"                  sid 01a132d7-…  tags [handoff:playwright-hp-2026-10-13, orchestrated-by:claude-h2o]

About 03:10 UTC (wrong):

g0t0  codex  label "Codex"                  sid null
g0t1  codex  label "Claude -> Codex test"   sid 01a1326a-…  tags [orchestrated-by:claude-h2o]   foreground:true

A screenshot taken at the same moment shows that g0t1 is still conversation B. Its header reads "Review Playwright takeover handoff", its body shows thread 01a132d7-…, and it shows "Pursuing goal …". So the census gave B's tab A's id, name and tags, and B's id is no longer on any row.

Things that happened to tab B between the two reads:

  • POST /ai-threads/inject {tid:"g0t1", prompt:"/goal …", submit:true} (a Codex slash command). The answer was ok:false, "typed and submitted, but the prompt never appeared in the conversation". That's expected, because a slash command never becomes a message, but the goal was set.
  • Codex then showed an inline approval card ("Allow registry checks …", Deny / Allow once). The card replaces the composer.

Why it matters

The skill tells callers to store sid and target by it. In this state, run-prompt {sid:"01a1326a-…"} or inject on the row labelled "Claude -> Codex test" would have typed into conversation B, the wrong one. That conversation was running autonomously under a goal. The caller only caught it by taking a screenshot before sending.

  1. Codex delivery reported but not sent (looks like #84, but this one claims success). run-prompt {new:true, type:"codex", submit:true, background:true} went tab_found → submitted → delivered, with inject.delivered_in_conversation:true and submitted:true. But the prompt was still in the composer, selected and unsent, and no Codex rollout file existed until a second inject {tid, submit:true} sent it. The job also said "no Codex hook event arrived within 30s … install the hooks". The hooks were installed and trusted. They never fired because the prompt was never sent, so that hint pointed at the wrong thing.
  2. Long multi-line prompt refused after typing. A 14.8 KB multi-line Markdown prompt to a new Codex tab ended inject_failed: "the chat box still holds other text, refusing to send it". The full text was in the composer. The exact-match check seems to fail after the composer re-wraps or normalizes long text. A short single-paragraph prompt into the same tab worked.

What would help

  1. Re-key a row's identity to whatever conversation the webview currently shows (for Codex, the visible thread id or header) rather than carrying a stale binding over. If they disagree, return sid:null with summary.identity saying so, rather than a confident wrong sid.
  2. Have inject / run-prompt {sid} refuse when the target tab's live conversation id doesn't match the requested sid.
  3. Count Codex delivered only once a new user message appears in the conversation, or the rollout grows. Don't count the send click.
  4. Compare the composer text with whitespace normalized (or by length plus hash of non-whitespace) for long prompts. Or report the mismatch with a diff instead of a bare refusal.
  5. Let inject detect an inline approval card replacing the composer. Report needs_you with the card's text instead of trying to type.

4 Replies

John Lauer · 9d ago

Thanks Aditya, this was precise enough to find it from the code.

Cause. Each tab claims a webview to read its conversation id from: by exact webview id first, then by title, then by position among visible composers. While tab B showed Codex's approval card its composer was gone, so B's webview dropped out of the list of live composers. B's exact claim failed, the title fallback ran, and with both tabs titled "Codex" it handed B tab A's webview, so B got A's id, name and tags. Nothing stopped two rows claiming the same webview, which is also why A's own row lost its id.

Fix (in the next insiders build, 1.0.603, after its release test):

  • Exact webview-id claims are taken first, across all groups.
  • A title claim is allowed only when it is unambiguous: one unclaimed webview with that title, and one tab with that label in the group.
  • No webview is ever claimed by two rows.
  • An ambiguous row now gets sid: null instead of a confident wrong one, so run-prompt {sid} / inject by sid cannot resolve to the wrong conversation.

Your other points (refusing a send when the tab's live conversation differs from the requested sid, counting Codex delivery only once the message appears, whitespace-normalized comparison for long prompts, and reporting an approval card as needs_you) are follow-ups; I'll reply here as they land.

John Lauer · 9d ago

Aditya, the fix is out in Hydrogen 1.0.603 (insiders), after a clean release test. Each tab now claims its own webview: exact webview ids are matched first across all tabs, a title match is used only when exactly one unclaimed webview has that title, and no webview is ever claimed twice. Two Codex tabs, one showing an approval card, keep their own ids. If you see a tab with another tab's id on 1.0.603 or later, reply here with the time and I will pull the census log.

Still open from this issue: refusing a send when the tab and conversation ids disagree, delivery confirmation for Codex, a whitespace-tolerant compare, and the approval card counting as "needs you".

John Lauer · 8d ago

Aditya, three more of your asks are in Hydrogen 1.0.613 (insiders):

  • 3. "Delivered" only when it was sent. Verification used to search the whole page for the prompt, and the page includes the chat box, so a prompt typed and never sent counted as delivered. Now it must have left the chat box and appear in the conversation. If it is still in the box, Hydrogen presses send once more and reports what is true: resent, still_in_box, and submitted:false with a plain error when it really did not go.
  • 4. Long formatted prompts. The editor reformats Markdown as it is typed, so the exact compare refused prompts that had arrived intact. It now compares letters and digits only, so extra or missing words still stop the send. On a real mismatch it says where: differs_at, expected_there, got_there and both lengths.
  • 5. Approval cards. When the chat box is replaced by an Allow/Deny card, inject answers needs_you with the card's text and its buttons instead of trying to type.

Checked on a test PC (1.0.613): run-prompt {new:true, type:"codex", submit:true} with a 2,673-character Markdown prompt (a heading, 25 bullet items with bold, emphasis and inline code, a code block, a numbered list and a quote) was accepted, sent, and confirmed: delivered_in_conversation: true, and Codex's hook moved the job to working.

Still open: 2. Refusing a sid that does not match the tab's live conversation. It needs an id the tab exposes reliably, and Codex does not. With item 1 (1.0.603), the census no longer hands one tab's id to another, which removes the case you hit.

John Lauer · 3d ago

Aditya, the last open item (2, refuse a sid that does not match the tab's live conversation) is in 7aedc5c6, for the next build after 1.0.658.

Codex's own page names its live conversation (the data-above-composer-conversation-id attribute the census already reads for Codex sids). Right before typing, /ai-threads/inject and run-prompt with a sid now read that attribute from the target tab's own webview, not from the census. If it names a different conversation, the send is refused with ok:false, live_conversation: <the id it shows>, and "nothing was typed". A tab that names no conversation (Claude, Kimi) is unaffected; their sids come from transcripts and hooks.

Together with 1.0.603 (no webview claimed by two rows), a stale or swapped pairing can no longer type into the wrong thread. Gate entry sid-live-conversation-guard: with two Codex conversations open, inject with A's sid and B's tab is refused and types nothing. Once it passes on the build, every item here is done and I will close this.

Log in to reply.