diff --git a/browser_tests/chat-history-v2.spec.ts b/browser_tests/chat-history-v2.spec.ts index 3d941c57..e1854990 100644 --- a/browser_tests/chat-history-v2.spec.ts +++ b/browser_tests/chat-history-v2.spec.ts @@ -1,24 +1,17 @@ import { test, expect } from './fixtures/panelTest' import { resolveHistoryStoreModuleUrl } from './fixtures/historyStoreModule' +import { routeWorktreeSource } from './fixtures/worktreeSource' const THREADS_KEY = 'comfyui-mcp.panel.threads' const META_KEY = 'comfyui-mcp.panel.historyMeta' -async function forceWorkflowScope(page: import('@playwright/test').Page) { - await page.evaluate(() => { - const w = window as any - const app = w.comfyAPI?.app?.app || w.app - const settings = app.ui.settings - if (!w.__cmcpOriginalGetSettingValue) { - w.__cmcpOriginalGetSettingValue = settings.getSettingValue.bind(settings) - } - settings.getSettingValue = (id: string) => - id === 'comfyui-mcp.chatScope' - ? 'workflow' - : w.__cmcpOriginalGetSettingValue(id) - }) -} +test.beforeEach(async ({ context }) => { + await routeWorktreeSource(context) +}) + +// mcp#884: the workflow/ask chat scopes are retired — chatScopeMode() is +// hard-wired to "panel". Every spec here runs the one shipping mode. async function indexedThreadCount(page: import('@playwright/test').Page): Promise { return page.evaluate(async () => { @@ -243,6 +236,9 @@ test('groups duplicate workflow titles by UUID and never resumes a foreign provi stopCapture() }) +// Panel scope keeps workflow PROVENANCE on every thread (archive grouping), +// and the unsaved-workflow durability path (#570) still stamps the stable +// per-instance UUID into graph.extra — silently, never via a dirty flag. test('embeds a stable workflow UUID and records provider/model workflow snapshots', async ({ page, panel, @@ -253,8 +249,6 @@ test('embeds a stable workflow UUID and records provider/model workflow snapshot await panel.openSidebar() await panel.connect() - await forceWorkflowScope(page) - const received = mockBridge.waitForUserMessage() await panel.sendMessage('workflow identity test') await received @@ -290,98 +284,93 @@ test('embeds a stable workflow UUID and records provider/model workflow snapshot expect(state.versions?.[state.messageVersion]?.nodeCount).toBeGreaterThanOrEqual(0) }) -test('workflow scope disables foreign chats and never restores a stale foreign pointer', async ({ +// mcp#884 (P0-2): a cold upgrade from a build that still had the workflow/ask +// scopes can leave a STALE panel:global pointer behind — the user switched to +// workflow mode long ago and kept conversing in per-workflow threads. With the +// tab-local pointer gone (browser restart) the hard-wired panel restoration +// must recover the conversation the user is actually in, not repaint the +// months-old pointer target over it. +test('a stale pre-upgrade panel pointer never restores over the current conversation', async ({ page, - panel, - mockBridge + panel }) => { + const staleAt = Date.now() - 45 * 24 * 60 * 60 * 1000 + const freshAt = Date.now() - 60 * 1000 + await page.addInitScript(({ threadsKey, metaKey, staleAt, freshAt }) => { + // Pre-#884 storage shape: an old panel-mode thread whose panel:global + // pointer was last stamped months ago, plus the per-workflow conversation + // the user actually kept using after switching the (now removed) setting. + localStorage.setItem(threadsKey, JSON.stringify([ + { + id: 'stale-panel-thread', + createdAt: staleAt, + updatedAt: staleAt, + ts: staleAt, + workflowKey: 'panel:global', + msgs: [{ + id: 'stale-panel-message', + role: 'user', + text: 'an old conversation from months ago', + createdAt: staleAt + }] + }, + { + id: 'current-workflow-thread', + createdAt: freshAt, + updatedAt: freshAt, + ts: freshAt, + workflowKey: 'workflow:wf-current', + workflowTitle: 'Current Workflow', + msgs: [{ + id: 'current-workflow-message', + role: 'user', + text: 'the conversation I am actually in', + createdAt: freshAt + }] + } + ])) + // The retired workflow mode stamped a workflow-scoped selection op on + // every thread creation/open, so a real pre-upgrade snapshot carries the + // newer workflow selection alongside the abandoned panel pointer. + localStorage.setItem(metaKey, JSON.stringify({ + updatedAt: freshAt, + activeByScope: { + 'panel:global': 'stale-panel-thread', + 'workflow:wf-current': 'current-workflow-thread' + }, + activeOps: { + 'panel:global': { + value: 'stale-panel-thread', + deleted: false, + updatedAt: staleAt + 1, + revision: { updatedAt: staleAt + 1, writerId: 'old-build', sequence: 1 } + }, + 'workflow:wf-current': { + value: 'current-workflow-thread', + deleted: false, + updatedAt: freshAt, + revision: { updatedAt: freshAt, writerId: 'old-build', sequence: 2 } + } + } + })) + }, { threadsKey: THREADS_KEY, metaKey: META_KEY, staleAt, freshAt }) + await panel.goto() - await panel.setBridgeUrl(mockBridge.url) await panel.openSidebar() - await panel.connect() - await forceWorkflowScope(page) - - const received = mockBridge.waitForUserMessage() - await panel.sendMessage('belongs only to workflow A') - await received - - await expect.poll(() => page.evaluate((key) => { - const threads = JSON.parse(localStorage.getItem(key) || '[]') - return threads.find((thread: any) => - thread.msgs?.some((m: any) => m.text === 'belongs only to workflow A'))?.workflowKey - }, THREADS_KEY)).toMatch(/^workflow:/) - const current = await page.evaluate((key) => { - const threads = JSON.parse(localStorage.getItem(key) || '[]') - return threads.find((thread: any) => - thread.msgs?.some((m: any) => m.text === 'belongs only to workflow A')) - }, THREADS_KEY) - expect(current?.workflowKey).toMatch(/^workflow:/) - - const storeModuleUrl = await resolveHistoryStoreModuleUrl(page) - await page.evaluate(async ({ - currentThread, - currentWorkflowKey, - storeModuleUrl - }) => { - const { ChatHistoryStore } = await import(storeModuleUrl) - const foreignStore = new ChatHistoryStore({ writerId: 'foreign-archive-test' }) - const canonical = await foreignStore.readCanonical() - const foreignThread = { - id: 'foreign-thread', - schemaVersion: 2, - createdAt: Date.now() + 10, - updatedAt: Date.now() + 10, - ts: Date.now() + 10, - workflowKey: 'workflow:definitely-another-workflow', - workflowTitle: 'Workflow B', - msgs: [{ id: 'foreign-message', role: 'user', text: 'must never appear on workflow A', createdAt: Date.now() + 10 }] - } - const meta = canonical.meta || {} - meta.activeByScope = { - ...(meta.activeByScope || {}), - [currentWorkflowKey]: currentThread, - 'workflow:definitely-another-workflow': 'foreign-thread' - } - foreignStore.persist([...(canonical.threads || []), foreignThread], meta) - const result = await foreignStore.flush() - if (result !== true && result?.ok !== true) { - throw new Error(`foreign archive seed failed: ${JSON.stringify(result)}`) - } - foreignStore.close() - sessionStorage.setItem('comfyui-mcp.panel.currentThreadId', 'foreign-thread') - }, { - currentThread: current.id, - currentWorkflowKey: current.workflowKey, - storeModuleUrl - }) + // The stale pointer loses to the newer conversation activity. + await expect(panel.userBubble('the conversation I am actually in')).toBeVisible() + await expect(panel.userBubble('an old conversation from months ago')).toHaveCount(0) + // The old panel-era chat is still an ordinary archive entry — visible and + // OPENABLE (panel scope has no foreign-workflow lockout), and opening it is + // a deliberate selection that repaints it. await panel.root.locator('button[title="Chat history"]').click() - let currentOnly = panel.root.getByTestId('history-current-workflow') - await currentOnly.uncheck() - let foreignRow = panel.root.locator('.cmcp-hist-row').filter({ hasText: 'must never appear on workflow A' }) - await expect(foreignRow).toBeVisible() - await expect(foreignRow.locator('.cmcp-hist-open')).toBeDisabled() - await expect(foreignRow.locator('.cmcp-hist-open')).toHaveAttribute('title', /open workflow b/i) - await panel.root.locator('button[title="Chat history"]').click() - - await page.evaluate(() => sessionStorage.clear()) - - await page.reload() - await panel.openSidebar() - // The hermetic fixture intentionally discards panel-setting writes at the - // HTTP boundary, so re-apply the user's persisted workflow mode after reload. - await forceWorkflowScope(page) - // The fixture's canvas is intentionally unsaved, so a full browser restart - // creates a fresh workflow UUID and a clean view. Crucially, the stale pointer - // from Workflow B is never used as a fallback. - await expect(panel.userBubble('must never appear on workflow A')).toHaveCount(0) - - await panel.root.locator('button[title="Chat history"]').click() - currentOnly = panel.root.getByTestId('history-current-workflow') - await currentOnly.uncheck() - foreignRow = panel.root.locator('.cmcp-hist-row').filter({ hasText: 'must never appear on workflow A' }) - await expect(foreignRow).toBeVisible() - await expect(foreignRow.locator('.cmcp-hist-open')).toBeDisabled() - await expect(foreignRow.locator('.cmcp-hist-open')).toHaveAttribute('title', /open workflow b/i) + const staleRow = panel.root + .locator('.cmcp-hist-row') + .filter({ hasText: 'an old conversation from months ago' }) + await expect(staleRow).toBeVisible() + await expect(staleRow.locator('.cmcp-hist-open')).toBeEnabled() + await staleRow.locator('.cmcp-hist-open').click() + await expect(panel.userBubble('an old conversation from months ago')).toBeVisible() }) diff --git a/browser_tests/conversation-persistence.spec.ts b/browser_tests/conversation-persistence.spec.ts index 4cf447d5..7cbb6c8f 100644 --- a/browser_tests/conversation-persistence.spec.ts +++ b/browser_tests/conversation-persistence.spec.ts @@ -6,52 +6,15 @@ import { test, expect } from './fixtures/panelTest' import { PanelPage } from './fixtures/PanelPage' import { resolveHistoryStoreModuleUrl } from './fixtures/historyStoreModule' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { routeWorktreeSource } from './fixtures/worktreeSource' const SESSION_KEY = 'comfyui-mcp.panel.sessionId' const CURRENT_THREAD_KEY = 'comfyui-mcp.panel.currentThreadId' const LOCAL_HISTORY_SNAPSHOT_KEY = 'comfyui-mcp.panel.historySnapshot' -const PANEL_SOURCE = readFileSync(resolve('web/js/comfyui-mcp-panel.js'), 'utf8') -const HISTORY_STORE_SOURCE = readFileSync( - resolve('web/js/lib/chat-history-store.js'), - 'utf8' -) - -async function forcePerWorkflowSettings(route: import('@playwright/test').Route) { - if (route.request().method() !== 'GET') return route.continue() - const response = await route.fetch() - const raw = await response.text() - let settings: Record = {} - if (raw.trim()) { - try { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) settings = parsed - } catch { - // Some live ComfyUI builds transiently return an empty/truncated settings - // body during startup. The test only needs a deterministic scope setting. - } - } - settings['comfyui-mcp.sessionFollowsPanel'] = false - const headers = response.headers() - delete headers['content-length'] - delete headers['content-encoding'] - await route.fulfill({ - status: 200, - headers: { ...headers, 'content-type': 'application/json' }, - body: JSON.stringify(settings) - }) -} test.beforeEach(async ({ context }) => { - // The target ComfyUI server may have been started before this worktree was - // created. Route the two reviewed modules from the checked-out source so the - // browser gate always exercises this commit rather than a stale server copy. - await context.route(/\/extensions\/[^/]+\/js\/comfyui-mcp-panel\.js(?:\?.*)?$/, (route) => - route.fulfill({ contentType: 'text/javascript', body: PANEL_SOURCE })) - await context.route(/\/extensions\/[^/]+\/js\/lib\/chat-history-store\.js(?:\?.*)?$/, (route) => - route.fulfill({ contentType: 'text/javascript', body: HISTORY_STORE_SOURCE })) + await routeWorktreeSource(context) }) async function indexedThreadCount(page: import('@playwright/test').Page): Promise { @@ -487,7 +450,9 @@ test('reload keeps the pointed conversation and live tab session during durable // so this is a deterministic completion signal for the final binding. await expect.poll(() => page.evaluate(() => { const meta = JSON.parse(localStorage.getItem('comfyui-mcp.panel.historyMeta') || '{}') - return meta.activeByScope?.['panel:global'] || null + // mcp#884 P0-2: the selection pointer is backend-scoped now; the legacy + // shared key remains only as a pre-migration fallback. + return meta.activeByScope?.['panel:backend:claude'] || meta.activeByScope?.['panel:global'] || null })).toBe(currentThreadId) await expect(panel.userBubble('conversation selected by this tab')).toBeVisible() await expect(panel.userBubble('newer background transcript')).toHaveCount(0) @@ -504,16 +469,17 @@ test('reload keeps the pointed conversation and live tab session during durable expect(await page.evaluate((key) => localStorage.getItem(key), LOCAL_HISTORY_SNAPSHOT_KEY)).not.toBeNull() }) -test('strict workflow storage sync detaches transcript todos and session before the next record', async ({ +// mcp#884/#897: workflow provenance is archive grouping, not conversation +// identity. The retired workflow scope used to DETACH the conversation when +// another tab re-keyed its provenance; panel-owned continuity must instead +// survive it — the session is orchestrator-global and one conversation spans +// every workflow, so a provenance edit can never cost the user their chat. +test('panel-owned continuity survives a remote provenance re-key', async ({ page, context, panel, mockBridge }) => { - await page.route( - (url) => /\/(api\/)?settings\/?$/.test(url.pathname), - forcePerWorkflowSettings - ) await panel.goto() await panel.setBridgeUrl(mockBridge.url) await panel.openSidebar() @@ -541,7 +507,6 @@ test('strict workflow storage sync detaches transcript todos and session before const updatedAt = Date.now() + 10_000 const revision = { updatedAt, writerId: 'tab-b', sequence: 1 } thread.workflowKey = 'workflow:foreign-provenance' - thread.todos = [{ text: 'foreign todo', status: 'active' }] thread.updatedAt = updatedAt thread.ts = updatedAt thread.fieldOps = { @@ -551,41 +516,39 @@ test('strict workflow storage sync detaches transcript todos and session before deleted: false, updatedAt, revision - }, - todos: { - value: [{ text: 'foreign todo', status: 'active' }], - deleted: false, - updatedAt, - revision: { ...revision, sequence: 2 } } } localStorage.setItem(snapshotKey, JSON.stringify(snapshot)) }, { snapshotKey: LOCAL_HISTORY_SNAPSHOT_KEY, threadId: currentThreadId }) - await expect.poll( - () => page.evaluate((key) => sessionStorage.getItem(key), SESSION_KEY) - ).toBeNull() - await expect.poll( - () => page.evaluate((key) => sessionStorage.getItem(key), CURRENT_THREAD_KEY) - ).toBeNull() - await expect(panel.userBubble('workflow A visible transcript')).toHaveCount(0) - await expect(panel.root.locator('.cmcp-todo-item')).toHaveCount(0) - + // The re-key propagates into this tab's merged record without detaching + // anything: same conversation, same live session, transcript still painted. + await expect.poll(() => page.evaluate(({ snapshotKey, threadId }) => { + const snapshot = JSON.parse(localStorage.getItem(snapshotKey) || '{}') + const thread = snapshot.threads?.find((candidate: any) => candidate.id === threadId) + return thread?.workflowKey || null + }, { snapshotKey: LOCAL_HISTORY_SNAPSHOT_KEY, threadId: currentThreadId })) + .toBe('workflow:foreign-provenance') + await expect(panel.userBubble('workflow A visible transcript')).toBeVisible() + expect(await page.evaluate((key) => sessionStorage.getItem(key), SESSION_KEY)) + .toBe('workflow-a-session') + expect(await page.evaluate((key) => sessionStorage.getItem(key), CURRENT_THREAD_KEY)) + .toBe(currentThreadId) + + // The next message continues the SAME conversation record. const next = mockBridge.waitForUserMessage() - await panel.sendMessage('fresh workflow A transcript') + await panel.sendMessage('continued after the provenance re-key') await next - const rebound = await page.evaluate(({ snapshotKey, sessionKey }) => { - const w = window as any - const app = w.comfyAPI?.app?.app || w.app - const workflowUuid = app.graph?.extra?.comfyui_mcp?.workflow_uuid + await expect.poll(() => page.evaluate(({ snapshotKey, threadId }) => { const snapshot = JSON.parse(localStorage.getItem(snapshotKey) || '{}') - const thread = snapshot.threads?.find((candidate: any) => - candidate.msgs?.some((message: any) => message.text === 'fresh workflow A transcript')) - return { workflowUuid, thread, sessionId: sessionStorage.getItem(sessionKey) } - }, { snapshotKey: LOCAL_HISTORY_SNAPSHOT_KEY, sessionKey: SESSION_KEY }) - expect(rebound.thread?.workflowKey).toBe(`workflow:${rebound.workflowUuid}`) - expect(rebound.thread?.sessionId).toBeUndefined() - expect(rebound.sessionId).toBeNull() + const thread = snapshot.threads?.find((candidate: any) => candidate.id === threadId) + return { + hasOriginal: thread?.msgs?.some((m: any) => m.text === 'workflow A visible transcript') ?? false, + hasContinued: thread?.msgs?.some((m: any) => m.text === 'continued after the provenance re-key') ?? false, + sessionId: thread?.sessionId ?? null + } + }, { snapshotKey: LOCAL_HISTORY_SNAPSHOT_KEY, threadId: currentThreadId })) + .toEqual({ hasOriginal: true, hasContinued: true, sessionId: 'workflow-a-session' }) await otherTab.close() }) @@ -595,10 +558,6 @@ test('workflow rename publishes alias tombstones and a stale tab cannot echo the panel, mockBridge }) => { - await page.route( - (url) => /\/(api\/)?settings\/?$/.test(url.pathname), - forcePerWorkflowSettings - ) await panel.goto() await panel.openSidebar() await page.evaluate(() => { diff --git a/browser_tests/fixtures/worktreeSource.ts b/browser_tests/fixtures/worktreeSource.ts new file mode 100644 index 00000000..e7aed048 --- /dev/null +++ b/browser_tests/fixtures/worktreeSource.ts @@ -0,0 +1,45 @@ +/** + * Serve this checkout's web/js tree in place of the target ComfyUI's installed + * copy. + * + * The dev ComfyUI loads the pack from a git-linked checkout that may be on a + * different branch (or simply older) than the worktree under test, and the + * panel is ~80 ES modules that must agree on their import shapes: mixing this + * worktree's comfyui-mcp-panel.js with a stale server lib/ kills the module at + * import time and the Agent tab never registers. Routing the WHOLE tree keeps + * the module graph coherent and makes the specs exercise this commit. + */ +import { readFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' +import type { BrowserContext } from '@playwright/test' + +const WEB_JS_ROOT = resolve('web/js') +const sourceCache = new Map() + +function worktreeSource(relPath: string): string | null { + const cached = sourceCache.get(relPath) + if (cached !== undefined) return cached + const file = resolve(WEB_JS_ROOT, relPath) + let body: string | null = null + // Refuse path escapes; unknown files fall through to the live server. + if (file === WEB_JS_ROOT || file.startsWith(WEB_JS_ROOT + sep)) { + try { + body = readFileSync(file, 'utf8') + } catch { + body = null + } + } + sourceCache.set(relPath, body) + return body +} + +export async function routeWorktreeSource(context: BrowserContext) { + await context.route(/\/extensions\/[^/]+\/js\/.+\.js(?:\?.*)?$/, (route) => { + const pathname = new URL(route.request().url()).pathname + const relPath = pathname.replace(/^.*\/extensions\/[^/]+\/js\//, '') + const body = worktreeSource(relPath) + return body == null + ? route.continue() + : route.fulfill({ contentType: 'text/javascript', body }) + }) +} diff --git a/browser_tests/unit/backend-switch.test.mjs b/browser_tests/unit/backend-switch.test.mjs index 999e7d6b..08cc28e9 100644 --- a/browser_tests/unit/backend-switch.test.mjs +++ b/browser_tests/unit/backend-switch.test.mjs @@ -16,7 +16,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { BACKEND_SWITCH, runBackendSwitch } from "../../web/js/lib/backend-switch.js"; +import { BACKEND_SWITCH, planBackendHandover, runBackendSwitch } from "../../web/js/lib/backend-switch.js"; const PANEL_SRC = readFileSync( fileURLToPath(new URL("../../web/js/comfyui-mcp-panel.js", import.meta.url)), @@ -29,13 +29,32 @@ const PANEL_SRC = readFileSync( * Every commit the panel performs is represented, because each is a distinct piece of * leaked state and asserting on a sampled subset is how five of six leaks stay invisible. */ -function recorder({ live = "claude", picked = "claude", invalidate = async () => true, replay = "prior chat" } = {}) { +function recorder({ + live = "claude", + picked = "claude", + invalidate = async () => true, + replay = "prior chat", + // mcp#884 — what the STORE says the incoming backend already has. Defaults to + // false ("no conversation yet"), which is the pre-mcp#884 world every test + // below was written against, so their meaning is unchanged. + incomingHasConversation = false, +} = {}) { const log = []; + // Recorded OFF the ordered log on purpose. These two are queries, not commits; + // logging them would shift every positional assertion in this file and make a + // behavioural change look like an ordering regression. + const invalidateOpts = []; + const askedAbout = []; let liveBackend = live; const effects = { liveBackend: () => liveBackend, pickedBackend: () => picked, - invalidate: async () => { + incomingHasConversation: (b) => { + askedAbout.push(b); + return incomingHasConversation; + }, + invalidate: async (opts) => { + invalidateOpts.push(opts); log.push("invalidate"); return invalidate(); }, @@ -49,11 +68,14 @@ function recorder({ live = "claude", picked = "claude", invalidate = async () => log.push("buildReplay"); return replay; }, - armContext: () => log.push("armContext"), + // The ARGUMENT matters now: `armContext(null)` is the CLEAR (mcp#884), and a + // recorder that logged both as "armContext" would let arming the outgoing + // transcript into the incoming conversation pass as a clear. + armContext: (ctx) => log.push(ctx == null ? "clearContext" : "armContext"), teardownAndConnect: () => log.push("teardownAndConnect"), disclose: (reason) => log.push(`disclose:${reason}`), }; - return { log, effects, setLive: (b) => { liveBackend = b; } }; + return { log, effects, invalidateOpts, askedAbout, setLive: (b) => { liveBackend = b; } }; } /** @@ -204,6 +226,184 @@ test("#1184 the replay is built AFTER the turn ends, and only armed when non-emp assert.ok(!ran(empty.log, "armContext"), "…but arming an empty preamble would send a header with no chat"); }); +// --------------------------------------------------------------------------- +// mcp#884 — THE HANDOVER. Both consequences of one question. +// --------------------------------------------------------------------------- + +test("mcp#884 the OUTGOING backend's thread keeps its session across a switch", async () => { + // The defect: `invalidate` cleared the outgoing THREAD's sessionId, so switching + // back later sent `new_session` instead of resuming — the per-backend persistence + // this branch adds, defeated by its own switch path. The tab pointer still goes; + // only the thread's own claim is preserved. + const rec = recorder({ live: "claude" }); + await runBackendSwitch("codex", rec.effects); + + assert.equal(rec.invalidateOpts.length, 1, "the invalidate still runs exactly once"); + assert.equal( + rec.invalidateOpts[0]?.preserveThreadSession, + true, + "a backend SWITCH must not destroy the outgoing conversation's session id", + ); +}); + +test("mcp#884 the incoming backend's OWN conversation is never handed the outgoing transcript", async () => { + // The other half. `loadThread` will resume the incoming backend's conversation and + // arm its own replay if it needs one; the outgoing transcript riding in as one-shot + // context would inject a different provider's chat into it. + const rec = recorder({ live: "claude", incomingHasConversation: true, replay: "User: hi" }); + const result = await runBackendSwitch("codex", rec.effects); + + assert.equal(result.switched, true, "it is still a switch"); + assert.deepEqual(rec.askedAbout, ["codex"], "the STORE is asked about the INCOMING backend"); + assert.ok(!ran(rec.log, "buildReplay"), "the outgoing transcript is not even built"); + assert.ok(!ran(rec.log, "armContext"), "and nothing is armed against the incoming conversation"); + assert.ok( + ran(rec.log, "clearContext"), + "a context armed earlier must be CLEARED, not merely skipped — it would ride the next message", + ); +}); + +test("mcp#884 a backend with NO conversation still gets the fresh-chat replay", async () => { + // The long-standing, disclosed behaviour, deliberately preserved: switching to a + // provider you have never used starts a fresh chat carrying the prior transcript as + // one-shot context. Only the case where the incoming backend HAS a conversation changed. + const rec = recorder({ live: "claude", incomingHasConversation: false, replay: "User: hi" }); + await runBackendSwitch("codex", rec.effects); + + assert.deepEqual(rec.askedAbout, ["codex"]); + assert.ok(ran(rec.log, "buildReplay"), "the transcript is built"); + assert.ok(ran(rec.log, "armContext"), "and armed into the fresh conversation"); + assert.ok(!ran(rec.log, "clearContext"), "nothing is cleared on the fresh path"); +}); + +test("mcp#884 planBackendHandover is ONE decision, so the two halves cannot drift", () => { + // Stated on the pure function as well as through the run, because the whole point of + // extracting it is that session preservation and replay disposal are consequences of a + // single question rather than two independent guards someone can fix by halves. + assert.deepEqual( + planBackendHandover({ switching: true, incomingHasConversation: true }), + { preserveOutgoingSession: true, replay: "clear" }, + ); + assert.deepEqual( + planBackendHandover({ switching: true, incomingHasConversation: false }), + { preserveOutgoingSession: true, replay: "arm" }, + ); + // A non-switch decides nothing: no session to hand over, no replay to dispose of. + for (const incoming of [true, false]) { + assert.deepEqual( + planBackendHandover({ switching: false, incomingHasConversation: incoming }), + { preserveOutgoingSession: false, replay: "leave" }, + ); + } + assert.deepEqual( + planBackendHandover(), + { preserveOutgoingSession: false, replay: "leave" }, + "called with nothing it must decide nothing, not throw", + ); +}); + +test("mcp#884 a NON-switch never asks the store about the incoming backend", async () => { + // Same rule as the invalidate: a first connect and a re-pick must stay fully + // synchronous and must not be gated on history-store state. + const firstConnect = recorder({ live: null, picked: "claude" }); + await runBackendSwitch("codex", firstConnect.effects); + assert.deepEqual(firstConnect.askedAbout, [], "a first connect asks nothing"); + assert.deepEqual(firstConnect.invalidateOpts, [], "and invalidates nothing"); + + const rePick = recorder({ live: "codex", picked: "codex" }); + await runBackendSwitch("codex", rePick.effects); + assert.deepEqual(rePick.askedAbout, [], "a re-pick of the live backend asks nothing"); +}); + +// --------------------------------------------------------------------------- +// mcp#884 — the panel side of the handover, RUN rather than inspected. +// +// The tests above prove `runBackendSwitch` PASSES `preserveThreadSession`. That is +// only half a proof: an `invalidateDurableAgentSession` that ignores the option +// destroys the outgoing session exactly as before while every assertion above stays +// green. (Verified by mutation — it survived the whole suite.) So drive the shipped +// body over stubs, the same "real panel source" convention used elsewhere. +// --------------------------------------------------------------------------- + +// Normalised: this checkout is CRLF and the anchor spans lines. +const PANEL_LF = PANEL_SRC.replace(/\r\n/g, "\n"); +const invalidateSrc = (() => { + const re = /\n {2}async function invalidateDurableAgentSession\([\s\S]*?\n {2}\}/g; + const all = [...PANEL_LF.matchAll(re)]; + assert.equal(all.length, 1, `expected exactly 1 invalidateDurableAgentSession, got ${all.length}`); + return all[0][0]; +})(); + +test("mcp#884 the extracted invalidate really is the shipped body", () => { + assert.match(invalidateSrc, /ssSet\(SESSION_KEY, null\)/, "slice covers the tab-pointer clear"); + assert.match(invalidateSrc, /historyStore\.flush\(\)/, "slice reaches the durability check"); +}); + +/** The REAL invalidateDurableAgentSession over one conversation and stub effects. */ +function buildInvalidate({ threadSessionId = "sess-claude-1" } = {}) { + const thread = { id: "t-outgoing", provider: "claude", sessionId: threadSessionId, msgs: [] }; + const session = new Map([["comfyui-mcp.panel.sessionId", threadSessionId]]); + let persists = 0; + const factory = new Function( + "deps", + ` + const { ssSet, SESSION_KEY, thread, historyStore, persistThreads } = deps; + ${invalidateSrc} + return invalidateDurableAgentSession; + `, + ); + const invalidate = factory({ + ssSet: (key, value) => { session.set(key, value); }, + SESSION_KEY: "comfyui-mcp.panel.sessionId", + thread, + // Only the two methods the body reaches. `reviseThread` deletes a field when the + // value is null, which is exactly what the real store does for a cleared session. + historyStore: { + reviseThread: (t, values) => { + for (const [k, v] of Object.entries(values)) { + if (v == null) delete t[k]; + else t[k] = v; + } + return t; + }, + flush: async () => true, + }, + persistThreads: () => { persists += 1; }, + }); + return { invalidate, thread, session, persists: () => persists }; +} + +test("mcp#884 a SWITCH preserves the outgoing conversation's session id", async () => { + const h = buildInvalidate(); + assert.equal(await h.invalidate({ preserveThreadSession: true }), true); + + assert.equal( + h.thread.sessionId, + "sess-claude-1", + "the outgoing backend's session outlives the switch — switching back must RESUME, not new_session", + ); + // …while the backend-agnostic TAB pointer still goes, or the incoming backend would + // adopt a session id belonging to the previous one. + assert.equal(h.session.get("comfyui-mcp.panel.sessionId"), null, "the tab pointer is still cleared"); + assert.ok(h.persists() > 0, "and the change is persisted"); +}); + +test("mcp#884 a RESTART/disconnect still destroys the session id", async () => { + // The opposite case, and why this is an option rather than a removal: that session is + // genuinely gone, so a preserved pointer would make the next resume name a session the + // orchestrator no longer has. + const h = buildInvalidate(); + assert.equal(await h.invalidate(), true); + assert.equal(h.thread.sessionId, undefined, "the default is still to destroy it"); + assert.equal(h.session.get("comfyui-mcp.panel.sessionId"), null); +}); + +test("mcp#884 preserveThreadSession:false is explicitly the destroying case", async () => { + const h = buildInvalidate(); + await h.invalidate({ preserveThreadSession: false }); + assert.equal(h.thread.sessionId, undefined); +}); + test("#1184 WIRING: the panel delegates, and keeps no commit above the guard", () => { // Without this the module can be correct and dead. The panel is 1.7MB of IIFE, so this is // a source assertion by necessity — but it is the specific one that matters: no write to @@ -244,7 +444,15 @@ test("#1184 WIRING: the panel delegates, and keeps no commit above the guard", ( // The commits may only appear as INJECTED effects, i.e. inside a callback the module // calls after the guard — never as statements the function runs on its own. assert.match(body, /commitSelection: \(next\) => \{/, "the selection writes must be an injected effect"); - assert.match(body, /invalidate: \(\) => invalidateDurableAgentSession\(\)/, "the guard must be injected too"); + // mcp#884 gave the guard an argument (`preserveThreadSession`), so match the injection + // rather than the exact old arity — and assert the options really are FORWARDED, because + // an `(opts) => invalidateDurableAgentSession()` that drops them silently reinstates the + // destroyed-outgoing-session defect while still looking injected. + assert.match( + body, + /invalidate: \(opts\) => invalidateDurableAgentSession\(opts\)/, + "the guard must be injected too, and must forward the handover options", + ); // The old shape, stated exactly so a revert is caught by name. assert.doesNotMatch( body, diff --git a/browser_tests/unit/chat-history-store.test.mjs b/browser_tests/unit/chat-history-store.test.mjs index ded54982..e1f3a2f7 100644 --- a/browser_tests/unit/chat-history-store.test.mjs +++ b/browser_tests/unit/chat-history-store.test.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' import { CHAT_HISTORY_LOCAL_SNAPSHOT_KEY, @@ -9,6 +12,8 @@ import { isThreadInScope, mergeHistorySnapshots, normalizeThread, + panelScopeKeyForBackend, + resolvePanelPointer, retainBoundedThreads, selectPanelThread, parseHistoryImport, @@ -1124,16 +1129,392 @@ test('panel selection preserves provenance and recovers when its tab pointer is assert.equal(threads[0].workflowKey, 'wf:workflows/a.json') }) -test('reload keeps the tab-pointed panel conversation instead of switching to a newer thread', () => { +// mcp#884 upgrade guard: a build that still had the workflow/ask scopes could +// leave the panel:global pointer behind and keep SELECTING per-workflow +// threads (the retired mode stamped a workflow-scoped active op on every +// thread creation and open). On upgrade the newest SELECTION wins — message +// timestamps are deliberately not evidence (gate P0-3: imports, straggler +// writes, and skewed clocks carry newer messages without any user selection). +test('a stale panel pointer loses to a newer retired-mode selection, never to mere messages', () => { + const threads = [ + { + id: 'stale-panel-thread', + workflowKey: 'panel:global', + updatedAt: 1_000, + msgs: [{ id: 'old-msg', role: 'user', text: 'months ago', createdAt: 1_000 }] + }, + { + id: 'current-workflow-thread', + workflowKey: 'workflow:wf-current', + updatedAt: 9_000, + msgs: [{ id: 'new-msg', role: 'user', text: 'this week', createdAt: 9_000 }] + } + ] + const stalePanelPointer = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:global', + 'stale-panel-thread', + { updatedAt: 1_001, writerId: 'old-build', sequence: 1 } + ) + + // The retired workflow mode recorded its selection as a workflow-scoped op — + // newer than the abandoned panel pointer, so it wins. + const withWorkflowSelection = updateMetadataEntry( + stalePanelPointer, + 'activeByScope', + 'workflow:wf-current', + 'current-workflow-thread', + { updatedAt: 8_000, writerId: 'old-build', sequence: 2 } + ) + assert.equal(selectPanelThread(threads, withWorkflowSelection)?.id, 'current-workflow-thread') + + // Same result when the panel op was compacted into the checkpoint baseline + // (value survives, its revision does not). + const compactedPanelValue = { + activeByScope: { + 'panel:global': 'stale-panel-thread', + 'workflow:wf-current': 'current-workflow-thread' + }, + activeOps: withWorkflowSelection.activeOps && Object.fromEntries( + Object.entries(withWorkflowSelection.activeOps) + .filter(([key]) => key !== 'panel:global') + ) + } + assert.equal(selectPanelThread(threads, compactedPanelValue)?.id, 'current-workflow-thread') + + // A panel pointer stamped AFTER the workflow selection (the user returned to + // panel mode / opened a chat from the archive) is the latest selection and + // sticks. + const returnedToPanel = updateMetadataEntry( + withWorkflowSelection, + 'activeByScope', + 'panel:global', + 'stale-panel-thread', + { updatedAt: 9_500, writerId: 'archive-open', sequence: 3 } + ) + assert.equal(selectPanelThread(threads, returnedToPanel)?.id, 'stale-panel-thread') + + // Gate P0-3: newer MESSAGES without any selection op (an imported archive + // keeps its original createdAt; a straggler write lands without a pointer + // move) never steal the selection. + assert.equal(selectPanelThread(threads, stalePanelPointer)?.id, 'stale-panel-thread') + + // A retired-mode op whose target no longer exists is not evidence either. + const danglingWorkflowSelection = updateMetadataEntry( + stalePanelPointer, + 'activeByScope', + 'workflow:wf-gone', + 'deleted-thread', + { updatedAt: 8_000, writerId: 'old-build', sequence: 2 } + ) + assert.equal(selectPanelThread(threads, danglingWorkflowSelection)?.id, 'stale-panel-thread') +}) + +// Gate P0-2: the selection pointer is BACKEND-scoped (one conversation per +// backend, mirroring the orchestrator's orchestrator:: session key). +test('panel selection is backend-scoped with a one-way legacy fallback', () => { + // `provider` is the ownership stamp record() writes on mint and on every append. + // It is what forks the LEGACY route per backend (mcp#884) — see the dedicated + // upgrade test below. + const threads = [ + { id: 'claude-thread', provider: 'claude', updatedAt: 100, msgs: [] }, + { id: 'codex-thread', provider: 'codex', updatedAt: 200, msgs: [] }, + { id: 'legacy-thread', provider: 'claude', updatedAt: 50, msgs: [] } + ] + let meta = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:backend:claude', + 'claude-thread', + { updatedAt: 1_000, writerId: 'claude-tab', sequence: 1 } + ) + meta = updateMetadataEntry( + meta, + 'activeByScope', + 'panel:backend:codex', + 'codex-thread', + { updatedAt: 2_000, writerId: 'codex-tab', sequence: 1 } + ) + + // Each backend resolves its own conversation; the other backend's newer + // selection is not evidence for this one. + assert.equal( + selectPanelThread(threads, meta, { scopeKey: 'panel:backend:claude' })?.id, + 'claude-thread' + ) + assert.equal( + selectPanelThread(threads, meta, { scopeKey: 'panel:backend:codex' })?.id, + 'codex-thread' + ) + + // A backend key never written falls back to the legacy shared pointer... + const legacyOnly = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:global', + 'legacy-thread', + { updatedAt: 500, writerId: 'pre-upgrade', sequence: 1 } + ) + assert.equal(resolvePanelPointer(legacyOnly, 'panel:backend:claude').activeId, 'legacy-thread') + assert.equal( + selectPanelThread(threads, legacyOnly, { scopeKey: 'panel:backend:claude' })?.id, + 'legacy-thread' + ) + + // ...but a written backend key (including a deliberate CLEAR) never falls + // back: migration is one-way per backend. + const cleared = updateMetadataEntry( + legacyOnly, + 'activeByScope', + 'panel:backend:claude', + null, + { updatedAt: 1_500, writerId: 'claude-tab', sequence: 2 } + ) + const clearedPointer = resolvePanelPointer(cleared, 'panel:backend:claude') + assert.equal(clearedPointer.activeId, null) + assert.equal(clearedPointer.cleared, true) + assert.equal(selectPanelThread(threads, cleared, { scopeKey: 'panel:backend:claude' }), null) + // The legacy pointer serves other backends only where it can — and it CANNOT here. + // `legacy-thread` is claude's (provider: 'claude'); handing it to codex as well is + // precisely the shared-transcript corruption the fork rule exists to stop. Codex + // resolves its OWN most recent conversation instead. + assert.equal( + selectPanelThread(threads, cleared, { scopeKey: 'panel:backend:codex' })?.id, + 'codex-thread' + ) +}) + +test('mcp#884 UPGRADE: one legacy pointer never becomes TWO backends conversation', () => { + // THE UPGRADE PATH EVERY EXISTING USER TAKES. A pre-upgrade snapshot has a single + // shared `panel:global` pointer and no per-backend keys, so before the fork 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 sharing and corrupting one transcript. + const legacy = { id: 'the-one-conversation', provider: 'claude', updatedAt: 500, msgs: [] } + const meta = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:global', + 'the-one-conversation', + { updatedAt: 500, writerId: 'pre-upgrade', sequence: 1 } + ) + + const forClaude = selectPanelThread([legacy], meta, { scopeKey: 'panel:backend:claude' }) + const forCodex = selectPanelThread([legacy], meta, { scopeKey: 'panel:backend:codex' }) + + // The owner keeps it — the single-backend upgrade, which is the common case, is + // completely unaffected. + assert.equal(forClaude?.id, 'the-one-conversation', 'the owning backend still adopts it') + // …and nobody else does. + assert.equal(forCodex, null, 'a second backend must NOT resolve the same conversation') + assert.notEqual( + forClaude?.id, + forCodex?.id, + 'two backends resolving one thread id is the corruption this rule exists to prevent' + ) + + // Stated for a third backend too: the rule is "only the owner", not "only the second + // one loses". + assert.equal(selectPanelThread([legacy], meta, { scopeKey: 'panel:backend:gemini' }), null) + + // The legacy thread is NOT deleted — it stays in history and opens through the picker + // like any archived conversation, exactly as the retired per-workflow threads do. + assert.ok([legacy].includes(legacy)) +}) + +test('mcp#884 UPGRADE: a provider-less legacy thread fails CLOSED rather than into every backend', () => { + // Very old snapshots can carry a thread with no provider stamp. There is no evidence + // of ownership, so no backend auto-adopts it: fail-open here is exactly the collision + // above, and the cost of failing closed is bounded and non-destructive (the + // conversation stays in history and opens through the picker). + const orphan = { id: 'no-provider', updatedAt: 500, msgs: [] } + const meta = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:global', + 'no-provider', + { updatedAt: 500, writerId: 'pre-upgrade', sequence: 1 } + ) + for (const backend of ['claude', 'codex', 'gemini']) { + assert.equal( + selectPanelThread([orphan], meta, { scopeKey: `panel:backend:${backend}` }), + null, + `${backend} must not claim a conversation nothing attributes to it` + ) + } +}) + +test('mcp#884 UPGRADE: the no-pointer recency fallback is forked per backend too', () => { + // The OTHER door into the same collision, and the one a fix aimed only at the legacy + // POINTER would miss: a snapshot with no panel pointer at all fell through to + // "most recently updated thread", which is equally the same id for every backend. + const threads = [ + { id: 'codex-newest', provider: 'codex', updatedAt: 900, msgs: [] }, + { id: 'claude-older', provider: 'claude', updatedAt: 100, msgs: [] } + ] + assert.equal( + selectPanelThread(threads, {}, { scopeKey: 'panel:backend:claude' })?.id, + 'claude-older', + 'claude falls back to ITS most recent conversation, not the globally newest one' + ) + assert.equal( + selectPanelThread(threads, {}, { scopeKey: 'panel:backend:codex' })?.id, + 'codex-newest' + ) +}) + +test("mcp#884 another backend's selection never competes — even for a thread THIS backend could claim", () => { + // REGRESSION GUARD FOR THE GUARD. The `panel:` skip in the compete loop was + // previously pinned by a fixture whose threads had no provider. Adding the upgrade + // fork MASKED that: another backend's op now usually resolves to a thread this + // backend cannot claim anyway, so deleting the skip stopped failing anything. + // + // The two rules are NOT the same rule. The fork asks "could this backend own the + // thread"; the skip asks "is another backend's selection evidence for mine". A + // thread whose provider matches BOTH questions separates them — which is exactly + // reachable, because a thread's provider changes when the user switches backends + // while it is open. + const threads = [ + { id: 'mine', provider: 'codex', updatedAt: 100, msgs: [] }, + // Same provider, so the fork happily allows it. Only the `panel:` skip stops it. + { id: 'claudes-pick', provider: 'codex', updatedAt: 900, msgs: [] } + ] + let meta = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:backend:codex', + 'mine', + { updatedAt: 1_000, writerId: 'codex-tab', sequence: 1 } + ) + // Written LATER, under ANOTHER backend's key. If it were allowed to compete it would + // win on revision and move codex onto claude's conversation. + meta = updateMetadataEntry( + meta, + 'activeByScope', + 'panel:backend:claude', + 'claudes-pick', + { updatedAt: 9_000, writerId: 'claude-tab', sequence: 2 } + ) + + assert.equal( + selectPanelThread(threads, meta, { scopeKey: 'panel:backend:codex' })?.id, + 'mine', + "a Claude tab's newer selection must not move the Codex conversation" + ) + // And the mirror, so the rule is not "codex always wins". + assert.equal( + selectPanelThread(threads, meta, { scopeKey: 'panel:backend:claude' })?.id, + 'claudes-pick' + ) +}) + +test('mcp#884 a backend pointer this backend WROTE is honoured whatever the provider stamp says', () => { + // The fork must not become a second, stricter gate on normal operation. A thread + // legitimately changes provider when the user switches backends while it is open, so + // a pointer the backend wrote for itself is its own evidence and outranks the stamp. + const threads = [{ id: 'mine', provider: 'claude', updatedAt: 100, msgs: [] }] + const meta = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:backend:codex', + 'mine', + { updatedAt: 1_000, writerId: 'codex-tab', sequence: 1 } + ) + assert.equal( + selectPanelThread(threads, meta, { scopeKey: 'panel:backend:codex' })?.id, + 'mine', + 'codex selected this conversation itself — the stale provider stamp must not veto that' + ) +}) + +test('metadata-only edits on an archived chat never steal the panel selection', () => { + // Rename/pin bump thread.updatedAt without new messages; grooming the archive + // must not hijack the conversation every tab is in. + const threads = [ + { + id: 'active-conversation', + workflowKey: 'workflow:wf-a', + updatedAt: 5_000, + msgs: [{ id: 'live-msg', role: 'user', text: 'live', createdAt: 5_000 }] + }, + { + id: 'renamed-archive', + workflowKey: 'workflow:wf-b', + updatedAt: 9_999, + title: 'freshly renamed', + msgs: [{ id: 'archived-msg', role: 'user', text: 'archived', createdAt: 100 }] + } + ] + const meta = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:global', + 'active-conversation', + { updatedAt: 4_000, writerId: 'writer', sequence: 1 } + ) + + assert.equal(selectPanelThread(threads, meta)?.id, 'active-conversation') +}) + +// mcp#884/#897: with the agent session orchestrator-global, the SHARED pointer +// is authoritative on reload — a tab preference only bridges legacy snapshots +// that predate the shared pointer. +test('reload keeps the tab-pointed panel conversation only until a shared pointer exists', () => { const threads = [ { id: 'visible', workflowKey: 'workflow:wf-a', updatedAt: 100, msgs: [] }, { id: 'newer-background', workflowKey: 'workflow:wf-b', updatedAt: 999, msgs: [] } ] + // Legacy snapshot (no panel:global pointer): the tab pointer is the only + // record of what this tab had open, so honor it. assert.equal(selectRestoreThread(threads, {}, { panelOwned: true, preferredThreadId: 'visible' })?.id, 'visible') + + // Shared pointer present: every tab must restore the same conversation the + // orchestrator's single session is in, tab preference notwithstanding. + const shared = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:global', + 'newer-background', + { updatedAt: 2_000, writerId: 'other-tab', sequence: 1 } + ) + assert.equal(selectRestoreThread(threads, shared, { + panelOwned: true, + preferredThreadId: 'visible' + })?.id, 'newer-background') + + // A deliberately cleared pointer (new chat elsewhere) restores the empty + // view, not the tab's old conversation. + const cleared = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:global', + null, + { updatedAt: 2_000, writerId: 'other-tab', sequence: 1 } + ) + assert.equal(selectRestoreThread(threads, cleared, { + panelOwned: true, + preferredThreadId: 'visible' + }), null) + + // A DANGLING pointer (its thread was evicted or lost in a partial merge) + // says nothing about which conversation the global session is in — the tab + // that was just using one is better evidence than guessing by recency. + const dangling = updateMetadataEntry( + {}, + 'activeByScope', + 'panel:global', + 'evicted-thread', + { updatedAt: 2_000, writerId: 'other-tab', sequence: 1 } + ) + assert.equal(selectRestoreThread(threads, dangling, { + panelOwned: true, + preferredThreadId: 'visible' + })?.id, 'visible') }) test('reload never accepts a tab pointer from another workflow', () => { @@ -2272,4 +2653,115 @@ test('#1171 a capped open reports failure exactly past the local shadow boundary } store.close() } -}) \ No newline at end of file +}) +// --------------------------------------------------------------------------- +// mcp#884 — THE INVARIANT, pinned as a gate rather than left to inspection. +// +// "Sessions are ORCHESTRATOR-scoped, never workflow-scoped or tab-scoped" is a +// project invariant, and this branch is what makes the panel honour it. The +// retired workflow/ask machinery is still PRESENT in the panel source as +// deliberately unreachable defence-in-depth (see historyScopeFollowsPanel()), +// which is a reasonable choice and also a standing hazard: the whole of it wakes +// up again the moment chatScopeMode() stops being a constant. Reading the source +// once proved it is unreachable today; these assertions are what keep it so. +// --------------------------------------------------------------------------- + +const PANEL_SRC = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '../../web/js/comfyui-mcp-panel.js'), + 'utf8' +).replace(/\r\n/g, '\n') + +/** The body of a top-level `function name() {` … column-0 `}`. */ +function topLevelFunction(name) { + const start = PANEL_SRC.indexOf(`\nfunction ${name}(`) + assert.notEqual(start, -1, `${name}() must exist as a top-level function`) + const end = PANEL_SRC.indexOf('\n}\n', start) + assert.ok(end > start, `${name}() must be closed`) + return PANEL_SRC.slice(start, end) +} + +test('mcp#884 chatScopeMode is a CONSTANT — no setting can reintroduce workflow scope', () => { + const body = topLevelFunction('chatScopeMode') + // Exactly one return, and it is the literal. A `getSetting(...)` read here is + // the single edit that would revive every workflow-scoped path below it. + const returns = [...body.matchAll(/return\s+([^;]*);/g)].map((m) => m[1].trim()) + assert.deepEqual(returns, ['"panel"'], 'chatScopeMode() returns the literal "panel" and nothing else') + assert.ok(!/getSetting|localStorage|sessionStorage/.test(body), 'it reads no stored value') +}) + +test('mcp#884 no chat-scope setting is registered any more', () => { + const start = PANEL_SRC.indexOf('function panelSettingsList() {') + assert.notEqual(start, -1) + const body = PANEL_SRC.slice(start, PANEL_SRC.indexOf('\n}\n', start)) + // The combo is what wrote the value chatScopeMode() used to read. A row for it + // cannot come back without this failing. + assert.ok(!/SETTING_CHAT_SCOPE/.test(body), 'the "Chat conversation scope" row stays retired') + assert.ok(!/SETTING_SESSION_FOLLOWS_PANEL/.test(body), 'the legacy boolean stays retired') + assert.ok(!/applyChatScope/.test(PANEL_SRC.replace(/\/\/[^\n]*/g, '')), 'no live scope switcher hook') +}) + +test('mcp#884 currentHistoryScopeKey resolves to a backend axis for a panel-owned chat', () => { + const at = PANEL_SRC.indexOf('function currentHistoryScopeKey(') + assert.notEqual(at, -1) + const body = PANEL_SRC.slice(at, PANEL_SRC.indexOf('\n }\n', at)) + // The workflow branch is the unreachable one and must STAY behind the guard — + // if the guard is ever deleted, the remaining return must still be the backend + // key, never workflowStorageKey(). + // The key is built by the store's exported helper now (mcp#884), so the panel and the + // backend-switch path cannot interpolate two different shapes for the same axis. + assert.match( + body, + /return panelScopeKeyForBackend\(/, + 'the panel-owned answer is keyed on the backend, the same axis as orchestrator::' + ) + // …and the helper really does produce that axis, so this is not just a rename. + assert.equal(panelScopeKeyForBackend('codex'), 'panel:backend:codex') + assert.equal(panelScopeKeyForBackend(null), 'panel:backend:claude', 'the documented default') + const wfReturn = /if \(!historyScopeFollowsPanel\(\)\) return workflowStorageKey/.test(body) + assert.ok(wfReturn, 'the workflow key is reachable ONLY through the historyScopeFollowsPanel() guard') +}) + +test('mcp#884 every selection-pointer WRITE uses the backend key, never a workflow key', () => { + // The pointer is the one piece of state that decides which conversation a tab + // renders and records into. If any writer can address it by a workflow key, the + // conversation is workflow-scoped again no matter what chatScopeMode() says. + const writes = [...PANEL_SRC.matchAll(/setActiveThread\(\s*([^,]+),/g)] + .map((m) => m[1].trim()) + .filter((arg) => arg !== 'scopeKey' || false) + const allowed = new Set([ + 'currentHistoryScopeKey()', // the backend key + 'scopeKey', // a local already assigned from currentHistoryScopeKey() + 'key' // the delete sweep, iterating keys that already exist in metadata + ]) + const offenders = writes.filter((arg) => !allowed.has(arg)) + assert.deepEqual(offenders, [], `setActiveThread called with a non-backend scope key: ${offenders.join(', ')}`) + + // …and the one `scopeKey` local really is the backend key, not a workflow one. + assert.match( + PANEL_SRC, + /const scopeKey = currentHistoryScopeKey\(\);/, + 'the scopeKey local is assigned from currentHistoryScopeKey()' + ) +}) + +test('mcp#884 the workflow-keyed session bind is unreachable while the chat is panel-owned', () => { + // `ssSet(SESSION_KEY, existing?.sessionId || null)` in onWorkflowMaybeChanged is + // the exact line that made a session belong to a WORKFLOW. It still exists, and + // it is only safe because the panel-owned branch returns before reaching it. + const at = PANEL_SRC.indexOf('function onWorkflowMaybeChanged() {') + assert.notEqual(at, -1) + const body = PANEL_SRC.slice(at, PANEL_SRC.indexOf('\n }\n', at)) + const guardAt = body.indexOf('if (followsPanel) {') + assert.ok(guardAt > -1, 'the panel-owned branch exists') + // The guard block must END in a return, so nothing below it can run. + const afterGuard = body.slice(guardAt) + const guardEnd = afterGuard.indexOf('\n }\n') + assert.ok(guardEnd > -1, 'the panel-owned branch closes at its own 4-space brace') + assert.match( + afterGuard.slice(0, guardEnd), + /\n {6}return;$/, + 'the panel-owned branch RETURNS — this is the only thing keeping the workflow-scoped tail dead' + ) + const tail = body.slice(guardAt + guardEnd) + assert.match(tail, /ssSet\(SESSION_KEY, existing\?\.sessionId/, 'the workflow-keyed bind lives in the dead tail') +}) diff --git a/browser_tests/unit/chat-scope-retired.test.mjs b/browser_tests/unit/chat-scope-retired.test.mjs new file mode 100644 index 00000000..b9582f53 --- /dev/null +++ b/browser_tests/unit/chat-scope-retired.test.mjs @@ -0,0 +1,130 @@ +// mcp#884/#897: the conversation is ALWAYS panel-owned. The orchestrator keys and +// persists ONE agent session per backend across every panel, tab and workflow, so a +// per-workflow chat is a bug, not a mode — a user left in `workflow` or `ask` scope +// gets several panel transcripts all mapping onto the single session the +// orchestrator actually runs, and the transcripts silently diverge from the agent's +// real context. +// +// WHY THIS FILE EXISTS. The retirement was previously pinned only by Playwright +// specs, which are NOT in CI (they need a live ComfyUI on :8188). A mutation test +// proved the gap: restoring `chatScopeMode()`'s old "read the stored setting" body +// left the ENTIRE unit suite green (4401/4401). Nothing in CI could tell that the +// retired scopes had come back. +// +// The load-bearing test below therefore EXTRACTS the shipped `chatScopeMode` and +// CALLS it, following the repo's "real panel source" convention (see +// context-ring-scope.test.mjs). It deliberately does not assert on the source text: +// a body of `if (false) return getSetting(...)` matches any regex written about it +// and still ships the right behaviour, while a body that genuinely reads the setting +// must FAIL — and only running it can tell those apart. +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +// Newlines normalized: checked out with CRLF on Windows, and the extraction below +// anchors on a column-0 closing brace. +const SRC = readFileSync(join(HERE, "../../web/js/comfyui-mcp-panel.js"), "utf8").replace(/\r\n/g, "\n"); + +/** The shipped `chatScopeMode`, ready to call. It is top-level, so its closing brace + * is the next column-0 `}`. The slice is proven to span the whole function before it + * is evaluated — a truncated slice would otherwise be a syntax error or, worse, a + * half-function that happens to parse. */ +function loadChatScopeMode(getSetting) { + const start = SRC.indexOf("function chatScopeMode() {"); + assert.notEqual(start, -1, "chatScopeMode() must exist in the panel source"); + const end = SRC.indexOf("\n}\n", start); + assert.notEqual(end, -1, "chatScopeMode() must be closed at a column-0 brace"); + const body = SRC.slice(start, end + 2); + assert.match(body, /\n\}$/, "the extracted slice does not end at the function's own brace"); + // `getSetting` is injected rather than left to the global scope so that a body which + // reads the retired setting RUNS the stub and returns its value (failing the + // assertions below) instead of throwing a ReferenceError — a throw would look like a + // different defect and could be "fixed" by loosening the test. + return new Function("getSetting", `${body}\nreturn chatScopeMode;`)(getSetting); +} + +// Every value an older build could have persisted into the retired setting, plus the +// shapes a corrupted or hand-edited store can produce. +const STORED_VALUES = [ + "workflow", + "ask", + "panel", + undefined, + null, + "", + "WORKFLOW", + "Workflow", + " workflow ", + 0, + false, + {}, + ["workflow"], +]; + +test("chatScopeMode() is panel-owned for EVERY stored value a retired scope could have left", () => { + for (const stored of STORED_VALUES) { + const reads = []; + const chatScopeMode = loadChatScopeMode((id) => { + reads.push(id); + return stored; + }); + assert.equal( + chatScopeMode(), + "panel", + `a stored scope of ${JSON.stringify(stored) ?? String(stored)} must be ignored, not honored`, + ); + } +}); + +test("chatScopeMode() does not consult the settings store at all", () => { + // Stronger than "the answer is panel": the retired value is not merely overridden, + // it is never read. A body that reads the setting and then coerces the result back + // to "panel" would pass the test above and would be one edit away from honoring it + // again; this pins that there is no live read to re-enable. + const reads = []; + const chatScopeMode = loadChatScopeMode((id) => { + reads.push(id); + return "workflow"; + }); + assert.equal(chatScopeMode(), "panel"); + assert.deepEqual(reads, [], `chatScopeMode() read settings: ${reads.join(", ")}`); +}); + +test("no Settings row offers a chat conversation scope any more", () => { + // Defense in depth for the other direction: even with chatScopeMode() hard-wired, a + // re-added combo would be a visible, clickable control that silently does nothing — + // and the obvious "fix" for that is to wire it back up. + const start = SRC.indexOf("function panelSettingsList() {"); + assert.notEqual(start, -1, "panelSettingsList() must exist"); + const end = SRC.indexOf("\n}\n", start); + assert.notEqual(end, -1, "panelSettingsList() must be closed"); + const body = SRC.slice(start, end); + // Prove the slice reaches the end of the registered list before concluding anything + // from its ABSENCE of a row (settings-i18n-keys.test.mjs uses the same guard): a + // truncated body would make this test vacuously pass. + assert.match(body, /\n {2}\];\s*$/, "the extracted settings body does not end at the returned array"); + + assert.ok( + !body.includes("SETTING_CHAT_SCOPE"), + "the retired chat-scope setting must not be registered", + ); + for (const retired of ["comfyui-mcp.chatScope", "comfyui-mcp.sessionFollowsPanel"]) { + assert.ok(!body.includes(retired), `the retired setting id ${retired} must not be registered`); + } +}); + +test("the retired scope machinery has no live caller left", () => { + // `applyChatScope` was the combo's onChange target and the one path that could flip + // scope at runtime; `askModeFollowsPanel` was the "ask" mode's answer. Either one + // surviving as live code is a way back to per-workflow sessions behind the + // orchestrator's back. Comments are allowed to explain the removal — code is not. + const code = SRC.split("\n") + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join("\n"); + for (const name of ["applyChatScope", "askModeFollowsPanel"]) { + assert.ok(!code.includes(name), `${name} must not survive as live code`); + } +}); diff --git a/browser_tests/unit/interactive-card-fence.test.mjs b/browser_tests/unit/interactive-card-fence.test.mjs index 1bedeca9..e4d2477d 100644 --- a/browser_tests/unit/interactive-card-fence.test.mjs +++ b/browser_tests/unit/interactive-card-fence.test.mjs @@ -341,7 +341,19 @@ test("the owner-less rule is PROVENANCE: record()'s mint is lastMintedThreadId's // record() must NOT retroactively adopt the minted thread as the turn OWNER — // if it did, #381's liveTurnThreadId semantics would change under it and this // rule would be dead code pretending to guard. - assert.ok(!record.includes("liveTurnThreadId"), "record() does not write the turn owner"); + // + // Matched as an ASSIGNMENT rather than a mention (mcp#884). Reading the turn + // owner inside record() is legitimate and now happens: the abandoned-turn + // output fence consults it to drop a straggler that belongs to a conversation + // no longer on screen. A bare substring test also failed on the COMMENT that + // explains that fence, which is the prose-predicate trap — it would have been + // "fixed" by renaming a comment, leaving the real rule unguarded. The forms + // enumerated here are the same ones the write-enumeration above accepts, so a + // `liveTurnThreadId ||= thread.id` smuggled into record() still fails. + const ownerWrites = [ + ...record.matchAll(/liveTurnThreadId\s*(?:\|\||\?\?|&&|[+\-*/%])?=(?!=)([^;]*);/g), + ].map((m) => m[1].trim()); + assert.deepEqual(ownerWrites, [], "record() does not write the turn owner"); }); test("loadThread's BLOCKED cross-workflow branch is why the owner-less rule needs provenance", () => { diff --git a/browser_tests/unit/turn-output-fence.test.mjs b/browser_tests/unit/turn-output-fence.test.mjs new file mode 100644 index 00000000..067bb622 --- /dev/null +++ b/browser_tests/unit/turn-output-fence.test.mjs @@ -0,0 +1,420 @@ +// The turn-output fence, driven through the SHIPPED bodies (mcp#884/#897). +// +// WHY THIS FILE EXISTS +// -------------------- +// The independent mutation gate on PR #680 disabled the fence at every call +// site — `if (false && turnOutputFenced())` in `record()`, `onSay`, `onStream` +// and `onTodo` — and the entire unit suite stayed green. The fence was correct +// and completely unpinned: its only coverage was a Playwright spec that does +// not run in CI. +// +// NOT A SOURCE-REGEX TEST, deliberately. `if (false && turnOutputFenced())` +// still contains the string `turnOutputFenced()`, so every regex anyone would +// write about these call sites matches the disabled form just as happily as the +// live one. The only thing that separates them is RUNNING them, so that is what +// this file does: it lifts the real `turnOutputFenced`, `pinTurnOwnerAtDispatch`, +// `record`, `onSay`, `onStream` and `onTodo` bodies straight out of the shipped +// panel and executes them over stubbed collaborators — the established "real +// panel source" convention (see interactive-card-fence.test.mjs's +// buildLifecycle(), context-ring-scope.test.mjs). +// +// The stub surface is deliberately observational, never a reimplementation: +// `persistThreads` counts, `appendAgent` records what it was handed, +// `getWorkflowTitle` returns a fixture string. The two collaborators that carry +// real logic — `ChatHistoryStore` (reviseThread/touchMessage) and +// `isThreadInScope` — are imported for REAL, so nothing about thread revision +// semantics is modelled here. +// +// THE RULE BEING PINNED +// --------------------- +// Agent-side output belongs to the conversation that OWNS the turn, pinned at +// `user_message` dispatch. If the shown conversation changed mid-turn (a history +// switch in this tab, or this tab passively adopting another tab's shared +// selection), that output is DROPPED — not painted into the conversation now on +// screen, and not re-routed into its owner either (a fresh stamp there would +// hand that thread the newest activity and yank the shared selection back). +// User-authored entries are exempt: they belong to the view the user typed into. +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { + CHAT_HISTORY_SCHEMA, + ChatHistoryStore, + isThreadInScope, +} from "../../web/js/lib/chat-history-store.js"; + +const panelPath = fileURLToPath(new URL("../../web/js/comfyui-mcp-panel.js", import.meta.url)); +// This checkout is CRLF. Normalise BEFORE matching: an LF-authored multi-line +// anchor silently misses against CRLF text, and a miss in an extraction harness +// reads exactly like a passing test. +const panelSrc = readFileSync(panelPath, "utf8").replace(/\r\n/g, "\n"); + +/** + * Extract one shipped body, asserting it occurs EXACTLY once. + * + * Not `match()`. A zero-match anchor throws here instead of injecting an empty + * string (which would make every assertion below vacuous), and a two-match + * anchor throws instead of silently picking the first — the failure mode that + * makes an extraction harness look green while driving nothing. + */ +function extractOnce(re, label) { + const all = [...panelSrc.matchAll(new RegExp(re.source, `${re.flags}g`))]; + assert.equal(all.length, 1, `${label}: expected exactly 1 match in the panel source, got ${all.length}`); + return all[0][0]; +} + +const turnOutputFencedSrc = extractOnce( + /\n {2}function turnOutputFenced\(\) \{[\s\S]*?\n {2}\}/, + "turnOutputFenced", +); +const pinTurnOwnerSrc = extractOnce( + /\n {2}function pinTurnOwnerAtDispatch\(\) \{[\s\S]*?\n {2}\}/, + "pinTurnOwnerAtDispatch", +); +const recordSrc = extractOnce(/\n {2}function record\(entry\) \{[\s\S]*?\n {2}\}/, "record"); +const onSaySrc = extractOnce(/\n {4}onSay\(text, meta\) \{[\s\S]*?\n {4}\},/, "onSay"); +const onStreamSrc = extractOnce(/\n {4}onStream\(msg\) \{[\s\S]*?\n {4}\},/, "onStream"); +const onTodoSrc = extractOnce(/\n {4}onTodo\(items\) \{[\s\S]*?\n {4}\},/, "onTodo"); + +/** Read a numeric panel constant rather than restating it here. */ +function panelConst(name) { + const m = panelSrc.match(new RegExp(`const ${name} = (\\d+);`)); + assert.ok(m, `could not read ${name} from the panel source`); + return Number(m[1]); +} +const MAX_THREADS = panelConst("MAX_THREADS"); +const MAX_THREAD_MSGS = panelConst("MAX_THREAD_MSGS"); +const MAX_WORKFLOW_VERSIONS = panelConst("MAX_WORKFLOW_VERSIONS"); + +// Sanity: the slices really span the bodies the assertions are about, so a +// future refactor that shrinks an anchor fails loudly instead of quietly +// driving a fragment. +test("the extracted slices really are the shipped bodies", () => { + assert.ok(recordSrc.includes("if (!thread) {"), "record() slice covers the mint branch"); + assert.ok(recordSrc.includes("thread.msgs.push(entry);"), "record() slice reaches the append"); + assert.ok(recordSrc.includes("persistThreads();"), "record() slice reaches the persist"); + assert.ok(turnOutputFencedSrc.includes("liveTurnThreadId"), "the fence reads the turn owner"); + assert.ok(onSaySrc.includes("appendAgent("), "onSay slice reaches its paint"); + assert.ok(onStreamSrc.includes("onStreamDelta("), "onStream slice reaches its paint"); + assert.ok(onTodoSrc.includes("renderTodo("), "onTodo slice reaches its paint"); +}); + +/** + * The REAL fence + owner pin + record() + the three transcript handlers over one + * shared closure, with collaborators stubbed. + * + * Every stub is either an observation point or a fixture value. The only pieces + * with behaviour are the real ChatHistoryStore and the real isThreadInScope. + */ +function buildRecorder() { + const painted = []; + const persistedAt = []; + const detached = []; + const activeThreadWrites = []; + const session = new Map(); + const historyStore = new ChatHistoryStore({ + storage: createMemoryStorage(), + indexedDb: null, + broadcastFactory: null, + }); + + const factory = new Function( + "deps", + ` + const { CHAT_HISTORY_SCHEMA, MAX_THREADS, MAX_THREAD_MSGS, MAX_WORKFLOW_VERSIONS, + SESSION_KEY, CURRENT_THREAD_KEY, crypto, historyStore, isThreadInScope, + historyScopeFollowsPanel, workflowStorageKey, currentHistoryScopeKey, + detachInvalidCurrentThread, workflowTabId, getWorkflowTitle, pickDefaultModel, + capHistoryThreads, setActiveThread, ssGet, ssSet, workflowVersionSnapshot, + persistThreads, extractA2UIFences, commitStream, appendAgent, paintFenceSpecs, + bumpThinking, noteActivity, onStreamDelta, renderTodo } = deps; + + // --- the panel's own mutable state, exactly the names record() closes over + let thread = null; + let threads = []; + let liveTurnThreadId = null; + let lastMintedThreadId = null; + let connectedBackend = "claude"; + let selectedBackend = "claude"; + let orchestratorCurrentModel = "sonnet-test"; + let modelCatalog = []; + const prefs = { model: "sonnet-test", effort: "medium" }; + + ${turnOutputFencedSrc} + ${pinTurnOwnerSrc} + ${recordSrc} + + const host = { + ${onSaySrc} + ${onStreamSrc} + ${onTodoSrc} + }; + + return { + host, + record, + turnOutputFenced, + // What every successful user_message dispatch does (mcp#884): the coming + // turn's output belongs to the conversation on screen right now. + pinTurnOwnerAtDispatch, + /** A conversation APPEARS on screen: history switch, or passive adoption + * of another tab's shared selection. record() was not involved. */ + showConversation(id) { + thread = id ? threads.find((t) => t.id === id) ?? null : null; + }, + /** Seed a conversation the way the store would have restored one. */ + seedConversation(id) { + const now = Date.now(); + const seeded = { + id, schemaVersion: CHAT_HISTORY_SCHEMA, createdAt: now, updatedAt: now, ts: now, + msgs: [], workflowKey: "panel:backend:claude", workflowVersions: {}, + }; + threads.push(seeded); + thread = seeded; + return seeded; + }, + endTurn() { liveTurnThreadId = null; }, + threadById: (id) => threads.find((t) => t.id === id) ?? null, + state: () => ({ + shown: thread?.id ?? null, + owner: liveTurnThreadId, + minted: lastMintedThreadId, + threadCount: threads.length, + }), + }; + `, + ); + + const built = factory({ + CHAT_HISTORY_SCHEMA, + MAX_THREADS, + MAX_THREAD_MSGS, + MAX_WORKFLOW_VERSIONS, + SESSION_KEY: "comfyui-mcp.panel.sessionId", + CURRENT_THREAD_KEY: "comfyui-mcp.panel.currentThreadId", + crypto: globalThis.crypto, + historyStore, + isThreadInScope, + // The one shipping mode: the conversation is always panel-owned. + historyScopeFollowsPanel: () => true, + workflowStorageKey: () => "panel:backend:claude", + currentHistoryScopeKey: () => "panel:backend:claude", + detachInvalidCurrentThread: (opts) => { detached.push(opts); return null; }, + workflowTabId: () => "tab-1", + getWorkflowTitle: () => "Untitled workflow", + pickDefaultModel: () => "sonnet-default", + capHistoryThreads: (list) => list, + setActiveThread: (scope, id) => activeThreadWrites.push({ scope, id }), + ssGet: (key) => (session.has(key) ? session.get(key) : null), + ssSet: (key, value) => { session.set(key, value); }, + workflowVersionSnapshot: () => null, + persistThreads: () => { persistedAt.push(Date.now()); }, + // --- onSay / onStream / onTodo collaborators, all observation points + extractA2UIFences: (text) => ({ text, specs: [] }), + commitStream: () => false, + appendAgent: (text) => painted.push({ kind: "agent", text }), + paintFenceSpecs: () => painted.push({ kind: "fence-specs" }), + bumpThinking: () => painted.push({ kind: "bump" }), + noteActivity: () => painted.push({ kind: "activity" }), + onStreamDelta: (msg) => painted.push({ kind: "delta", id: msg?.id ?? null }), + renderTodo: (items) => painted.push({ kind: "todo", n: items?.length ?? 0 }), + }); + + return { ...built, painted, persistedAt, detached, activeThreadWrites }; +} + +function createMemoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => { map.set(k, String(v)); }, + removeItem: (k) => { map.delete(k); }, + key: (i) => [...map.keys()][i] ?? null, + get length() { return map.size; }, + }; +} + +/** The abandoned-turn shape: the turn is pinned to A, then B lands on screen. */ +function abandonedTurn() { + const h = buildRecorder(); + h.seedConversation("t-A"); + h.pinTurnOwnerAtDispatch(); // user_message dispatched while A was shown + h.seedConversation("t-B"); + h.showConversation("t-B"); // adoption / history switch — B is now on screen + assert.equal(h.state().owner, "t-A", "the turn is still owned by A"); + assert.equal(h.state().shown, "t-B", "and B is what the tab is showing"); + return h; +} + +// --------------------------------------------------------------------------- +// record() — the recording half of the fence +// --------------------------------------------------------------------------- + +test("LOAD-BEARING: an abandoned turn's agent record is DROPPED, not filed under the adopted conversation", () => { + const h = abandonedTurn(); + const before = h.persistedAt.length; + + const entry = { role: "assistant", text: "output from the turn A owned" }; + const returned = h.record(entry); + + assert.deepEqual( + h.threadById("t-B").msgs, + [], + "the straggler must not be appended to the conversation now on screen", + ); + assert.equal(h.persistedAt.length, before, "and nothing is persisted for it"); + assert.equal(returned, entry, "record() still returns the entry so callers do not crash on a drop"); +}); + +test("LOAD-BEARING: the dropped record is not re-routed into its OWNER thread either", () => { + // Deliberate: stamping it into A now would hand A the newest conversation + // activity and yank the shared selection straight back (selectPanelThread + // recency). The turn was abandoned exactly like an interrupt. + const h = abandonedTurn(); + h.record({ role: "assistant", text: "straggler" }); + assert.deepEqual(h.threadById("t-A").msgs, [], "the owner thread is not stamped either"); +}); + +test("LOAD-BEARING: a fenced record does not MINT a conversation for the straggler", () => { + // The nastier shape: nothing on screen at all. A fence that only skipped the + // append would still fall through record()'s mint branch and create a whole + // conversation for output nobody asked for. + const h = buildRecorder(); + h.seedConversation("t-A"); + h.pinTurnOwnerAtDispatch(); + h.showConversation(null); + + h.record({ role: "assistant", text: "straggler onto a blank view" }); + assert.equal(h.state().threadCount, 1, "no conversation is minted for a fenced record"); + assert.equal(h.state().minted, null, "and nothing claims to have been minted"); +}); + +test("EXEMPT: a USER entry is recorded even when the turn owner is elsewhere", () => { + // The user typed into the view they are looking at, by definition. A fence + // that dropped this would silently eat the user's own message after a switch. + const h = abandonedTurn(); + const entry = { role: "user", text: "typed into B" }; + h.record(entry); + + const msgs = h.threadById("t-B").msgs; + assert.equal(msgs.length, 1, "the user's own message lands in the view they typed into"); + assert.equal(msgs[0], entry, "and it is the SAME object — record() mutates in place, never clones"); + assert.ok(h.persistedAt.length > 0, "and it is persisted"); +}); + +test("NORMAL PATH: the turn's own conversation still records agent output", () => { + const h = buildRecorder(); + h.seedConversation("t-A"); + h.pinTurnOwnerAtDispatch(); + + h.record({ role: "assistant", text: "a reply in the turn's own conversation" }); + assert.equal(h.threadById("t-A").msgs.length, 1, "the ordinary case is untouched by the fence"); + assert.ok(h.persistedAt.length > 0); +}); + +test("NORMAL PATH: with no turn in flight nothing is fenced", () => { + // liveTurnThreadId is null between turns — the fence must be inert then, or + // every restore/replay path would start dropping records. + const h = buildRecorder(); + h.seedConversation("t-A"); + assert.equal(h.state().owner, null); + assert.equal(h.turnOutputFenced(), false); + + h.record({ role: "assistant", text: "no live turn" }); + assert.equal(h.threadById("t-A").msgs.length, 1); +}); + +test("the fence lifts once the abandoned turn ends and a new one is dispatched in B", () => { + // The ordinary continuation: the user carries on in B. Its own turn's output + // must record normally — the fence is about ownership, not about B. + const h = abandonedTurn(); + h.record({ role: "assistant", text: "dropped" }); + assert.deepEqual(h.threadById("t-B").msgs, []); + + h.pinTurnOwnerAtDispatch(); // the user sends a message in B + h.record({ role: "assistant", text: "B's own turn" }); + assert.equal(h.threadById("t-B").msgs.length, 1, "B's own turn records normally"); +}); + +// --------------------------------------------------------------------------- +// onSay / onStream / onTodo — the painting half of the fence +// --------------------------------------------------------------------------- + +test("LOAD-BEARING: an abandoned turn's committed say does not paint into the adopted conversation", () => { + const h = abandonedTurn(); + h.host.onSay("a reply belonging to conversation A", { id: "m-1" }); + assert.deepEqual(h.painted, [], "no bubble, no thinking bump, no activity reset"); +}); + +test("LOAD-BEARING: an abandoned turn's stream delta does not open a preview bubble in the adopted conversation", () => { + const h = abandonedTurn(); + h.host.onStream({ id: "m-1", delta: "half a sen" }); + assert.deepEqual(h.painted, [], "no preview bubble is opened for a turn this tab no longer shows"); +}); + +test("LOAD-BEARING: an abandoned turn's plan update does not repaint the todo tray", () => { + const h = abandonedTurn(); + h.host.onTodo([{ text: "step one", status: "running" }]); + assert.deepEqual(h.painted, [], "the tray is not repainted with another conversation's plan"); +}); + +test("NORMAL PATH: say / stream / todo all paint in the turn's own conversation", () => { + const h = buildRecorder(); + h.seedConversation("t-A"); + h.pinTurnOwnerAtDispatch(); + + h.host.onSay("hello", { id: "m-1" }); + h.host.onStream({ id: "m-2", delta: "partial" }); + h.host.onTodo([{ text: "step one" }]); + + assert.deepEqual( + h.painted.map((p) => p.kind), + ["agent", "bump", "activity", "delta", "activity", "todo"], + "the unfenced path is exactly the shipped behaviour", + ); +}); + +test("the painting fence and the recording fence agree on the same question", () => { + // They are separate call sites reading one predicate. If they ever disagree, + // output paints without being recorded (or worse, the reverse) — so pin that + // the single predicate really is what both consult. + const fenced = abandonedTurn(); + assert.equal(fenced.turnOutputFenced(), true); + fenced.host.onSay("x", { id: "m-1" }); + fenced.record({ role: "assistant", text: "x" }); + assert.deepEqual(fenced.painted, []); + assert.deepEqual(fenced.threadById("t-B").msgs, []); + + const open = buildRecorder(); + open.seedConversation("t-A"); + open.pinTurnOwnerAtDispatch(); + assert.equal(open.turnOutputFenced(), false); + open.host.onSay("x", { id: "m-1" }); + open.record({ role: "assistant", text: "x" }); + assert.ok(open.painted.length > 0); + assert.equal(open.threadById("t-A").msgs.length, 1); +}); + +test("ownership is pinned at DISPATCH, not only at turn:working (mcp#884)", () => { + // The hole pinTurnOwnerAtDispatch closes: an adoption's endTurnLocally() + // discards a turn:working that lands inside the stale-working window, so an + // owner pinned only at turn:working would still be null exactly when the + // fence needs it — and the abandoned turn's output would flow straight into + // the adopted conversation. Drive the shipped pin to prove it is armed + // before any turn frame arrives. + const h = buildRecorder(); + h.seedConversation("t-A"); + h.pinTurnOwnerAtDispatch(); + assert.equal(h.state().owner, "t-A", "dispatch alone pins the owner"); + + h.seedConversation("t-B"); + h.showConversation("t-B"); + h.record({ role: "assistant", text: "output from A's turn" }); + assert.deepEqual( + h.threadById("t-B").msgs, + [], + "so the straggler is fenced even though no turn:working was ever seen", + ); +}); diff --git a/browser_tests/workflow-chat-identity.spec.ts b/browser_tests/workflow-chat-identity.spec.ts index 1ac71415..664a6db2 100644 --- a/browser_tests/workflow-chat-identity.spec.ts +++ b/browser_tests/workflow-chat-identity.spec.ts @@ -1,36 +1,22 @@ import { test, expect } from './fixtures/panelTest' +import { MockBridge } from './fixtures/MockBridge' +import { PanelPage } from './fixtures/PanelPage' import { resolveHistoryStoreModuleUrl } from './fixtures/historyStoreModule' +import { routeWorktreeSource } from './fixtures/worktreeSource' const THREADS_KEY = 'comfyui-mcp.panel.threads' const CURRENT_THREAD_KEY = 'comfyui-mcp.panel.currentThreadId' -const SESSION_KEY = 'comfyui-mcp.panel.sessionId' -async function setWorkflowScope(page: import('@playwright/test').Page) { - await page.waitForFunction(() => { - const w = window as any - const app = w.comfyAPI?.app?.app || w.app - return typeof app?.ui?.settings?.setSettingValue === 'function' - }) - await page.evaluate(() => { - const w = window as any - const app = w.comfyAPI?.app?.app || w.app - const settings = app.ui.settings - if (!w.__cmcpOriginalGetSettingValue) { - w.__cmcpOriginalGetSettingValue = settings.getSettingValue.bind(settings) - } - settings.getSettingValue = (id: string) => - id === 'comfyui-mcp.chatScope' - ? 'workflow' - : w.__cmcpOriginalGetSettingValue(id) - }) - await expect.poll(() => page.evaluate(() => { - const w = window as any - const app = w.comfyAPI?.app?.app || w.app - return app?.ui?.settings?.getSettingValue?.('comfyui-mcp.chatScope') - })).toBe('workflow') -} +test.beforeEach(async ({ context }) => { + await routeWorktreeSource(context) +}) -test('opening a workflow does not dirty it and first record embeds silently', async ({ +// mcp#884: the workflow/ask chat scopes are retired — chatScopeMode() is +// hard-wired to "panel", so these specs exercise the one shipping mode. The +// retired workflow scope used to embed a UUID into graph.extra on first +// record; panel scope resolves workflow PROVENANCE for the thread without +// writing to the graph at all — and, as before, without ever dirtying it. +test('opening a workflow does not dirty it and first record keeps provenance off-graph', async ({ page, panel, mockBridge @@ -41,7 +27,6 @@ test('opening a workflow does not dirty it and first record embeds silently', as const app = w.comfyAPI?.app?.app || w.app return !!app?.graph && !!app?.extensionManager?.workflow?.activeWorkflow }) - await setWorkflowScope(page) const before = await page.evaluate(() => { const w = window as any const app = w.comfyAPI?.app?.app || w.app @@ -83,81 +68,35 @@ test('opening a workflow does not dirty it and first record embeds silently', as await panel.setBridgeUrl(mockBridge.url) await panel.connect() - - // #847 — SAVE FIRST, then assert the embed. + // The greeting record resolves this workflow's identity as thread + // provenance (history metadata) without re-stamping the deleted graph tag. // - // The panel embeds its identity into `graph.extra` only for a PERSISTED - // workflow: #570 fails closed on the copyable carrier, because an embedded - // uuid on an unsaved canvas would be inherited by a copy/import and - // cross-resume the source's conversation. So on a rig whose default canvas is - // an unsaved 'Untitled' — which is most of them — this assertion could never - // pass, and the spec was failing on the environment rather than on the panel. - // - // Save through the panel's own command so the workflow is genuinely persisted, - // then re-zero the counters: saving legitimately touches the graph, and the - // claim under test is that the EMBED is silent, not that saving is. - // Timestamp AND a nonce: two workers can start in the same millisecond, and - // each test's cleanup deletes by name — a collision would have one test - // delete the other's workflow out from under it (codex). - const savedAs = `cmcp-e2e-identity-${Date.now().toString(36)}-${Math.random() - .toString(36) - .slice(2, 8)}` - const saveReply = await mockBridge.command('workflow_save_as', { name: savedAs }) - expect(saveReply.ok, JSON.stringify(saveReply).slice(0, 300)).toBe(true) - await expect - .poll(() => - page.evaluate( - () => - ((window as any).comfyAPI?.app?.app || (window as any).app)?.extensionManager?.workflow - ?.activeWorkflow?.isPersisted ?? false - ) - ) - .toBe(true) - await page.evaluate(() => { - ;(window as any).__cmcpIdentityMutationCalls = { before: 0, after: 0, dirty: 0 } - const w = window as any - const app = w.comfyAPI?.app?.app || w.app - if (app.graph?.extra?.comfyui_mcp) delete app.graph.extra.comfyui_mcp - }) - - // The embed rides the next RECORD, so give it one. - await panel.sendMessage('first record after save') - // Wait for a WELL-FORMED uuid, not merely something non-null: the assertion - // below checks the shape, and a gate that accepts any truthy value would let a - // malformed write satisfy the wait and then fail confusingly (codex). - await expect - .poll(() => - page.evaluate( - () => - ((window as any).comfyAPI?.app?.app || (window as any).app)?.graph?.extra?.comfyui_mcp - ?.workflow_uuid ?? null - ) - ) - .toMatch(/^[0-9a-f-]{36}$/i) + // mcp#884 — this replaces main's #847 "save first, THEN assert the embed" + // block. That block existed to make the embed assertion reachable on an + // unsaved canvas, and it ran under `setWorkflowScope(page)`, forcing + // `comfyui-mcp.chatScope = 'workflow'`. Neither survives here: the scope + // setting is retired, so the workflow-scoped embed path + // (`workflowStorageKey({ embed: true })`) is never reached and there is no + // graph tag to assert. Provenance now rides history metadata instead, which + // is the whole point — a conversation is no longer keyed off the canvas. + await expect.poll(() => page.evaluate((threadsKey) => { + const threads = JSON.parse(localStorage.getItem(threadsKey) || '[]') + return threads.find((t: any) => t.msgs?.length)?.workflowKey ?? null + }, THREADS_KEY)).toMatch(/^workflow:/) const recorded = await page.evaluate(() => { const w = window as any const app = w.comfyAPI?.app?.app || w.app return { - uuid: app.graph?.extra?.comfyui_mcp?.workflow_uuid, + embedded: app.graph?.extra?.comfyui_mcp?.workflow_uuid ?? null, calls: w.__cmcpIdentityMutationCalls } }) - expect(recorded.uuid).toMatch(/^[0-9a-f-]{36}$/i) + expect(recorded.embedded).toBeNull() expect(recorded.calls).toEqual({ before: 0, after: 0, dirty: 0 }) - - // Do not leave the file behind. This spec has to SAVE to exercise the embed, - // and a suite that drops a workflow into the user's own workflows folder on - // every run is its own small defect. - await page.evaluate(async (name) => { - try { - await fetch(`/api/userdata/${encodeURIComponent(`workflows/${name}.json`)}`, { - method: 'DELETE' - }) - } catch { - // Best-effort: a failed cleanup must not fail a passing assertion. - } - }, savedAs) + // No cleanup needed: main's version had to SAVE a workflow to reach the embed, + // and deleted the file afterwards. This version never saves — the canvas is + // left exactly as it was found. }) test('default mode opens pre-upgrade history without re-keying it', async ({ @@ -202,135 +141,289 @@ test('default mode opens pre-upgrade history without re-keying it', async ({ ]) }) -test('settings hydration never adopts a loose session into a workflow thread', async ({ +/** Seed an archived cross-workflow conversation's CONTENT into the shared + * canonical store. Deliberately content-only: the SELECTION must travel + * through the real actor (a panel's loadThread), never a direct meta write + * (gate P2-6). */ +async function seedCrossWorkflowThread(page: import('@playwright/test').Page) { + const storeModuleUrl = await resolveHistoryStoreModuleUrl(page) + await page.evaluate(async ({ storeModuleUrl }) => { + const { ChatHistoryStore } = await import(storeModuleUrl) + const seedStore = new ChatHistoryStore({ writerId: 'content-seed-test' }) + const canonical = await seedStore.readCanonical() + const at = Date.now() - 60_000 + seedStore.persist([ + ...(canonical.threads || []), + { + id: 'cross-workflow-thread', + createdAt: at, + updatedAt: at, + ts: at, + workflowKey: 'workflow:definitely-another-workflow', + workflowTitle: 'Workflow B', + msgs: [{ + id: 'cross-msg-1', + role: 'user', + text: 'archived cross-workflow conversation', + createdAt: at + }] + } + ], canonical.meta || {}) + const result = await seedStore.flush() + if (result !== true && (result as any)?.ok !== true) { + throw new Error(`content seed failed: ${JSON.stringify(result)}`) + } + await seedStore.close?.() + }, { storeModuleUrl }) +} + +// mcp#884/#897 (P0-1): the agent session is orchestrator-scoped per backend — +// ONE conversation across every tab and workflow. The selection moves through +// the REAL actor seam: a second panel's loadThread (history click) dispatches +// the session frame and only then publishes the shared pointer; this tab +// passively adopts it, and the next message typed here is recorded into the +// adopted conversation — never into the thread this tab used to show. +test('adopts the shared conversation another tab selected, across workflows', async ({ page, + context, panel, mockBridge }) => { await panel.goto() - await page.evaluate(({ threadsKey, currentThreadKey }) => { - const w = window as any - const app = w.comfyAPI?.app?.app || w.app - const settings = app.ui.settings - const originalGet = settings.getSettingValue.bind(settings) - w.__cmcpPerWorkflowHydrated = false - settings.getSettingValue = (id: string) => - id === 'comfyui-mcp.chatScope' - ? (w.__cmcpPerWorkflowHydrated ? 'workflow' : 'panel') - : originalGet(id) - localStorage.setItem(threadsKey, JSON.stringify([{ - id: 'workflow-before-hydration', - ts: Date.now(), - workflowKey: 'workflow:existing-scope', - msgs: [{ role: 'user', text: 'workflow thread before hydration' }] - }])) - sessionStorage.setItem(currentThreadKey, 'workflow-before-hydration') - }, { threadsKey: THREADS_KEY, currentThreadKey: CURRENT_THREAD_KEY }) - + await panel.setBridgeUrl(mockBridge.url) await panel.openSidebar() - await expect(panel.userBubble('workflow thread before hydration')).toBeVisible() - await page.evaluate((sessionKey) => { - const w = window as any - w.__cmcpPerWorkflowHydrated = true - sessionStorage.setItem(sessionKey, 'live-tab-session') - }, SESSION_KEY) + await panel.connect() + + const received = mockBridge.waitForUserMessage() + await panel.sendMessage('conversation A marker') + await received + + await seedCrossWorkflowThread(page) + + // A second REAL panel is the actor: it connects, opens its history picker, + // and clicks the archived row — driving loadThread (dispatch + publish). + const otherTab = await context.newPage() + const otherPanel = new PanelPage(otherTab) + await otherTab.goto(page.url()) + await otherPanel.openSidebar() + await otherPanel.setBridgeUrl(mockBridge.url) + await otherPanel.connect() + await otherPanel.root.locator('button[title="Chat history"]').click() + const archivedRow = otherPanel.root + .locator('.cmcp-hist-row') + .filter({ hasText: 'archived cross-workflow conversation' }) + await expect(archivedRow.locator('.cmcp-hist-open')).toBeEnabled() + await archivedRow.locator('.cmcp-hist-open').click() + await expect(otherPanel.userBubble('archived cross-workflow conversation')).toBeVisible() + + // This tab follows the shared selection without a reload... + await expect(panel.userBubble('archived cross-workflow conversation')).toBeVisible() + await expect + .poll(() => page.evaluate((key) => sessionStorage.getItem(key), CURRENT_THREAD_KEY)) + .toBe('cross-workflow-thread') + // ...and the next message typed HERE is recorded into the adopted + // conversation (the one the backend's session is in). + const next = mockBridge.waitForUserMessage() + await panel.sendMessage('recorded into the adopted conversation') + await next + await expect.poll(() => page.evaluate((threadsKey) => { + const threads = JSON.parse(localStorage.getItem(threadsKey) || '[]') + const adopted = threads.find((t: any) => t.id === 'cross-workflow-thread') + return adopted?.msgs?.some((m: any) => m.text === 'recorded into the adopted conversation') ?? false + }, THREADS_KEY)).toBe(true) + + // Panel scope has no foreign-workflow lockout: the conversation this tab + // showed before remains an openable archive entry, workflow provenance and + // all (one conversation spans workflows — mcp#884's invariant). + await panel.root.locator('button[title="Chat history"]').click() + const previousRow = panel.root.locator('.cmcp-hist-row').filter({ hasText: 'conversation A marker' }) + await expect(previousRow).toBeVisible() + await expect(previousRow.locator('.cmcp-hist-open')).toBeEnabled() + await otherTab.close() +}) + +// Gate round-3 finding 1 (one conversation PER BACKEND, the switch flow): +// entering a backend must adopt THAT backend's own conversation — the +// orchestrator keys its session orchestrator::, so keeping the +// previous provider's thread on screen would run the new session against a +// conversation this backend does not own. +test('switching backends adopts that backend\'s own conversation', async ({ + page, + panel, + mockBridge +}) => { + await panel.goto() await panel.setBridgeUrl(mockBridge.url) + await panel.openSidebar() await panel.connect() - // #106's contract: a workflow-scoped conversation NEVER adopts a loose tab - // session — its session must arrive through a thread selected for its exact - // scope. Hydration clears the loose key instead of binding it to the wrong - // conversation, and the new greeting thread starts fresh without it. - await expect - .poll(async () => { - const state = await page.evaluate(({ threadsKey, sessionKey }) => ({ - sessionId: sessionStorage.getItem(sessionKey), - threads: JSON.parse(localStorage.getItem(threadsKey) || '[]') - }), { threadsKey: THREADS_KEY, sessionKey: SESSION_KEY }) - const greeting = state.threads.find((t: any) => - t.msgs?.some((m: any) => m.text === 'Panel agent ready.')) - return { - sessionId: state.sessionId, - beforeScope: state.threads.find((t: any) => t.id === 'workflow-before-hydration')?.workflowKey, - greetingScope: greeting?.workflowKey ?? null, - greetingSession: greeting?.sessionId ?? null + const received = mockBridge.waitForUserMessage() + await panel.sendMessage('claude conversation marker') + await received + const claudeThreadId = await page.evaluate((key) => sessionStorage.getItem(key), CURRENT_THREAD_KEY) + expect(claudeThreadId).not.toBeNull() + + // PRE-EXISTING state an earlier Codex session left behind: its conversation + // and its backend-scoped selection. (Setup data, not the behavior under + // test — the seam under test is the handshake's switch adoption below.) + const storeModuleUrl = await resolveHistoryStoreModuleUrl(page) + await page.evaluate(async ({ storeModuleUrl }) => { + const { ChatHistoryStore, updateMetadataEntry } = await import(storeModuleUrl) + const seedStore = new ChatHistoryStore({ writerId: 'codex-prior-session' }) + const canonical = await seedStore.readCanonical() + const at = Date.now() - 120_000 + const meta = updateMetadataEntry( + canonical.meta || {}, + 'activeByScope', + 'panel:backend:codex', + 'codex-own-thread', + { updatedAt: at + 1, writerId: 'codex-prior-session', sequence: 1 } + ) + seedStore.persist([ + ...(canonical.threads || []), + { + id: 'codex-own-thread', + createdAt: at, + updatedAt: at, + ts: at, + provider: 'codex', + workflowKey: 'workflow:codex-earlier-workflow', + msgs: [{ + id: 'codex-msg-1', + role: 'user', + text: 'codex conversation from before', + createdAt: at + }] } - }, { timeout: 15_000 }) - .toEqual({ - sessionId: null, - beforeScope: 'workflow:existing-scope', - greetingScope: expect.stringMatching(/^workflow:/) as unknown as string, - greetingSession: null - }) + ], meta) + const result = await seedStore.flush() + if (result !== true && (result as any)?.ok !== true) { + throw new Error(`codex state seed failed: ${JSON.stringify(result)}`) + } + await seedStore.close?.() + }, { storeModuleUrl }) + + // Reconnect to an orchestrator that reports the CODEX backend. + const codexBridge = new MockBridge({ port: 0, backend: 'codex' }) + await codexBridge.start() + try { + await panel.setBridgeUrl(codexBridge.url) + await panel.connect() + + // The handshake adopts codex's own conversation... + await expect(panel.userBubble('codex conversation from before')).toBeVisible() + await expect + .poll(() => page.evaluate((key) => sessionStorage.getItem(key), CURRENT_THREAD_KEY)) + .toBe('codex-own-thread') + // ...and claude's selection still names claude's conversation. + expect(await page.evaluate(() => { + const meta = JSON.parse(localStorage.getItem('comfyui-mcp.panel.historyMeta') || '{}') + return meta.activeByScope?.['panel:backend:claude'] || null + })).toBe(claudeThreadId) + } finally { + await codexBridge.close() + } }) -test('embeds a workflow UUID and blocks a foreign transcript pointer', async ({ +// Gate P0-1: THE COMMIT IS THE TRANSITION. A tab that cannot reach the +// orchestrator can still open an archive for READING, but it must not publish +// the shared selection — the backend never entered that conversation, so no +// connected tab may be moved onto it. +test('a disconnected tab cannot move the shared conversation', async ({ page, context, panel, mockBridge }) => { await panel.goto() - // Make the per-workflow setting available before the panel mounts; the - // shared fixture intentionally strips real user settings. - await setWorkflowScope(page) await panel.setBridgeUrl(mockBridge.url) await panel.openSidebar() await panel.connect() const received = mockBridge.waitForUserMessage() - await panel.sendMessage('workflow identity marker') + await panel.sendMessage('conversation A marker') await received + const threadA = await page.evaluate((key) => sessionStorage.getItem(key), CURRENT_THREAD_KEY) + expect(threadA).not.toBeNull() - const current = await page.evaluate((threadsKey) => { - const w = window as any - const app = w.comfyAPI?.app?.app || w.app - const threads = JSON.parse(localStorage.getItem(threadsKey) || '[]') - return { - uuid: app?.graph?.extra?.comfyui_mcp?.workflow_uuid, - thread: threads.find((t: any) => t.msgs?.some((m: any) => m.text === 'workflow identity marker')) - } - }, THREADS_KEY) - - expect(current.uuid).toMatch(/^[0-9a-f-]{36}$/i) - expect(current.thread?.workflowKey).toBe(`workflow:${current.uuid}`) + await seedCrossWorkflowThread(page) - // A foreign (other-workflow) thread arrives the way foreign threads really - // do in this architecture: another tab writes it through the store, landing - // in the shared canonical. (Direct localStorage writes are just this tab's - // own cache and are legitimately overwritten by the owning panel's flush.) - const storeModuleUrl = await resolveHistoryStoreModuleUrl(page) + // A second panel that is NOT connected opens the archived conversation. const otherTab = await context.newPage() + const otherPanel = new PanelPage(otherTab) await otherTab.goto(page.url()) - await otherTab.evaluate(async ({ threadsKey, currentThreadKey, storeModuleUrl }) => { - const { ChatHistoryStore } = await import(storeModuleUrl) - const foreignStore = new ChatHistoryStore({ writerId: 'foreign-tab-test' }) - const existing = JSON.parse(localStorage.getItem(threadsKey) || '[]') - foreignStore.persist([ - ...existing, - { - id: 'foreign-thread', - ts: Date.now() + 10, - workflowKey: 'workflow:definitely-another-workflow', - msgs: [{ id: 'foreign-msg-1', role: 'user', text: 'must never restore on this workflow' }] - } - ], {}) - await foreignStore.flush() - await foreignStore.close?.() - }, { threadsKey: THREADS_KEY, currentThreadKey: CURRENT_THREAD_KEY, storeModuleUrl }) + await otherPanel.openSidebar() + await otherPanel.root.locator('button[title="Chat history"]').click() + const archivedRow = otherPanel.root + .locator('.cmcp-hist-row') + .filter({ hasText: 'archived cross-workflow conversation' }) + await archivedRow.locator('.cmcp-hist-open').click() + // The disconnected tab gets its own local view of the archive... + await expect(otherPanel.userBubble('archived cross-workflow conversation')).toBeVisible() + + // ...but the connected tab is NOT moved: no session transition was + // dispatched, so no selection was published. + await page.waitForTimeout(800) + await expect(panel.userBubble('conversation A marker')).toBeVisible() + expect(await page.evaluate((key) => sessionStorage.getItem(key), CURRENT_THREAD_KEY)).toBe(threadA) await otherTab.close() +}) - await page.reload() - await setWorkflowScope(page) +// Gate P0-4: output of an abandoned turn must not reach the conversation the +// user opened mid-turn — including BEFORE any turn:working frame arrived (the +// owner is pinned at user_message dispatch, not at turn:working, because an +// adoption/switch's endTurnLocally discards a working frame landing inside the +// stale-working window). Covers the say/record fence AND the card paths the +// first round left open (ask_user, set_todo). +test('an abandoned turn cannot leak output into a conversation opened mid-turn', async ({ + page, + panel, + mockBridge +}) => { + await panel.goto() + await panel.setBridgeUrl(mockBridge.url) await panel.openSidebar() - await expect(panel.userBubble('must never restore on this workflow')).toHaveCount(0) + await panel.connect() + + await seedCrossWorkflowThread(page) + + const received = mockBridge.waitForUserMessage() + await panel.sendMessage('turn A prompt') + await received + // Deliberately NO turn:working yet — the pre-working hole the gate flagged. + // The user opens the archived conversation mid-flight (the real actor: + // loadThread ends the local turn, dispatches the session frame, publishes). await panel.root.locator('button[title="Chat history"]').click() - const currentOnly = panel.root.getByTestId('history-current-workflow') - if (await currentOnly.isVisible()) await currentOnly.uncheck() - const foreign = panel.root.locator('.cmcp-hist-row').filter({ hasText: 'must never restore on this workflow' }) - await expect(foreign).toBeVisible() - await expect(foreign.locator('.cmcp-hist-open')).toBeDisabled() - await expect(foreign).toHaveCSS('opacity', '0.48') + const archivedRow = panel.root + .locator('.cmcp-hist-row') + .filter({ hasText: 'archived cross-workflow conversation' }) + await archivedRow.locator('.cmcp-hist-open').click() + await expect(panel.userBubble('archived cross-workflow conversation')).toBeVisible() + + // Turn A's late output arrives: a committed say, a plan update, and an + // interactive question card. None of it may reach the opened conversation. + mockBridge.say('late straggler from the abandoned turn') + mockBridge.send({ rid: 'gate-todo-1', cmd: 'set_todo', items: [{ text: 'abandoned todo', status: 'active' }] }) + mockBridge.send({ rid: 'gate-ask-1', cmd: 'ask_user', question: 'abandoned question?', options: [{ label: 'yes' }] }) + await page.waitForTimeout(600) + await expect( + panel.agentBubbles.filter({ hasText: 'late straggler from the abandoned turn' }) + ).toHaveCount(0) + await expect(panel.root.locator('.cmcp-question')).toHaveCount(0) + await expect(panel.root.locator('.cmcp-todo-item').filter({ hasText: 'abandoned todo' })).toHaveCount(0) + expect(await page.evaluate((threadsKey) => { + const threads = JSON.parse(localStorage.getItem(threadsKey) || '[]') + return threads.some((t: any) => + t.msgs?.some((m: any) => String(m.text || '').includes('late straggler'))) + }, THREADS_KEY)).toBe(false) + + // The abandoned conversation still holds the user's own prompt — user + // records are never fenced. + expect(await page.evaluate((threadsKey) => { + const threads = JSON.parse(localStorage.getItem(threadsKey) || '[]') + return threads.some((t: any) => + t.msgs?.some((m: any) => m.text === 'turn A prompt')) + }, THREADS_KEY)).toBe(true) }) diff --git a/docs/design/chat-history-v2.md b/docs/design/chat-history-v2.md index 0528daac..3ede88d3 100644 --- a/docs/design/chat-history-v2.md +++ b/docs/design/chat-history-v2.md @@ -5,11 +5,48 @@ messages each) with a versioned, workflow-aware history system. ## Conversation scope -Settings → ComfyUI MCP Agent → General → **Chat conversation scope**: - -- **Panel** keeps one conversation while canvases change. -- **Workflow** keeps an independent collection of conversations for every graph. -- **Ask** chooses between those behaviors whenever the active workflow changes. +The conversation is always **panel-owned**: one conversation per backend that +spans every browser tab and every workflow. The agent session behind it is +orchestrator-scoped (comfyui-mcp#897) and persists in `~/.comfyui-mcp/sessions`, +so switching, saving, renaming, or creating workflows — or moving between tabs — +never swaps or resets the chat. + +The former "Chat conversation scope" setting (**Panel** / **Workflow** / **Ask**) +was removed in comfyui-mcp#884: the workflow and ask modes were per-workflow +sessions under another name, which contradicts the orchestrator-global session. +Stored values of the old setting are ignored, and conversations created under +the retired modes remain in history as ordinary archive entries, openable from +any workflow. + +Which conversation is *the* conversation is shared state, not tab state: the +**backend-scoped** active pointer `panel:backend:` in history metadata +(one conversation per backend, mirroring the orchestrator's +`orchestrator::` session key; the pre-existing shared `panel:global` +key remains a one-way read fallback until a backend's key is first written). +Every tab resolves its own backend's pointer through one selector +(`selectPanelThread`/`resolvePanelPointer`) — on cold restore and on cross-tab +sync alike — and a tab whose selection moved adopts the new thread passively +(it repaints; only the tab the user acted in sends +`resume_session`/`new_session`). + +**The commit is the transition:** an acting tab dispatches the session frame +first and publishes the pointer only when the frame actually left its socket — +a disconnected tab can still read an archive locally, but cannot move the +other tabs onto a conversation the backend never entered. + +**Selection evidence only:** a pointer left stale by a pre-#884 build loses to +a *newer selection* (the retired workflow mode stamped workflow-scoped active +ops on every thread creation/open), never to mere message timestamps — an +imported archive, a straggler write, or a skewed clock carries newer messages +without any user selection and must not move the shared conversation. + +**Turn ownership:** a turn's owner is pinned when its `user_message` is +dispatched (not at `turn:working`), and every transcript output — says, stream +deltas, plan updates, question cards, media, A2UI cards, command activity — +is fenced against a conversation the turn does not own. The prompt itself is +filed at dispatch time too: if the selection moves while attachments upload or +grounding runs, the recorded prompt is relocated (tombstoned + re-recorded) +into the conversation that will actually consume it. The plus button starts a new conversation without deleting older chats. The history button opens search, current-workflow filtering, rename, pin, delete, @@ -19,9 +56,10 @@ export, and merge-import controls. Bridge routing uses `wf::`/`tmp:` (the saved form is tab-scoped since #640, so two browser tabs on one file register distinct routes) because the -orchestrator binds agents to the current tab. Transcript identity is separate: -`workflow:`. The UUID is stored in -`workflow.extra.comfyui_mcp.workflow_uuid` on the first per-workflow chat. +orchestrator binds agents to the current tab. Workflow identity is separate: +`workflow:`, stored in `workflow.extra.comfyui_mcp.workflow_uuid` by the +unsaved-workflow durability path (#570). Threads carry that key as ride-along +**provenance** for archive grouping — it does not scope the conversation. Renaming therefore preserves history. Opening a copied graph as another workflow detects the repeated UUID and gives the copy a fresh identity. A path→UUID alias diff --git a/locales/ar/main.json b/locales/ar/main.json index 3b4cca9f..72cd9c37 100644 --- a/locales/ar/main.json +++ b/locales/ar/main.json @@ -372,7 +372,6 @@ "api_tokens": "رموز API", "apps_one_click_micro_apps_built_from": "التطبيقات — تطبيقات مصغّرة بنقرة واحدة مبنية من مسارات سير العمل: حوّل، وشغّل محليًا أو على RunPod، وشارك.", "ask_label_for_commands_context": "اسأل {label}… اكتب / للأوامر أو @ للسياق", - "ask_whenever_the_workflow_changes": "اسألني كلما تغيّر سير العمل", "asked_the_agent_to_help_you_set_up": "طُلب من الوكيل مساعدتك في إعداد {label}.", "at_menu_context": "سياق", "at_menu_node_type": "نوع العقدة", @@ -429,9 +428,6 @@ "chat_history_could_not_be_saved_keep": "تعذّر حفظ سجل المحادثات. أبقِ هذا التبويب مفتوحًا، وحرّر مساحة تخزين في المتصفح، ثم أرسل أو حرّر مرة واحدة لإعادة المحاولة.", "chat_history_import_exceeds_the_25_mb": "استيراد سجل المحادثات يتجاوز حد 25 ميغابايت", "chat_history_was_cleared_from_this_browser": "مُسح سجل المحادثات من هذا المتصفح.", - "chat_scope_ask_whenever_the_workflow_changes": "نطاق المحادثة ← السؤال كلما تغيّر سير العمل.", - "chat_scope_panel_wide_conversation": "نطاق المحادثة ← محادثة على مستوى اللوحة كلها.", - "chat_scope_separate_histories_for_each_workflow": "نطاق المحادثة ← سجل منفصل لكل سير عمل.", "chat_title": "عنوان المحادثة", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -488,7 +484,6 @@ "context_window_fills_as_the_agent_reports": "نافذة السياق — تمتلئ كلما أبلغ الوكيل عن استخدامه", "context_window_pct_used": "استُخدم نحو {pct}٪ من نافذة السياق", "context_window_used": "المستخدَم من نافذة السياق", - "continue_the_current_agent_panel_conversation_on": "هل تريد متابعة محادثة لوحة الوكيل الحالية على «{name}»؟", "conversation_only": "المحادثة فقط", "copied": "نُسخ ✓", "copy_url": "نسخ الرابط", @@ -783,10 +778,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "لا شيء لاسترجاعه — لم تُلتقط أي لقطة للمخطط في هذه الجلسة بعد.", "nothing_to_rewind_yet_no_message_or": "لا شيء لإرجاعه بعد — لا توجد رسالة أو لقطة للمخطط من هذه الجلسة.", "nothing_was_sent_and_nothing_was_stored": "لم يُرسل شيء ولم يُخزَّن شيء. انتظر أن يسأل الوكيل مجددًا على الاتصال الجديد — ولا تلصق القيمة في المحادثة.", - "ok_carry_this_chat_to_the_new": "موافق: انقل هذه المحادثة إلى لوحة الرسم الجديدة.\nإلغاء: افتح سجل المحادثات المنفصل لسير العمل هذا.", "ollama_local": "Ollama (محلي)", "ollama_local_free_our_comfyui_fine_tune": "Ollama (محلي، مجاني — نسختنا المضبوطة لـ ComfyUI)", - "one_chat_across_workflows": "اللوحة — محادثة واحدة عبر كل مسارات سير العمل", "open_before_resuming_this_chat": "افتح {workflow} قبل استئناف هذه المحادثة", "open_file": "فتح الملف", "open_in_a_new_browser_tab": "فتح في تبويب متصفح جديد", @@ -1031,7 +1024,6 @@ "workflow": "سير العمل · {version}", "workflow_saved": "حُفظ «{workflow}»", "workflow_saved_as": "حُفظ باسم «{workflow}»", - "workflow_separate_chat_histories": "سير العمل — سجل محادثات منفصل لكل واحد", "workflow_snapshot": "لقطة سير العمل", "working": "جارٍ العمل…", "your_agent_inline": "وكيلك", diff --git a/locales/ar/settings.json b/locales/ar/settings.json index f707ace2..1358b1c3 100644 --- a/locales/ar/settings.json +++ b/locales/ar/settings.json @@ -23,10 +23,6 @@ "name": "خلفية الوكيل الافتراضية", "tooltip": "الوكيل العامل في الخلفية الذي تتصل به اللوحة افتراضيًا. يعمل Claude على اشتراكك في Claude؛ ويعمل ChatGPT على حسابك في Codex (ChatGPT)؛ ويعمل Gemini على تسجيل دخولك إلى Google (Gemini). يحدّد هذا خلفية اللوحة (وأي مجموعة أدناه تحدّد بيئة التشغيل)؛ وما زال بإمكانك التبديل مباشرةً من منتقي النماذج (التبديل المباشر يخصّ الجلسة فقط ولا يغيّر هذا الافتراضي)." }, - "comfyui-mcp_chatScope": { - "name": "نطاق المحادثة", - "tooltip": "اللوحة: محادثة واحدة تتبع كل لوحة رسم. سير العمل: لكل سير عمل محفوظ مجموعة محادثات دائمة خاصة به، تُعرَّف بمعرّف UUID مضمّن بحيث تحتفظ إعادة التسمية بالسجل وتُفصل النسخ. اسألني: اختر في كل مرة تبدّل فيها سير العمل ما إذا كنت تريد نقل المحادثة الحالية. وتصمد كل الأوضاع أمام إعادة تشغيل ComfyUI أو MCP بالكامل." - }, "comfyui-mcp_autoConnect": { "name": "الاتصال التلقائي عند التحميل", "tooltip": "اتصل بالوكيل تلقائيًا (مع تشغيل المنسّق المحلي) عند فتح اللوحة، دون النقر على «اتصال». معطّل افتراضيًا — فالمنسّق لا يبدأ عادةً إلا بنقرة «اتصال» صريحة." diff --git a/locales/en/main.json b/locales/en/main.json index fc25eb0e..f9d2352d 100644 --- a/locales/en/main.json +++ b/locales/en/main.json @@ -336,7 +336,6 @@ "api_tokens": "API tokens", "apps_one_click_micro_apps_built_from": "Apps — one-click micro-apps built from workflows: convert, run locally or on RunPod, share.", "ask_label_for_commands_context": "Ask {label}… / for commands, @ for context", - "ask_whenever_the_workflow_changes": "Ask whenever the workflow changes", "asked_the_agent_to_help_you_set_up": "Asked the agent to help you set up {label}.", "at_menu_context": "context", "at_menu_node_type": "node type", @@ -377,9 +376,6 @@ "chat_history_could_not_be_saved_keep": "Chat history could not be saved. Keep this tab open, free browser storage, then send or edit once to retry.", "chat_history_import_exceeds_the_25_mb": "Chat history import exceeds the 25 MB limit", "chat_history_was_cleared_from_this_browser": "Chat history was cleared from this browser.", - "chat_scope_ask_whenever_the_workflow_changes": "Chat scope → ask whenever the workflow changes.", - "chat_scope_panel_wide_conversation": "Chat scope → panel-wide conversation.", - "chat_scope_separate_histories_for_each_workflow": "Chat scope → separate histories for each workflow.", "chat_title": "Chat title", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -428,7 +424,6 @@ "context_window_fills_as_the_agent_reports": "Context window — fills as the agent reports usage", "context_window_pct_used": "Context window ~{pct}% used", "context_window_used": "Context window used", - "continue_the_current_agent_panel_conversation_on": "Continue the current Agent Panel conversation on \"{name}\"?", "conversation_only": "Conversation only", "copied": "Copied ✓", "copy_url": "Copy URL", @@ -651,10 +646,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "Nothing to revert — no graph snapshot captured in this session yet.", "nothing_to_rewind_yet_no_message_or": "Nothing to rewind yet — no message or graph snapshot from this session.", "nothing_was_sent_and_nothing_was_stored": "Nothing was sent and nothing was stored. Wait for the agent to ask again on the new connection — do not paste the value into the chat.", - "ok_carry_this_chat_to_the_new": "OK: carry this chat to the new canvas.\nCancel: open this workflow's separate chat history.", "ollama_local": "Ollama (local)", "ollama_local_free_our_comfyui_fine_tune": "Ollama (local, free — our ComfyUI fine-tune)", - "one_chat_across_workflows": "Panel — one chat across workflows", "open_before_resuming_this_chat": "Open {workflow} before resuming this chat", "open_file": "Open file", "open_in_a_new_browser_tab": "Open in a new browser tab", @@ -875,7 +868,6 @@ "workflow": "workflow · {version}", "workflow_saved": "Saved “{workflow}”", "workflow_saved_as": "Saved as “{workflow}”", - "workflow_separate_chat_histories": "Workflow — separate chat histories", "workflow_snapshot": "Workflow snapshot", "working": "Working…", "your_agent_inline": "your agent", diff --git a/locales/en/settings.json b/locales/en/settings.json index 9c4a0f26..bf51a1bb 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -23,10 +23,6 @@ "name": "Default agent backend", "tooltip": "Which background agent the panel connects to by default. Claude runs on your Claude subscription; ChatGPT runs on your Codex (ChatGPT) account; Gemini runs on your Google (Gemini) login. Seeds the panel's backend (and which group below seeds the runtime); you can still switch live in the model picker (a live switch is session-only and does NOT change this default)." }, - "comfyui-mcp_chatScope": { - "name": "Chat conversation scope", - "tooltip": "Panel: one conversation follows every canvas. Workflow: each saved workflow has its own persistent set of chats, identified by an embedded UUID so renames keep history and copies separate. Ask: choose whether to carry the current conversation whenever you switch workflows. All modes survive full ComfyUI/MCP restarts." - }, "comfyui-mcp_autoConnect": { "name": "Auto-connect on load", "tooltip": "Automatically connect the agent (starting the local orchestrator) when the panel opens, without clicking Connect. Off by default — the orchestrator is otherwise only started by an explicit Connect click." diff --git a/locales/es/main.json b/locales/es/main.json index fb0b8da4..dd6141f4 100644 --- a/locales/es/main.json +++ b/locales/es/main.json @@ -345,7 +345,6 @@ "api_tokens": "Tokens de API", "apps_one_click_micro_apps_built_from": "Apps — micro-apps de un clic creadas a partir de flujos de trabajo: convierte, ejecuta en local o en RunPod, comparte.", "ask_label_for_commands_context": "Pregunta a {label}… / para comandos, @ para contexto", - "ask_whenever_the_workflow_changes": "Preguntar cada vez que cambie el flujo de trabajo", "asked_the_agent_to_help_you_set_up": "Se le pidió al agente que te ayude a configurar {label}.", "at_menu_context": "contexto", "at_menu_node_type": "tipo de nodo", @@ -390,9 +389,6 @@ "chat_history_could_not_be_saved_keep": "No se pudo guardar el historial de chats. Deja esta pestaña abierta, libera almacenamiento del navegador y luego envía o edita algo una vez para reintentarlo.", "chat_history_import_exceeds_the_25_mb": "La importación del historial de chats supera el límite de 25 MB", "chat_history_was_cleared_from_this_browser": "Se borró el historial de chats de este navegador.", - "chat_scope_ask_whenever_the_workflow_changes": "Ámbito del chat → preguntar cada vez que cambie el flujo de trabajo.", - "chat_scope_panel_wide_conversation": "Ámbito del chat → una conversación para todo el panel.", - "chat_scope_separate_histories_for_each_workflow": "Ámbito del chat → historiales separados para cada flujo de trabajo.", "chat_title": "Título del chat", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -443,7 +439,6 @@ "context_window_fills_as_the_agent_reports": "Ventana de contexto — se llena a medida que el agente informa de su uso", "context_window_pct_used": "Ventana de contexto ~{pct}% usada", "context_window_used": "Ventana de contexto usada", - "continue_the_current_agent_panel_conversation_on": "¿Continuar en “{name}” la conversación actual del panel del agente?", "conversation_only": "Solo conversación", "copied": "Copiado ✓", "copy_url": "Copiar la URL", @@ -684,10 +679,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "Nada que revertir — en esta sesión todavía no se ha capturado ninguna instantánea del grafo.", "nothing_to_rewind_yet_no_message_or": "Aún no hay nada que retroceder — no hay ningún mensaje ni instantánea del grafo de esta sesión.", "nothing_was_sent_and_nothing_was_stored": "No se envió ni se guardó nada. Espera a que el agente lo vuelva a pedir en la nueva conexión — no pegues el valor en el chat.", - "ok_carry_this_chat_to_the_new": "Aceptar: llevar este chat al lienzo nuevo.\nCancelar: abrir el historial de chats propio de este flujo de trabajo.", "ollama_local": "Ollama (local)", "ollama_local_free_our_comfyui_fine_tune": "Ollama (local, gratis — nuestro ajuste fino para ComfyUI)", - "one_chat_across_workflows": "Panel — un chat para todos los flujos de trabajo", "open_before_resuming_this_chat": "Abre {workflow} antes de reanudar este chat", "open_file": "Abrir archivo", "open_in_a_new_browser_tab": "Abrir en una pestaña nueva del navegador", @@ -914,7 +907,6 @@ "workflow": "flujo de trabajo · {version}", "workflow_saved": "Se guardó “{workflow}”", "workflow_saved_as": "Se guardó como “{workflow}”", - "workflow_separate_chat_histories": "Flujo de trabajo — historiales de chat separados", "workflow_snapshot": "Instantánea del flujo de trabajo", "working": "Trabajando…", "your_agent_inline": "tu agente", diff --git a/locales/es/settings.json b/locales/es/settings.json index e77f5298..49ac8324 100644 --- a/locales/es/settings.json +++ b/locales/es/settings.json @@ -23,10 +23,6 @@ "name": "Backend del agente por defecto", "tooltip": "A qué agente en segundo plano se conecta el panel por defecto. Claude funciona con tu suscripción de Claude; ChatGPT, con tu cuenta de Codex (ChatGPT); Gemini, con tu inicio de sesión de Google (Gemini). Define el backend inicial del panel (y qué grupo de los de abajo alimenta el entorno de ejecución); aún puedes cambiarlo en caliente desde el selector de modelos (ese cambio dura solo la sesión y NO modifica este valor por defecto)." }, - "comfyui-mcp_chatScope": { - "name": "Ámbito de la conversación del chat", - "tooltip": "Panel: una sola conversación acompaña a todos los lienzos. Flujo de trabajo: cada flujo guardado tiene su propio conjunto persistente de chats, identificado por un UUID incrustado, de modo que renombrarlo conserva el historial y las copias se mantienen separadas. Preguntar: eliges si llevarte la conversación actual cada vez que cambias de flujo de trabajo. Todos los modos sobreviven a reinicios completos de ComfyUI/MCP." - }, "comfyui-mcp_autoConnect": { "name": "Conectar automáticamente al cargar", "tooltip": "Conecta el agente automáticamente (iniciando el orquestador local) cuando se abre el panel, sin pulsar Conectar. Desactivado por defecto: de lo contrario, el orquestador solo se inicia con un clic explícito en Conectar." diff --git a/locales/fa/main.json b/locales/fa/main.json index c45a1530..ae55463c 100644 --- a/locales/fa/main.json +++ b/locales/fa/main.json @@ -336,7 +336,6 @@ "api_tokens": "توکن‌های API", "apps_one_click_micro_apps_built_from": "اپ‌ها — ریزاپ‌های تک‌کلیکی ساخته‌شده از گردش‌کارها: تبدیل کنید، محلی یا روی RunPod اجرا کنید، هم‌رسانی کنید.", "ask_label_for_commands_context": "از {label} بپرسید… / برای فرمان‌ها، @ برای زمینه", - "ask_whenever_the_workflow_changes": "هر بار که گردش‌کار عوض شد بپرس", "asked_the_agent_to_help_you_set_up": "از عامل خواسته شد در برپاسازی {label} کمکتان کند.", "at_menu_context": "زمینه", "at_menu_node_type": "نوع گره", @@ -377,9 +376,6 @@ "chat_history_could_not_be_saved_keep": "تاریخچهٔ گفت‌وگو ذخیره نشد. این زبانه را باز نگه دارید، فضای ذخیره‌سازی مرورگر را آزاد کنید، بعد یک بار پیامی بفرستید یا ویرایش کنید تا دوباره تلاش شود.", "chat_history_import_exceeds_the_25_mb": "درون‌ریزی تاریخچهٔ گفت‌وگو از سقف ۲۵ مگابایت فراتر می‌رود", "chat_history_was_cleared_from_this_browser": "تاریخچهٔ گفت‌وگو از این مرورگر پاک شد.", - "chat_scope_ask_whenever_the_workflow_changes": "دامنهٔ گفت‌وگو ← هر بار که گردش‌کار عوض شد بپرس.", - "chat_scope_panel_wide_conversation": "دامنهٔ گفت‌وگو ← یک گفت‌وگو برای کل پنل.", - "chat_scope_separate_histories_for_each_workflow": "دامنهٔ گفت‌وگو ← تاریخچهٔ جداگانه برای هر گردش‌کار.", "chat_title": "عنوان گفت‌وگو", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -428,7 +424,6 @@ "context_window_fills_as_the_agent_reports": "پنجرهٔ زمینه — همین‌طور که عامل مصرف را گزارش می‌دهد پر می‌شود", "context_window_pct_used": "حدود {pct}٪ از پنجرهٔ زمینه مصرف شده", "context_window_used": "مصرف پنجرهٔ زمینه", - "continue_the_current_agent_panel_conversation_on": "گفت‌وگوی فعلی پنل عامل روی «{name}» ادامه پیدا کند؟", "conversation_only": "فقط گفت‌وگو", "copied": "کپی شد ✓", "copy_url": "کپی نشانی", @@ -651,10 +646,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "چیزی برای بازگرداندن نیست — هنوز در این نشست اسنپ‌شاتی از گراف ثبت نشده.", "nothing_to_rewind_yet_no_message_or": "هنوز چیزی برای عقب بردن نیست — نه پیامی از این نشست هست، نه اسنپ‌شات گرافی.", "nothing_was_sent_and_nothing_was_stored": "چیزی فرستاده نشد و چیزی ذخیره نشد. صبر کنید تا عامل روی اتصال تازه دوباره بپرسد — مقدار را در گفت‌وگو نچسبانید.", - "ok_carry_this_chat_to_the_new": "تأیید: این گفت‌وگو به بوم تازه منتقل شود.\nلغو: تاریخچهٔ گفت‌وگوی جداگانهٔ همین گردش‌کار باز شود.", "ollama_local": "Ollama (محلی)", "ollama_local_free_our_comfyui_fine_tune": "Ollama (محلی، رایگان — مدل ریزتنظیم‌شدهٔ ما برای ComfyUI)", - "one_chat_across_workflows": "پنل — یک گفت‌وگو در همهٔ گردش‌کارها", "open_before_resuming_this_chat": "پیش از ادامهٔ این گفت‌وگو {workflow} را باز کنید", "open_file": "باز کردن فایل", "open_in_a_new_browser_tab": "باز کردن در زبانهٔ تازهٔ مرورگر", @@ -875,7 +868,6 @@ "workflow": "گردش‌کار · {version}", "workflow_saved": "«{workflow}» ذخیره شد", "workflow_saved_as": "با نام «{workflow}» ذخیره شد", - "workflow_separate_chat_histories": "گردش‌کار — تاریخچهٔ گفت‌وگوی جداگانه", "workflow_snapshot": "اسنپ‌شات گردش‌کار", "working": "در حال کار…", "your_agent_inline": "عامل شما", diff --git a/locales/fa/settings.json b/locales/fa/settings.json index 34346535..b411324c 100644 --- a/locales/fa/settings.json +++ b/locales/fa/settings.json @@ -23,10 +23,6 @@ "name": "بک‌اند پیش‌فرض عامل", "tooltip": "پنل به‌طور پیش‌فرض به کدام عامل پس‌زمینه وصل شود. Claude روی اشتراک Claude شما اجرا می‌شود؛ ChatGPT روی حساب Codex (ChatGPT) شما؛ Gemini روی ورود Google (Gemini) شما. این گزینه بک‌اند پنل را مقداردهی اولیه می‌کند (و تعیین می‌کند کدام گروه پایین، زمان اجرا را مقداردهی کند)؛ هنوز می‌توانید در انتخابگر مدل به‌صورت زنده تغییر دهید (تغییر زنده فقط برای همان نشست است و این پیش‌فرض را عوض نمی‌کند)." }, - "comfyui-mcp_chatScope": { - "name": "دامنهٔ گفت‌وگوی چت", - "tooltip": "پنل: یک گفت‌وگو همراه هر بومی می‌آید. گردش‌کار: هر گردش‌کار ذخیره‌شده مجموعهٔ ماندگار گفت‌وگوهای خودش را دارد که با یک UUID جاسازی‌شده شناسایی می‌شود، پس تغییر نام تاریخچه را حفظ می‌کند و نسخه‌های کپی جدا می‌مانند. پرسیدن: هر بار که گردش‌کار را عوض می‌کنید، انتخاب کنید گفت‌وگوی فعلی همراهتان بیاید یا نه. همهٔ حالت‌ها از راه‌اندازی دوبارهٔ کامل ComfyUI/MCP جان سالم به در می‌برند." - }, "comfyui-mcp_autoConnect": { "name": "اتصال خودکار هنگام بارگذاری", "tooltip": "وقتی پنل باز می‌شود، عامل را خودکار وصل کن (و ارکستریتور محلی را راه بینداز)، بدون زدن دکمهٔ «اتصال». به‌طور پیش‌فرض خاموش است — در غیر این صورت ارکستریتور فقط با کلیک صریح روی «اتصال» راه می‌افتد." diff --git a/locales/fr/main.json b/locales/fr/main.json index ddde703e..9ba76dd4 100644 --- a/locales/fr/main.json +++ b/locales/fr/main.json @@ -345,7 +345,6 @@ "api_tokens": "Jetons API", "apps_one_click_micro_apps_built_from": "Apps — micro-applis en un clic créées à partir de workflows : convertir, exécuter en local ou sur RunPod, partager.", "ask_label_for_commands_context": "Demandez à {label}… / pour les commandes, @ pour le contexte", - "ask_whenever_the_workflow_changes": "Demander à chaque changement de workflow", "asked_the_agent_to_help_you_set_up": "L'agent a été chargé de vous aider à configurer {label}.", "at_menu_context": "contexte", "at_menu_node_type": "type de nœud", @@ -390,9 +389,6 @@ "chat_history_could_not_be_saved_keep": "L'historique des conversations n'a pas pu être enregistré. Gardez cet onglet ouvert, libérez de l'espace de stockage dans le navigateur, puis envoyez ou modifiez un message une fois pour réessayer.", "chat_history_import_exceeds_the_25_mb": "L'import de l'historique dépasse la limite de 25 Mo", "chat_history_was_cleared_from_this_browser": "L'historique des conversations a été effacé de ce navigateur.", - "chat_scope_ask_whenever_the_workflow_changes": "Portée des conversations → demander à chaque changement de workflow.", - "chat_scope_panel_wide_conversation": "Portée des conversations → une conversation pour tout le panneau.", - "chat_scope_separate_histories_for_each_workflow": "Portée des conversations → un historique distinct par workflow.", "chat_title": "Titre de la conversation", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -443,7 +439,6 @@ "context_window_fills_as_the_agent_reports": "Fenêtre de contexte — se remplit à mesure que l'agent signale son usage", "context_window_pct_used": "Fenêtre de contexte utilisée à ~{pct} %", "context_window_used": "Fenêtre de contexte utilisée", - "continue_the_current_agent_panel_conversation_on": "Continuer la conversation actuelle du panneau agent sur « {name} » ?", "conversation_only": "Conversation seule", "copied": "Copié ✓", "copy_url": "Copier l'URL", @@ -684,10 +679,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "Rien à restaurer — aucun instantané du graphe n'a encore été capturé dans cette session.", "nothing_to_rewind_yet_no_message_or": "Rien à rembobiner pour l'instant — aucun message ni instantané du graphe dans cette session.", "nothing_was_sent_and_nothing_was_stored": "Rien n'a été envoyé et rien n'a été stocké. Attendez que l'agent repose la question sur la nouvelle connexion — ne collez pas la valeur dans la conversation.", - "ok_carry_this_chat_to_the_new": "OK : emporter cette conversation vers le nouveau canevas.\nAnnuler : ouvrir l'historique de conversation propre à ce workflow.", "ollama_local": "Ollama (local)", "ollama_local_free_our_comfyui_fine_tune": "Ollama (local, gratuit — notre fine-tune ComfyUI)", - "one_chat_across_workflows": "Panneau — une conversation pour tous les workflows", "open_before_resuming_this_chat": "Ouvrez {workflow} avant de reprendre cette conversation", "open_file": "Ouvrir le fichier", "open_in_a_new_browser_tab": "Ouvrir dans un nouvel onglet du navigateur", @@ -914,7 +907,6 @@ "workflow": "workflow · {version}", "workflow_saved": "« {workflow} » enregistré", "workflow_saved_as": "Enregistré sous « {workflow} »", - "workflow_separate_chat_histories": "Workflow — historiques de conversation distincts", "workflow_snapshot": "Instantané du workflow", "working": "Traitement…", "your_agent_inline": "votre agent", diff --git a/locales/fr/settings.json b/locales/fr/settings.json index 5f856bb7..6a59f129 100644 --- a/locales/fr/settings.json +++ b/locales/fr/settings.json @@ -23,10 +23,6 @@ "name": "Backend d'agent par défaut", "tooltip": "L'agent d'arrière-plan auquel le panneau se connecte par défaut. Claude tourne sur votre abonnement Claude ; ChatGPT sur votre compte Codex (ChatGPT) ; Gemini sur votre connexion Google (Gemini). Amorce le backend du panneau (et détermine quel groupe ci-dessous amorce le runtime) ; vous pouvez toujours changer à chaud dans le sélecteur de modèles (un changement à chaud ne vaut que pour la session et ne modifie PAS cette valeur par défaut)." }, - "comfyui-mcp_chatScope": { - "name": "Portée des conversations", - "tooltip": "Panneau : une seule conversation suit tous les canevas. Workflow : chaque workflow enregistré possède son propre jeu persistant de conversations, identifié par un UUID intégré, de sorte que les renommages conservent l'historique et que les copies restent séparées. Demander : vous choisissez à chaque changement de workflow si la conversation en cours doit suivre. Tous les modes survivent à un redémarrage complet de ComfyUI/MCP." - }, "comfyui-mcp_autoConnect": { "name": "Connexion automatique au chargement", "tooltip": "Connecte automatiquement l'agent (en démarrant l'orchestrateur local) à l'ouverture du panneau, sans cliquer sur Connecter. Désactivé par défaut — sinon l'orchestrateur n'est démarré que par un clic explicite sur Connecter." diff --git a/locales/ja/main.json b/locales/ja/main.json index 141d9209..3dfc49c7 100644 --- a/locales/ja/main.json +++ b/locales/ja/main.json @@ -327,7 +327,6 @@ "api_tokens": "API トークン", "apps_one_click_micro_apps_built_from": "アプリ — ワークフローから作るワンクリックのミニアプリ。変換して、ローカルまたは RunPod で実行し、共有できます。", "ask_label_for_commands_context": "{label} に質問… / でコマンド、@ でコンテキスト", - "ask_whenever_the_workflow_changes": "ワークフローが変わるたびに確認する", "asked_the_agent_to_help_you_set_up": "{label} のセットアップを手伝うようエージェントに依頼しました。", "at_menu_context": "コンテキスト", "at_menu_node_type": "ノードタイプ", @@ -364,9 +363,6 @@ "chat_history_could_not_be_saved_keep": "チャット履歴を保存できませんでした。このタブを開いたままブラウザーの空き容量を確保し、一度送信または編集してやり直してください。", "chat_history_import_exceeds_the_25_mb": "チャット履歴のインポートが 25 MB の上限を超えています", "chat_history_was_cleared_from_this_browser": "このブラウザーからチャット履歴を消去しました。", - "chat_scope_ask_whenever_the_workflow_changes": "チャットの範囲 → ワークフローが変わるたびに確認。", - "chat_scope_panel_wide_conversation": "チャットの範囲 → パネル全体で 1 つの会話。", - "chat_scope_separate_histories_for_each_workflow": "チャットの範囲 → ワークフローごとに別の履歴。", "chat_title": "チャットのタイトル", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT(Codex)", @@ -413,7 +409,6 @@ "context_window_fills_as_the_agent_reports": "コンテキストウィンドウ — エージェントが使用量を報告するたびに増えます", "context_window_pct_used": "コンテキストウィンドウ 約 {pct}% 使用", "context_window_used": "使用済みコンテキストウィンドウ", - "continue_the_current_agent_panel_conversation_on": "現在のエージェントパネルの会話を「{name}」で続けますか?", "conversation_only": "会話のみ", "copied": "コピーしました ✓", "copy_url": "URL をコピー", @@ -618,10 +613,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "戻せるものがありません — このセッションではまだグラフのスナップショットを取得していません。", "nothing_to_rewind_yet_no_message_or": "まだ巻き戻せるものがありません — このセッションのメッセージもグラフスナップショットもありません。", "nothing_was_sent_and_nothing_was_stored": "何も送信されず、何も保存されていません。新しい接続でエージェントがもう一度尋ねてくるのを待ってください — 値をチャットに貼り付けないでください。", - "ok_carry_this_chat_to_the_new": "OK: このチャットを新しいキャンバスに引き継ぎます。\nキャンセル: このワークフロー専用のチャット履歴を開きます。", "ollama_local": "Ollama(ローカル)", "ollama_local_free_our_comfyui_fine_tune": "Ollama(ローカル、無料 — 当プロジェクトの ComfyUI ファインチューン)", - "one_chat_across_workflows": "パネル — ワークフローをまたいで 1 つのチャット", "open_before_resuming_this_chat": "このチャットを再開する前に {workflow} を開いてください", "open_file": "ファイルを開く", "open_in_a_new_browser_tab": "新しいブラウザータブで開く", @@ -836,7 +829,6 @@ "workflow": "ワークフロー · {version}", "workflow_saved": "「{workflow}」を保存しました", "workflow_saved_as": "「{workflow}」として保存しました", - "workflow_separate_chat_histories": "ワークフロー — チャット履歴を分ける", "workflow_snapshot": "ワークフローのスナップショット", "working": "処理中…", "your_agent_inline": "エージェント", diff --git a/locales/ja/settings.json b/locales/ja/settings.json index aff26649..1dd482e6 100644 --- a/locales/ja/settings.json +++ b/locales/ja/settings.json @@ -23,10 +23,6 @@ "name": "既定のエージェントバックエンド", "tooltip": "パネルが既定で接続するバックグラウンドエージェントです。Claude は Claude のサブスクリプション、ChatGPT は Codex(ChatGPT)アカウント、Gemini は Google(Gemini)のログインで動作します。パネルのバックエンドと、下のどのグループがランタイムを初期化するかを決めます。モデルピッカーからその場で切り替えることもできますが、その切り替えはセッション限りで、この既定値は変わりません。" }, - "comfyui-mcp_chatScope": { - "name": "チャットの適用範囲", - "tooltip": "Panel: 1 つの会話がすべてのキャンバスに付いて回ります。Workflow: 保存済みのワークフローごとに専用のチャット群を保持し、埋め込み UUID で識別するため、名前を変えても履歴は残り、複製は別々になります。Ask: ワークフローを切り替えるたびに現在の会話を引き継ぐかを選べます。いずれのモードも ComfyUI/MCP を完全に再起動しても維持されます。" - }, "comfyui-mcp_autoConnect": { "name": "起動時に自動接続", "tooltip": "パネルを開いたときに「接続」を押さなくてもエージェントを自動で接続し、ローカルのオーケストレーターを起動します。既定はオフで、その場合オーケストレーターは「接続」を明示的に押したときにだけ起動します。" diff --git a/locales/ko/main.json b/locales/ko/main.json index 2faa594b..c77748ec 100644 --- a/locales/ko/main.json +++ b/locales/ko/main.json @@ -327,7 +327,6 @@ "api_tokens": "API 토큰", "apps_one_click_micro_apps_built_from": "앱 — 워크플로우로 만든 원클릭 미니 앱: 변환하고, 로컬이나 RunPod에서 실행하고, 공유하세요.", "ask_label_for_commands_context": "{label}에게 질문… / 명령어, @ 컨텍스트", - "ask_whenever_the_workflow_changes": "워크플로우가 바뀔 때마다 확인", "asked_the_agent_to_help_you_set_up": "{label} 설정을 도와달라고 에이전트에게 요청했습니다.", "at_menu_context": "컨텍스트", "at_menu_node_type": "노드 유형", @@ -364,9 +363,6 @@ "chat_history_could_not_be_saved_keep": "대화 기록을 저장하지 못했습니다. 이 탭을 열어두고 브라우저 저장 공간을 확보한 뒤, 한 번 보내거나 편집해서 다시 시도하세요.", "chat_history_import_exceeds_the_25_mb": "대화 기록 가져오기가 25MB 제한을 초과했습니다", "chat_history_was_cleared_from_this_browser": "이 브라우저에서 대화 기록을 지웠습니다.", - "chat_scope_ask_whenever_the_workflow_changes": "대화 범위 → 워크플로우가 바뀔 때마다 확인.", - "chat_scope_panel_wide_conversation": "대화 범위 → 패널 전체 대화.", - "chat_scope_separate_histories_for_each_workflow": "대화 범위 → 워크플로우별 개별 기록.", "chat_title": "대화 제목", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -413,7 +409,6 @@ "context_window_fills_as_the_agent_reports": "컨텍스트 창 — 에이전트가 사용량을 보고할 때마다 채워집니다", "context_window_pct_used": "컨텍스트 창 약 {pct}% 사용", "context_window_used": "사용한 컨텍스트 창", - "continue_the_current_agent_panel_conversation_on": "현재 에이전트 패널 대화를 \"{name}\"에서 이어갈까요?", "conversation_only": "대화만", "copied": "복사됨 ✓", "copy_url": "URL 복사", @@ -618,10 +613,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "되돌릴 것이 없습니다 — 이 세션에서 아직 그래프 스냅숏을 캡처하지 않았습니다.", "nothing_to_rewind_yet_no_message_or": "되감을 것이 아직 없습니다 — 이 세션의 메시지나 그래프 스냅숏이 없습니다.", "nothing_was_sent_and_nothing_was_stored": "아무것도 전송되지 않았고 저장되지도 않았습니다. 새 연결에서 에이전트가 다시 물어볼 때까지 기다리세요 — 값을 대화창에 붙여넣지 마세요.", - "ok_carry_this_chat_to_the_new": "확인: 이 대화를 새 캔버스로 가져갑니다.\n취소: 이 워크플로우의 별도 대화 기록을 엽니다.", "ollama_local": "Ollama (로컬)", "ollama_local_free_our_comfyui_fine_tune": "Ollama (로컬, 무료 — 우리가 만든 ComfyUI 파인튜닝)", - "one_chat_across_workflows": "패널 — 워크플로우 전체에서 하나의 대화", "open_before_resuming_this_chat": "이 대화를 이어가려면 {workflow}을(를) 여세요", "open_file": "파일 열기", "open_in_a_new_browser_tab": "새 브라우저 탭에서 열기", @@ -836,7 +829,6 @@ "workflow": "워크플로우 · {version}", "workflow_saved": "“{workflow}” 저장됨", "workflow_saved_as": "“{workflow}”(으)로 저장됨", - "workflow_separate_chat_histories": "워크플로우 — 개별 대화 기록", "workflow_snapshot": "워크플로우 스냅숏", "working": "작업 중…", "your_agent_inline": "에이전트", diff --git a/locales/ko/settings.json b/locales/ko/settings.json index 8d680e93..bcf0b302 100644 --- a/locales/ko/settings.json +++ b/locales/ko/settings.json @@ -23,10 +23,6 @@ "name": "기본 에이전트 백엔드", "tooltip": "패널이 기본으로 연결할 백그라운드 에이전트입니다. Claude는 Claude 구독으로, ChatGPT는 Codex(ChatGPT) 계정으로, Gemini는 Google(Gemini) 로그인으로 동작합니다. 패널의 백엔드와 아래 어느 그룹이 런타임을 초기화할지를 정합니다. 모델 선택기에서 실시간으로 전환할 수도 있으며, 실시간 전환은 해당 세션에만 적용되고 이 기본값은 바뀌지 않습니다." }, - "comfyui-mcp_chatScope": { - "name": "대화 범위", - "tooltip": "Panel: 하나의 대화가 모든 캔버스를 따라다닙니다. Workflow: 저장된 워크플로우마다 고유한 대화 묶음을 유지하며, 내장 UUID로 식별하므로 이름을 바꿔도 기록이 남고 복사본은 서로 분리됩니다. Ask: 워크플로우를 전환할 때마다 현재 대화를 가져갈지 선택합니다. 모든 모드는 ComfyUI/MCP를 완전히 재시작해도 유지됩니다." - }, "comfyui-mcp_autoConnect": { "name": "열 때 자동 연결", "tooltip": "패널이 열릴 때 연결 버튼을 누르지 않아도 에이전트를 자동으로 연결하고 로컬 오케스트레이터를 시작합니다. 기본값은 꺼짐이며, 그렇지 않으면 오케스트레이터는 연결을 직접 눌렀을 때만 시작됩니다." diff --git a/locales/pt-BR/main.json b/locales/pt-BR/main.json index 6845fede..c199e7f1 100644 --- a/locales/pt-BR/main.json +++ b/locales/pt-BR/main.json @@ -345,7 +345,6 @@ "api_tokens": "Tokens de API", "apps_one_click_micro_apps_built_from": "Apps — micro-apps de um clique feitos a partir de workflows: converta, rode localmente ou no RunPod, compartilhe.", "ask_label_for_commands_context": "Pergunte ao {label}… / para comandos, @ para contexto", - "ask_whenever_the_workflow_changes": "Perguntar sempre que o workflow mudar", "asked_the_agent_to_help_you_set_up": "Pedi ao agente para ajudar você a configurar o {label}.", "at_menu_context": "contexto", "at_menu_node_type": "tipo de nó", @@ -390,9 +389,6 @@ "chat_history_could_not_be_saved_keep": "Não foi possível salvar o histórico de conversas. Mantenha esta aba aberta, libere espaço de armazenamento no navegador e envie ou edite uma vez para tentar de novo.", "chat_history_import_exceeds_the_25_mb": "A importação do histórico de conversas passa do limite de 25 MB", "chat_history_was_cleared_from_this_browser": "O histórico de conversas foi apagado deste navegador.", - "chat_scope_ask_whenever_the_workflow_changes": "Escopo das conversas → perguntar sempre que o workflow mudar.", - "chat_scope_panel_wide_conversation": "Escopo das conversas → uma conversa para todo o painel.", - "chat_scope_separate_histories_for_each_workflow": "Escopo das conversas → históricos separados para cada workflow.", "chat_title": "Título da conversa", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -443,7 +439,6 @@ "context_window_fills_as_the_agent_reports": "Janela de contexto — enche conforme o agente reporta o uso", "context_window_pct_used": "Janela de contexto ~{pct}% usada", "context_window_used": "Janela de contexto usada", - "continue_the_current_agent_panel_conversation_on": "Continuar a conversa atual do painel do agente em \"{name}\"?", "conversation_only": "Só a conversa", "copied": "Copiado ✓", "copy_url": "Copiar URL", @@ -684,10 +679,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "Nada a reverter — nenhum snapshot do grafo foi capturado nesta sessão ainda.", "nothing_to_rewind_yet_no_message_or": "Nada para retroceder ainda — nenhuma mensagem ou snapshot do grafo desta sessão.", "nothing_was_sent_and_nothing_was_stored": "Nada foi enviado e nada foi armazenado. Espere o agente perguntar de novo na nova conexão — não cole o valor no chat.", - "ok_carry_this_chat_to_the_new": "OK: levar esta conversa para o novo canvas.\nCancelar: abrir o histórico de conversas separado deste workflow.", "ollama_local": "Ollama (local)", "ollama_local_free_our_comfyui_fine_tune": "Ollama (local, grátis — o nosso fine-tune para ComfyUI)", - "one_chat_across_workflows": "Painel — uma conversa para todos os workflows", "open_before_resuming_this_chat": "Abra {workflow} antes de retomar esta conversa", "open_file": "Abrir arquivo", "open_in_a_new_browser_tab": "Abrir em uma nova aba do navegador", @@ -914,7 +907,6 @@ "workflow": "workflow · {version}", "workflow_saved": "“{workflow}” salvo", "workflow_saved_as": "Salvo como “{workflow}”", - "workflow_separate_chat_histories": "Workflow — históricos de conversa separados", "workflow_snapshot": "Snapshot do workflow", "working": "Trabalhando…", "your_agent_inline": "o seu agente", diff --git a/locales/pt-BR/settings.json b/locales/pt-BR/settings.json index c8f3f6c3..61287d5e 100644 --- a/locales/pt-BR/settings.json +++ b/locales/pt-BR/settings.json @@ -23,10 +23,6 @@ "name": "Backend padrão do agente", "tooltip": "A qual agente em segundo plano o painel se conecta por padrão. O Claude roda na sua assinatura do Claude; o ChatGPT roda na sua conta do Codex (ChatGPT); o Gemini roda no seu login do Google (Gemini). Define o backend inicial do painel (e qual grupo abaixo alimenta o runtime); você ainda pode trocar ao vivo no seletor de modelos (a troca ao vivo vale só para a sessão e NÃO muda este padrão)." }, - "comfyui-mcp_chatScope": { - "name": "Escopo das conversas", - "tooltip": "Painel: uma conversa acompanha todos os canvas. Workflow: cada workflow salvo tem o seu próprio conjunto persistente de conversas, identificado por um UUID embutido, então renomear preserva o histórico e as cópias ficam separadas. Perguntar: você escolhe se leva a conversa atual sempre que troca de workflow. Todos os modos sobrevivem a reinícios completos do ComfyUI/MCP." - }, "comfyui-mcp_autoConnect": { "name": "Conectar automaticamente ao carregar", "tooltip": "Conecta o agente automaticamente (iniciando o orquestrador local) quando o painel abre, sem você clicar em Conectar. Desligado por padrão — fora isso, o orquestrador só é iniciado por um clique explícito em Conectar." diff --git a/locales/ru/main.json b/locales/ru/main.json index 9d4be09a..1d05c083 100644 --- a/locales/ru/main.json +++ b/locales/ru/main.json @@ -354,7 +354,6 @@ "api_tokens": "API-токены", "apps_one_click_micro_apps_built_from": "Приложения — микроприложения в один клик на основе воркфлоу: конвертируйте, запускайте локально или на RunPod, делитесь.", "ask_label_for_commands_context": "Спросить {label}… / — команды, @ — контекст", - "ask_whenever_the_workflow_changes": "Спрашивать при каждой смене воркфлоу", "asked_the_agent_to_help_you_set_up": "Агента попросили помочь вам настроить {label}.", "at_menu_context": "контекст", "at_menu_node_type": "тип узла", @@ -403,9 +402,6 @@ "chat_history_could_not_be_saved_keep": "Не удалось сохранить историю чатов. Не закрывайте эту вкладку, освободите место в хранилище браузера, затем отправьте или измените одно сообщение, чтобы повторить попытку.", "chat_history_import_exceeds_the_25_mb": "Импорт истории чатов превышает лимит в 25 МБ", "chat_history_was_cleared_from_this_browser": "История чатов удалена из этого браузера.", - "chat_scope_ask_whenever_the_workflow_changes": "Область чата → спрашивать при каждой смене воркфлоу.", - "chat_scope_panel_wide_conversation": "Область чата → один разговор на всю панель.", - "chat_scope_separate_histories_for_each_workflow": "Область чата → отдельная история для каждого воркфлоу.", "chat_title": "Название чата", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -458,7 +454,6 @@ "context_window_fills_as_the_agent_reports": "Окно контекста — заполняется по мере того, как агент сообщает о расходе", "context_window_pct_used": "Окно контекста занято ~{pct}%", "context_window_used": "Использовано окна контекста", - "continue_the_current_agent_panel_conversation_on": "Продолжить текущий разговор панели агента на «{name}»?", "conversation_only": "Только разговор", "copied": "Скопировано ✓", "copy_url": "Копировать URL", @@ -717,10 +712,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "Откатывать нечего — в этой сессии ещё не сделано ни одного снимка графа.", "nothing_to_rewind_yet_no_message_or": "Отматывать пока нечего — в этой сессии нет ни сообщения, ни снимка графа.", "nothing_was_sent_and_nothing_was_stored": "Ничего не отправлено и ничего не сохранено. Дождитесь, пока агент снова спросит на новом соединении, — не вставляйте значение в чат.", - "ok_carry_this_chat_to_the_new": "OK: перенести этот чат на новый холст.\nОтмена: открыть отдельную историю чатов этого воркфлоу.", "ollama_local": "Ollama (локально)", "ollama_local_free_our_comfyui_fine_tune": "Ollama (локально, бесплатно — наш дообученный вариант для ComfyUI)", - "one_chat_across_workflows": "Панель — один чат на все воркфлоу", "open_before_resuming_this_chat": "Откройте {workflow}, прежде чем продолжать этот чат", "open_file": "Открыть файл", "open_in_a_new_browser_tab": "Открыть в новой вкладке браузера", @@ -953,7 +946,6 @@ "workflow": "воркфлоу · {version}", "workflow_saved": "Сохранено «{workflow}»", "workflow_saved_as": "Сохранено как «{workflow}»", - "workflow_separate_chat_histories": "Воркфлоу — отдельные истории чатов", "workflow_snapshot": "Снимок воркфлоу", "working": "Работаю…", "your_agent_inline": "ваш агент", diff --git a/locales/ru/settings.json b/locales/ru/settings.json index abf4ea63..e175d956 100644 --- a/locales/ru/settings.json +++ b/locales/ru/settings.json @@ -23,10 +23,6 @@ "name": "Бэкенд агента по умолчанию", "tooltip": "К какому фоновому агенту панель подключается по умолчанию. Claude работает на вашей подписке Claude; ChatGPT — на вашем аккаунте Codex (ChatGPT); Gemini — на вашем входе Google (Gemini). Задаёт стартовый бэкенд панели (и то, какая группа ниже задаёт рантайм); переключиться вживую всё равно можно в выборе модели (живое переключение действует только на сессию и НЕ меняет это значение по умолчанию)." }, - "comfyui-mcp_chatScope": { - "name": "Область разговора в чате", - "tooltip": "Панель: один разговор следует за любым холстом. Воркфлоу: у каждого сохранённого воркфлоу свой постоянный набор чатов, опознаваемый по встроенному UUID, поэтому переименование сохраняет историю, а копия получает свою. Спрашивать: при каждой смене воркфлоу решайте сами, переносить ли текущий разговор. Все режимы переживают полный перезапуск ComfyUI и MCP." - }, "comfyui-mcp_autoConnect": { "name": "Автоподключение при загрузке", "tooltip": "Автоматически подключать агента (запуская локальный оркестратор) при открытии панели, без нажатия «Подключить». По умолчанию выключено — иначе оркестратор запускается только явным нажатием «Подключить»." diff --git a/locales/tr/main.json b/locales/tr/main.json index 143c07f9..fd94d63c 100644 --- a/locales/tr/main.json +++ b/locales/tr/main.json @@ -336,7 +336,6 @@ "api_tokens": "API token'ları", "apps_one_click_micro_apps_built_from": "Uygulamalar — iş akışlarından kurulan tek tıklık mikro uygulamalar: dönüştürün, yerelde ya da RunPod'da çalıştırın, paylaşın.", "ask_label_for_commands_context": "{label} ile konuşun… komutlar için /, bağlam için @", - "ask_whenever_the_workflow_changes": "İş akışı her değiştiğinde sor", "asked_the_agent_to_help_you_set_up": "{label} kurulumunda size yardım etmesi ajandan istendi.", "at_menu_context": "bağlam", "at_menu_node_type": "düğüm türü", @@ -377,9 +376,6 @@ "chat_history_could_not_be_saved_keep": "Sohbet geçmişi kaydedilemedi. Bu sekmeyi açık tutun, tarayıcı depolamasında yer açın, sonra yeniden denemek için bir kez mesaj gönderin ya da düzenleyin.", "chat_history_import_exceeds_the_25_mb": "Sohbet geçmişi içe aktarımı 25 MB sınırını aşıyor", "chat_history_was_cleared_from_this_browser": "Sohbet geçmişi bu tarayıcıdan temizlendi.", - "chat_scope_ask_whenever_the_workflow_changes": "Sohbet kapsamı → iş akışı her değiştiğinde sor.", - "chat_scope_panel_wide_conversation": "Sohbet kapsamı → panel genelinde tek konuşma.", - "chat_scope_separate_histories_for_each_workflow": "Sohbet kapsamı → her iş akışı için ayrı geçmiş.", "chat_title": "Sohbet başlığı", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -428,7 +424,6 @@ "context_window_fills_as_the_agent_reports": "Bağlam penceresi — ajan kullanım bildirdikçe dolar", "context_window_pct_used": "Bağlam penceresinin ~%{pct} kadarı kullanıldı", "context_window_used": "Kullanılan bağlam penceresi", - "continue_the_current_agent_panel_conversation_on": "Mevcut Agent Panel konuşması \"{name}\" üzerinde sürdürülsün mü?", "conversation_only": "Yalnızca konuşma", "copied": "Kopyalandı ✓", "copy_url": "Adresi kopyala", @@ -651,10 +646,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "Geri alınacak bir şey yok — bu oturumda henüz grafik anlık görüntüsü alınmadı.", "nothing_to_rewind_yet_no_message_or": "Henüz geri sarılacak bir şey yok — bu oturumdan ne mesaj ne de grafik anlık görüntüsü var.", "nothing_was_sent_and_nothing_was_stored": "Hiçbir şey gönderilmedi ve hiçbir şey saklanmadı. Ajanın yeni bağlantıda tekrar sormasını bekleyin — değeri sohbete yapıştırmayın.", - "ok_carry_this_chat_to_the_new": "Tamam: bu sohbeti yeni tuvale taşı.\nİptal: bu iş akışının ayrı sohbet geçmişini aç.", "ollama_local": "Ollama (yerel)", "ollama_local_free_our_comfyui_fine_tune": "Ollama (yerel, ücretsiz — bizim ComfyUI ince ayarımız)", - "one_chat_across_workflows": "Panel — iş akışları arasında tek sohbet", "open_before_resuming_this_chat": "Bu sohbeti sürdürmeden önce {workflow} iş akışını açın", "open_file": "Dosyayı aç", "open_in_a_new_browser_tab": "Yeni bir tarayıcı sekmesinde aç", @@ -875,7 +868,6 @@ "workflow": "iş akışı · {version}", "workflow_saved": "“{workflow}” kaydedildi", "workflow_saved_as": "“{workflow}” olarak kaydedildi", - "workflow_separate_chat_histories": "İş akışı — ayrı sohbet geçmişleri", "workflow_snapshot": "İş akışı anlık görüntüsü", "working": "Çalışıyor…", "your_agent_inline": "ajanınız", diff --git a/locales/tr/settings.json b/locales/tr/settings.json index 5d88b721..85793364 100644 --- a/locales/tr/settings.json +++ b/locales/tr/settings.json @@ -23,10 +23,6 @@ "name": "Varsayılan ajan arka ucu", "tooltip": "Panelin varsayılan olarak hangi arka plan ajanına bağlanacağı. Claude, Claude aboneliğinizle çalışır; ChatGPT, Codex (ChatGPT) hesabınızla; Gemini, Google (Gemini) girişinizle. Panelin arka ucunu belirler (ve aşağıdaki hangi grubun çalışma zamanını belirleyeceğini); model seçiciden anlık olarak yine de geçiş yapabilirsiniz (anlık geçiş yalnızca o oturum içindir ve bu varsayılanı DEĞİŞTİRMEZ)." }, - "comfyui-mcp_chatScope": { - "name": "Sohbet konuşma kapsamı", - "tooltip": "Panel: tek bir konuşma her tuvali izler. İş akışı: kaydedilmiş her iş akışının kendi kalıcı sohbet kümesi olur; gömülü bir UUID ile tanındığı için yeniden adlandırmalar geçmişi korur, kopyalar ayrı kalır. Sor: iş akışı değiştirdiğinizde mevcut konuşmayı taşıyıp taşımayacağınızı her seferinde seçin. Tüm kipler ComfyUI/MCP yeniden başlatmalarından sağ çıkar." - }, "comfyui-mcp_autoConnect": { "name": "Açılışta otomatik bağlan", "tooltip": "Panel açıldığında ajanı (yerel orkestratörü başlatarak) Bağlan'a tıklamadan otomatik bağlar. Varsayılan olarak kapalı — aksi hâlde orkestratör yalnızca açık bir Bağlan tıklamasıyla başlatılır." diff --git a/locales/zh-TW/main.json b/locales/zh-TW/main.json index 56135dd4..d8d02923 100644 --- a/locales/zh-TW/main.json +++ b/locales/zh-TW/main.json @@ -327,7 +327,6 @@ "api_tokens": "API 權杖", "apps_one_click_micro_apps_built_from": "應用 —— 由工作流程打造的一鍵微應用:轉換、在本機或 RunPod 上執行、分享。", "ask_label_for_commands_context": "問 {label}… 打 / 叫出指令、打 @ 帶入脈絡", - "ask_whenever_the_workflow_changes": "每次工作流程改變時詢問", "asked_the_agent_to_help_you_set_up": "已請代理協助你設定 {label}。", "at_menu_context": "脈絡", "at_menu_node_type": "節點類型", @@ -364,9 +363,6 @@ "chat_history_could_not_be_saved_keep": "無法儲存聊天記錄。請讓這個分頁保持開啟,先釋出瀏覽器儲存空間,再送出或編輯一次以重試。", "chat_history_import_exceeds_the_25_mb": "匯入的聊天記錄超過 25 MB 上限", "chat_history_was_cleared_from_this_browser": "已從這個瀏覽器清除聊天記錄。", - "chat_scope_ask_whenever_the_workflow_changes": "聊天範圍 → 每次工作流程改變時詢問。", - "chat_scope_panel_wide_conversation": "聊天範圍 → 整個面板共用一段對話。", - "chat_scope_separate_histories_for_each_workflow": "聊天範圍 → 每個工作流程各自獨立的記錄。", "chat_title": "聊天標題", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -413,7 +409,6 @@ "context_window_fills_as_the_agent_reports": "上下文視窗 —— 會隨著代理回報用量而累積", "context_window_pct_used": "上下文視窗已用約 {pct}%", "context_window_used": "上下文視窗使用量", - "continue_the_current_agent_panel_conversation_on": "要在「{name}」上繼續目前這段代理面板的對話嗎?", "conversation_only": "只有對話", "copied": "已複製 ✓", "copy_url": "複製網址", @@ -618,10 +613,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "沒有東西可以還原 —— 這個工作階段還沒有擷取過任何節點圖快照。", "nothing_to_rewind_yet_no_message_or": "還沒有東西可以倒回 —— 這個工作階段沒有訊息,也沒有節點圖快照。", "nothing_was_sent_and_nothing_was_stored": "什麼都沒有送出,也什麼都沒有存下來。請等代理在新的連線上再問一次 —— 不要把值直接貼進聊天裡。", - "ok_carry_this_chat_to_the_new": "確定:把這段聊天帶到新的畫布。\n取消:開啟這個工作流程自己的聊天記錄。", "ollama_local": "Ollama(本機)", "ollama_local_free_our_comfyui_fine_tune": "Ollama(本機,免費 —— 我們針對 ComfyUI 微調的模型)", - "one_chat_across_workflows": "面板 —— 所有工作流程共用一段聊天", "open_before_resuming_this_chat": "請先開啟 {workflow} 再繼續這段聊天", "open_file": "開啟檔案", "open_in_a_new_browser_tab": "在新的瀏覽器分頁開啟", @@ -836,7 +829,6 @@ "workflow": "工作流程 · {version}", "workflow_saved": "已儲存「{workflow}」", "workflow_saved_as": "已另存為「{workflow}」", - "workflow_separate_chat_histories": "工作流程 —— 各自獨立的聊天記錄", "workflow_snapshot": "工作流程快照", "working": "處理中…", "your_agent_inline": "你的代理", diff --git a/locales/zh-TW/settings.json b/locales/zh-TW/settings.json index 0ad5140e..724d9021 100644 --- a/locales/zh-TW/settings.json +++ b/locales/zh-TW/settings.json @@ -23,10 +23,6 @@ "name": "預設代理後端", "tooltip": "面板預設要連線的背景代理。Claude 使用你的 Claude 訂閱;ChatGPT 使用你的 Codex(ChatGPT)帳號;Gemini 使用你的 Google(Gemini)登入。這會決定面板的初始後端(以及下方哪一組設定會用於執行階段);你仍然可以在模型選單中即時切換(即時切換只影響目前的工作階段,不會更動這個預設值)。" }, - "comfyui-mcp_chatScope": { - "name": "聊天對話範圍", - "tooltip": "面板:同一段對話跟著每個畫布走。工作流程:每個已儲存的工作流程各自擁有一組持久的聊天,以內嵌的 UUID 辨識,所以改名不會弄丟記錄、複製出來的檔案也會分開。詢問:每次切換工作流程時,都問你要不要把目前的對話帶過去。三種模式都能撐過 ComfyUI/MCP 的完整重新啟動。" - }, "comfyui-mcp_autoConnect": { "name": "載入時自動連線", "tooltip": "面板開啟時就自動連線代理(並啟動本機協調器),不必按「連線」。預設為關閉 —— 否則協調器只會在你明確按下「連線」時才啟動。" diff --git a/locales/zh/main.json b/locales/zh/main.json index 690cf945..04608cf7 100644 --- a/locales/zh/main.json +++ b/locales/zh/main.json @@ -327,7 +327,6 @@ "api_tokens": "API 令牌", "apps_one_click_micro_apps_built_from": "应用 —— 由工作流构建的一键微应用:转换、本地或 RunPod 运行、分享。", "ask_label_for_commands_context": "向 {label} 提问… 输入 / 查看命令,输入 @ 引用上下文", - "ask_whenever_the_workflow_changes": "每次工作流变化时询问", "asked_the_agent_to_help_you_set_up": "已请智能体帮你配置 {label}。", "at_menu_context": "上下文", "at_menu_node_type": "节点类型", @@ -364,9 +363,6 @@ "chat_history_could_not_be_saved_keep": "无法保存聊天记录。请保持此标签页打开,腾出浏览器存储空间,然后发送或编辑一次以重试。", "chat_history_import_exceeds_the_25_mb": "导入的聊天记录超过 25 MB 上限", "chat_history_was_cleared_from_this_browser": "已从此浏览器清除聊天记录。", - "chat_scope_ask_whenever_the_workflow_changes": "对话范围 → 每次工作流变化时询问。", - "chat_scope_panel_wide_conversation": "对话范围 → 面板级的统一对话。", - "chat_scope_separate_histories_for_each_workflow": "对话范围 → 每个工作流各自独立的记录。", "chat_title": "对话标题", "chatgpt": "ChatGPT", "chatgpt_codex": "ChatGPT (Codex)", @@ -413,7 +409,6 @@ "context_window_fills_as_the_agent_reports": "上下文窗口 —— 随智能体报告用量而增长", "context_window_pct_used": "上下文窗口已用约 {pct}%", "context_window_used": "已用上下文窗口", - "continue_the_current_agent_panel_conversation_on": "要在“{name}”上继续当前的智能体面板对话吗?", "conversation_only": "仅对话", "copied": "已复制 ✓", "copy_url": "复制 URL", @@ -618,10 +613,8 @@ "nothing_to_revert_no_graph_snapshot_captured": "没有可回退的内容 —— 本次会话还没有捕获任何工作流快照。", "nothing_to_rewind_yet_no_message_or": "还没有可倒回的内容 —— 本次会话既没有消息也没有工作流快照。", "nothing_was_sent_and_nothing_was_stored": "什么都没有发送,也什么都没有保存。请等智能体在新连接上再问一次 —— 不要把值粘贴到聊天里。", - "ok_carry_this_chat_to_the_new": "确定:把这个对话带到新画布。\n取消:打开该工作流自己的聊天记录。", "ollama_local": "Ollama(本地)", "ollama_local_free_our_comfyui_fine_tune": "Ollama(本地,免费 —— 我们的 ComfyUI 微调模型)", - "one_chat_across_workflows": "面板 —— 跨工作流共用一个对话", "open_before_resuming_this_chat": "继续这个对话前请先打开{workflow}", "open_file": "打开文件", "open_in_a_new_browser_tab": "在新浏览器标签页中打开", @@ -836,7 +829,6 @@ "workflow": "工作流 · {version}", "workflow_saved": "已保存“{workflow}”", "workflow_saved_as": "已另存为“{workflow}”", - "workflow_separate_chat_histories": "工作流 —— 各自独立的聊天记录", "workflow_snapshot": "工作流快照", "working": "处理中…", "your_agent_inline": "你的智能体", diff --git a/locales/zh/settings.json b/locales/zh/settings.json index a39e49ee..dc0dd358 100644 --- a/locales/zh/settings.json +++ b/locales/zh/settings.json @@ -23,10 +23,6 @@ "name": "默认智能体后端", "tooltip": "面板默认连接的后台智能体。Claude 使用你的 Claude 订阅;ChatGPT 使用你的 Codex(ChatGPT)账号;Gemini 使用你的 Google(Gemini)登录。它决定面板的后端,以及下面哪个分组用于初始化运行时;你仍可在模型选择器中实时切换,实时切换只对当前会话生效,不会更改此默认值。" }, - "comfyui-mcp_chatScope": { - "name": "聊天会话范围", - "tooltip": "Panel:一段对话跟随所有画布。Workflow:每个已保存的工作流拥有各自持久的对话集合,通过内嵌 UUID 识别,因此重命名会保留历史、复制则各自独立。Ask:每次切换工作流时询问是否带上当前对话。所有模式在 ComfyUI/MCP 完全重启后依然保留。" - }, "comfyui-mcp_autoConnect": { "name": "打开时自动连接", "tooltip": "面板打开时自动连接智能体(并启动本地编排器),无需点击“连接”。默认关闭 —— 否则编排器只会在你明确点击“连接”时启动。" diff --git a/web/js/comfyui-mcp-panel.js b/web/js/comfyui-mcp-panel.js index 10dae670..1e5becef 100644 --- a/web/js/comfyui-mcp-panel.js +++ b/web/js/comfyui-mcp-panel.js @@ -116,7 +116,10 @@ import { ChatHistoryStore, isThreadInScope, mergeHistorySnapshots, + panelScopeKeyForBackend, + resolvePanelPointer, retainBoundedThreads, + selectPanelThread, selectRestoreThread, selectThreadForScope, updateMetadataEntry, @@ -3448,6 +3451,13 @@ const RELOAD_POST_TIMEOUT_MS = 10000; // resumed session to continue where it left off. The REBOOT/SOFT_RELOAD cases // (deliberate, agent-known) are handled first and clear this so we don't double-nudge. const MID_TASK_KEY = "comfyui-mcp.panel.midTaskResume"; +// A session reset (new_session) this tab OWED but could not dispatch — the user +// deleted the active conversation while disconnected (gate round-3 finding 2: +// the tombstone + pointer clear must propagate, but the backend would otherwise +// stay in the deleted session). Stores the backend scope key the reset applies +// to; fired on the next ready ack, and ONLY while the shared pointer is still +// in the cleared state this tab left — any newer act supersedes and drops it. +const PENDING_SESSION_RESET_KEY = "comfyui-mcp.panel.pendingSessionReset"; // The OUTAGE the mid-task nudge weighs. A FAST reconnect (panel swap / WS blip; // orchestrator alive) vs a SLOW one (real ComfyUI restart; orchestrator died + // respawned) is how we tell a spurious bounce from a real one — only the slow case @@ -3818,13 +3828,10 @@ const SETTING_MOBILE_BETA = "comfyui-mcp.mobileAppBeta"; const SETTING_FLAG_APPS = "comfyui-mcp.featureFlag.apps"; const SETTING_FLAG_TRAINING = "comfyui-mcp.featureFlag.training"; const SETTING_FLAG_RUNPOD = "comfyui-mcp.featureFlag.runpod"; -// Session ownership: when TRUE (default), the conversation belongs to the PANEL -// — switching/saving/renaming/creating workflows never swaps or resets the chat; -// the agent just gets told (mechanically, on the next message) which canvas it's -// now operating on. When FALSE, the legacy per-workflow behavior: each workflow -// keeps its own thread + agent session and switching tabs switches conversations. -const SETTING_SESSION_FOLLOWS_PANEL = "comfyui-mcp.sessionFollowsPanel"; -const SETTING_CHAT_SCOPE = "comfyui-mcp.chatScope"; +// RETIRED setting ids (mcp#884): "comfyui-mcp.sessionFollowsPanel" (legacy +// boolean) and "comfyui-mcp.chatScope" (panel/workflow/ask combo). The +// conversation is always panel-owned now; stored values under these ids are +// ignored and must never be re-minted for anything else. const MOBILE_IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/ws65s4a2"; // beta-testers external group const MOBILE_ANDROID_FIREBASE_URL = "https://appdistribution.firebase.dev/i/27a5cccde72ffb42"; // beta testers group const SETTING_EXTERNAL_ORCH = "comfyui-mcp.externalOrchestrator"; @@ -3919,7 +3926,6 @@ const TOKEN_BUTTON_LABEL = { // (no-ops when the value already matches) so a setSetting→onChange echo can't loop. const panelHooks = { applyBackend: null, // (id) - applyChatScope: null, // ("panel"|"workflow"|"ask") applyModel: null, // (id) applyEffort: null, // (id|"") applyBridgeUrl: null, // (url) @@ -4111,12 +4117,16 @@ async function applyPanelLocale(explicit) { return null; } } -/** Conversation ownership. The legacy boolean remains a read-only migration - * source so existing users keep their chosen behavior. */ +/** Conversation ownership (mcp#884 — owner-stated invariant): the conversation + * ALWAYS belongs to the panel. One agent session spans every workflow and every + * tab; the orchestrator keys and persists it (in ~/.comfyui-mcp/sessions, since + * mcp#897), so a workflow-scoped chat is a bug, never a mode. The old + * "workflow"/"ask" scopes are retired — their stored setting values are ignored + * (not migrated), and per-workflow threads created under them remain in + * history, reachable through the history picker like any archived + * conversation. */ function chatScopeMode() { - const mode = getSetting(SETTING_CHAT_SCOPE); - if (mode === "panel" || mode === "workflow" || mode === "ask") return mode; - return getSetting(SETTING_SESSION_FOLLOWS_PANEL) === false ? "workflow" : "panel"; + return "panel"; } function setSetting(id, value) { try { @@ -4696,29 +4706,10 @@ function panelSettingsList() { panelHooks.applyBackend?.(v); }, }, - { - id: SETTING_CHAT_SCOPE, - name: "Chat conversation scope", - get category() { return cat(tr("panel.general", "General"), "Chat conversation scope"); }, - sortOrder: 146, - tooltip: - "Panel: one conversation follows every canvas. Workflow: each saved workflow has its own persistent set of chats, " + - "identified by an embedded UUID so renames keep history and copies separate. Ask: choose whether to carry the " + - "current conversation whenever you switch workflows. All modes survive full ComfyUI/MCP restarts.", - type: "combo", - get options() { - return [ - { value: "panel", text: tr("panel.one_chat_across_workflows", "Panel — one chat across workflows") }, - { value: "workflow", text: tr("panel.workflow_separate_chat_histories", "Workflow — separate chat histories") }, - { value: "ask", text: tr("panel.ask_whenever_the_workflow_changes", "Ask whenever the workflow changes") }, - ]; - }, - defaultValue: getSetting(SETTING_SESSION_FOLLOWS_PANEL) === false ? "workflow" : "panel", - onChange: (v) => { - if (suppressSettingOnChange || !settingsArmed) return; - panelHooks.applyChatScope?.(v); - }, - }, + // mcp#884 — the "Chat conversation scope" combo is retired: the conversation + // is ALWAYS panel-owned (one session across every workflow and tab, keyed and + // persisted by the orchestrator). chatScopeMode() is hard-wired to "panel"; + // a stored "workflow"/"ask" value from an older build is simply ignored. { id: SETTING_AUTOCONNECT, name: "Auto-connect on load", @@ -22481,6 +22472,7 @@ function buildPanel() { `Walk me through it for my OS: install the CLI (\`${meta.install}\`), sign in (\`${meta.login}\`), ` + `then in this panel pick ${meta.label} in the provider picker and click Connect. Give exact terminal commands.`; if (client.isConnected() && client.sendUserMessage(prompt)) { + pinTurnOwnerAtDispatch(); appendSystem(tr("panel.asked_the_agent_to_help_you_set_up", "Asked the agent to help you set up {label}.", { label: meta.label })); } else { input.value = prompt; @@ -24338,9 +24330,6 @@ function buildPanel() { let threads = localHistory.threads; let historyMeta = localHistory.meta; let thread = null; // created lazily on first recorded message - // In "ask" mode this is chosen at each workflow switch. The initial canvas - // behaves as per-workflow until there is actually a switch to ask about. - let askModeFollowsPanel = false; function nextHistoryRevision() { return historyStore.nextRevision(Math.max(Date.now(), Number(historyMeta?.updatedAt) + 1 || 0)); @@ -24364,12 +24353,21 @@ function buildPanel() { applyWorkflowAliasesFromHistory(); function historyScopeFollowsPanel() { - const mode = chatScopeMode(); - return mode === "panel" || (mode === "ask" && askModeFollowsPanel); + // Constant since mcp#884 (chatScopeMode() is hard-wired to "panel"). Kept as + // the named seam the remaining scope guards read, so they stay honest + // defense-in-depth instead of silently deleted invariants. + return chatScopeMode() === "panel"; } function currentHistoryScopeKey({ embed = false } = {}) { - return historyScopeFollowsPanel() ? "panel:global" : workflowStorageKey({ embed }); + if (!historyScopeFollowsPanel()) return workflowStorageKey({ embed }); + // One conversation PER BACKEND (gate P0-2): the orchestrator keys its + // session orchestrator:: (mcp#897), so the shared selection + // pointer carries the same axis — a Claude tab's selection must never move + // a Codex tab's conversation. The legacy shared "panel:global" key remains + // a read fallback inside resolvePanelPointer until this backend's key is + // first written. + return panelScopeKeyForBackend(connectedBackend || selectedBackend); } function setActiveThread(scopeKey, threadId, updatedAt = nextHistoryRevision()) { @@ -24426,9 +24424,18 @@ function buildPanel() { }); } - async function invalidateDurableAgentSession() { + async function invalidateDurableAgentSession({ preserveThreadSession = false } = {}) { + // The TAB pointer always goes: it is backend-agnostic, so leaving it set would let + // the NEXT backend adopt a session id belonging to the previous one. ssSet(SESSION_KEY, null); - if (thread) historyStore.reviseThread(thread, { sessionId: null }); + // The THREAD's sessionId is a different claim (mcp#884/#897). Sessions are keyed + // orchestrator::, so on a BACKEND SWITCH the outgoing backend's session + // outlives this call and its conversation must keep pointing at it — otherwise + // switching back sends `new_session` and the per-backend persistence this branch + // adds is defeated by its own switch path. A restart/disconnect invalidate is the + // opposite case: that session is genuinely gone, so the pointer must be cleared or + // the next resume would name a session the orchestrator no longer has. + if (thread && !preserveThreadSession) historyStore.reviseThread(thread, { sessionId: null }); persistThreads(); // #1171 — DELIBERATELY UNBOUNDED, after a bound was added here and removed again. // @@ -24537,6 +24544,87 @@ function buildPanel() { return true; } + /** True when a live (or just-abandoned) turn belongs to a conversation other + * than the one on screen. Every user-visible transcript output — says, + * stream deltas, todo/plan updates, question cards, media, A2UI cards, + * command activity — must consult this before painting or recording (gate + * P0-4): an abandoned turn's card left interactive in the adopted + * conversation is not "transient", it is a control the user can act on in + * the wrong session. */ + function turnOutputFenced() { + return Boolean(liveTurnThreadId) && (thread?.id ?? null) !== liveTurnThreadId; + } + + /** Did another tab's write change what THIS tab has painted for the same + * conversation? Length + tail id is deliberately coarse: it catches appended + * turns (the cross-tab case that matters) without repainting on pure + * metadata edits (rename/pin/todo). */ + function transcriptChangedRemotely(before, after) { + const beforeMsgs = Array.isArray(before?.msgs) ? before.msgs : []; + const afterMsgs = Array.isArray(after?.msgs) ? after.msgs : []; + if (beforeMsgs.length !== afterMsgs.length) return true; + if (!beforeMsgs.length) return false; + return beforeMsgs[beforeMsgs.length - 1]?.id !== afterMsgs[afterMsgs.length - 1]?.id; + } + + /** mcp#884/#897 — the agent session is orchestrator-global, so which + * conversation a tab renders and records into is SHARED state, resolved by + * selectPanelThread from the synced snapshot. A tab that kept its own thread + * after the shared selection moved would file the user's next message under + * a transcript the agent is no longer in (wrong-conversation rendering + + * transcript mis-attribution). Adoption is deliberately PASSIVE: the tab the + * user acted in already told the orchestrator (resume_session/new_session), + * so a passive tab never sends session frames — N tabs echoing new_session + * after one delete would reset (and could race) the single global session. */ + function adoptSharedPanelSelection(currentThreadId) { + // Same dangling-pointer rule as selectRestoreThread: a pointer naming a + // thread that no longer exists says nothing about where the backend's + // session is, so a tab that still has its conversation keeps it instead of + // jumping to whatever merge recency would guess. The pointer is resolved + // under THIS tab's backend key (gate P0-2) — another backend's selection + // is that backend's conversation and never moves this tab. + const scopeKey = currentHistoryScopeKey(); + const pointer = resolvePanelPointer(historyMeta, scopeKey); + const current = currentThreadId + ? threads.find((candidate) => candidate.id === currentThreadId) + : null; + const pointerDangling = pointer.activeId != null && + !threads.some((candidate) => candidate.id === pointer.activeId); + const target = pointerDangling && current + ? current + : selectPanelThread(threads, historyMeta, { scopeKey }); + if (target && target.id === currentThreadId) { + const before = thread; + rebindCurrentThreadRecord(target); + // Mirror remotely appended turns onto this tab's idle view. Never mid + // paint: a live local turn (streaming bubbles) or an unresolved live A2UI + // card would be orphaned by resetFeed, so those keep the rebind only. + if (!agentWorking && liveA2uiCards.size === 0 && transcriptChangedRemotely(before, target)) { + paintThread(target); + refreshContextRingForScope(); + } + return; + } + if (!target && !currentThreadId) return; // nothing rendered, nothing selected + // The shared selection moved (or was cleared) in another tab — follow it. + endTurnLocally(); + if (target) { + ssSet(CURRENT_THREAD_KEY, target.id); + // Provider-local ids only: never stage another provider's session for a + // later reload-resume (same rule as loadThread). + ssSet(SESSION_KEY, resumableSessionId(target)); + paintThread(target); + } else { + thread = null; + ssSet(CURRENT_THREAD_KEY, null); + ssSet(SESSION_KEY, null); + turnAnchors = []; // fresh conversation view → no rewind anchors + resetFeed(); + renderTodo([], { persist: false }); + } + refreshContextRingForScope(); + } + const unsubscribeHistorySync = historyStore.subscribe((incoming) => { const currentThreadId = thread?.id; const merged = mergeHistorySnapshots({ threads, meta: historyMeta }, incoming); @@ -24545,16 +24633,7 @@ function buildPanel() { // any local-diff pass so a received tombstone cannot be echoed as a new set. applyWorkflowAliasesFromHistory(); threads = capHistoryThreads(merged.threads, currentThreadId); - if (currentThreadId) { - const refreshed = threads.find((candidate) => candidate.id === currentThreadId); - const followsPanel = historyScopeFollowsPanel(); - if (refreshed && (followsPanel || isThreadInScope(refreshed, currentHistoryScopeKey()))) { - rebindCurrentThreadRecord(refreshed); - } else { - const scopeKey = followsPanel ? null : currentHistoryScopeKey(); - detachInvalidCurrentThread({ scopeKey, rebind: !followsPanel }); - } - } + adoptSharedPanelSelection(currentThreadId); if (!histPop.hidden) renderHistory(); }); @@ -24610,6 +24689,25 @@ function buildPanel() { } function record(entry) { + // #381's turn-ownership rule, extended from usage frames to ALL agent-side + // records (codex P0 on mcp#884/#897): a live turn's output belongs to the + // conversation that OWNS the turn — pinned at user_message dispatch and + // again at turn:working, and READ here (through turnOutputFenced) but never + // written: interactive-card-fence.test.mjs pins that, because a record() + // that retroactively adopted the minted thread as owner would silently + // redefine #381's semantics and turn the card fence's provenance rule into + // dead code. If the shown conversation changed mid-turn — a history + // switch in this tab, or this tab passively adopting another tab's shared + // selection — agent output must not be filed under the conversation now on + // screen. It is DROPPED, not re-routed to its owner: the turn was + // abandoned exactly like an interrupt, and stamping its output into the + // owner thread NOW would hand that thread the newest conversation + // activity and yank the shared selection straight back (selectPanelThread + // recency). User-authored entries are exempt — they belong to the view + // the user typed into, by definition. + if (entry?.role !== "user" && turnOutputFenced()) { + return entry; + } const followsPanel = historyScopeFollowsPanel(); // Panel-owned continuity uses a global active-thread pointer, but the thread // keeps the stable workflow UUID as provenance for archive grouping. Panel @@ -24657,7 +24755,7 @@ function buildPanel() { threads.push(thread); if (threads.length > MAX_THREADS) threads = capHistoryThreads(threads, thread.id); ssSet(CURRENT_THREAD_KEY, thread.id); - setActiveThread(followsPanel ? "panel:global" : workflowKey, thread.id); + setActiveThread(currentHistoryScopeKey(), thread.id); // THIS is the only place a conversation is created. Record it so the // interactive-card fence can recognise the conversation an owner-less turn // minted for itself, and only that one (see lastMintedThreadId). @@ -25516,7 +25614,8 @@ function buildPanel() { const reply = coerceMessageText(text); appendUser(reply, {}); const ok = client?.sendUserMessage?.(reply); - if (!ok) appendSystem(tr("panel.card_reply_couldn_t_be_sent_agent", "Card reply couldn't be sent — agent disconnected.")); + if (ok) pinTurnOwnerAtDispatch(); + else appendSystem(tr("panel.card_reply_couldn_t_be_sent_agent", "Card reply couldn't be sent — agent disconnected.")); } /** @@ -26162,6 +26261,17 @@ function buildPanel() { }, DELIVERY_TIMEOUT_MS); } + /** Pin the coming turn's owner at DISPATCH time (gate P0-4). Waiting for + * turn:working leaves a hole: an adoption's endTurnLocally() discards a + * working frame that lands inside the stale-working window, so ownership + * would stay null exactly when the transcript fences need it — and the + * abandoned turn's output would flow into the adopted conversation. Any + * successful user_message starts (or continues) a turn whose output belongs + * to the conversation on screen at the moment of dispatch. */ + function pinTurnOwnerAtDispatch() { + liveTurnThreadId = thread?.id ?? null; + } + function trackSend(mid, statusEl, payload, raw, materialize) { // A `materialize` fn means this is a QUEUED send: nothing is painted inline yet; // it waits in the pending tray and `materialize()` paints it at the END of the @@ -26177,7 +26287,12 @@ function buildPanel() { }); const ok = client.sendUserMessage(payload.text, payload.context, payload.images, mid); if (!ok) setMsgStatus(mid, "failed"); // socket wasn't open — instant fail - else armDeliveryTimeout(mid); + else { + armDeliveryTimeout(mid); + // A queued send joins the turn that already owns the pin; only an idle + // send opens the next turn. + if (!materialize) pinTurnOwnerAtDispatch(); + } renderTray(); // surface it in the pending tray (not inline) } @@ -26190,7 +26305,10 @@ function buildPanel() { setMsgStatus(mid, "queued"); const ok = client.sendUserMessage(entry.payload.text, entry.payload.context, entry.payload.images, mid); if (!ok) setMsgStatus(mid, "failed"); - else armDeliveryTimeout(mid); + else { + armDeliveryTimeout(mid); + if (!entry.materialize) pinTurnOwnerAtDispatch(); + } } else { // requeue:true — this is SEND NOW, not a plain Stop: re-queue the turn the // agent was interrupted on so BOTH it and this queued message get answered. @@ -26638,7 +26756,24 @@ function buildPanel() { // tab's point of view — clear agentWorking first so resetFeed() below won't // rebuild a working indicator onto the fresh, empty chat. endTurnLocally(); - setActiveThread(currentHistoryScopeKey(), null); + // THE COMMIT IS THE TRANSITION (gate P0-1, same rule as loadThread): tell + // the orchestrator to forget the session FIRST, and publish the shared + // pointer clear only when that frame actually left. A disconnected tab + // still gets its fresh local view, but the backend's conversation — and + // therefore every other tab's — is unchanged until a transition really + // happens (the next recorded message republishes the selection). + const dispatched = notifyBackend + ? client?.sendFrame?.({ type: "new_session" }) === true + : false; + if (dispatched) ssSet(PENDING_SESSION_RESET_KEY, null); // transition delivered + else if (notifyBackend) { + // Couldn't reach the orchestrator: this tab still OWES the reset (it may + // have just deleted the active conversation — the tombstone and pointer + // clear propagate regardless). Queue it for the next ready ack; the + // pointer-state guard there drops it if anything newer happened first. + ssSet(PENDING_SESSION_RESET_KEY, currentHistoryScopeKey()); + } + if (dispatched || !notifyBackend) setActiveThread(currentHistoryScopeKey(), null); thread = null; turnAnchors = []; // fresh conversation → no rewind anchors ssSet(CURRENT_THREAD_KEY, null); @@ -26652,9 +26787,6 @@ function buildPanel() { setContextPct(0); ctxLabel.textContent = "—"; persistThreads(); - // Tell the orchestrator to forget this tab's session so the NEXT message - // starts a genuinely fresh agent (no memory of the prior conversation). - if (notifyBackend) client?.sendFrame?.({ type: "new_session" }); } function paintThread(t) { @@ -26725,23 +26857,37 @@ function buildPanel() { // with a bounded visible-transcript replay below. historyStore.reviseThread(t, { sessionId: null }); } + // THE COMMIT IS THE TRANSITION (gate P0-1): dispatch the session frame + // FIRST, and publish the shared selection only when the frame actually + // left this socket. A disconnected tab still switches its OWN view — + // reading an archive offline is legitimate — but it must not move every + // other tab onto a conversation the backend never entered. (Two connected + // actors can still interleave: pointer revisions and socket delivery are + // ordered independently, and reconciling that needs the orchestrator to + // confirm transitions — mcp#897's side. This closes every panel-local + // failure mode: closed socket, missing route, send throw.) + const dispatched = sessionId + ? client?.sendFrame?.({ type: "resume_session", session_id: sessionId }) === true + : client?.sendFrame?.({ type: "new_session" }) === true; + // A delivered transition supersedes any reset this tab still owed. + if (dispatched) ssSet(PENDING_SESSION_RESET_KEY, null); ssSet(CURRENT_THREAD_KEY, t.id); - setActiveThread(followsPanel ? "panel:global" : (t.workflowKey || scopeKey), t.id); + // Resume this conversation's agent session (or start fresh if it has none), + // so typing continues THIS chat rather than whatever was last active. + ssSet(SESSION_KEY, sessionId); + if (dispatched) setActiveThread(currentHistoryScopeKey(), t.id); persistThreads(); paintThread(t); // #381/codex-P2: `thread` is now `t`, so repaint the ring from THIS // conversation's own last-known fill (blank if none) — selecting an older // history entry in the same workflow must not keep the prior chat's usage. refreshContextRingForScope(); - // Resume this conversation's agent session (or start fresh if it has none), - // so typing continues THIS chat rather than whatever was last active. - ssSet(SESSION_KEY, sessionId); - if (sessionId) client?.sendFrame?.({ type: "resume_session", session_id: sessionId }); - else { - client?.sendFrame?.({ type: "new_session" }); + if (!sessionId) { // Provider sessions can expire or be intentionally removed. A new backend // session receives a compact replay once, so continuing an archived chat // still has useful memory instead of only repainting bubbles locally. + // Armed even when the frame could not be sent: the replay context rides + // the next user message on whatever socket delivers it. armVisibleTranscriptReplay(); } return true; @@ -26771,22 +26917,6 @@ function buildPanel() { if (wfid === currentWorkflowId) return; // case 1: no change const initial = currentWorkflowId == null; - if (!initial && chatScopeMode() === "ask") { - const name = wf?.filename || wfkey || wfid; - // Two keys, not one: the OK/Cancel body is the same in both branches of every - // future scope prompt, and splitting it keeps the question (which interpolates a - // filename) apart from the fixed explanation of the two buttons. - askModeFollowsPanel = window.confirm( - tr("panel.continue_the_current_agent_panel_conversation_on", 'Continue the current Agent Panel conversation on "{name}"?', { - name, - }) + - "\n\n" + - tr( - "panel.ok_carry_this_chat_to_the_new", - "OK: carry this chat to the new canvas.\nCancel: open this workflow's separate chat history.", - ), - ); - } const followsPanel = historyScopeFollowsPanel(); // PANEL-OWNED SESSION (default): the conversation is the unit of continuity @@ -26891,7 +27021,7 @@ function buildPanel() { workflowKey: workflowStorageKey(), workflowTitle: getWorkflowTitle(), }); // archive provenance - setActiveThread("panel:global", thread.id); + setActiveThread(currentHistoryScopeKey(), thread.id); persistThreads(); } currentWorkflowId = wfid; @@ -27874,6 +28004,12 @@ function buildPanel() { // can't out-race the session resume.) }, onSay(text, meta) { + // A committed reply belongs to the turn's owning conversation. After a + // mid-turn switch (history switch here, or passive adoption of another + // tab's shared selection — mcp#884/#897) the straggler must neither + // paint into nor be recorded under the conversation now on screen + // (record() enforces the recording half at its choke point). + if (turnOutputFenced()) return; // Message COMMIT time — the one place fenced ```a2ui blocks are detected // (backends without panel tools, e.g. Ollama family, can only emit cards // this way). Malformed JSON is left in `stripped` as a normal code block. @@ -27895,6 +28031,9 @@ function buildPanel() { }, // Live streaming deltas (thinking + reply text) before the committed say. onStream(msg) { + // Same ownership fence as onSay: a delta from an abandoned turn must not + // open a fresh preview bubble inside the newly adopted conversation. + if (turnOutputFenced()) return; onStreamDelta(msg); noteActivity(); // streaming output is real turn activity → reset the clock }, @@ -27903,6 +28042,23 @@ function buildPanel() { onAsk(msg, socketId) { // Fence FIRST: a card from a turn this tab no longer owns must not paint, // and must not revive the working indicator on its way past either. + // + // mcp#884: this SUPERSEDES the plain `turnOutputFenced()` refusal this + // branch originally added here. `classifyInteractiveCard` decides the same + // question with strictly more evidence — it also weighs `agentWorking` and + // `lastMintedThreadId`, so an owner-less turn that minted its own thread is + // not mistaken for a turn painting into somebody else's conversation. + // + // The two fences are NOT interchangeable, which is why both survive: + // turnOutputFenced() asks only "is the shown conversation the turn's + // owner", which is right for transcript output (a say/stream/todo has + // nowhere legitimate to go when the answer is no) but WRONG here, where a + // refusal costs the agent a tool error and so must be the precise test. + // + // pinTurnOwnerAtDispatch() (mcp#884) narrows this fence's own documented + // residual: ownership is now pinned at user_message dispatch, so a turn + // whose `turn:working` is discarded by the stale-working guard no longer + // reaches the classifier with a null owner. fenceInteractiveCard("ask_user"); const p = paintQuestion(msg, socketId); bumpThinking(); @@ -27911,6 +28067,9 @@ function buildPanel() { }, // The agent called panel_set_todo — render/update the live plan tray. onTodo(items) { + // Ownership fence (same rule as onSay): an abandoned turn's plan update + // must not repaint the tray or persist todos into the adopted thread. + if (turnOutputFenced()) return; renderTodo(items); }, // The agent called panel_show_media — render images/videos/audio directly in @@ -27927,6 +28086,12 @@ function buildPanel() { // an unpresentable kind gets a link), and an item the panel cannot present // is reported apart from `painted` instead of counted as a success. onShowMedia(items) { + // Ownership fence: media from an abandoned turn must not paint into the + // adopted conversation. Refuse honestly (tool error) — the record choke + // point would drop the persistence half anyway. + if (turnOutputFenced()) { + throw new Error("The conversation changed while this media was in flight — it was not shown."); + } return composeShowMediaReply(items, { paintImage, paintVideo, @@ -27993,6 +28158,11 @@ function buildPanel() { }, // The agent called panel_ui_render / panel_ui_update — A2UI cards in the chat. onUiRender(msg) { + // Ownership fence: an A2UI card from an abandoned turn would sit + // interactive in the adopted conversation. Refuse honestly (tool error). + if (turnOutputFenced()) { + throw new Error("The conversation changed while this card was in flight — it was not rendered."); + } const v = validateA2UISpec(msg.spec); if (!v.ok) { // Client-side wall (fence path has no server check; tool path double-checks). @@ -28193,7 +28363,11 @@ function buildPanel() { canvasToolsProvenEpoch = agentSessionEpoch; }, onCommand(cmd, msg, reply) { - appendActivity(cmd, msg, reply); + // Ownership fence for the transcript half only: the command already + // executed against the canvas (canvas targeting is governed by the + // workflow-UUID fences), but its activity card belongs to the turn's + // owning conversation, not the one now on screen. + if (!turnOutputFenced()) appendActivity(cmd, msg, reply); bumpThinking(); // After an edit, follow the action: dart to the edited NODE (25% pad) so // the user watches the change land, then zoom back out to a full fit once @@ -28385,6 +28559,11 @@ function buildPanel() { // A real provider switch = the connected backend changed AND we were // already connected to something (not the first connect, not a re-pick). const switched = connectedBackend !== null && connectedBackend !== backend; + // The backend selection key can change on a real switch AND on a first + // connect that lands on a different backend than the restored default. + // Captured BEFORE `connectedBackend` moves, because that is what the key + // is derived from. + const previousScopeKey = currentHistoryScopeKey(); if (switched) { appendSystem( tr( @@ -28410,6 +28589,37 @@ function buildPanel() { running: el.dataset.running === "1", })), ); + // ONE CONVERSATION PER BACKEND (gate round-3 finding 1): entering a + // backend adopts THAT backend's own conversation — the session the + // orchestrator runs for it is keyed orchestrator::, so keeping + // the previous backend's thread on screen would run the new session + // against a conversation this backend does not own, while reloads and + // other tabs resolve its real one. loadThread is the normal actor path + // (dispatch + publish under the NEW key); with no conversation yet, + // newChat gives the fresh view. A reconnect to the SAME backend leaves + // the view untouched (keys equal). + const nextScopeKey = currentHistoryScopeKey(); + if (previousScopeKey !== nextScopeKey) { + const target = selectPanelThread(threads, historyMeta, { scopeKey: nextScopeKey }); + if (switched) { + appendSystem(target + ? `Switched to ${BACKEND_LABELS[backend]} — continuing its own conversation (sessions aren't shared across providers).` + : `Switched to ${BACKEND_LABELS[backend]} — it has no conversation yet, so this starts a fresh chat.`); + } + // Run the transition even when the target is the thread ALREADY on + // screen (codex round-4: first connect can land on a different + // backend than the restored default while the new scope resolves the + // same legacy thread) — the new backend's session still needs the + // resume/new_session + replay alignment and the publish under ITS + // key, all of which loadThread owns (its provider check scrubs a + // foreign session id and arms the transcript replay). + if (target) loadThread(target); + else if (thread) newChat(); + } else if (switched) { + appendSystem( + `Switched to ${BACKEND_LABELS[backend]} — sessions aren't shared across providers, so this starts a fresh chat.`, + ); + } } // Apply the catalog AFTER the backend is known so effort mapping is correct. applyModelCatalog(list); @@ -28535,6 +28745,27 @@ function buildPanel() { renderBackendChips(knownBackends); } } + // Gate round-3 finding 2: fire a session reset this tab still OWES from + // deleting the active conversation while disconnected. The tombstone and + // pointer clear already propagated; without this the backend would keep + // the deleted conversation's session alive. Guarded three ways: same + // backend scope as when it was queued, the shared pointer is still in + // the cleared state this tab left (any newer act — here or in another + // tab — supersedes and drops the reset), and the frame actually sends. + if (ack?.kind === "ready") { + const owedScope = ssGet(PENDING_SESSION_RESET_KEY); + if (owedScope) { + ssSet(PENDING_SESSION_RESET_KEY, null); + const pointer = resolvePanelPointer(historyMeta, owedScope); + if ( + owedScope === currentHistoryScopeKey() && + pointer.activeId == null && + pointer.cleared + ) { + client?.sendFrame?.({ type: "new_session" }); + } + } + } // Post-restart auto-resume (#3): the "ready" ack is sent after the // orchestrator armed hello.resume, so resuming the agent is safe now. // #585 routes this through the correlated restart-resume flow below, which @@ -28552,9 +28783,9 @@ function buildPanel() { appendSystem(tr("panel.agent_reloaded_session_resumed", "Agent reloaded — session resumed.")); if (origin === "agent") { showThinking(); - client.sendUserMessage( + if (client.sendUserMessage( "✅ You were just soft-reloaded to pick up code changes (no ComfyUI restart) — your tools and system prompt are now the latest build. Continue exactly what you were doing before the reload.", - ); + )) pinTurnOwnerAtDispatch(); } return; } @@ -28596,9 +28827,9 @@ function buildPanel() { if (!shouldNudgeAfterMidTaskReconnect({ outageMs: bridgeOutage.outageMs() })) return; appendSystem(tr("panel.reconnected_picking_up_where_we_left_off", "Reconnected — picking up where we left off.")); showThinking(); - client.sendUserMessage( + if (client.sendUserMessage( "✅ Your connection dropped mid-task (e.g. ComfyUI was restarted, possibly by another agent installing nodes). The session resumed with full context — continue exactly what you were doing before the drop; if you were mid-build or mid-edit, pick it right back up.", - ); + )) pinTurnOwnerAtDispatch(); } }, getResume: () => ssGet(SESSION_KEY), @@ -29363,6 +29594,7 @@ function buildPanel() { : "✅ ComfyUI just restarted to load newly-installed custom nodes (now available). Continue what you were doing before the restart — if you were mid-build, pick it back up."); const mid = newMid(); const sent = client.sendUserMessage(text, undefined, undefined, mid); + if (sent) pinTurnOwnerAtDispatch(); if (!sent) { // Nothing left the panel, so give the budget its attempt back — but only when // we know the increment persisted. If it didn't, the count is already whatever @@ -30387,10 +30619,18 @@ function buildPanel() { const { switched } = await runBackendSwitch(id, { liveBackend: () => connectedBackend, pickedBackend: () => selectedBackend, - // The old provider's session must be durably invalid before any reconnect can observe - // it. If the reconnect fails or the browser closes, a reload must start fresh rather - // than restore a foreign session. - invalidate: () => invalidateDurableAgentSession(), + // What the INCOMING backend already has, answered by the STORE under that backend's + // own scope key (mcp#884). This is the single question `planBackendHandover` turns + // into both the session-preservation and the replay decision, so they cannot drift. + incomingHasConversation: (next) => + Boolean(selectPanelThread(threads, historyMeta, { + scopeKey: panelScopeKeyForBackend(next), + })), + // The old provider's TAB session pointer must be durably invalid before any reconnect + // can observe it. If the reconnect fails or the browser closes, a reload must start + // fresh rather than restore a foreign session. `preserveThreadSession` keeps the + // OUTGOING conversation's own sessionId so switching back can resume it. + invalidate: (opts) => invalidateDurableAgentSession(opts), // CENTRALIZED per-backend seeding: every switch path routes through here — the backend // chips, the model-popover provider row, AND the Settings backend combo // (panelHooks.applyBackend). Seeding from the NEW backend's group before connecting is @@ -32251,7 +32491,12 @@ function buildPanel() { ? { id: a.id, content: full.slice(0, PASTED_DISPLAY_CAP), truncated: true } : { id: a.id, content: full }; }); - const painted = isQueued ? null : appendUser(text, { mid, attachments: pastedTexts }); + let painted = isQueued ? null : appendUser(text, { mid, attachments: pastedTexts }); + // The conversation the optimistic record above was filed under. The awaits + // below (attachment uploads, grounding, validation) can span a cross-tab + // adoption, and the DISPATCH decides which conversation consumes the + // prompt — re-checked just before trackSend (gate P0-5). + const recordedThreadId = thread?.id ?? null; // Capture the pre-turn graph so /revert can undo this turn's edits in one step. captureGraphSnapshot(mid, text); showThinking(); @@ -32357,6 +32602,32 @@ function buildPanel() { // graph until it independently re-runs. Conditional + deduped (event-driven). const valBanner = await validationBanner(); if (valBanner) sendText = valBanner + sendText; + // The target conversation is decided at DISPATCH, not at type time (gate + // P0-5): if the shared selection moved while we awaited above, relocate the + // optimistically recorded prompt into the conversation the backend will + // actually consume it in — remove + tombstone the old copy (so a merge + // cannot resurrect it), then re-record and repaint in the current view. + // Queued sends are exempt: they record at MATERIALIZE time (dequeue), + // which is already dispatch-side of any adoption. + if (!isQueued && (thread?.id ?? null) !== recordedThreadId) { + const source = threads.find((candidate) => candidate.id === recordedThreadId); + const msgs = source?.msgs; + if (msgs) { + const i = msgs.findIndex((m) => m.role === "user" && m.mid === mid); + if (i >= 0) { + const [removed] = msgs.splice(i, 1); + const now = Math.max(Date.now(), Number(source.updatedAt) + 1 || 0); + if (removed?.id) { + source.deletedMessages = source.deletedMessages || {}; + source.deletedMessages[removed.id] = now; + } + source.updatedAt = now; + source.ts = now; + } + } + painted = appendUser(text, { mid, attachments: pastedTexts }); + paintMedia(); + } // Track delivery: trackSend marks "Sending…", then the working ack flips it // to "✓ Seen" (or a timeout / closed socket flips it to "Not delivered"). // `text` (the raw composer text) is kept so ✎ can restore it for editing. @@ -32759,34 +33030,11 @@ function buildPanel() { // push carries the new backend's values. No set_options is sent here. connectBackend(id); }; - panelHooks.applyChatScope = (mode) => { - if (!['panel', 'workflow', 'ask'].includes(mode)) return; - askModeFollowsPanel = mode === "panel"; - const targetKey = mode === "panel" ? "panel:global" : workflowStorageKey(); - const panelTargetId = mode === "panel" ? historyMeta.activeByScope?.[targetKey] : null; - let target = mode === "panel" - ? threads.find((candidate) => candidate.id === panelTargetId) - : threadForWorkflow(targetKey); - if (!target && mode === "panel" && thread) { - // First switch to panel-owned mode: carry the visible conversation into - // the global selection slot without discarding its workflow provenance. - target = thread; - setActiveThread(targetKey, target.id); - persistThreads(); - } - if (target) loadThread(target); - else newChat({ notifyBackend: false }); - currentWorkflowId = null; - onWorkflowMaybeChanged(); - refreshContextRingForScope(); // #381: the scope mode changed — reflect the target scope's fill - appendSystem( - mode === "panel" - ? tr("panel.chat_scope_panel_wide_conversation", "Chat scope → panel-wide conversation.") - : mode === "workflow" - ? tr("panel.chat_scope_separate_histories_for_each_workflow", "Chat scope → separate histories for each workflow.") - : tr("panel.chat_scope_ask_whenever_the_workflow_changes", "Chat scope → ask whenever the workflow changes."), - ); - }; + // mcp#884 — panelHooks.applyChatScope is gone with the retired scope setting: + // its only caller was the removed combo's onChange, and keeping a live scope + // switcher around would be a ready-made way to reintroduce per-workflow + // sessions behind the orchestrator's back (the invariant is ONE conversation + // per backend across every tab and workflow). panelHooks.applyModel = (id) => { const next = (id || "").trim(); // Blank = "Auto (let the agent pick)" → CLEAR the forced model live: un-pin so a @@ -33012,7 +33260,6 @@ function buildPanel() { // Drop the Settings→panel hooks so the dialog can't drive a torn-down panel // (a freshly-mounted panel re-registers them). panelHooks.applyBackend = null; - panelHooks.applyChatScope = null; panelHooks.applyModel = null; panelHooks.applyEffort = null; panelHooks.applyBridgeUrl = null; diff --git a/web/js/lib/backend-switch.js b/web/js/lib/backend-switch.js index 4ec38af0..bbabf9fc 100644 --- a/web/js/lib/backend-switch.js +++ b/web/js/lib/backend-switch.js @@ -25,6 +25,50 @@ // 1.7MB panel IIFE is not possible, and an outcome-only test passes against the buggy order // just as happily as against the fixed one. +// --------------------------------------------------------------------------- +// mcp#884 — THE HANDOVER. +// +// The order above was built when a provider switch ALWAYS meant a fresh session: +// destroy the outgoing session, replay the outgoing transcript into the incoming +// provider, done. mcp#884/#897 changed the premise — each backend now keeps its +// OWN durable conversation and its own orchestrator-keyed session — and the two +// halves disagreed: +// +// - the invalidate cleared the OUTGOING THREAD's sessionId, so switching back +// later sent `new_session` instead of resuming: the per-backend persistence +// this PR exists to add, defeated by its own switch path; +// - the replay armed the OUTGOING conversation's transcript before connecting, +// so it could be replayed into the INCOMING backend's own existing thread. +// +// Both are consequences of ONE question the switch never asked: what does the +// incoming backend already have? `planBackendHandover` asks it once and derives +// both, so the two can no longer drift apart. +// --------------------------------------------------------------------------- + +/** What the outgoing session and the armed replay must do on a handover. + * + * `replay`: + * - "arm" — the incoming backend has no conversation, so this really is a + * fresh chat and the outgoing transcript rides in as one-shot + * context (the long-standing, disclosed behaviour); + * - "clear" — the incoming backend HAS its own conversation, which `loadThread` + * will resume. The outgoing transcript must not ride into it, and + * anything already armed must be dropped rather than left to be + * consumed by the next message in the wrong conversation; + * - "leave" — not a switch; nothing to decide. + * + * `preserveOutgoingSession`: the outgoing THREAD keeps its sessionId so the + * backend can be resumed when the user switches back. The TAB pointer is still + * cleared either way — that is the thing that must not leak across backends. + */ +export function planBackendHandover({ switching, incomingHasConversation } = {}) { + if (!switching) return { preserveOutgoingSession: false, replay: "leave" }; + return { + preserveOutgoingSession: true, + replay: incomingHasConversation ? "clear" : "arm", + }; +} + /** What a switch did, for the caller's disclosure and for tests. */ export const BACKEND_SWITCH = Object.freeze({ SWITCHED: "switched", @@ -46,7 +90,8 @@ export const BACKEND_SWITCH = Object.freeze({ * @param {{ * liveBackend: () => string|null, // `connectedBackend`, READ LATE (see below) * pickedBackend: () => string|null, // `selectedBackend` - * invalidate: () => Promise, + * incomingHasConversation: (id: string) => boolean, // ask the STORE, not panel state + * invalidate: (opts: {preserveThreadSession: boolean}) => Promise, * seedPrefs: (id: string) => void, * commitSelection: (id: string) => void, * endTurn: () => void, @@ -61,6 +106,7 @@ export async function runBackendSwitch(id, effects) { const { liveBackend, pickedBackend, + incomingHasConversation, invalidate, seedPrefs, commitSelection, @@ -80,11 +126,25 @@ export async function runBackendSwitch(id, effects) { // handshake landed underneath us. let landedOn = startedOn; + // Asked BEFORE anything is committed, and answered from the store rather than from + // panel state, so it is independent of `selectedBackend` moving underneath us. + const handover = planBackendHandover({ + switching, + incomingHasConversation: switching ? incomingHasConversation(id) === true : false, + }); + if (switching) { // THE ONLY AWAIT BEFORE A COMMIT, and the non-switching path must never reach it: a // first connect and a re-pick of the live backend stay fully synchronous, so neither is // ever gated on the history store's health. - const invalidated = await invalidate(); + // + // `preserveThreadSession` (mcp#884): the outgoing THREAD keeps its sessionId. The old + // unconditional clear was correct when a switch meant the session was gone; now the + // orchestrator keys sessions per backend, so the outgoing backend's session outlives + // this switch and its thread must keep pointing at it. The TAB pointer is still cleared. + const invalidated = await invalidate({ + preserveThreadSession: handover.preserveOutgoingSession, + }); // THE INVALIDATE IS DESTRUCTIVE BEFORE IT REPORTS, and an earlier version of this file // claimed the opposite. `invalidateDurableAgentSession` clears the session key, nulls @@ -148,8 +208,17 @@ export async function runBackendSwitch(id, effects) { // there. Only the replay belongs here, and only when this is really still a switch: // arming a fresh-session preamble against a provider that already holds the // conversation is what shipped the whole transcript back to the backend that had it. - const replay = buildReplay(); - if (replay) armContext(replay); + if (handover.replay === "arm") { + const replay = buildReplay(); + if (replay) armContext(replay); + } else { + // The incoming backend HAS its own conversation; `loadThread` will resume it and + // arm its OWN replay if it needs one. Cleared rather than merely skipped: a context + // armed earlier in this tab would otherwise be consumed by the next user message, + // inside a conversation it does not belong to. `armContext(null)` is the clear — + // the client stores only a non-empty string. + armContext(null); + } } teardownAndConnect(id); diff --git a/web/js/lib/chat-history-store.js b/web/js/lib/chat-history-store.js index c272cbf3..ef705e32 100644 --- a/web/js/lib/chat-history-store.js +++ b/web/js/lib/chat-history-store.js @@ -629,21 +629,161 @@ export function selectThreadForScope(threads, meta, scopeKey) { return candidates.find((thread) => thread.id === activeId) || candidates[0] || null; } -/** Select the panel-owned conversation without changing its workflowKey. The - * global id lives only in metadata; each thread keeps its ride-along workflow - * provenance for archive grouping. Legacy snapshots without that pointer - * recover the most recently updated conversation. */ -export function selectPanelThread(threads, meta) { +/** The legacy shared selection key. Rounds 1-2 of mcp#884 kept ONE pointer for + * every backend under this key; the orchestrator keys its session per backend + * (orchestrator::, mcp#897), so the panel pointer now carries the + * same axis ("panel:backend:") and this key remains a read-only migration + * fallback. */ +export const LEGACY_PANEL_SCOPE = "panel:global"; + +const PANEL_BACKEND_PREFIX = "panel:backend:"; + +/** The selection-pointer key for ONE backend. The orchestrator keys its session + * `orchestrator::` (mcp#897), so the panel pointer carries the same + * axis. Exported so the panel and the backend-switch path build the key the + * same way rather than each interpolating their own. */ +export function panelScopeKeyForBackend(backend) { + return `${PANEL_BACKEND_PREFIX}${backend || "claude"}`; +} + +/** The backend a panel scope key names, or null when it is not a backend key + * (the legacy shared key, or a retired workflow scope). */ +export function backendOfScopeKey(scopeKey) { + if (typeof scopeKey !== "string" || !scopeKey.startsWith(PANEL_BACKEND_PREFIX)) return null; + return scopeKey.slice(PANEL_BACKEND_PREFIX.length) || null; +} + +/** + * Can `backend` claim this thread on the UPGRADE path? + * + * mcp#884 fork rule. A pre-upgrade snapshot has one shared `panel:global` + * pointer and no per-backend keys, so every backend's key falls back to the + * SAME thread id — Claude and Codex both resolve one thread, `loadThread` + * scrubs its foreign session, and `record()` rewrites its provider on every + * append. Two backends then share and corrupt one transcript. This hits every + * existing user on their first upgrade, so the legacy route is forked here + * instead: a thread is claimable only by the backend that actually owns it. + * + * `provider` is that ownership stamp — `record()` writes it on mint and on + * every append, so any thread carrying messages carries a provider. + * + * A thread with NO provider FAILS CLOSED (nobody auto-adopts it). Fail-open is + * exactly the collision above, and the cost of failing closed is bounded and + * non-destructive: nothing is deleted, the conversation stays in history and + * opens through the picker like any archived one. The common single-backend + * upgrade is unaffected — that user's thread carries their provider and is + * adopted normally. + */ +function threadClaimableByBackend(thread, backend) { + if (!backend) return true; + const provider = thread?.provider; + return typeof provider === "string" && provider ? provider === backend : false; +} + +/** Resolve the panel-owned selection pointer for one backend scope. + * + * Returns { key, activeId, revision, cleared }: + * - key: the scope key the pointer was found under (backend key, or the + * legacy shared key when the backend key has never been written), + * - activeId: the selected thread id, or null, + * - revision: the causal revision of the selection op (null when the op was + * compacted into the checkpoint baseline), + * - cleared: true when the pointer was DELIBERATELY cleared (new chat) — an + * absent pointer is not a clear. + * + * A backend key that has been written (value or clear) never falls back to + * the legacy key: migration is one-way per backend. */ +export function resolvePanelPointer(meta, scopeKey = LEGACY_PANEL_SCOPE) { + const keys = scopeKey === LEGACY_PANEL_SCOPE ? [scopeKey] : [scopeKey, LEGACY_PANEL_SCOPE]; + for (const key of keys) { + const values = meta?.activeByScope; + const operation = meta?.activeOps?.[key]; + const hasValue = values != null && typeof values === "object" && Object.hasOwn(values, key) && + values[key] != null; + if (hasValue) { + return { key, activeId: values[key], revision: operationRevision(operation), cleared: false }; + } + if (operation) { + return { + key, + activeId: operation.deleted === true ? null : (operation.value ?? null), + revision: operationRevision(operation), + cleared: operation.deleted === true || operation.value == null, + }; + } + } + return { key: scopeKey, activeId: null, revision: null, cleared: false }; +} + +/** Select the panel-owned conversation for one backend without changing any + * thread's workflowKey. The selection id lives only in metadata; each thread + * keeps its ride-along workflow provenance for archive grouping. Legacy + * snapshots without any pointer recover the most recently updated + * conversation. + * + * This is the ONE definition of "the conversation" per backend (mcp#884/#897): + * the agent session is orchestrator-scoped per backend, so every tab — cold + * restore and cross-tab sync alike — must resolve the same thread from the + * same shared state, under the same backend key. + * + * Stale-pointer guard (the mcp#884 upgrade path) — SELECTION evidence only: + * the retired per-workflow mode recorded every selection as a workflow-scoped + * active op, so a pre-upgrade snapshot where the user kept conversing in + * workflow mode carries workflow:* ops NEWER than the abandoned panel + * pointer. The newest selection op that still resolves to a live thread wins. + * Message timestamps are deliberately NOT evidence: an imported archive, a + * straggler write, or a skewed clock can carry newer messages without any + * user selection, and must not move the shared conversation (gate P0-3). + * Other backends' panel keys are not evidence either — their selection is + * their own conversation, not this backend's. */ +export function selectPanelThread(threads, meta, { scopeKey = LEGACY_PANEL_SCOPE } = {}) { const candidates = [...(Array.isArray(threads) ? threads : [])] .sort((a, b) => finiteTs(b?.updatedAt || b?.ts) - finiteTs(a?.updatedAt || a?.ts)); - const activeId = meta?.activeByScope?.["panel:global"]; - if (!activeId && meta?.activeOps?.["panel:global"]?.deleted === true) return null; - return candidates.find((thread) => thread?.id === activeId) || candidates[0] || null; + const pointer = resolvePanelPointer(meta, scopeKey); + if (pointer.cleared && pointer.activeId == null) return null; + // THE UPGRADE FORK (see threadClaimableByBackend). Evidence written under THIS + // backend's own key is per-backend by construction and needs no gate. Every + // other route into a thread here is SHARED across backends and would hand the + // same id to all of them: + // - the legacy `panel:global` pointer (pointer.key !== scopeKey), + // - the no-pointer recency fallback below, + // - the retired workflow-scoped selection ops, which were never per-backend. + const backend = backendOfScopeKey(scopeKey); + const claimable = (thread) => threadClaimableByBackend(thread, backend); + const pointerIsOwn = pointer.key === scopeKey; + const pointedRaw = candidates.find((thread) => thread?.id === pointer.activeId) || null; + // A pointer this backend wrote itself names its own conversation whatever the + // provider stamp says (a thread legitimately changes provider when the user + // switches backends while it is open); only the shared routes are forked. + const pointed = pointedRaw && (pointerIsOwn || claimable(pointedRaw)) ? pointedRaw : null; + // The recency fallback is ALWAYS forked for a backend scope — including when + // this backend's own pointer named a thread that has since been deleted, or + // it would grab whatever another backend most recently used. + if (!pointed) return candidates.find(claimable) || null; + let latest = { revision: pointer.revision, threadId: pointed.id }; + for (const [key, operation] of Object.entries(meta?.activeOps || {})) { + // Only RETIRED-mode (workflow/path/tmp scoped) selection ops compete; every + // panel:* key is either this pointer or another backend's conversation. + if (typeof key !== "string" || key.startsWith("panel:")) continue; + if (!operation || operation.deleted === true || operation.value == null) continue; + const target = candidates.find((thread) => thread?.id === operation.value && claimable(thread)); + if (!target) continue; + const revision = operationRevision(operation); + if (compareRevisions(revision, latest.revision) > 0) { + latest = { revision, threadId: target.id }; + } + } + return candidates.find((thread) => thread.id === latest.threadId) || pointed; } -/** Choose the durable conversation for reload without replacing a conversation - * already selected by this browser tab. In per-workflow mode that preferred - * pointer is still subject to the strict workflow scope guard. */ +/** Choose the durable conversation for reload. Panel-owned (the only shipping + * mode since mcp#884): the SHARED per-backend selection is authoritative for + * every tab — honoring a tab-local preference over it would let a reloading + * tab render a conversation the backend's single session (mcp#897) is no + * longer in. `scopeKey` is the backend selection key ("panel:backend:"); + * the tab pointer only bridges legacy snapshots that predate the shared + * pointer, where nothing else records what this tab had open. In per-workflow + * mode the preferred pointer is still subject to the strict scope guard. */ export function selectRestoreThread( threads, meta, @@ -652,10 +792,23 @@ export function selectRestoreThread( const preferred = preferredThreadId ? (Array.isArray(threads) ? threads : []).find((candidate) => candidate?.id === preferredThreadId) : null; - if (preferred && (panelOwned || isThreadInScope(preferred, scopeKey))) return preferred; - return panelOwned - ? selectPanelThread(threads, meta) - : selectThreadForScope(threads, meta, scopeKey); + if (panelOwned) { + const panelScope = scopeKey || LEGACY_PANEL_SCOPE; + const pointer = resolvePanelPointer(meta, panelScope); + const pointerResolves = pointer.activeId != null && + (Array.isArray(threads) ? threads : []).some((candidate) => candidate?.id === pointer.activeId); + const deliberateClear = pointer.cleared && pointer.activeId == null; + // A DANGLING pointer (names a thread that no longer exists — eviction race, + // partial merge) carries no information about which conversation the + // backend session is in; the tab that was just using one is better + // evidence there. + if (pointerResolves || deliberateClear) { + return selectPanelThread(threads, meta, { scopeKey: panelScope }); + } + return preferred || selectPanelThread(threads, meta, { scopeKey: panelScope }); + } + if (preferred && isThreadInScope(preferred, scopeKey)) return preferred; + return selectThreadForScope(threads, meta, scopeKey); } /** Apply a strict recency cap without evicting conversations that are still