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),