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.
This commit is contained in:
Nathaniel Parson Koroso
2026-07-09 13:41:18 -07:00
parent c4fc77b514
commit a5020b0b69
5 changed files with 242 additions and 32 deletions

View File

@@ -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')
})
})

View File

@@ -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<ActiveTab | null>(() => {
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<void> {
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<void> {
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<void> {
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()
}

View File

@@ -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 })

View File

@@ -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

View File

@@ -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<string, unknown>
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),