Skip to content

feat: add safe long-running support chat workflow - #2982

Open
alectimison-maker wants to merge 14 commits into
webbrain-one:mainfrom
alectimison-maker:feat/chat-support-workflow
Open

feat: add safe long-running support chat workflow#2982
alectimison-maker wants to merge 14 commits into
webbrain-one:mainfrom
alectimison-maker:feat/chat-support-workflow

Conversation

@alectimison-maker

Copy link
Copy Markdown
Contributor

Summary

Closes #2981.

Adds a durable, event-driven support chat workflow for long-running conversations:

  • Adds a browser-neutral chat state/evidence kernel with delta tracking, stable message IDs, idempotent sends, resolution evidence, and fail-closed user-input stops.
  • Adds matching Chrome and Firefox DOM observation for active thread identity, messages, composer state, safety signals, and explicit resolution evidence.
  • Exposes guarded chat_observe and chat_send tools with exact thread/composer binding, fresh pre-send observation, recipient guard integration, and independently verified outgoing bubbles.
  • Persists chat workflow state in session storage, including pending outbound sends, and restores it across worker restarts.
  • Makes schedule_resume durable for chat waits and guides the model to resume after 60–120 seconds using chat_observe and only the new-message delta.
  • Covers the resident-conversation hydration race and storage-unavailable fail-closed paths.

Implementation was split into six focused commits on this single PR.

Verification

  • npm test
  • 2203 passed, 0 failed
  • Security corpus: 60/60
  • PDF selection: 11 passed, 0 failed
  • npm run build:zip (Chrome, Edge, Firefox)
  • Chrome/Firefox parity tests pass

@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

@alectimison-maker is attempting to deploy a commit to the esokullu's projects Team on Vercel.

A member of the Team first needs to authorize it.

@esokullu

esokullu commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Strengths: thread_key + composer_ref binding, pre-send persist, outgoing bubble verification, OTP/PIN fail-closed, chat_observe only in Ask mode, Chrome/Firefox parity, untrusted-content gate.

Findings:

  1. DOM is too broad. chat-observation.js collects [role="listitem"] / article; if there is no composer, it falls back to root main/body. On non-chat pages, there is a risk of a fake “incoming” delta → nextAction: reply + schedule_resume loop. A semantic chat root / allowlist is essential.
  2. chat_send bypasses the recipient guard. It directly uses set_field + submit; it does not reuse the message-recipient-guard binding from email compose. This risks uncontrolled sending in the wrong composer.
  3. thread_changed never rebinds. The old threadKey remains; every observe pauses again. A clear/restart is essential — document it if intentional, otherwise add a rebind path.

Note: Resolution evidence relying solely on data-webbrain-*-verified is good; class-name direction heuristics are weak, but may lead to an incorrect reply rather than failing open.

Seven fixes from review of the chat support workflow. Chrome and Firefox
copies stay byte-identical.

- collectMessages capped with slice(0, MAX_ITEMS), keeping the oldest 200
  bubbles while the kernel keeps the last 200. Past 200 messages the
  observer returned the top of the transcript forever and nextAction never
  reached reply.
- ignoredMessageNode disqualified any node under an [aria-live] or
  [role="alert"] ancestor. Intercom- and Zendesk-shaped transcripts are one
  aria-live region, so every message was filtered out. The status-region
  check now applies to the node itself; interactive and navigational
  ancestors still disqualify.
- markerFor treated an absent aria-current as present, since '' !== 'false'.
  threadKey bound to the first row of the conversation rail instead of the
  open thread and stopped changing on thread switch, defeating the
  thread_changed guard.
- chat_send accepted a caller-supplied composer_ref whenever the observation
  had none, since the drift comparison was vacuous. The observed ref is now
  required and must match exactly.
- message_too_long was unreachable: normalizeChatText had already sliced to
  the cap, so a 6,000-character body was truncated and dispatched instead of
  rejected. Normalize one character past the cap so the check fires.
- pendingOutbound cleared only on a matching outgoing bubble, so a dispatch
  that never rendered blocked that text forever. attemptedAt now ages the
  record out after 10 minutes.
- The pin and auth heuristics matched as substrings, so shipping, spinner,
  pinned, author and authorize each tripped a fail-closed needs_user_input
  stop. Both are word-bounded now, on inferred signals only.

node test/run.js: 2203 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196wte9FS1SnsrKvrQUbB7m
@esokullu

esokullu commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Reviewed this at high effort and pushed 0cfb1df to the branch with seven fixes. Both the Chrome and Firefox copies are patched and still byte-identical; node test/run.js is 2203/0.

Fixed in 0cfb1df

OrderingcollectMessages ended with slice(0, MAX_ITEMS), so it kept the oldest 200 bubbles, while normalizeChatSnapshot keeps the last 200. In any thread past 200 rendered messages the observer returns the top of the transcript forever, newMessages stays empty, and nextAction never becomes reply.

Live regionsignoredMessageNode disqualified any node with an [aria-live] or [role="alert"] ancestor. Intercom- and Zendesk-shaped transcripts are typically one aria-live="polite" region, so every message got filtered and chat_observe reported an empty conversation. The status-region check now applies to the node itself; interactive and navigational ancestors still disqualify.

Thread binding — in markerFor, attribute(node, 'aria-current') returns '' when the attribute is absent, and '' !== 'false', so .find matched the first visible element carrying any data-conversation-id/data-thread-id/data-chat-id — usually the top row of the conversation rail rather than the open thread. threadKey bound to the wrong conversation and then stopped changing when the user switched threads, which silently defeats the thread_changed drift guard chat_send depends on.

Composer drift guardif (!composerRef || (before.composer?.ref && composerRef !== before.composer.ref)) is vacuous when the observation has no composer ref, so a model-supplied arbitrary composer_ref passed through to set_field. The observed ref is now required and must match exactly.

message_too_long was unreachablenormalizeChatText had already sliced to MAX_MESSAGE_TEXT, so body.length > 4000 could never hold and a 6,000-character chat_send was silently truncated to 4,000 and dispatched rather than rejected. Normalizing one character past the cap makes the check fire.

Stuck pendingOutbound — the record cleared only when a matching outgoing bubble appeared. If set_field throws or the page drops the message it never expires (attemptedAt was stored but never read), and every later chat_send of that text returns send_pending indefinitely. attemptedAt now ages it out after 10 minutes. Hydration still round-trips the record verbatim, so the durability test at test/run.js:5195 is unaffected.

Substring heuristics/pin/ and /auth/ were tested against joined name/id/aria-label/data-testid, so shipping, spinner, pinned, author and authorize each tripped a fail-closed needs_user_input stop and paused the chat on an unrelated field. Both are word-bounded now, on the inferred signals only — explicit data-* opt-ins are untouched.

Left for you

These five are judgment calls about the design rather than mechanical fixes, so I didn't touch them.

  1. Transcript in every tool result (agent.js:17690, medium) — _observeChatWorkflow returns {...observation, chatWorkflow}, spreading up to 200 × 4,000 characters into the result on every call, which contradicts the tool's own "consume only the new-message delta" contract. Trimming it changes the documented result shape.

  2. Repeated replies are permanently unsendable (chat-workflow.js:368, medium) — messageKey is hash(threadKey + canonical text) with no time or position component. Support asks two yes/no questions, the agent answers "Yes." to the first, and the second "Yes." comes back already_sent with no escape hatch. Adding a nonce or a position component would loosen the idempotency guarantee the PR is built around, so it's your call where that line sits.

  3. Page-controlled resolution evidence (chat-observation.js:331 and agentConnected, medium) — both read from data-webbrain-*-verified attributes. The module header treats message text as untrusted, but these attributes are equally attacker-controlled, and the chat_observe description makes the snapshot the trust anchor for "do not claim a refund … without verified evidence." A lookalike support page can set them and drive the session to issue_resolved with a fabricated refund and case number. This is the one I'd want resolved before merge.

  4. State lost on user_input_cleared (chat-workflow.js:264, low) — resetting to waiting_for_transfer discards counterparty_replied/we_responded. When a transient password field disappears and no new message arrived, nextAction becomes schedule_resume and the agent sleeps 60–120s instead of replying. The fix needs a remembered pre-pause state.

  5. Deleted pageerror assertion (test/webmcp-e2e.mjs:244, low) — the WebMCP tool-exception assertion goes away here. It's unrelated to the chat feature, so it's probably worth pulling back out of this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_0196wte9FS1SnsrKvrQUbB7m

@esokullu

esokullu commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

btw @alectimison-maker have we used the /watch slashcommand here? i think it could be used here.

@alectimison-maker

Copy link
Copy Markdown
Contributor Author

Implemented the remaining design findings in 5186f5b98:

  • chat_observe now requires a semantic chat root (no generic main/body fallback), projects only a bounded new-message delta, and keeps the model-facing result below the normal tool-result envelope.
  • chat_send preflights the exact set_field({submit:true}) dispatch through the existing message-recipient guard, failing closed when classification or recipient binding is inconclusive.
  • Thread drift now pauses safely and supports an explicit rebind_thread_key only after the user confirms the new thread.
  • Send idempotency is scoped to the latest incoming-message anchor, so the same short reply can be sent again for a genuinely new question while retries in the same context remain blocked.
  • user_input_cleared restores the pre-pause state.
  • Generic DOM attributes no longer self-authorize agent connection, refund, auto-renewal, or case-number evidence; those remain null until an independent trusted verifier supplies them.
  • Oversized message/event deltas are bounded and marked when truncated.

I kept the WebMCP page-error compatibility adjustment because Chrome 153 reports the protocol tool exception without a duplicate Playwright pageerror; the protocol response is still asserted and the local WebMCP smoke passes.

/watch remains intentionally separate: it schedules URL-targeted tasks and cannot safely resume this chat session's thread/pending state, so the workflow continues to use durable schedule_resume.

Validation:

  • npm test — 2203 passed, 0 failed; security 60/60
  • npm run test:webmcp — passed
  • Chrome/Firefox workflow and observer copies remain mirrored.

@alectimison-maker

Copy link
Copy Markdown
Contributor Author

Follow-up in a7cbcc1b2: the bounded delta now also reports deltaTruncated:true whenever older entries are dropped to stay within the model result budget. This makes recovery explicit instead of silently presenting a partial delta.

Final rerun: node test/run.js 2203/0, npm run test:security 60/60, and npm run test:webmcp passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved send-race, recipient-preflight, thread-binding, draft-loss, and idempotency defects can cause blocked, stale, or duplicate messages.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a durable, cross-browser support-chat workflow with observation, guarded sending, persistence, resumable waits, and safety stops.

Changes:

  • Adds mirrored chat observation and state-machine implementations.
  • Integrates chat_observe/chat_send, permission gating, persistence, and scheduling.
  • Adds parity, safety, durability, and protocol tests.
File summaries
File Description
test/webmcp-e2e.mjs Updates exception assertions for current Chrome behavior.
test/run.js Adds chat workflow, persistence, safety, and parity tests.
src/chrome/src/content/content.js Exposes composer refs and chat observation dispatch.
src/chrome/src/content/chat-observation.js Implements Chrome DOM chat observation.
src/chrome/src/agent/tools.js Defines and exposes chat tools.
src/chrome/src/agent/permission-gate.js Adds chat tool capability requirements.
src/chrome/src/agent/mutation-tools.js Classifies chat sends as mutations.
src/chrome/src/agent/chat-workflow.js Implements chat state, deltas, and deduplication.
src/chrome/src/agent/agent.js Integrates chat execution, persistence, and resume handling.
src/chrome/manifest.json Registers the chat observation content script.
src/firefox/src/content/content.js Exposes composer refs and chat observation dispatch.
src/firefox/src/content/chat-observation.js Implements Firefox DOM chat observation.
src/firefox/src/agent/tools.js Defines and exposes chat tools.
src/firefox/src/agent/permission-gate.js Adds chat tool capability requirements.
src/firefox/src/agent/mutation-tools.js Classifies chat sends as mutations.
src/firefox/src/agent/chat-workflow.js Implements chat state, deltas, and deduplication.
src/firefox/src/agent/agent.js Integrates chat execution, persistence, and resume handling.
src/firefox/manifest.json Registers the chat observation content script.
Review details

Suppressed comments (10)

src/chrome/src/agent/chat-workflow.js:426

  • Mandatory: chat_send clears and replaces the composer, but this decision only checks availability. If the user has typed a draft while the workflow was waiting, the next send silently destroys it. Fail closed when the freshly observed composer is non-empty (or explicitly reconcile its exact contents before submission).
  if (snapshot.composer.available !== true) {
    return { ok: false, reason: 'composer_unavailable', error: 'The active conversation composer is not available.' };
  }

src/firefox/src/agent/chat-workflow.js:426

  • Mandatory: chat_send clears and replaces the composer, but this decision only checks availability. If the user has typed a draft while the workflow was waiting, the next send silently destroys it. Fail closed when the freshly observed composer is non-empty (or explicitly reconcile its exact contents before submission).
  if (snapshot.composer.available !== true) {
    return { ok: false, reason: 'composer_unavailable', error: 'The active conversation composer is not available.' };
  }

src/chrome/src/content/chat-observation.js:382

  • Mandatory: this URL-only fallback is not an exact conversation binding. A support widget or SPA can switch to another case while keeping the same URL and no exposed identity, so threadKey remains unchanged and the send-time drift check authorizes the wrong thread. Fail closed unless a conversation-specific marker or independently verified identity is available, or add an adapter-provided stable thread identity.
      const threadKey = compact(
        conversationId
          ? `dom:${conversationId}`
          : (url && conversationIdentity ? `${url}#${conversationIdentity}` : url || conversationIdentity || 'document'),
        240,

src/firefox/src/content/chat-observation.js:382

  • Mandatory: this URL-only fallback is not an exact conversation binding. A support widget or SPA can switch to another case while keeping the same URL and no exposed identity, so threadKey remains unchanged and the send-time drift check authorizes the wrong thread. Fail closed unless a conversation-specific marker or independently verified identity is available, or add an adapter-provided stable thread identity.
      const threadKey = compact(
        conversationId
          ? `dom:${conversationId}`
          : (url && conversationIdentity ? `${url}#${conversationIdentity}` : url || conversationIdentity || 'document'),
        240,

src/chrome/src/agent/chat-workflow.js:180

  • The fallback IDs are not stable across the 200-message sliding window because occurrence numbering restarts after truncation. For example, snapshots containing 200 and then 201 identical no-ID messages both normalize to the same occurrence IDs 0–199, so the newly appended message produces no delta. Preserve a stable per-bubble identity before truncation or reconcile no-ID messages against prior snapshots.
  for (const raw of (Array.isArray(source.messages) ? source.messages : []).slice(-MAX_MESSAGES)) {
    const provisionalText = canonicalChatText(raw?.text ?? raw?.content ?? raw?.message);
    const provisionalDirection = normalizeDirection(raw?.direction ?? raw?.authorRole ?? raw?.role);
    const occurrence = seenOccurrences.get(`${provisionalDirection}\u001f${provisionalText}`) || 0;
    seenOccurrences.set(`${provisionalDirection}\u001f${provisionalText}`, occurrence + 1);

src/firefox/src/agent/chat-workflow.js:180

  • The fallback IDs are not stable across the 200-message sliding window because occurrence numbering restarts after truncation. For example, snapshots containing 200 and then 201 identical no-ID messages both normalize to the same occurrence IDs 0–199, so the newly appended message produces no delta. Preserve a stable per-bubble identity before truncation or reconcile no-ID messages against prior snapshots.
  for (const raw of (Array.isArray(source.messages) ? source.messages : []).slice(-MAX_MESSAGES)) {
    const provisionalText = canonicalChatText(raw?.text ?? raw?.content ?? raw?.message);
    const provisionalDirection = normalizeDirection(raw?.direction ?? raw?.authorRole ?? raw?.role);
    const occurrence = seenOccurrences.get(`${provisionalDirection}\u001f${provisionalText}`) || 0;
    seenOccurrences.set(`${provisionalDirection}\u001f${provisionalText}`, occurrence + 1);

src/chrome/src/agent/agent.js:17816

  • The recipient preflight is missing the text that the eventual set_field dispatch will submit. _probeMessageRecipientGuard derives messageBody from args.text for submit-scoped set_field; with the normal empty composer this call therefore produces an empty body and fails verification, so chat_send cannot work on any adapter with recipient guarding enabled. Preflight the exact dispatch arguments.
    src/firefox/src/agent/agent.js:15565
  • The recipient preflight is missing the text that the eventual set_field dispatch will submit. _probeMessageRecipientGuard derives messageBody from args.text for submit-scoped set_field; with the normal empty composer this call therefore produces an empty body and fails verification, so chat_send cannot work on any adapter with recipient guarding enabled. Preflight the exact dispatch arguments.
    src/chrome/src/agent/chat-workflow.js:327
  • Clearing pendingOutbound on thread drift discards the only durable record of an uncertain send exactly when its result has not been verified. After re-binding the original thread, the same text can be dispatched again even if the first send succeeded. Preserve the pending record keyed to the original thread and require explicit reconciliation before allowing a rebind or retry.
      state: transitionChatState(session.state, { type: 'thread_changed' }),
      userInput: { required: true, reason: 'thread_changed', message: 'The active conversation changed. Select and verify the intended thread before continuing.' },
      stopReason: 'thread_changed',
      pendingOutbound: null,
    };

src/firefox/src/agent/chat-workflow.js:327

  • Clearing pendingOutbound on thread drift discards the only durable record of an uncertain send exactly when its result has not been verified. After re-binding the original thread, the same text can be dispatched again even if the first send succeeded. Preserve the pending record keyed to the original thread and require explicit reconciliation before allowing a rebind or retry.
      state: transitionChatState(session.state, { type: 'thread_changed' }),
      userInput: { required: true, reason: 'thread_changed', message: 'The active conversation changed. Select and verify the intended thread before continuing.' },
      stopReason: 'thread_changed',
      pendingOutbound: null,
    };
  • Files reviewed: 18/18 changed files
  • Comments generated: 6
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/chrome/src/agent/agent.js Outdated
Comment on lines +17765 to +17769
const prior = this.chatSessions.get(tabId) || createChatSession();
const observed = advanceChatSession(prior, before);
this.chatSessions.set(tabId, observed.session);
this._persist(tabId);
const workflow = () => this._chatWorkflowView(observed);
Comment thread src/chrome/src/agent/chat-workflow.js Outdated
Comment on lines +58 to +60
// A dispatch that never produced a visible bubble must not block the same text
// forever. `attemptedAt` ages the pending record out so the workflow can retry.
const PENDING_OUTBOUND_TTL_MS = 10 * 60 * 1000;
Comment on lines +234 to +235
const collectMessages = (root, composer) => {
const candidates = queryMany(root, MESSAGE_SELECTORS)
Comment thread src/firefox/src/agent/agent.js Outdated
Comment on lines +15514 to +15518
const prior = this.chatSessions.get(tabId) || createChatSession();
const observed = advanceChatSession(prior, before);
this.chatSessions.set(tabId, observed.session);
this._persist(tabId);
const workflow = () => this._chatWorkflowView(observed);
Comment thread src/firefox/src/agent/chat-workflow.js Outdated
Comment on lines +58 to +60
// A dispatch that never produced a visible bubble must not block the same text
// forever. `attemptedAt` ages the pending record out so the workflow can retry.
const PENDING_OUTBOUND_TTL_MS = 10 * 60 * 1000;
Comment on lines +234 to +235
const collectMessages = (root, composer) => {
const candidates = queryMany(root, MESSAGE_SELECTORS)
alectimison-maker and others added 3 commits September 6, 2026 23:35
Two stale-state bugs from review of webbrain-one#2982, rebuilt onto the author's newer
Copilot-review base (249a927, 6f15704) so the fixes survive both the
observer's truncation and the removed TTL.

The fallback id for a bubble the page does not identify is positional, and
both layers numbered occurrences after truncating: the observer after its
200-item cap, the kernel after its own. The numbering restarts at 0 as older
bubbles age out, so past the cap a genuinely new duplicate normalizes onto an
id seenMessageIds already holds and produces no delta. The observer now
assigns the ordinal over the whole deduped transcript before its cap and
reports it; the kernel prefers a supplied ordinal and otherwise counts before
its own cap. The Copilot review moved the kernel to count the full array, but
the observer still slices before the kernel sees it, so the DOM path needs the
observer change; the kernel preference keeps an observer-supplied ordinal from
being recounted.

pendingOutbound.key embeds the reply anchor the message was sent under, but
outgoingMessageKeys recomputes that anchor from DOM order. A counterparty
message that renders after a send can move the anchor so the recomputed key
never matches; without the TTL, the pending record then claims the send
indeterminately and blocks every later dispatch. A newly visible outgoing
bubble carrying the pending text proves delivery without depending on the
anchor, so advanceChatSession now clears the pending record on that too.
Explicit reconciliation stays available for the genuinely-uncertain case.

Tests: three assertions in the workflow block (anchor drift, duplicate ids
past the cap, observer-assigned ordinal honored) and one in the observation
block (numbering before the cap). Each fails on the author's head and passes
with the fix; the over-cap kernel delta is already handled by the author's
kernel and is kept as regression coverage.
@esokullu

esokullu commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Rebuilt the two outstanding review fixes from cc38280f5 on top of the current head (cfec1e1b) instead of force-pushing over the newer work, so the Copilot-review commits (249a9273e, 6f15704cf) and everything before them stay intact. Both Chrome/Firefox copies are patched and still byte-identical; node test/run.js blocks that touch these modules pass 3/3.

1. Fallback ids past the retention cap. The Copilot round moved the kernel to count occurrences over the full transcript, which fixes sources that feed >200 raw messages. But the observer still slices to its 200-item cap before numbering (chat-observation.js), so on the DOM path the numbering restarts at the window edge — past ~200 bubbles a genuinely new duplicate normalizes onto an already-seen id and never surfaces. The observer now assigns the ordinal over the whole deduped transcript before its cap and reports it, and the kernel prefers a supplied ordinal instead of recounting. The cap-observation test checks 205 duplicates now number 5..204 rather than restarting 0..199.

2. Anchor drift on the pending record. pendingOutbound.key embeds the reply anchor the send went under, but outgoingMessageKeys recomputes anchors from DOM order. With the TTL removed, a counterparty message that renders above our bubble after a send moves the anchor, the recomputed key never matches, and the pending record claims the send indeterminately — blocking every later dispatch until a manual reconcile. A newly visible outgoing bubble carrying the pending text is the same proof of delivery, so advanceChatSession now clears the pending record on that match too; explicit reconcile_pending_outbound remains for the genuinely-uncertain case.

I kept the Copilot base's decisions (no TTL, block on any pending, reconcile tool, composer-empty guard, strict thread identity) and layered only these two fixes.

Each new assertion was verified to fail on the pre-rebase head and pass now; the kernel-side duplicate-delta case is kept as regression coverage since the author's kernel already handles it.

Separate CI heads-up: the tab-chat persistence suite still hits Maximum call stack size exceeded on a ~6 MB image payload roughly 3 runs in 4, and it does so on the base commit too — the earlier green full-suite runs were luck. It's untouched by this PR; happy to chase it separately if wanted.

…-undelivered send

Code review of the chat_observe / chat_send kernel turned up several ways a
session could get stuck in a state only an explicit user reconciliation could
clear, plus a pause that could be escaped without the rebind the tool contract
requires.

- _sendChatWorkflow left the pending record in place when the pre-dispatch
  persist failed, even though nothing had been dispatched. Every later send
  then failed with send_pending, making the error's own "retry after storage is
  available" advice impossible to follow. Restore the non-pending session and
  report the delta the call already consumed.
- A dispatch that proves it never reached the page (ref not found, content
  action timeout, thrown error) now clears pendingOutbound before the
  verification observation instead of stranding the workflow.
- advanceChatSession lifts a thread_changed pause only when the bound threadKey
  is observed again. Previously any counterparty_replied or outgoing_verified
  event moved the state out of needs_user_input while stopReason stayed
  thread_changed forever, leaving the rebind precondition permanently satisfied
  so the session could be reset at any later point.
- chat_send and the rebind path compare against the normalized threadKey that
  chat_observe reported, not the raw content-script value. bounded() maps
  control characters to spaces and compact() does not, so a conversation id
  containing one made both permanently unusable on that page.
- The failed-after-observation branch reports the session's own threadKey and
  resolutionEvidence rather than reading them off the failure object, which is
  exactly when the model needs them to reconcile an uncertain send.
- collectMessages collapses ancestor/descendant message nodes on containment
  plus text containment, so a wrapper and its bubble that each carry an id, or
  a wrapper whose innerText adds a timestamp, no longer report twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FH7NV9Zop2f6JPka8V1h5g
@alectimison-maker

Copy link
Copy Markdown
Contributor Author

Thanks for rebuilding these fixes on top of the current head and preserving the newer commits.

The two fixes make sense to me:

  • assigning fallback ordinals before applying the 200-message retention cap prevents duplicate IDs and missed new messages;
  • clearing pendingOutbound when a matching outgoing bubble is observed handles anchor drift while preserving the fail-closed behavior for genuinely uncertain sends.

I also agree that the ~6 MB image Maximum call stack size exceeded issue is pre-existing and outside the scope of this PR. It can be addressed separately.

Thanks for the thorough review.

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.

handle chat

3 participants