fix(panel): the conversation is always panel-owned — retire the workflow/ask chat scopes (mcp#884) - #680
Conversation
…low/ask chat scopes (mcp#884) Companion to artokun/comfyui-mcp#884 (P0: agent sessions were workflow-scoped). The owner-stated invariant: one agent session spans every workflow and every tab, keyed and persisted by the orchestrator. The legacy 'workflow' and 'ask' chat scopes were per-workflow sessions by another name, so chatScopeMode() is hard-wired to 'panel' and the Settings combo is removed. Stored setting values from older builds are ignored; per-workflow threads created under the old modes remain in history and resume through the history picker like any archived conversation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Cross-tab history selection can corrupt session bindings, while existing tests and documentation still assume retired scopes.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Retires legacy workflow/ask conversation scopes to enforce orchestrator-managed shared sessions.
Changes:
- Hard-wires chat scope to panel-wide.
- Removes the conversation-scope setting.
File summaries
| File | Description |
|---|---|
web/js/comfyui-mcp-panel.js |
Enforces panel scope and removes its selector. |
Review details
Suppressed comments (2)
web/js/comfyui-mcp-panel.js:2606
- The browser suites still force the retired
workflowmode and assert its behavior. For example,browser_tests/chat-history-v2.spec.ts:292-385andworkflow-chat-identity.spec.ts:203-270expect foreign chats to be disabled, whileconversation-persistence.spec.ts:502-583expects a cross-scope sync to detach the session; all of those assertions become false with this unconditional return. Update or retire those cases and add coverage that storedworkflow/askvalues are ignored and workflow switches emit neithernew_sessionnorresume_session.
web/js/comfyui-mcp-panel.js:3045 - Removing this setting leaves the checked-in design guide incorrect:
docs/design/chat-history-v2.md:6-12still directs users to “Chat conversation scope” and documents the retired Workflow and Ask options. Update that section so the public documentation matches the only supported behavior.
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| * ALWAYS belongs to the panel. One agent session spans every workflow and every | ||
| * tab; the orchestrator keys and persists it (in ~/.comfyui-mcp/sessions), so a |
…on, not the tab (mcp#884 gate P0s) The companion mcp#897 makes the agent session orchestrator-scoped: ONE conversation per backend across every tab and workflow. The previous commit hard-wired the panel scope, but the HISTORY layer still let each tab decide which thread to render and record into. This commit makes thread identity shared state resolved by one selector: - selectPanelThread is now the single definition of "the conversation", with a stale-pointer guard: the panel:global pointer only pins a conversation over newer CONVERSATION activity (message recency, never rename/pin edits) when the pointer write itself is the most recent action. Fixes the cold-upgrade restore of a months-old chat (P0-2). - selectRestoreThread: the shared pointer is authoritative on reload when it resolves (or was deliberately cleared); the tab-local pointer only bridges legacy snapshots and dangling pointers. - The history-sync subscriber passively ADOPTS the shared selection: a tab whose selection moved repaints to the new thread and re-keys its sessionStorage, so its next user_message is recorded where the agent actually is (P0-1). Passive tabs never send session frames — only the tab the user acted in drives the orchestrator. - Removed the last scope machinery the retired setting fed (applyChatScope, askModeFollowsPanel, the ask confirm) and the retired setting id consts. Tests: unit coverage for the new selector rules (stale pointer, deliberate open, metadata-edit immunity, shared-vs-tab pointer, dangling pointer); rewrote the five workflow-scope Playwright cases (plus a sixth in conversation-persistence) to the panel-mode contract, including live e2e repros of both P0s; specs now route the whole web/js tree from the worktree so they exercise this commit instead of the stale linked checkout. Docs: chat-history-v2.md describes the single panel-owned conversation and the removed setting. Lands together with comfyui-mcp#897; neither is complete alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…versation (codex P0, round 2) Passive adoption (and a same-tab history switch) can happen while a turn is still streaming. The turn's owner is already pinned at turn:working (liveTurnThreadId, #381); extend that ownership rule from usage frames to transcript output: when the shown conversation is no longer the turn's owner, say/stream/todo frames neither paint nor record, and record() drops any non-user entry at its choke point. Dropped, not re-routed: stamping the straggler into its owner thread now would hand it the newest conversation activity and yank the shared selection straight back. The adoption e2e now runs codex's exact scenario — turn in flight, another tab moves the selection, a late say arrives — and asserts it lands nowhere; mutation-checked (disabling the fence makes the spec fail). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion-evidence guard, full output fence, dispatch-time filing (gate round 3, five P0s) P0-2 (structural, first): the shared selection pointer is now BACKEND-scoped (panel:backend:<id>), mirroring the orchestrator's orchestrator::<backend> session key — a Claude tab's selection can no longer move a Codex tab's conversation or cost it its resumable session. The legacy panel:global key is a one-way read fallback (resolvePanelPointer) until a backend's key is first written. P0-1: THE COMMIT IS THE TRANSITION. loadThread and newChat dispatch the session frame FIRST and publish the shared selection only when the frame actually left the socket — a disconnected tab still switches its own view but cannot move every other tab onto a conversation the backend never entered. (Cross-socket reorder between two connected actors still needs orchestrator-confirmed transitions — mcp#897's side; noted in the PR body.) P0-3: the stale-pointer guard now compares like with like — SELECTION evidence only. The retired workflow mode stamped workflow-scoped active ops on every thread creation/open; the newest selection op that resolves to a live thread wins. Message timestamps are no longer evidence: an imported archive, a straggler write, or a skewed clock cannot move the shared conversation, and equal-revision ties are total-ordered by compareRevisions. P0-4: turn ownership is pinned at user_message DISPATCH (pinTurnOwnerAtDispatch at every send site), closing the pre-turn:working hole where an adoption's endTurnLocally discarded the working frame inside the stale-working window and left ownership null. The fence now covers EVERY user-visible output path: say/stream/todo (round 2) plus ask_user, show_media, ui_render (honest tool errors — an interactive card in the wrong conversation is not transient) and command activity cards. P0-5: the prompt's target conversation is decided at DISPATCH, not type time. If the shared selection moves while sendCurrent awaits attachment uploads / grounding / validation, the optimistically recorded prompt is relocated — tombstoned out of the old thread and re-recorded + repainted in the current one — before the frame goes out. P2-6: the adoption e2e now drives the real actor seam (a second connected panel clicking the history row → loadThread), plus two new specs: a disconnected actor cannot move the shared conversation, and an abandoned turn's say/todo/ask output cannot leak into a conversation opened mid-turn (pre-working hole covered; content is seeded, selection is never written directly). Unit: selection-evidence + backend-scoping + one-way fallback matrix (2390/2390 pass). Live Playwright: 14/15 + the new disconnected-actor spec (the 1 failure is pre-existing, filed panel#694). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onnected delete queues its session reset (gate round 3 follow-ups) Codex re-review of the round-3 commit found two flow holes the backend scoping introduced: 1. Provider switching never adopted the target backend's conversation: the handshake flipped the selection key while the previous provider's thread stayed on screen, so the new backend's session ran against a conversation it does not own while reloads and other tabs resolved its real one. The handshake now compares the selection key before/after the backend resolves and, when it changed, adopts the new backend's own conversation through the normal actor path (loadThread → dispatch + publish under the NEW key; newChat when it has none). Reconnects to the same backend are untouched (keys equal). E2E: a second MockBridge reporting backend:codex; the panel adopts the seeded codex conversation and claude's selection is left intact; mutation-checked. 2. Deleting the ACTIVE conversation while disconnected cleared and persisted the shared pointer but silently lost the new_session frame — peers correctly went blank (the transcript is deleted) but the backend kept the deleted conversation's session. newChat now queues the reset it owes (PENDING_SESSION_RESET_KEY, per backend scope); the next ready ack fires it under three guards: same backend scope, the shared pointer is still in the cleared state this tab left (any newer act supersedes and drops it), and the frame actually sends. A delivered transition (loadThread/newChat dispatch) clears the queue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thread is already on screen (codex round-4 corner) First connect can land on a different backend than the restored default while the new backend's scope resolves the SAME legacy thread the panel already shows. The target.id !== thread.id guard then skipped the transition entirely, leaving the new backend's session unaligned (a foreign provider's session id staged from restore, no replay). The adoption block now runs loadThread on every backend-key change with a target — its provider check scrubs the foreign session id, arms the transcript replay, and publishes under the new backend's key. Same-backend reconnects remain untouched (keys equal). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… turn asked for them (#711) * fix(panel): fence ask_user / request_secret to the conversation whose turn asked Found by the independent gate on PR #680 and re-verified on origin/main. The panel renders two INTERACTIVE cards on the agent's behalf that COLLECT a value from the user and hand it back as the tool result: `request_secret` (a masked token input) and `ask_user` (a question card). Both painted unconditionally, while the handler sitting between them in the same object (`onThinking`) is fenced on `agentWorking` precisely so a late frame from a turn the user already ended cannot act on a screen it no longer owns. For these two the consequence is not a stray indicator but a stray VALUE: an abandoned or superseded turn could paint a secure input into whatever conversation the tab happened to be showing, and the token typed there came back as the result of a turn belonging to a DIFFERENT conversation. mcp #897 made agent sessions orchestrator-scoped, so "which conversation is on screen" and "which turn this frame belongs to" are now genuinely separable. The fence is the PAIR (agentWorking && liveTurnThreadId === thread?.id): a turn must be in flight in this tab, AND the conversation captured as that turn's owner at turn start must be the conversation on screen. Neither half suffices alone -- see the header of web/js/lib/interactive-card-fence.js for why the rid/epoch on the dispatch path and the frame's own fields cannot discriminate. A refused card answers the agent with an explicit ok:false naming what was refused, stating that nothing was shown, collected or stored, and giving the one next step that works -- the tone command-liveness.js already uses for this class. Nothing is painted anywhere else, and no value can be logged because the refusal runs before any card exists. Deliberately does NOT address PR #680's structural blocker (the panel publishes shared conversation state off sendFrame() returning true, which needs orchestrator-confirmed session transitions that do not exist yet). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(panel): answer my own gate — observation-only refusal wording, lifecycle tests, residuals documented Round 1 of my independent codex gate raised three findings. SEVERE (a straggler turn:working past onTurn's guard re-authorizes whatever conversation is on screen) and IMPORTANT (the mirror: a genuinely fresh turn's turn:working landing INSIDE that guard is discarded, so a legitimate card is refused) share one root cause: the `turn` frame carries a state and no turn identity. Closing either means putting a turn/conversation id on the wire, which is a comfyui-mcp protocol change and out of scope for this panel fix. Both are now documented in the module header AND pinned by a test each, so the residual is visible and a future turn-id has a test to flip. The fix remains strictly better than origin/main in both directions: the refused case fails CLOSED with an honest error, and the SEVERE case needs a precondition main required nothing for. The IMPORTANT finding also showed the refusal wording asserting a cause the panel never observed ("has already ended"), which is exactly wrong in that case. command-liveness.js's rule is 'reports what we OBSERVED, never a guess' — the no_live_turn text now states the observation and offers causes as examples. The next-step advice no longer implies a retry will help either. MINOR (the handler tests injected idealized state, so they proved the predicate rather than shipped lifecycle behaviour): added a section that wires the REAL endTurnLocally and onTurn bodies to the REAL fence and handlers over one closure with a fake clock, so agentWorking/liveTurnThreadId are produced by shipped code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(panel): let an owner-less turn own the conversation it MINTS, but only that one Gate round 2 found a real false refusal I had argued away in the design. A turn that begins on a view with no conversation captures liveTurnThreadId = null. Its own first output then runs record(), which MINTS the conversation now on screen. Refusing whenever owner !== shown therefore refused a card that belongs to the visible turn, in the visible conversation. The obvious inverse — paint whenever the owner is null — re-opens a hole in the other direction: loadThread()'s cross-workflow BLOCKED branch calls detachInvalidCurrentThread({rebind:true}) and RETURNS without endTurnLocally(), so a thread-less live turn can find an OLD conversation on screen. The discriminator is whether the shown conversation came into existence DURING this turn. onTurn('working') now stamps liveTurnStartedAt (cleared on done) and the fence compares it against thread.createdAt, which record()'s mint stamps off the same Date.now(). Missing or unusable timestamps fail CLOSED. A source-level test pins both stamps to the same clock and pins that record() does NOT retroactively adopt the thread as the turn owner, so the comparison cannot quietly become dead code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(panel): decide the owner-less case by PROVENANCE, not by age Gate round 3 broke round 2's discriminator, correctly. Comparing the shown conversation's createdAt against the turn start proves it is NEWER than the turn, not that this turn created it: a conversation minted in ANOTHER TAB after the turn began can sync into this tab's history and be rebound onto the screen by detachInvalidCurrentThread(), and it would have passed. Replaced with the fact itself. record()'s mint branch — the only place a conversation is ever created — records the id in a module-level `lastMintedThreadId`, and onTurn('working') resets it, so a non-null value means exactly 'record() created this conversation during the turn now running'. The owner-less branch paints only against that exact id. A conversation that merely APPEARED can never satisfy it, whatever its age. liveTurnStartedAt and the timestamp comparison are gone. The marker is module-scoped deliberately: record() runs from many points inside the panel builder closure, and a `let` declared partway down that closure would be in its temporal dead zone for any call reaching record() earlier. Only one panel is mounted at a time. Also from round 3: - MINOR: the 'liveTurnStartedAt is 0 between turns' comment was untrue because endTurnLocally() left it stale — moot, the variable no longer exists. - MINOR: the source pins were loose enough to miss a plausible refactor. The mint pin is now brace-bounded to record()'s own thread-creation branch (verified: an assignment moved to a sibling branch now fails it, where before it passed), the record() slice is sanity-checked to actually span record(), and every assignment to lastMintedThreadId in the file is enumerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(panel): harden the provenance pins against assignment forms and reset hoisting Narrow re-gate of the previous commit found the marker itself sound (no forgery path, remount-safe, nothing dangling) but the SOURCE PINS loose in two ways: - the writer enumeration only matched a literal `= `, so a later `lastMintedThreadId ||= replacement.id` in a rebind path would have slipped through while letting a conversation nobody minted vouch for itself; - the reset pin accepted a reset hoisted above onTurn's 'working' branch, which would fire on 'done' too — a weaker meaning than 'since this turn began'. Both now fail loudly (mutation-verified: each hypothetical regression applied to the panel source fails this test). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
433 commits of main, two conflicted files, eleven hunks. The branch is 6 commits old and main moved the whole i18n layer under it, so most hunks were "main translated the thing this branch deletes". Resolutions, and why: * PENDING_SESSION_RESET_KEY vs bridgeOutage (#1145) — kept BOTH. Main replaced `lastBridgeDownAt` with a tracker; that variable had no readers left, so dropping it was the correct half of the resolution, not a loss. * chatScopeMode / applyPanelLocale — kept main's new locale loader AND the branch's ownership comment. The function body did not actually conflict: main never touched it, so the branch's `return "panel"` applied cleanly. * "Chat conversation scope" combo, the `ask` window.confirm, and panelHooks.applyChatScope — branch wins, all three deleted. Main had only wrapped their strings in tr(). Deleting them IS this PR. * onAsk — main wins outright. Both sides had added an ownership fence; main's `fenceInteractiveCard()` decides the same question with strictly more evidence (it also weighs `agentWorking` and `lastMintedThreadId`), and it carries the `socketId` parameter main's `paintQuestion` now needs. The branch's plainer `turnOutputFenced()` refusal here would have been a worse duplicate. * Backend-switch handshake — kept both: the branch's `previousScopeKey` capture (before `connectedBackend` moves, which is what the key derives from) and main's "sessions aren't shared across providers" notice. * record()'s mint — kept the branch's backend-scoped `setActiveThread` AND main's `lastMintedThreadId` write. * workflow-chat-identity.spec.ts — branch wins. Main's #847 block saved a workflow to make an embed assertion reachable, and ran under `setWorkflowScope(page)` forcing `chatScope: 'workflow'`. Neither exists here: no scope setting, so `workflowStorageKey({ embed: true })` is never reached and there is no graph tag to assert. Main's cleanup block went with it — it deleted the file that is no longer created (`savedAs` would have been undefined; caught by tsc, which test:unit does not cover). Fallout the merge exposed, fixed here: * Retiring the setting orphaned its i18n rows. Removed `comfyui-mcp_chatScope` from all 12 `locales/*/settings.json` and the 8 now-dead scope strings from all 12 `locales/*/main.json` — the settings-i18n-keys and i18n catalog gates both failed on them, which is the gates working. * interactive-card-fence's "record() does not write the turn owner" matched a bare SUBSTRING, so it failed on the branch's COMMENT explaining the output fence. Narrowed to an ASSIGNMENT check, using the same assignment forms the test's own write-enumeration already accepts. Mutation-checked: adding `liveTurnThreadId = thread.id;` to record() still fails it. Verified: `npm run test:unit` 4406/4407 (1 todo, 0 fail) with the i18n, tool-vocabulary and panel-scope gates chained; `tsc --noEmit` clean. Refs artokun/comfyui-mcp#884, artokun/comfyui-mcp#897 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Sessions are ORCHESTRATOR-scoped, never workflow-scoped or tab-scoped" is a project invariant, and this branch is what makes the panel honour it. Until now that was only checkable by reading: `chatScopeMode()` is a constant, so every workflow-scoped path is unreachable — but nothing FAILED if it stopped being a constant, and the retired machinery is all still present as deliberate defence-in-depth. That combination is one edit away from waking up. Five assertions, each mutation-checked against the edit it is supposed to catch: 1. `chatScopeMode()` returns the literal "panel" and reads no stored value. (Mutation: make it read SETTING_CHAT_SCOPE again → fails.) 2. No chat-scope row is registered in panelSettingsList(), and no live `applyChatScope` hook survives outside comments. 3. `currentHistoryScopeKey()`'s panel answer is keyed `panel:backend:<id>` — the same axis as the orchestrator's `orchestrator::<backend>` — and the workflow key stays reachable ONLY behind the `historyScopeFollowsPanel()` guard. (Mutation: revert the key to the shared "panel:global" → fails.) 4. EVERY `setActiveThread` write addresses the backend key, never a workflow key. The pointer decides which conversation a tab renders and records into, so a single workflow-keyed writer would make the conversation workflow-scoped again whatever `chatScopeMode()` says. (Mutation: one writer switched to `workflowStorageKey()` → fails.) 5. `onWorkflowMaybeChanged`'s panel-owned branch RETURNS, which is the only thing keeping the workflow-keyed `ssSet(SESSION_KEY, existing?.sessionId)` tail dead. (Mutation: delete that `return;` → fails.) Assertion 5 is the one worth keeping. That line is where a session used to belong to a WORKFLOW; it still exists, and its deadness rests entirely on a `return` several hundred lines above it. Nothing else in the suite noticed that. Refs artokun/comfyui-mcp#884 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
|
| assertion | mutation that must kill it | result |
|---|---|---|
chatScopeMode() is a literal constant |
make it read SETTING_CHAT_SCOPE again |
fails ✓ |
| no chat-scope row/hook is registered | — (static) | — |
the pointer is keyed panel:backend:<id> |
revert to shared panel:global |
fails ✓ |
| every pointer write uses the backend key | one writer → workflowStorageKey() |
fails ✓ |
| the panel-owned branch RETURNS | delete that return; |
fails ✓ |
Merge resolutions worth a second pair of eyes
Eleven hunks; most were "main translated the thing this branch deletes", where the branch wins. Three were judgement calls:
onAsk— I took main's side outright. Both sides had independently added an ownership fence. Main'sfenceInteractiveCard()answers the same question with strictly more evidence (it also weighsagentWorkingandlastMintedThreadId, so an owner-less turn that minted its own thread is not mistaken for one painting into someone else's conversation), and it carries thesocketIdargument main'spaintQuestionnow needs. Commit 3's plainerturnOutputFenced()refusal here would have been a worse duplicate. The branch's P0-4ask_userfence is therefore superseded, not lost — worth knowing when re-reading that commit's rationale.workflow-chat-identity.spec.ts— branch wins. Main's e2e: 2 specs fail — chat-history-v2 row count, workflow-chat-identity first record #847 block saved a workflow so an embed assertion could be reached, and ran undersetWorkflowScope(page)forcingchatScope: 'workflow'. Neither survives: with no scope setting,workflowStorageKey({ embed: true })is never reached and there is no graph tag to assert. Main's cleanup block went with it — otherwisesavedAswas undefined.tsccaught that; notetest:unitdoes not typecheck the specs, so I rantsc --noEmitseparately (clean).interactive-card-fence's "record() does not write the turn owner" matched a bare substring and so failed on the branch's comment explaining the output fence. I narrowed it to an assignment check, reusing the assignment forms that test's own write-enumeration already accepts. Mutation-checked: puttingliveTurnThreadId = thread.id;intorecord()still fails it. Flagging it because I edited a guard that came from main.
Merge fallout, fixed
Retiring the setting orphaned its translations, and two gates caught it: comfyui-mcp_chatScope removed from all 12 locales/*/settings.json, and the 8 dead scope strings from all 12 locales/*/main.json.
Tests
npm run test:unit — 4406 pass, 0 fail, 1 todo (4407), with the i18n, tool-vocabulary and panel-scope gates chained. tsc --noEmit clean. Playwright is unchanged from the PR body and still not in CI.
For the maintainer
- This still lands with fix(orchestrator): sessions are orchestrator-scoped — one agent across all tabs and workflows (#884) comfyui-mcp#897 — the panel half alone does not make the session orchestrator-global.
- Copilot's review predates commits 2–6 (it reviewed 1/1 files, commit 1 only). Its inline finding — cross-tab selection corrupting session bindings — is what commits 2–6 exist to answer; its two suppressed notes (specs still forcing
workflowmode, and the design doc) were already addressed on the branch. - Still a draft, still yours to un-draft and land. Refs P0: agent sessions are workflow-scoped — context is lost on Workflow → New, on switching workflows, and across tabs comfyui-mcp#884.
…scoped-sessions # Conflicts: # browser_tests/workflow-chat-identity.spec.ts # web/js/comfyui-mcp-panel.js
The retirement was covered only by Playwright specs, which are not in CI (they need a live ComfyUI on :8188). A mutation proved the gap: restoring chatScopeMode()'s old "read the stored setting" body left the whole unit suite green at 4401/4401, so nothing in CI could tell that the retired workflow/ask scopes had come back — and a user left in one of them gets several panel transcripts mapping onto the single session the orchestrator actually runs. The load-bearing cases EXTRACT the shipped function and CALL it against a stubbed settings store, per the repo's "real panel source" convention. A source regex would not do: `if (false) return getSetting(...)` matches any pattern written about the old body while still shipping correct behaviour, and the mutation that matters must fail. Both new cases fail against that mutation. The two structural cases (no registered scope row, no live applyChatScope / askModeFollowsPanel) are defense in depth for the other direction — a re-added combo would be a visible control that silently does nothing, and the obvious "fix" for that is to wire it back up. They deliberately do NOT fail on the chatScopeMode mutation, which is precision, not weakness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…okun/comfyui-mcp-panel into fix/884-orchestrator-scoped-sessions # Conflicts: # browser_tests/workflow-chat-identity.spec.ts # web/js/comfyui-mcp-panel.js
The independent mutation gate disabled the fence at every call site -- `if (false && turnOutputFenced())` in record(), onSay, onStream and onTodo -- and the whole unit suite stayed green. The fence was correct and completely unpinned: its only coverage was a Playwright spec that is not in CI. A source-regex test cannot close this. `if (false && turnOutputFenced())` still contains the string every regex would match, so only RUNNING the call sites separates the live fence from a disabled one. So this drives the shipped bodies. turnOutputFenced, pinTurnOwnerAtDispatch, record, onSay, onStream and onTodo are lifted straight out of the panel source and executed over stubbed collaborators -- the established "real panel source" convention (interactive-card-fence.test.mjs's buildLifecycle, context-ring-scope.test.mjs). The stub surface stays observational rather than a reimplementation: 15 one-line stubs that count calls or return fixture values, plus the REAL ChatHistoryStore and the REAL isThreadInScope, so no thread-revision semantics are modelled here. Every anchor is asserted to occur exactly once -- this checkout is CRLF, and an LF-authored multi-line anchor that silently misses reads exactly like a passing test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ions' into fix/884-orchestrator-scoped-sessions
Three P1s from the codex review of ef5de74, and they share one cause: the per-backend conversation model was still coupled to the OLD provider-switch flow, which was built when switching ALWAYS meant a fresh session. Fixed at TWO seams rather than three point-fixes or one: 1. lib/chat-history-store.js -- the UPGRADE FORK. A pre-upgrade snapshot holds one shared `panel:global` pointer and no per-backend keys, so every backend key fell back to the SAME thread id: Claude and Codex both claimed it, loadThread scrubbed its foreign session and record() rewrote its provider on every append. Two providers shared and corrupted one transcript, on the first upgrade of every existing user. The legacy route is now forked by `provider`, the ownership stamp record() already writes. Both doors are closed -- the legacy pointer AND the no-pointer recency fallback, which was equally "the same id for every backend". A pointer a backend wrote for ITSELF still wins over the stamp, so normal operation is untouched. A provider-less thread fails closed; nothing is deleted and it still opens through the picker. This cannot live in the switch path: cold restore reaches it with no switch involved. 2. lib/backend-switch.js -- ONE handover decision. `planBackendHandover` asks the store once what the incoming backend already has, and derives both consequences, so they cannot drift apart again: - the outgoing THREAD keeps its sessionId (the tab pointer still goes), so switching back resumes instead of sending new_session -- the per-backend persistence this branch exists to add was being defeated by its own switch path; - the outgoing transcript is armed as one-shot context ONLY when the incoming backend has no conversation. When it HAS one, loadThread resumes it and the armed context is CLEARED rather than merely skipped -- anything armed earlier would otherwise ride the next message into the wrong conversation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mutation 2 REGRESSED and 9 was never covered:
[2] The upgrade fork MASKED the `panel:` skip. Its fixture threads had no
provider, so after the fork another backend`s op resolved to a thread this
backend could not claim anyway and deleting the skip stopped failing
anything. The two rules are different questions -- "could this backend own
it" vs "is another backend`s selection evidence for mine" -- so the guard is
now pinned with a thread whose provider matches BOTH, which is reachable
because a thread`s provider changes when the user switches backends while it
is open.
[9] The handover tests proved runBackendSwitch PASSES preserveThreadSession, not
that the panel HONOURS it. An invalidateDurableAgentSession that ignores the
option destroys the outgoing session exactly as before and every assertion
stayed green. The shipped body is now extracted and RUN, both directions:
a switch preserves the thread session, a restart still destroys it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Panel-side companion to artokun/comfyui-mcp#897 (MERGED 2026-08-06), which made agent sessions orchestrator-scoped — one session per backend across every panel, tab and workflow, keyed and persisted by the orchestrator. That half landed; this is the half that was still missing. Until it lands,
mainships a panel that can still put the user in a per-workflow conversation while the orchestrator runs a single global session, so the transcripts silently diverge from the agent's real context.What this changes
1. The workflow/ask chat scopes are retired.
chatScopeMode()is hard-wired to"panel". The Settings → "Chat conversation scope" combo,panelHooks.applyChatScope,askModeFollowsPaneland the "ask"window.confirmare all removed — there is no live scope switcher left to reintroduce per-workflow sessions behind the orchestrator's back. A storedworkflow/askvalue from an older build is ignored, not migrated. Per-workflow threads created under the old modes stay in history and open through the picker like any archived conversation; nothing is deleted.historyScopeFollowsPanel()is kept as a named seam (now constant) so the remaining per-workflow guards stay honest defense-in-depth rather than silently deleted invariants. That is a reasonable choice and also a standing hazard — the whole retired path wakes up the momentchatScopeMode()stops being a constant — so it is now pinned by gates rather than left to inspection (below).2. Which conversation a tab renders is SHARED state, not tab-local. One selector —
selectPanelThread— resolves it on cold restore and on cross-tab sync alike. A tab either acts (loadThread / newChat: dispatch the session frame, then publish the pointer) or adopts (reads the pointer and repaints). Adoption is passive, so N tabs never race to reset the one global session.3. The selection pointer is BACKEND-scoped (
panel:backend:<id>), the same axis as the orchestrator'sorchestrator::<backend>session key — a Claude tab's selection must not move a Codex tab's conversation.resolvePanelPointerkeeps a one-way legacy fallback topanel:global: once a backend key is written (including a deliberate clear) it never falls back again.4. Stale-pointer guard, selection evidence only. Only SELECTION ops compete, total-ordered by
compareRevisions. MessagecreatedAtis deliberately not evidence — an imported archive, a straggler write or a skewed clock must not move the shared conversation. The retired workflow mode stamped a workflow-scoped active op on every thread open, so a genuine pre-upgrade snapshot restores the conversation the user was actually in.5. Turn-output fencing. Ownership is pinned at
user_messagedispatch (pinTurnOwnerAtDispatch), not only atturn:working;onSay/onStream/onTodoreturn early andrecord()drops any non-user entry when the shown conversation is not the turn's owner. The straggler is dropped like an interrupt's, deliberately not re-routed into its owner thread (a fresh stamp there would yank the shared selection straight back).6. The backend switch asks what the incoming backend HAS. A codex review found three P1s that shared one cause: the new per-backend conversation model was still coupled to the old provider-switch flow, which was built when switching always meant a fresh session. Fixed at two seams, not three point-fixes and not one (see Why two seams below):
lib/chat-history-store.js). A pre-upgrade snapshot holds one sharedpanel:globalpointer and no per-backend keys, so every backend key fell back to the same thread id: Claude and Codex both claimed it,loadThreadscrubbed its foreign session, andrecord()rewrote its provider on every append — two providers sharing and corrupting one transcript, on the first upgrade of every existing user. The legacy route is now forked byprovider, the ownership stamprecord()already writes. Both doors are closed: the legacy pointer and the no-pointer recency fallback, which was equally "the same id for every backend". A pointer a backend wrote for itself still outranks the stamp, so normal operation is untouched (a thread legitimately changes provider when the user switches backends while it is open). A provider-less thread fails closed; nothing is deleted and it still opens through the picker.lib/backend-switch.js).planBackendHandoverasks the store once what the incoming backend already has and derives both consequences, so they cannot drift apart again: (a) the outgoing thread keeps itssessionId— the tab pointer still goes — so switching back resumes instead of sendingnew_session, which is the per-backend persistence this PR exists to add and was being defeated by its own switch path; (b) the outgoing transcript is armed as one-shot context only when the incoming backend has no conversation. When it has one,loadThreadresumes it and any armed context is cleared rather than merely skipped — anything armed earlier would otherwise ride the next user message into the wrong conversation.Merge with main
This branch was ~440 commits behind.
origin/mainis merged in (now current throughv0.14.41); 11 conflict hunks incomfyui-mcp-panel.jsand 1 inworkflow-chat-identity.spec.ts, resolved as:lib/interactive-card-fence.js. Main independently built an interactive-card fence for the same problem class — its header records that it was found by the independent gate on this PR and deliberately scoped to leave fix(panel): the conversation is always panel-owned — retire the workflow/ask chat scopes (mcp#884) #680's structural work alone. Both fences survive, because they are not interchangeable:fenceInteractiveCard()(main's classifier) guardsask_user/request_secretand weighsagentWorking+lastMintedThreadIdas well, so an owner-less turn that minted its own thread is not mistaken for one painting into somebody else's conversation. This branch'sturnOutputFenced()asks only "is the shown conversation the turn's owner", which is right for transcript output but would false-refuse that legitimate card — and a refusal costs the agent a tool error, so it must be the precise test.pinTurnOwnerAtDispatchnarrows main's fence's own documented residual: a turn whoseturn:workingis discarded by the stale-working guard no longer reaches the classifier with a null owner.bridgeOutagetracker (The mid-task nudge measures one backoff step, not the outage — a fast-returning restart loses its nudge #1145) supersedes this branch'slastBridgeDownAt; main's i18ntr()conversions kept throughout.interactive-card-fence.test.mjs's "record() does not write the turn owner" assertion is matched as an assignment rather than a bare substring. Reading the owner insiderecord()is legitimate and now happens (the output fence consults it); the substring form also failed on the comment explaining that fence — the prose-predicate trap, which would have been "fixed" by renaming a comment while leaving the real rule unguarded. The accepted assignment forms mirror the write-enumeration above it, so aliveTurnThreadId ||= thread.idsmuggled intorecord()still fails.workflow-chat-identity.spec.ts: main's e2e: 2 specs fail — chat-history-v2 row count, workflow-chat-identity first record #847 save-then-assert-the-embed sequence is dropped. It passed only because the test forced theworkflowscope this PR retires — the graph embed is written solely on theworkflowStorageKey({ embed: true })path, which panel scope never calls. Under the one shipping mode nothing is saved and nothing is embedded; provenance lives in history metadata and the graph is never touched.comfyui-mcp_chatScopeinsettings.jsonplus 8panel.*keys inmain.json, across all 12 locales) — caught by the i18n round-trip and settings-key gates.Why two seams and not one
The review suggested collapsing all three P1s into a single decision in the switch path. Two of them do collapse — session preservation and replay disposal are both consequences of "what does the incoming backend have", and they are now one function so a future edit cannot fix them by halves.
The upgrade fork cannot join them: cold restore reaches it with no switch involved. A reload on a pre-upgrade snapshot resolves the pointer directly through
selectPanelThread, so a fix living inrunBackendSwitchwould leave the corruption fully reachable by simply reloading the tab. It is a resolution rule and belongs in the store. Forcing it into the switch path would have been a worse design that also failed to fix the bug.Tests
npm run test:unit— 4449 tests, 4448 pass, 0 fail, 1 todo.npm run typecheckclean.check-panel-scopepasses (every name resolves — the gate that catches a name resolving in a sibling function, the live risk when merge resolution moves code between functions).One test,
#671 verifyInstalled … inside the reply window, is a wall-clock budget assertion that fails only when the box is loaded (it failed once during a run that took 104 s against a normal 40-70 s, and passes 122/122 in isolation). It covers ComfyUI-Manager install verification and is untouched by this branch.Complementary gate suites pin the invariants:
browser_tests/unit/chat-history-store.test.mjs— source-level gates over the whole retired surface:chatScopeModeis a constant, no scope setting is registered,currentHistoryScopeKeyresolves to a backend axis, every selection-pointer write uses the backend key, the workflow-keyed session bind is unreachable.browser_tests/unit/chat-scope-retired.test.mjs— the behavioural half of the retirement: it extracts the shippedchatScopeModeand calls it against an injected settings store, for every value a retired scope could have persisted.browser_tests/unit/turn-output-fence.test.mjs(new — closes the fence gap, see below).browser_tests/unit/backend-switch.test.mjs— the handover:planBackendHandoveras a pure decision, the effects it drives throughrunBackendSwitch, and the shippedinvalidateDurableAgentSessionbody extracted and run.browser_tests/unit/chat-history-store.test.mjs— the upgrade fork, including a test that two backends cannot resolve one legacy thread id, the provider-less fail-closed case, and the recency-fallback door.Both behavioural suites are deliberately not source-regex tests.
if (false) return getSetting(...)andif (false && turnOutputFenced())match every regex anyone would write about those call sites while shipping the opposite behaviour, so only running them distinguishes the two.Mutation results (committed first, then broken, then restored from an in-memory snapshot)
All twelve KILLED, re-run in full against the current head.
resolvePanelPointer— make the legacy fallback two-way (a cleared backend key falls back topanel:globalagain)selectPanelThread— let another backend's selection op compete (drop thepanel:skip)chatScopeMode()— restore the old "read the stored setting" body, un-retiring workflow/askrecord()— disable the turn-output fence viaif (false && …)onSay/onStream/onTodo— disable all three transcript fences viaif (false && …)record()— drop the USER-entry exemption (fence the user's own message too)pinTurnOwnerAtDispatch()— stop pinning at dispatch (ownership only atturn:working)preserveThreadSession(destroy the outgoing backend's session again)Mutations 3, 4 and 7 are the ones that mattered. 3 and 4 were this PR's headline behaviour with no CI coverage at all; 7 is the upgrade path every existing user takes.
Two of these survived their first run and are worth the reviewer's attention, because both are the failure mode where a fix looks tested and is not:
provider, so after the fork another backend's op resolved to a thread this backend could not claim anyway, and deleting thepanel:skip stopped failing anything. The two rules are different questions — "could this backend own it" vs "is another backend's selection evidence for mine" — and the guard is now pinned with a thread whose provider satisfies both, which is reachable precisely because a thread's provider changes when the user switches backends while it is open.runBackendSwitchpassespreserveThreadSession; nothing proved the panel honours it. AninvalidateDurableAgentSessionthat ignores the option destroys the outgoing session exactly as before and every assertion stayed green. The shipped body is now extracted and run, in both directions: a switch preserves the thread session, a restart still destroys it.(Mutation 10 reports fewer total tests because it makes a whole test file throw at load; that is still a detection, just a louder one.)
The runner asserts an exact occurrence count for every anchor before applying it, and restores from an in-memory snapshot rather than
git checkout. That guard earned its keep twice: mutation 1's first anchor matched 3 sites and was reportedNOT APPLIEDrather than as a kill.How the turn-output fence gap (mutation 4) was closed
browser_tests/unit/turn-output-fence.test.mjslifts the realturnOutputFenced,pinTurnOwnerAtDispatch,record,onSay,onStreamandonTodobodies out of the shipped panel source and executes them over stubbed collaborators — the established "real panel source" convention (interactive-card-fence.test.mjs'sbuildLifecycle(),context-ring-scope.test.mjs).The earlier concern was that
record()(124 lines) would need ~35 stubs and the harness would become a copy of the panel. It did not: the surface is 15 one-line stubs, every one either an observation point (persistThreadscounts,appendAgentrecords what it was handed) or a fixture value (getWorkflowTitle,workflowTabId). The two collaborators that carry real logic —ChatHistoryStore(reviseThread/touchMessage) andisThreadInScope— are imported for real, so no thread-revision semantics are modelled in the test. Panel constants (MAX_THREADS,MAX_THREAD_MSGS,MAX_WORKFLOW_VERSIONS) are read out of the source rather than restated.Extracting the fence predicate into
web/js/lib/was considered and rejected:turnOutputFenced()is already a correct two-line predicate, and testing it in isolation would not have killed this mutation. The mutation disables the call sites, not the predicate —if (false && correctPredicate())passes any test written aboutcorrectPredicate. Only running the call sites separates them.What the 14 new tests assert, all through the shipped bodies:
record()'s mint branch and create a conversation for output nobody asked for;record()mutates in place, never clones);onSay/onStream/onTodopaint nothing when fenced;turn:workingframe arrives;Each source anchor is asserted to occur exactly once before it is evaluated. This checkout is CRLF, and an LF-authored multi-line anchor that silently misses would inject an empty body and make every assertion vacuous — which reads exactly like a passing test. (That guard earned its keep: the first run of mutation 1's own anchor matched 3 sites and was correctly reported as NOT APPLIED rather than as a kill.)
Not verified: Playwright
The Playwright specs are not in CI (they need a live ComfyUI on :8188 with
--workers=1) and were not re-run against this head. The adoption, backend-switch and abandoned-turn e2e claims should be treated as unverified. The unit gates above now cover the fence, the scope retirement, the upgrade fork and the handover in CI, but the multi-tab adoption choreography and the live backend-switch round trip are still only claimed, not demonstrated, on this head — and the backend-switch changes above are exactly the area a live round trip would exercise.Known residual (unchanged, and it is the honest one)
The panel publishes the shared selection off
sendFrame()returningtrue, which proves only that the local socket accepted bytes — not that the orchestrator applied the transition. Two connected actors racing can therefore still disagree. Closing that needs orchestrator-confirmed session transitions, which do not exist on the wire; it is mcp-side work, and main'sinteractive-card-fence.jsindependently reached and documented the same conclusion.🤖 Generated with Claude Code