From a5020b0b69ca19edb91c193385dd407285be8d78 Mon Sep 17 00:00:00 2001 From: Nathaniel Parson Koroso Date: Thu, 9 Jul 2026 13:41:18 -0700 Subject: [PATCH] feat(agent): upload the canvas with sends to seed the server draft postMessage now carries the serialized active graph when it changed since the last upload (2MB cap, re-upload after failed sends or thread switches), per the agreed cloud contract; current servers ignore the field. The ack binds the uploaded tab to the thread's workflow id, so agent patches apply in place on the user's own tab. Empty drafts park everywhere, and the conflict dialog still guards modified tabs. --- .../extensions/agent/AgentPanelRoot.test.ts | 156 ++++++++++++++++-- .../extensions/agent/AgentPanelRoot.vue | 65 ++++++-- .../composables/agent/useAgentSession.test.ts | 23 +++ .../composables/agent/useAgentSession.ts | 20 ++- .../agent/services/agent/agentRestClient.ts | 10 ++ 5 files changed, 242 insertions(+), 32 deletions(-) diff --git a/src/workbench/extensions/agent/AgentPanelRoot.test.ts b/src/workbench/extensions/agent/AgentPanelRoot.test.ts index 7b8578bdcf..16be4361ef 100644 --- a/src/workbench/extensions/agent/AgentPanelRoot.test.ts +++ b/src/workbench/extensions/agent/AgentPanelRoot.test.ts @@ -46,11 +46,17 @@ vi.mock('@/scripts/api', () => { } }) -const appMock = vi.hoisted(() => ({ - loadGraphData: vi.fn(), - graph: { nodes: [] as unknown[] }, - canvas: undefined as { graph: { nodes: unknown[] } } | undefined -})) +const appMock = vi.hoisted(() => { + const graph = { + nodes: [] as unknown[], + serialize: () => ({ version: 0.4, nodes: graph.nodes }) + } + return { + loadGraphData: vi.fn(), + graph, + canvas: undefined as { graph: { nodes: unknown[] } } | undefined + } +}) vi.mock('@/scripts/app', () => ({ app: appMock })) @@ -932,7 +938,7 @@ describe('AgentPanelRoot workflow binding', () => { }) it('retries once without the speculative id when the server rejects it', async () => { - makeTab('someone-elses-uuid') + const tab = makeTab('someone-elses-uuid') const bodies: unknown[] = [] vi.stubGlobal( 'fetch', @@ -968,8 +974,8 @@ describe('AgentPanelRoot workflow binding', () => { expect(bodies[0]).toMatchObject({ workflow_id: 'someone-elses-uuid' }) expect(bodies[1]).not.toHaveProperty('workflow_id') - // The minted id was NOT adopted onto the active tab (it is not the id we - // sent), so its first patch takes the new-tab path, not the active tab. + // The send uploaded this tab's canvas, so the minted id binds to it and + // its first patch applies IN the active tab (the draft mirrors the tab). const graph = { version: 0.4, nodes: [{ id: 9 }] } socket.emit('message', { data: JSON.stringify({ @@ -983,7 +989,7 @@ describe('AgentPanelRoot workflow binding', () => { }) }) await vi.waitFor(() => - expect(app.loadGraphData).toHaveBeenCalledWith(graph, true, true, null) + expect(app.loadGraphData).toHaveBeenCalledWith(graph, true, true, tab) ) }) @@ -1062,7 +1068,7 @@ describe('AgentPanelRoot workflow binding', () => { await screen.findByRole('button', { name: 'Stop' }) tab.isModified = true - patch(1, { version: 0.4, nodes: [] }) + patch(1, { version: 0.4, nodes: [{ id: 2 }] }) await screen.findByText(i18n.global.t('agent.conflictTitle')) // The X close is a defer, same as Cancel: nothing applies. await userEvent.click( @@ -1098,7 +1104,7 @@ describe('AgentPanelRoot workflow binding', () => { await screen.findByRole('button', { name: 'Stop' }) tab.isModified = true - patch(1, { version: 0.4, nodes: [] }) + patch(1, { version: 0.4, nodes: [{ id: 2 }] }) await screen.findByText(i18n.global.t('agent.conflictTitle')) await userEvent.click( screen.getByRole('button', { name: i18n.global.t('g.cancel') }) @@ -1174,7 +1180,7 @@ describe('AgentPanelRoot workflow binding', () => { // The user edited the tab after the turn started: no silent overwrite. tab.isModified = true - patch(1, { version: 0.4, nodes: [] }) + patch(1, { version: 0.4, nodes: [{ id: 1 }] }) expect( await screen.findByText(i18n.global.t('agent.conflictTitle')) ).toBeInTheDocument() @@ -1184,7 +1190,7 @@ describe('AgentPanelRoot workflow binding', () => { await userEvent.click( screen.getByRole('button', { name: i18n.global.t('g.cancel') }) ) - patch(2, { version: 0.4, nodes: [] }) + patch(2, { version: 0.4, nodes: [{ id: 1 }] }) await nextTick() expect(app.loadGraphData).not.toHaveBeenCalled() expect( @@ -1227,7 +1233,7 @@ describe('AgentPanelRoot workflow binding', () => { await screen.findByRole('button', { name: 'Stop' }) tab.isModified = true - patch(1, { version: 0.4, nodes: [] }) + patch(1, { version: 0.4, nodes: [{ id: 1 }] }) await screen.findByText(i18n.global.t('agent.conflictTitle')) await userEvent.click( screen.getByRole('button', { name: i18n.global.t('agent.keepMine') }) @@ -1318,8 +1324,8 @@ describe('AgentPanelRoot workflow binding', () => { }) expect(vi.mocked(app.loadGraphData).mock.calls[1][3]).toBe(tab) }) - it('parks an empty unbound draft instead of opening a blank tab', async () => { - makeTab() + it('parks an empty minted draft, then applies the first real patch to the uploading tab', async () => { + const tab = makeTab() mockMessagesEndpoint('wf-42') render(AgentPanelRoot, { global: { plugins: [i18n] } }) @@ -1334,11 +1340,12 @@ describe('AgentPanelRoot workflow binding', () => { await nextTick() expect(app.loadGraphData).not.toHaveBeenCalled() - // The first patch with real nodes opens the tab as usual. + // The send uploaded this tab's canvas, so the minted id is bound to it + // and the first patch with real nodes applies in place. const graph = { version: 0.4, nodes: [{ id: 1 }] } patch(2, graph) await vi.waitFor(() => - expect(app.loadGraphData).toHaveBeenCalledWith(graph, true, true, null) + expect(app.loadGraphData).toHaveBeenCalledWith(graph, true, true, tab) ) }) @@ -1398,4 +1405,117 @@ describe('AgentPanelRoot workflow binding', () => { selection: { node_ids: ['12'], nodes: [serialized] } }) }) + + it('uploads the canvas once per change and binds the minted id for in-place applies', async () => { + const tab = makeTab() + const bodies = mockMessagesEndpoint('wf-mint') + appMock.graph.nodes = [{ id: 1 }] + + render(AgentPanelRoot, { global: { plugins: [i18n] } }) + + await userEvent.type(screen.getByRole('textbox'), 'help me') + await userEvent.click(screen.getByRole('button', { name: 'Send' })) + await screen.findByRole('button', { name: 'Stop' }) + + // First send carries the serialized canvas; the tab is unsaved, so no id. + expect(bodies[0]).toMatchObject({ + workflow: { + content: { version: 0.4, nodes: [{ id: 1 }] }, + base_version: null + } + }) + expect(bodies[0]).not.toHaveProperty('workflow_id') + + socket.emit('message', { + data: JSON.stringify({ + type: 'agent_message_done', + data: { message_id: 'm-1', thread_id: 'th-1' } + }) + }) + await screen.findByRole('button', { name: 'Send' }) + + // Unchanged graph: the second send skips the upload. + await userEvent.type(screen.getByRole('textbox'), 'and more') + await userEvent.click(screen.getByRole('button', { name: 'Send' })) + await vi.waitFor(() => expect(bodies).toHaveLength(2)) + expect(bodies[1]).not.toHaveProperty('workflow') + + // The uploaded draft mirrors the tab, so the minted id applies in place. + const graph = { version: 0.4, nodes: [{ id: 2 }] } + socket.emit('message', { + data: JSON.stringify({ + type: 'draft_patch', + data: { + workflow_id: 'wf-mint', + base_version: 0, + version: 1, + content: graph + } + }) + }) + await vi.waitFor(() => + expect(app.loadGraphData).toHaveBeenCalledWith(graph, true, true, tab) + ) + }) + + it('parks an empty draft even for the bound tab', async () => { + const tab = makeTab('wf-42') + mockMessagesEndpoint('wf-42') + + render(AgentPanelRoot, { global: { plugins: [i18n] } }) + + await userEvent.type(screen.getByRole('textbox'), 'help me') + await userEvent.click(screen.getByRole('button', { name: 'Send' })) + await screen.findByRole('button', { name: 'Stop' }) + + // The server's initial empty draft must neither blank the bound tab nor + // raise the conflict dialog against a user-edited one. + tab.isModified = true + patch(1, { version: 0.4, nodes: [] }) + await nextTick() + await nextTick() + expect(app.loadGraphData).not.toHaveBeenCalled() + expect( + screen.queryByText(i18n.global.t('agent.conflictTitle')) + ).not.toBeInTheDocument() + }) + + it('re-uploads the canvas after a failed send', async () => { + makeTab() + appMock.graph.nodes = [{ id: 1 }] + const bodies: unknown[] = [] + let calls = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + if (!url.includes('/messages') || init?.method !== 'POST') + return new Response('{}', { status: 200 }) + calls += 1 + bodies.push(JSON.parse(String(init?.body))) + if (calls === 1) return new Response('{}', { status: 500 }) + return new Response( + JSON.stringify({ + thread_id: 'th-1', + message_id: 'm-1', + workflow_id: 'wf-mint' + }), + { status: 202, headers: { 'Content-Type': 'application/json' } } + ) + }) + ) + + render(AgentPanelRoot, { global: { plugins: [i18n] } }) + + await userEvent.type(screen.getByRole('textbox'), 'one') + await userEvent.click(screen.getByRole('button', { name: 'Send' })) + await vi.waitFor(() => expect(bodies).toHaveLength(1)) + + // The failed send consumed the guard without reaching the server; the + // retry must carry the canvas again. + await userEvent.type(screen.getByRole('textbox'), 'two') + await userEvent.click(screen.getByRole('button', { name: 'Send' })) + await vi.waitFor(() => expect(bodies).toHaveLength(2)) + expect(bodies[0]).toHaveProperty('workflow') + expect(bodies[1]).toHaveProperty('workflow') + }) }) diff --git a/src/workbench/extensions/agent/AgentPanelRoot.vue b/src/workbench/extensions/agent/AgentPanelRoot.vue index 9fb9273b3f..e16442b596 100644 --- a/src/workbench/extensions/agent/AgentPanelRoot.vue +++ b/src/workbench/extensions/agent/AgentPanelRoot.vue @@ -35,6 +35,7 @@ import { useAgentDraftStore } from './stores/agent/agentDraftStore' import { useAgentWorkflowTabBindingStore } from './stores/agent/agentWorkflowTabBindingStore' import { buildTranscriptMarkdown } from './services/agent/agentTranscript' import { createAgentRestClient } from './services/agent/agentRestClient' +import type { WorkflowUpload } from './services/agent/agentRestClient' import { createReconnectingEventSource } from './services/agent/agentEventSource' import { useAgentChatHistoryStore } from './stores/agent/agentChatHistoryStore' import { useAgentPanelStore } from './stores/agent/agentPanelStore' @@ -114,14 +115,46 @@ const activeTab = computed(() => { return active ? { name: active.filename } : null }) -// Bind only when the server confirmed the id we sent for that tab; a freshly -// minted id stays unbound so its first patch opens (and binds) its own tab. +// The turn runs against the canvas as of the send: upload the serialized +// graph when it changed since the last upload so the server can seed the +// thread's draft from it (cloud postMessage.workflow contract; servers +// without the field ignore it). Oversized graphs are skipped, matching the +// server's 413 limit. +const MAX_UPLOAD_CHARS = 2_000_000 +let lastSentGraph: string | null = null +let snapshotTabPath: string | null = null + +function takeWorkflowSnapshot(): WorkflowUpload | undefined { + const graph = app.graph?.serialize() + if (!graph) return undefined + const serialized = JSON.stringify(graph) + if (serialized === lastSentGraph || serialized.length > MAX_UPLOAD_CHARS) + return undefined + lastSentGraph = serialized + snapshotTabPath = workflowStore.activeWorkflow?.path ?? null + return { content: graph, base_version: draftStore.version } +} + +// A fresh thread gets a fresh draft on the server: re-upload on next send. +function resetSnapshotGuard(): void { + lastSentGraph = null + snapshotTabPath = null +} + +// Bind when the server confirmed the id we sent for that tab, or when the +// send uploaded this tab's canvas: the draft then mirrors the tab, so even a +// freshly minted id applies in place instead of opening a new tab. function onWorkflowAdopted( workflowId: string, - sent: WorkflowTurnContext | undefined + sent: WorkflowTurnContext | undefined, + uploaded: boolean ): void { - if (sent !== undefined && sent.id === workflowId) + if (sent !== undefined && sent.id === workflowId) { bindingStore.bind(workflowId, sent.tabPath) + return + } + if (uploaded && snapshotTabPath !== null) + bindingStore.bind(workflowId, snapshotTabPath) } const { @@ -142,7 +175,8 @@ const { events, workflow: { current: activeWorkflowTurnContext, - adopted: onWorkflowAdopted + adopted: onWorkflowAdopted, + snapshot: takeWorkflowSnapshot } }) @@ -250,6 +284,11 @@ async function applyDraft(): Promise { lastApplied.version >= version ) return + // An agent draft with no nodes is noise (the server's freshly minted + // draft starts empty): applying it would blank a bound tab or conjure an + // empty one. Park until a patch carries actual nodes. + const nodes = (content as { nodes?: unknown }).nodes + if (!Array.isArray(nodes) || nodes.length === 0) return const boundTab = boundTabFor(workflowId) if (boundTab) { if (workflowStore.activeWorkflow?.path !== boundTab.path) return @@ -260,10 +299,6 @@ async function applyDraft(): Promise { await loadDraft(workflowId, version, content, boundTab) return } - // The server's freshly minted draft starts empty; conjuring a blank tab - // for it only confuses. Park until a patch carries actual nodes. - const nodes = (content as { nodes?: unknown }).nodes - if (!Array.isArray(nodes) || nodes.length === 0) return await loadDraft(workflowId, version, content, null) } finally { applying = false @@ -359,6 +394,7 @@ watch(threadId, (id) => history.setActive(id), { immediate: true }) void refreshHistory() async function onSelectHistory(id: string): Promise { + resetSnapshotGuard() await loadThread(id) void refreshHistory() } @@ -394,7 +430,15 @@ function onSend(text: string, attachments: ComposerAttachment[]): void { // its version may never advance, so the version watch alone cannot re-drive. applySuppressed = false void applyDraft() - void sendMessage(text, attachments, tagsWithNodeData(consumeSelection())) + void sendMessage( + text, + attachments, + tagsWithNodeData(consumeSelection()) + ).then((ok) => { + // A failed send consumed the snapshot guard without reaching the + // server; drop it so the next send re-uploads. + if (!ok) resetSnapshotGuard() + }) } function onStop(): void { @@ -402,6 +446,7 @@ function onStop(): void { } function onNewChat(): void { + resetSnapshotGuard() newChat() } diff --git a/src/workbench/extensions/agent/composables/agent/useAgentSession.test.ts b/src/workbench/extensions/agent/composables/agent/useAgentSession.test.ts index 12363ed436..eef3ff272f 100644 --- a/src/workbench/extensions/agent/composables/agent/useAgentSession.test.ts +++ b/src/workbench/extensions/agent/composables/agent/useAgentSession.test.ts @@ -409,6 +409,29 @@ describe('useAgentSession (v1 composition root)', () => { expect(sightedBody.selection?.context).not.toContain('ask them to save') }) + it('(h4) attaches the workflow snapshot and reports the upload to adopted', async () => { + const rest = fakeRest() + const adopted = vi.fn() + const session = useAgentSession({ + rest, + events: fakeEvents().source, + workflow: { + current: () => undefined, + adopted, + snapshot: () => ({ + content: { nodes: [{ id: 1 }] }, + base_version: null + }) + } + }) + session.start() + await session.sendMessage('hi') + expect(vi.mocked(rest.postMessage).mock.calls[0][1]).toMatchObject({ + workflow: { content: { nodes: [{ id: 1 }] }, base_version: null } + }) + expect(adopted).toHaveBeenCalledWith('wf-1', undefined, true) + }) + it('(h3) a selection whose definitions all dropped does not claim they are included', async () => { const rest = fakeRest() const session = useAgentSession({ rest, events: fakeEvents().source }) diff --git a/src/workbench/extensions/agent/composables/agent/useAgentSession.ts b/src/workbench/extensions/agent/composables/agent/useAgentSession.ts index 595fdc8851..4e0a08caeb 100644 --- a/src/workbench/extensions/agent/composables/agent/useAgentSession.ts +++ b/src/workbench/extensions/agent/composables/agent/useAgentSession.ts @@ -4,7 +4,10 @@ import { i18n } from '@/i18n' import type { TurnId } from '../../schemas/agentApiSchema' import { isAgentEvent, parseAgentWsEvent } from '../../schemas/agentApiSchema' import { AgentApiError } from '../../services/agent/agentRestClient' -import type { AgentRestClient } from '../../services/agent/agentRestClient' +import type { + AgentRestClient, + WorkflowUpload +} from '../../services/agent/agentRestClient' import { useAgentConversationStore } from '../../stores/agent/agentConversationStore' import { useAgentDraftStore } from '../../stores/agent/agentDraftStore' @@ -76,7 +79,14 @@ export interface AgentSessionDeps { events: AgentEventSource workflow?: { current(): WorkflowTurnContext | undefined - adopted(workflowId: string, sent: WorkflowTurnContext | undefined): void + adopted( + workflowId: string, + sent: WorkflowTurnContext | undefined, + uploaded: boolean + ): void + // The canvas snapshot to seed the server draft with, or undefined when + // unchanged since the last upload. + snapshot?(): WorkflowUpload | undefined } } @@ -187,13 +197,15 @@ export function useAgentSession(deps: AgentSessionDeps) { } sending.value = true const wfContext = workflow?.current() + const upload = workflow?.snapshot?.() const input = { content: text, selection: tags !== undefined && tags.length > 0 ? selectionPayload(tags, wfContext !== undefined) : undefined, - attachments: attachments?.map((attachment) => attachment.ref) + attachments: attachments?.map((attachment) => attachment.ref), + workflow: upload } async function postTurn(threadId: string) { try { @@ -216,7 +228,7 @@ export function useAgentSession(deps: AgentSessionDeps) { localStorage.setItem(THREAD_STORAGE_KEY, ack.thread_id) if (ack.workflow_id !== undefined) { draftStore.bind(ack.workflow_id) - workflow?.adopted(ack.workflow_id, wfContext) + workflow?.adopted(ack.workflow_id, wfContext, upload !== undefined) } // The ONE branding seam: the server-minted message_id becomes the TurnId. const turnId = ack.message_id as TurnId diff --git a/src/workbench/extensions/agent/services/agent/agentRestClient.ts b/src/workbench/extensions/agent/services/agent/agentRestClient.ts index 490cb8bd9c..e2c0a87d4b 100644 --- a/src/workbench/extensions/agent/services/agent/agentRestClient.ts +++ b/src/workbench/extensions/agent/services/agent/agentRestClient.ts @@ -41,11 +41,20 @@ export interface AgentRestClientDeps { fetchImpl?: typeof fetch } +// The client's canvas, uploaded with a turn so the server seeds the thread's +// draft from it before the agent runs. base_version null force-seeds; a +// non-null value guards against clobbering an agent write (server 409s). +export interface WorkflowUpload { + content: unknown + base_version: number | null +} + export interface PostMessageInput { content: string workflowId?: string selection?: Record attachments?: string[] + workflow?: WorkflowUpload } interface IngestErrorBody { @@ -129,6 +138,7 @@ export function createAgentRestClient(deps: AgentRestClientDeps) { if (req.workflowId !== undefined) body.workflow_id = req.workflowId if (req.selection !== undefined) body.selection = req.selection if (req.attachments !== undefined) body.attachments = req.attachments + if (req.workflow !== undefined) body.workflow = req.workflow return request( `/api/agent/threads/${threadId}/messages`, await jsonInit('POST', body),