diff --git a/apps/server/src/modules/agent/acp/external-agent-realization.test.ts b/apps/server/src/modules/agent/acp/external-agent-realization.test.ts index d8c89e872..179ba2690 100644 --- a/apps/server/src/modules/agent/acp/external-agent-realization.test.ts +++ b/apps/server/src/modules/agent/acp/external-agent-realization.test.ts @@ -11,6 +11,7 @@ vi.mock('../../workspace/paths.js', () => ({ })); import { ExternalAgentRealizationService } from './external-agent-realization.js'; +import { AgentNodeBindingCoordinator } from '../agent-node-binding.js'; import type { ExternalAgentRealizationError } from './external-agent-realization.js'; import type { AcpHandle, AcpWorkloadSpec } from '../agenetes/drivers.js'; @@ -54,6 +55,7 @@ const selectableTarget: AgentNodeTarget = { }; function createHarness(options?: { + bindingCoordinator?: boolean; agentTarget?: AgentNodeTarget | null; record?: ThreadRecord; collect?: () => Promise<{ @@ -73,7 +75,24 @@ function createHarness(options?: { const handle = { control: vi.fn().mockResolvedValue({ ok: true }), } as unknown as AcpHandle; - const createHandle = vi.fn(() => handle); + let durableRecord = options?.record; + const createHandle = vi.fn((spec: AcpWorkloadSpec) => { + durableRecord ??= { + spec, + driverSchemaVersion: 1, + state: { driverState: {} }, + }; + return handle; + }); + const promote = vi.fn().mockResolvedValue(undefined); + const release = vi.fn(); + const acquireTurn = vi.fn((): (() => void) | null => release); + const binding = new AgentNodeBindingCoordinator({ + record: () => durableRecord, + hasHistory: () => false, + promote, + acquireTurn, + }); const subscribeTitles = vi.fn(); const buildSpec = vi.fn( ({ @@ -145,12 +164,19 @@ function createHarness(options?: { ), resolveFixedAgentNode: vi.fn().mockResolvedValue(target), collectSpacePrompt, - readRecord: vi.fn(() => options?.record), + readRecord: vi.fn(() => durableRecord), createHandle, buildSpec, subscribeProfileCache: vi.fn(), subscribeTitles, ensureSession, + ...(options?.bindingCoordinator + ? { + confirmBinding: (...args: Parameters) => + binding.confirm(...args), + acquireTurn, + } + : {}), }); return { service, @@ -160,10 +186,135 @@ function createHarness(options?: { buildSpec, collectSpacePrompt, ensureSession, + promote, + acquireTurn, + release, + record: () => durableRecord, }; } describe('ExternalAgentRealizationService', () => { + it('rejects a persisted internal node before opening its mismatched external execution', async () => { + const node = { + ...target, + agentBinding: { kind: 'internal' as const }, + bindingState: 'editing' as const, + }; + const h = createHarness({ + bindingCoordinator: true, + agentTarget: node, + record: { + spec: { + threadId: target.threadId, + namespace: { name: target.canvasId }, + kind: 'external', + workloadType: 'Deployment', + spec: { binding: targetBinding }, + }, + driverSchemaVersion: 1, + state: { driverState: {} }, + } as ThreadRecord, + }); + await expect( + h.service.realize({ + canvasId: node.canvasId, + threadId: node.threadId, + agentTarget: node, + fixedTarget: null, + logger, + }), + ).rejects.toMatchObject({ code: 'agent_binding_conflict' }); + expect(h.promote).toHaveBeenCalledOnce(); + expect(h.createHandle).not.toHaveBeenCalled(); + expect(h.ensureSession).not.toHaveBeenCalled(); + }); + + it('binds a first control before opening the session without a prompt projection', async () => { + const node = { ...target, bindingState: 'editing' as const }; + const h = createHarness({ bindingCoordinator: true, agentTarget: node }); + h.promote.mockImplementation(async () => { + expect(h.record()?.spec.kind).toBe('external'); + expect(h.ensureSession).not.toHaveBeenCalled(); + expect(h.handle.control).not.toHaveBeenCalled(); + }); + const realized = await h.service.realize({ + canvasId: node.canvasId, + threadId: node.threadId, + fixedTarget: node, + requestedBinding: targetBinding, + logger, + }); + expect(h.promote).toHaveBeenCalledOnce(); + expect(node).not.toHaveProperty('invocationToken'); + expect(node.status).toBe('idle'); + expect(node.content).toBe(''); + expect(h.release).toHaveBeenCalledOnce(); + await h.service.ensureSession(realized, logger); + }); + + it('keeps canonical execution after promotion fails and completes it on retry', async () => { + const node = { ...target, bindingState: 'editing' as const }; + const h = createHarness({ bindingCoordinator: true, agentTarget: node }); + h.promote.mockRejectedValueOnce(new Error('Bound persistence failed')); + const options = { + canvasId: node.canvasId, + threadId: node.threadId, + fixedTarget: node, + requestedBinding: targetBinding, + logger, + }; + await expect(h.service.realize(options)).rejects.toThrow( + 'Bound persistence failed', + ); + expect(h.record()).toBeDefined(); + expect(node.bindingState).toBe('editing'); + expect(h.ensureSession).not.toHaveBeenCalled(); + await h.service.realize(options); + expect(node.bindingState).toBe('bound'); + expect(h.promote).toHaveBeenCalledTimes(2); + expect(h.buildSpec).toHaveBeenCalledOnce(); + }); + + it('refuses first control realization while another operation owns admission', async () => { + const h = createHarness({ bindingCoordinator: true }); + h.acquireTurn.mockReturnValueOnce(null); + await expect( + h.service.realize({ + canvasId: 'canvas-1', + threadId: 'thread-1', + fixedTarget: target, + requestedBinding: targetBinding, + logger, + }), + ).rejects.toMatchObject({ code: 'agent_draft_busy' }); + expect(h.createHandle).not.toHaveBeenCalled(); + }); + + it('does not create a delayed execution after preparation is cancelled', async () => { + const controller = new AbortController(); + const h = createHarness({ + bindingCoordinator: true, + agentTarget: { ...target }, + collect: async () => { + controller.abort(); + return null; + }, + }); + await expect( + h.service.realize({ + canvasId: 'canvas-1', + threadId: 'thread-1', + fixedTarget: { ...target }, + requestedBinding: targetBinding, + signal: controller.signal, + logger, + }), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(h.createHandle).not.toHaveBeenCalled(); + expect(h.promote).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + it('realizes first control with the fixed Space Prompt and node instructions', async () => { const harness = createHarness(); const realized = await harness.service.realize({ diff --git a/apps/server/src/modules/agent/acp/external-agent-realization.ts b/apps/server/src/modules/agent/acp/external-agent-realization.ts index 99e8b5cb8..538044da9 100644 --- a/apps/server/src/modules/agent/acp/external-agent-realization.ts +++ b/apps/server/src/modules/agent/acp/external-agent-realization.ts @@ -15,6 +15,10 @@ import { type AcpHandle, type AcpWorkloadSpec, } from '../agenetes/drivers.js'; +import { + agentNodeBinding, + AgentNodeBindingError, +} from '../agent-node-binding.js'; import { agentThreadResolver, type AgentNodeTarget, @@ -22,6 +26,7 @@ import { } from '../agent-thread-resolver.js'; import { conversationTitleService } from '../conversation-title.service.js'; import { resolveSpacePrompt } from '../space-instruction-frames.js'; +import { acquireAgentTurn } from '../turn-lease.js'; import { ensureProfileCacheSubscription } from './profile-cache-port.js'; import { getExternalAgentRuntimeConfig } from './runtime-config.js'; import { buildAcpWorkloadSpec } from './service.js'; @@ -57,6 +62,9 @@ export interface RealizeExternalAgentThreadOptions { agentTarget?: AgentNodeTarget | null; fixedTarget?: FixedAgentNodeTarget | null; logger: FastifyBaseLogger; + signal?: AbortSignal; + /** The prompt coordinator already owns admission across preparation. */ + turnLeaseHeld?: boolean; } export interface RealizedExternalAgentThread { @@ -64,6 +72,7 @@ export interface RealizedExternalAgentThread { fixedTarget: FixedAgentNodeTarget | null; spec: AcpWorkloadSpec; handle: AcpHandle; + agentTarget?: AgentNodeTarget | null; } interface RealizationDependencies { @@ -88,6 +97,8 @@ interface RealizationDependencies { realized: RealizedExternalAgentThread, logger: FastifyBaseLogger, ) => Promise; + confirmBinding?: typeof agentNodeBinding.confirm; + acquireTurn?: typeof acquireAgentTurn; } function bindingFromSpec(spec: AcpWorkloadSpec): ExternalBinding { @@ -144,6 +155,8 @@ const DEFAULT_DEPENDENCIES: RealizationDependencies = { subscribeTitles: (canvasId, threadId) => conversationTitleService.subscribe(canvasId, threadId), ensureSession: ensureSessionFromCanonicalSpec, + confirmBinding: (...args) => agentNodeBinding.confirm(...args), + acquireTurn: acquireAgentTurn, }; export class ExternalAgentRealizationService { @@ -185,6 +198,29 @@ export class ExternalAgentRealizationService { private async realizeOnce( options: RealizeExternalAgentThreadOptions, namespace: Namespace, + ): Promise { + const record = this.dependencies.readRecord(namespace, options.threadId); + const release = + !record && !options.turnLeaseHeld && this.dependencies.acquireTurn + ? this.dependencies.acquireTurn(options.threadId) + : undefined; + if (release === null) { + throw new AgentNodeBindingError( + 'agent_draft_busy', + `Thread ${options.threadId} is preparing or running`, + ); + } + try { + return await this.realizeAdmitted(options, namespace, record); + } finally { + release?.(); + } + } + + private async realizeAdmitted( + options: RealizeExternalAgentThreadOptions, + namespace: Namespace, + record: ReturnType, ): Promise { const fixedTarget = options.fixedTarget === undefined @@ -205,7 +241,10 @@ export class ExternalAgentRealizationService { ) : null)) : options.agentTarget; - const record = this.dependencies.readRecord(namespace, options.threadId); + if (agentTarget) + await this.dependencies.confirmBinding?.(agentTarget, { + record: record ?? null, + }); if (record) { if (record.spec.kind !== EXTERNAL_DRIVER_KIND) { @@ -219,6 +258,7 @@ export class ExternalAgentRealizationService { const realized = { binding, fixedTarget, + agentTarget, spec, handle: this.dependencies.createHandle(spec), }; @@ -235,7 +275,10 @@ export class ExternalAgentRealizationService { return realized; } - const binding = fixedTarget?.agentBinding ?? options.requestedBinding; + const binding = + agentTarget?.agentBinding ?? + fixedTarget?.agentBinding ?? + options.requestedBinding; if (!binding || binding.kind !== 'external') { throw new ExternalAgentRealizationError( 'external_binding_required', @@ -244,22 +287,24 @@ export class ExternalAgentRealizationService { } if ( - fixedTarget && + agentTarget && options.requestedBinding && options.requestedBinding.profileId !== binding.profileId ) { throw new ExternalAgentRealizationError( 'external_binding_conflict', - `Thread ${options.threadId} is fixed to Profile ${binding.profileId}`, + `Thread ${options.threadId} is configured for Profile ${binding.profileId}`, ); } + options.signal?.throwIfAborted(); const collected = agentTarget ? await this.dependencies.collectSpacePrompt( agentTarget.canvasId, agentTarget.nodeId, ) : null; + options.signal?.throwIfAborted(); if ( collected && (collected.diagnostics.truncated || @@ -282,14 +327,17 @@ export class ExternalAgentRealizationService { binding, threadId: options.threadId, canvasId: options.canvasId, - cwd: fixedTarget ? undefined : options.requestedCwd, - ...(fixedTarget?.launchOverrides - ? { launchOverrides: fixedTarget.launchOverrides } + cwd: agentTarget ? undefined : options.requestedCwd, + ...((agentTarget?.launchOverrides ?? fixedTarget?.launchOverrides) + ? { + launchOverrides: + agentTarget?.launchOverrides ?? fixedTarget?.launchOverrides, + } : {}), spacePrompt: collected?.markdown, }); if ( - fixedTarget && + agentTarget && options.requestedCwd !== undefined && options.requestedCwd !== spec.spec.cwd ) { @@ -302,9 +350,12 @@ export class ExternalAgentRealizationService { const realized = { binding, fixedTarget, + agentTarget, spec, handle: this.dependencies.createHandle(spec), }; + if (agentTarget) + await this.dependencies.confirmBinding?.(agentTarget, { required: true }); this.dependencies.subscribeProfileCache( options.threadId, binding.profileId, @@ -322,7 +373,8 @@ export class ExternalAgentRealizationService { realized: RealizedExternalAgentThread, options: RealizeExternalAgentThreadOptions, ): void { - const fixedBinding = realized.fixedTarget?.agentBinding; + const fixedBinding = + realized.agentTarget?.agentBinding ?? realized.fixedTarget?.agentBinding; if ( fixedBinding && (fixedBinding.kind !== 'external' || @@ -333,7 +385,10 @@ export class ExternalAgentRealizationService { `Fixed Agent Node for thread ${options.threadId} does not match its realized Profile`, ); } - const fixedCwd = realized.fixedTarget?.launchOverrides?.workingDirPath; + const fixedCwd = ( + realized.agentTarget?.launchOverrides ?? + realized.fixedTarget?.launchOverrides + )?.workingDirPath; if (fixedCwd !== undefined && fixedCwd !== realized.spec.spec.cwd) { throw new ExternalAgentRealizationError( 'external_working_directory_conflict', @@ -371,6 +426,9 @@ export function realizationHttpError(error: unknown): { body: { message: error.message, code: error.code }, }; } + if (error instanceof AgentNodeBindingError) { + return { status: 409, body: { message: error.message, code: error.code } }; + } const message = error instanceof Error ? error.message : String(error); return { status: 503, diff --git a/apps/server/src/modules/agent/agent-node-binding.test.ts b/apps/server/src/modules/agent/agent-node-binding.test.ts new file mode 100644 index 000000000..8582c6460 --- /dev/null +++ b/apps/server/src/modules/agent/agent-node-binding.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it, vi } from 'vitest'; + +import { AgentNodeBindingCoordinator } from './agent-node-binding.js'; + +import type { AgentNodeTarget } from './agent-thread-resolver.js'; +import type { ThreadRecord } from '@agenetes/agenetes'; + +function target(): AgentNodeTarget { + return { + canvasId: 'canvas-a', + nodeId: 'node-agent', + threadId: 'thread-a', + agentBinding: { kind: 'internal' }, + bindingState: 'editing', + }; +} + +function record(kind = 'internal', workloadType = 'Deployment'): ThreadRecord { + return { + spec: { + threadId: 'thread-a', + namespace: { name: 'canvas-a' }, + kind, + workloadType, + spec: + kind === 'external' + ? { binding: { profileId: 'profile-a', alias: 'Agent' } } + : {}, + }, + state: { driverState: {} }, + driverSchemaVersion: 1, + } as ThreadRecord; +} + +function harness(existing?: ThreadRecord, history = false) { + const readRecord = vi.fn(() => existing); + const promote = vi.fn().mockResolvedValue(undefined); + const release = vi.fn(); + const acquireTurn = vi.fn((): (() => void) | null => release); + const coordinator = new AgentNodeBindingCoordinator({ + record: readRecord, + hasHistory: () => history, + promote, + acquireTurn, + }); + return { coordinator, readRecord, promote, release, acquireTurn }; +} + +describe('AgentNodeBindingCoordinator', () => { + it('leaves a genuinely fresh draft Editing without creating execution', async () => { + const h = harness(); + const node = target(); + expect(await h.coordinator.confirm(node)).toBeNull(); + expect(node.bindingState).toBe('editing'); + expect(h.promote).not.toHaveBeenCalled(); + }); + + it.each(['Deployment', 'Job'])( + 'confirms a canonical %s record monotonically', + async (workloadType) => { + const h = harness(record('internal', workloadType)); + const node = target(); + expect(await h.coordinator.confirm(node)).toEqual({ kind: 'internal' }); + expect(node.bindingState).toBe('bound'); + expect(h.promote).toHaveBeenCalledOnce(); + await h.coordinator.confirm(node); + expect(h.promote).toHaveBeenCalledOnce(); + }, + ); + + it('completes a failed promotion on the next explicit guarded operation', async () => { + const h = harness(record()); + const node = target(); + h.promote.mockRejectedValueOnce(new Error('Canvas unavailable')); + await expect(h.coordinator.confirm(node)).rejects.toThrow( + 'Canvas unavailable', + ); + expect(node.bindingState).toBe('editing'); + await expect( + h.coordinator.guardDraftEdit(node, { + agentBinding: { + kind: 'external', + profileId: 'different', + alias: 'Other', + }, + }), + ).rejects.toMatchObject({ code: 'agent_binding_conflict' }); + expect(node.bindingState).toBe('bound'); + expect(h.promote).toHaveBeenLastCalledWith(node, true); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it('rejects Bound preparation edits without another ThreadStore lookup', async () => { + const h = harness(); + const node = { ...target(), bindingState: 'bound' as const }; + await expect( + h.coordinator.guardDraftEdit(node, { + agentLaunchOverrides: { additionalInitialPreamble: 'Changed' }, + }), + ).rejects.toMatchObject({ code: 'agent_binding_conflict' }); + expect(h.readRecord).not.toHaveBeenCalled(); + }); + + it.each(['editing', undefined] as const)( + 'confirms unchanged preparation on %s nodes and retains admission', + async (bindingState) => { + const h = harness(record()); + const node = { ...target(), bindingState }; + const release = await h.coordinator.guardDraftEdit(node, { + agentBinding: { kind: 'internal' }, + agentLaunchOverrides: null, + }); + expect(node.bindingState).toBe('bound'); + expect(h.promote).toHaveBeenCalledWith(node, true); + expect(h.readRecord).toHaveBeenCalledOnce(); + expect(h.release).not.toHaveBeenCalled(); + release(); + expect(h.release).toHaveBeenCalledOnce(); + }, + ); + + it('retries failed promotion when Save resubmits unchanged preparation', async () => { + const h = harness(record()); + const node = target(); + h.promote.mockRejectedValueOnce(new Error('Write failed')); + const patch = { agentBinding: { kind: 'internal' } }; + await expect(h.coordinator.guardDraftEdit(node, patch)).rejects.toThrow( + 'Write failed', + ); + expect(node.bindingState).toBe('editing'); + const release = await h.coordinator.guardDraftEdit(node, patch); + expect(node.bindingState).toBe('bound'); + expect(h.promote).toHaveBeenCalledTimes(2); + release(); + expect(h.release).toHaveBeenCalledTimes(2); + }); + + it('skips canonical lookup and admission for unchanged Bound preparation', async () => { + const h = harness(); + await h.coordinator.guardDraftEdit( + { ...target(), bindingState: 'bound' }, + { + agentBinding: { kind: 'internal' }, + agentLaunchOverrides: {}, + }, + ); + expect(h.readRecord).not.toHaveBeenCalled(); + expect(h.acquireTurn).not.toHaveBeenCalled(); + }); + + it.each([ + { existing: record('external'), binding: { kind: 'internal' } as const }, + { + existing: record(), + binding: { + kind: 'external', + profileId: 'profile-a', + alias: 'Agent', + } as const, + }, + { + existing: record('external'), + binding: { + kind: 'external', + profileId: 'other', + alias: 'Agent', + } as const, + }, + ])( + 'promotes canonical existence but rejects conflicting persisted identity %#', + async ({ existing, binding }) => { + const h = harness(existing); + const node = { ...target(), agentBinding: binding }; + await expect(h.coordinator.confirm(node)).rejects.toMatchObject({ + code: 'agent_binding_conflict', + }); + expect(node.bindingState).toBe('bound'); + expect(h.promote).toHaveBeenCalledOnce(); + }, + ); + + it('never resets Bound when its execution record is missing', async () => { + const h = harness(); + const node = { ...target(), bindingState: 'bound' as const }; + await expect(h.coordinator.confirm(node)).rejects.toMatchObject({ + code: 'execution_record_missing', + }); + expect(node.bindingState).toBe('bound'); + expect(h.promote).not.toHaveBeenCalled(); + }); + + it('does not rebind legacy history without its canonical record', async () => { + const h = harness(undefined, true); + await expect(h.coordinator.confirm(target())).rejects.toMatchObject({ + code: 'execution_record_missing', + }); + }); + + it('allows a failed-preparation token with no execution history to retry', async () => { + const h = harness(); + expect( + await h.coordinator.confirm({ + ...target(), + invocationToken: 'failed-preparation', + }), + ).toBeNull(); + }); + + it.each([ + { ...record(), spec: { ...record().spec, threadId: 'other' } }, + { ...record(), spec: { ...record().spec, namespace: { name: 'other' } } }, + record('unknown'), + { + ...record('external'), + spec: { ...record('external').spec, spec: { binding: {} } }, + }, + ])('rejects invalid canonical identity %#', async (existing) => { + const h = harness(existing as ThreadRecord); + await expect(h.coordinator.confirm(target())).rejects.toMatchObject({ + code: 'execution_record_invalid', + }); + expect(h.promote).not.toHaveBeenCalled(); + }); + + it('retains nonblocking draft admission through the actual Canvas write', async () => { + const h = harness(); + const release = await h.coordinator.guardDraftEdit(target(), { + agentBinding: { kind: 'external', profileId: 'new', alias: 'New' }, + }); + expect(h.acquireTurn).toHaveBeenCalledWith('thread-a'); + expect(h.release).not.toHaveBeenCalled(); + release(); + expect(h.release).toHaveBeenCalledOnce(); + h.acquireTurn.mockReturnValueOnce(null); + await expect( + h.coordinator.guardDraftEdit(target(), { + agentLaunchOverrides: { additionalInitialPreamble: 'New' }, + }), + ).rejects.toMatchObject({ code: 'agent_draft_busy' }); + }); + + it('allows unchanged preparation echoed by a layout save while admission owns confirmation', async () => { + const h = harness(); + h.acquireTurn.mockReturnValueOnce(null); + await h.coordinator.guardDraftEdit(target(), { + agentBinding: { kind: 'internal' }, + label: 'Updated', + }); + expect(h.readRecord).not.toHaveBeenCalled(); + expect(h.promote).not.toHaveBeenCalled(); + }); + + it('keeps ask/operate and display metadata outside execution identity', async () => { + const h = harness(record()); + await h.coordinator.guardDraftEdit( + { ...target(), bindingState: 'bound' }, + { agentMode: 'operate', label: 'Updated' }, + ); + expect(h.acquireTurn).not.toHaveBeenCalled(); + expect(h.readRecord).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/agent/agent-node-binding.ts b/apps/server/src/modules/agent/agent-node-binding.ts new file mode 100644 index 000000000..2fbec6b84 --- /dev/null +++ b/apps/server/src/modules/agent/agent-node-binding.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { agentBindingSchema } from '@huabu/shared'; +import { + AGENT_NODE_PREPARATION_KEYS, + changesAgentNodePreparation, +} from '@huabu/shared/canvas-engine'; + +import { + agenetes, + EXTERNAL_DRIVER_KIND, + INTERNAL_DRIVER_KIND, +} from './agenetes/drivers.js'; +import { agentNodeLifecycle } from './agent-node-lifecycle.js'; +import { acquireAgentTurn } from './turn-lease.js'; +import { canvasAcpNamespace } from '../workspace/paths.js'; + +import type { AgentNodeTarget } from './agent-thread-resolver.js'; +import type { AgentBinding } from '@huabu/shared'; + +type ExecutionRecord = NonNullable>; + +interface BindingDependencies { + record: (target: AgentNodeTarget) => ExecutionRecord | undefined; + hasHistory: (target: AgentNodeTarget) => boolean; + promote: (target: AgentNodeTarget, alreadyLocked?: boolean) => Promise; + acquireTurn: typeof acquireAgentTurn; +} + +export class AgentNodeBindingError extends Error { + constructor( + public readonly code: + | 'execution_record_missing' + | 'execution_record_invalid' + | 'agent_binding_conflict' + | 'agent_draft_busy', + message: string, + ) { + super(message); + this.name = 'AgentNodeBindingError'; + } +} + +export function sameAgentIdentity( + left: AgentBinding, + right: AgentBinding, +): boolean { + return ( + left.kind === right.kind && + (left.kind !== 'external' || + (right.kind === 'external' && left.profileId === right.profileId)) + ); +} + +const DEFAULT_DEPENDENCIES: BindingDependencies = { + record: (target) => + agenetes.record(canvasAcpNamespace(target.canvasId), target.threadId), + hasHistory: (target) => + agenetes.history(canvasAcpNamespace(target.canvasId), target.threadId, { + withTail: true, + }).turns.length > 0, + promote: (target, alreadyLocked) => + agentNodeLifecycle.bind(target, alreadyLocked), + acquireTurn: acquireAgentTurn, +}; + +/** Canonical record confirmation, shared by prompts, controls and draft writes. */ +export class AgentNodeBindingCoordinator { + constructor( + private readonly dependencies: BindingDependencies = DEFAULT_DEPENDENCIES, + ) {} + + async confirm( + target: AgentNodeTarget, + options: { + required?: boolean; + alreadyLocked?: boolean; + record?: ExecutionRecord | null; + } = {}, + ): Promise { + const record = + options.record === undefined + ? this.dependencies.record(target) + : options.record; + if (!record) { + if ( + options.required || + target.bindingState === 'bound' || + this.dependencies.hasHistory(target) + ) { + throw new AgentNodeBindingError( + 'execution_record_missing', + `Thread ${target.threadId} has no canonical execution record`, + ); + } + return null; + } + const spec = record.spec; + if ( + spec.threadId !== target.threadId || + spec.namespace.name !== target.canvasId + ) { + throw new AgentNodeBindingError( + 'execution_record_invalid', + `Thread ${target.threadId} has a mismatched execution record`, + ); + } + let binding: AgentBinding; + if (spec.kind === INTERNAL_DRIVER_KIND) { + binding = { kind: 'internal' }; + } else if (spec.kind === EXTERNAL_DRIVER_KIND) { + const driverSpec = spec.spec as { binding?: Record }; + const parsed = agentBindingSchema.safeParse({ + ...driverSpec.binding, + kind: 'external', + }); + if (!parsed.success) { + throw new AgentNodeBindingError( + 'execution_record_invalid', + `Thread ${target.threadId} has an invalid external execution binding`, + ); + } + binding = parsed.data; + } else { + throw new AgentNodeBindingError( + 'execution_record_invalid', + `Thread ${target.threadId} uses an unsupported driver`, + ); + } + if (target.bindingState !== 'bound') { + await this.dependencies.promote(target, options.alreadyLocked); + target.bindingState = 'bound'; + } + this.assertRequestedBinding(binding, target.agentBinding); + return binding; + } + + assertRequestedBinding(saved: AgentBinding, requested?: AgentBinding): void { + if (requested && !sameAgentIdentity(saved, requested)) { + throw new AgentNodeBindingError( + 'agent_binding_conflict', + 'The requested Agent does not match the acknowledged execution binding', + ); + } + } + + /** + * Called with the Canvas mutex held. Never wait for a turn here: move and + * prompt admission acquire these same resources in opposite order. + * Keep the returned lease until the draft has actually been persisted. + */ + async guardDraftEdit( + target: AgentNodeTarget, + patch: Record, + ): Promise<() => void> { + if ( + !AGENT_NODE_PREPARATION_KEYS.some((key) => + Object.prototype.hasOwnProperty.call(patch, key), + ) + ) + return () => {}; + const changed = changesAgentNodePreparation( + { + agentBinding: target.agentBinding, + agentLaunchOverrides: target.launchOverrides, + }, + patch, + ); + if (target.bindingState === 'bound') { + if (changed) + throw new AgentNodeBindingError( + 'agent_binding_conflict', + 'Execution preparation cannot change after binding', + ); + return () => {}; + } + const release = this.dependencies.acquireTurn(target.threadId); + if (!release) { + // A layout save can echo unchanged preparation while an admitted turn + // owns confirmation. It cannot replace that turn's configuration. + if (!changed) return () => {}; + throw new AgentNodeBindingError( + 'agent_draft_busy', + `Thread ${target.threadId} is preparing or running`, + ); + } + try { + const canonical = await this.confirm(target, { alreadyLocked: true }); + if (canonical && changed) { + throw new AgentNodeBindingError( + 'agent_binding_conflict', + 'Execution preparation cannot change after binding', + ); + } + return release; + } catch (error) { + release(); + throw error; + } + } +} + +export const agentNodeBinding = new AgentNodeBindingCoordinator(); diff --git a/apps/server/src/modules/agent/agent-node-fsm.integration.test.ts b/apps/server/src/modules/agent/agent-node-fsm.integration.test.ts new file mode 100644 index 000000000..8fb08e922 --- /dev/null +++ b/apps/server/src/modules/agent/agent-node-fsm.integration.test.ts @@ -0,0 +1,446 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import multipart from '@fastify/multipart'; +import fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createId } from '@huabu/shared'; + +import { agenetes } from './agenetes/drivers.js'; +import { buildHuabuPiWorkloadSpec } from './agenetes/pi-driver.js'; +import { + agentNodeBinding, + AgentNodeBindingCoordinator, +} from './agent-node-binding.js'; +import { agentNodeLifecycle } from './agent-node-lifecycle.js'; +import { agentThreadResolver } from './agent-thread-resolver.js'; +import { acquireAgentTurn } from './turn-lease.js'; +import { + applyDeltasOnServerAlreadyLocked, + executeOnServer, +} from '../canvas/canvas-executor.js'; +import canvasRoutes from '../canvas/canvas.route.js'; +import { createSpace, space, withCanvasMutex } from '../storage/index.js'; +import { + forEachProductProfile, + mountTestWorkspace, + type MountedTestStorage, +} from '../storage/testing.js'; +import { canvasAcpNamespace } from '../workspace/paths.js'; + +import type { AgentNodeTarget } from './agent-thread-resolver.js'; +import type { CanvasNode } from '@huabu/shared/canvas-engine'; + +forEachProductProfile((profile, label) => { + describe(`Agent Node runtime persistence (${label})`, () => { + let mounted: MountedTestStorage; + let target: AgentNodeTarget; + + beforeEach(async () => { + mounted = await mountTestWorkspace(profile, 'agent-fsm-runtime-'); + const canvasId = createId('canvas'); + const created = await createSpace(canvasId, 'Runtime FSM'); + if (!created.ok) throw new Error('Space creation failed'); + target = { + canvasId, + nodeId: createId('node'), + threadId: createId('thread'), + agentBinding: { kind: 'internal' }, + bindingState: 'editing', + }; + const node: CanvasNode = { + id: target.nodeId, + type: 'question', + position: { x: 0, y: 0 }, + data: { + threadId: target.threadId, + agentBinding: target.agentBinding, + bindingState: 'editing', + }, + }; + await space(canvasId).write({ + expectedVersion: 0, + nextRecord: { + ...created.record, + version: 1, + state: { nodes: [node], edges: [] }, + }, + nodeMutations: [ + { + kind: 'put', + nodeId: target.nodeId, + record: { + nodeId: target.nodeId, + type: 'question', + label: 'Agent', + content: '', + }, + authoritativeInsert: true, + }, + ], + }); + }); + + afterEach(async () => { + if (target) agenetes.close(target.threadId); + await mounted?.close(); + }); + + function createCanonicalExecution( + workloadType: 'Deployment' | 'Job' = 'Deployment', + ) { + return agenetes.create( + buildHuabuPiWorkloadSpec({ + kind: 'internal', + workloadType, + threadId: target.threadId, + namespace: canvasAcpNamespace(target.canvasId), + canvasId: target.canvasId, + systemPrompt: 'Test', + toolNames: [], + initialMessages: [], + maxIterations: 1, + toolExecution: 'sequential', + }), + ); + } + + async function current() { + const canvas = await space(target.canvasId).read(); + if (!canvas) throw new Error('Space disappeared'); + return canvas.state.nodes[0] as CanvasNode; + } + + it.each(['Deployment', 'Job'] as const)( + 'confirms a durable %s record before any run starts', + async (workloadType) => { + createCanonicalExecution(workloadType); + const namespace = canvasAcpNamespace(target.canvasId); + expect( + agenetes.record(namespace, target.threadId)?.spec.workloadType, + ).toBe(workloadType); + expect(agenetes.history(namespace, target.threadId).turns).toHaveLength( + 0, + ); + await agentNodeBinding.confirm(target, { required: true }); + expect((await current()).data).toMatchObject({ bindingState: 'bound' }); + expect((await current()).data).not.toHaveProperty('invocationToken'); + expect((await current()).data).not.toHaveProperty('status'); + expect( + (await space(target.canvasId).nodes.read(target.nodeId))?.record + .content, + ).toBe(''); + }, + ); + + it('persists both FSM projections through the portable writer and keeps stale terminals inert', async () => { + createCanonicalExecution(); + await agentNodeBinding.confirm(target, { required: true }); + await agentNodeLifecycle.start(target, 'First submitted intent', 'first'); + await agentNodeLifecycle.error(target, 'Preparation failed', 'first'); + await agentNodeLifecycle.start(target, 'Follow-up', 'second'); + await agentNodeLifecycle.done(target, 'first'); + expect((await current()).data).toMatchObject({ + bindingState: 'bound', + invocationToken: 'second', + status: 'running', + }); + expect( + (await space(target.canvasId).nodes.read(target.nodeId))?.record + .content, + ).toBe('First submitted intent'); + await agentNodeLifecycle.done(target, 'second'); + await agentNodeLifecycle.acknowledge(target, 'first'); + expect((await current()).data.viewed).toBe(false); + await agentNodeLifecycle.acknowledge(target, 'second'); + expect((await current()).data.viewed).toBe(true); + agenetes.close(target.threadId); + await mounted.reopen(); + expect((await current()).data).toMatchObject({ + bindingState: 'bound', + invocationToken: 'second', + status: 'done', + viewed: true, + }); + const loaded = await agentThreadResolver.resolveAgentNode( + target.canvasId, + target.threadId, + ); + if (!loaded) throw new Error('Agent Node disappeared'); + expect(await agentNodeBinding.confirm(loaded)).toEqual({ + kind: 'internal', + }); + }); + + it('completes the partial record-to-Bound write inside the next guarded edit without rebinding', async () => { + createCanonicalExecution(); + const coordinator = new AgentNodeBindingCoordinator({ + record: () => + agenetes.record(canvasAcpNamespace(target.canvasId), target.threadId), + hasHistory: () => false, + promote: vi.fn().mockRejectedValue(new Error('Projection unavailable')), + acquireTurn: acquireAgentTurn, + }); + await expect(coordinator.confirm(target)).rejects.toThrow( + 'Projection unavailable', + ); + expect((await current()).data.bindingState).toBe('editing'); + await expect( + executeOnServer({ + canvasId: target.canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { + nodeId: target.nodeId, + patch: { + agentBinding: { + kind: 'external', + profileId: 'new', + alias: 'Other', + }, + }, + }, + ], + }, + ], + }), + ).rejects.toMatchObject({ code: 'agent_binding_conflict' }); + expect((await current()).data).toMatchObject({ + bindingState: 'bound', + agentBinding: { kind: 'internal' }, + }); + }); + + it('rejects draft edits nonblockingly during admission and allows fresh projection writes', async () => { + const release = acquireAgentTurn(target.threadId); + if (!release) throw new Error('Unexpected busy thread'); + try { + await expect( + executeOnServer({ + canvasId: target.canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { + nodeId: target.nodeId, + patch: { + agentLaunchOverrides: { + additionalInitialPreamble: 'Late draft', + }, + }, + }, + ], + }, + ], + }), + ).rejects.toMatchObject({ code: 'agent_draft_busy' }); + await agentNodeLifecycle.start(target, 'Admitted prompt', 'admitted'); + expect((await current()).data).toMatchObject({ + invocationToken: 'admitted', + status: 'running', + bindingState: 'editing', + }); + } finally { + release(); + } + }); + + it.each(['command', 'put', 'delta'] as const)( + 'keeps same-config promotion and fresh CAS during %s Save', + async (operation) => { + createCanonicalExecution(); + const before = await space(target.canvasId).read(); + if (!before) throw new Error('Space disappeared'); + const node = await current(); + if (operation === 'command') { + const result = await executeOnServer({ + canvasId: target.canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { + nodeId: target.nodeId, + patch: { + agentBinding: { kind: 'internal' }, + label: 'Saved', + }, + }, + ], + }, + ], + }); + expect(result.fromVersion).toBe(before.version + 1); + expect(result.toVersion).toBe(before.version + 2); + } else if (operation === 'put') { + const app = fastify(); + try { + await app.register(multipart); + await app.register(canvasRoutes, { prefix: '/canvas' }); + const response = await app.inject({ + method: 'PUT', + url: `/canvas/${target.canvasId}`, + payload: { + version: before.version, + state: { + ...before.state, + nodes: [ + { + ...node, + data: { + agentBinding: { kind: 'internal' }, + label: 'Saved', + }, + }, + ], + }, + }, + }); + expect(response.statusCode, response.body).toBe(200); + expect(response.json().version).toBe(before.version + 2); + } finally { + await app.close(); + } + } else { + const result = await withCanvasMutex(target.canvasId, () => + applyDeltasOnServerAlreadyLocked({ + canvasId: target.canvasId, + originator: { source: 'ui' }, + deltas: [ + { + type: 'REPLACE_NODE', + prev: node, + next: { ...node, data: { ...node.data, label: 'Saved' } }, + }, + ], + }), + ); + expect(result.fromVersion).toBe(before.version + 1); + expect(result.toVersion).toBe(before.version + 2); + } + expect((await current()).data).toMatchObject({ + bindingState: 'bound', + agentBinding: { kind: 'internal' }, + }); + agenetes.close(target.threadId); + await mounted.reopen(); + expect((await current()).data.bindingState).toBe('bound'); + expect((await space(target.canvasId).read())?.version).toBe( + before.version + 2, + ); + }, + ); + + it('keeps the promotion even when the rest of the command is a no-op', async () => { + createCanonicalExecution(); + const canvas = await space(target.canvasId).read(); + if (!canvas) throw new Error('Space disappeared'); + const before = canvas.version; + const result = await executeOnServer({ + canvasId: target.canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { + nodeId: target.nodeId, + patch: { agentBinding: { kind: 'internal' } }, + }, + ], + }, + ], + }); + expect(result.fromVersion).toBe(before + 1); + expect(result.toVersion).toBe(before + 1); + expect((await current()).data.bindingState).toBe('bound'); + }); + + it('allows a layout PUT with unchanged preparation while a prompt is admitted', async () => { + const release = acquireAgentTurn(target.threadId); + if (!release) throw new Error('Unexpected busy thread'); + const app = fastify(); + try { + await app.register(multipart); + await app.register(canvasRoutes, { prefix: '/canvas' }); + const canvas = await space(target.canvasId).read(); + if (!canvas) throw new Error('Space disappeared'); + const response = await app.inject({ + method: 'PUT', + url: `/canvas/${target.canvasId}`, + payload: { + version: canvas.version, + state: { + nodes: [ + { + id: target.nodeId, + type: 'question', + position: { x: 42, y: 0 }, + data: { agentBinding: { kind: 'internal' } }, + }, + ], + edges: [], + }, + }, + }); + expect(response.statusCode, response.body).toBe(200); + expect((await current()).position).toEqual({ x: 42, y: 0 }); + } finally { + release(); + await app.close(); + } + }); + + it('does not confirm or lock an untouched draft while replaying another node', async () => { + const created = await executeOnServer({ + canvasId: target.canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + id: createId('node'), + nodeType: 'text', + position: { x: 50, y: 50 }, + data: { content: 'Text' }, + }, + ], + }, + ], + }); + const inserted = created.deltas.find( + (delta) => delta.type === 'INSERT_NODE', + ); + if (!inserted || inserted.type !== 'INSERT_NODE') + throw new Error('Text was not created'); + const release = acquireAgentTurn(target.threadId); + if (!release) throw new Error('Unexpected busy thread'); + try { + await withCanvasMutex(target.canvasId, () => + applyDeltasOnServerAlreadyLocked({ + canvasId: target.canvasId, + originator: { source: 'ui' }, + deltas: [ + { + type: 'REPLACE_NODE', + prev: inserted.node, + next: { ...inserted.node, position: { x: 100, y: 100 } }, + }, + ], + }), + ); + expect((await current()).data.bindingState).toBe('editing'); + } finally { + release(); + } + }); + }); +}); diff --git a/apps/server/src/modules/agent/agent-node-lifecycle.test.ts b/apps/server/src/modules/agent/agent-node-lifecycle.test.ts index d8c3c5d3f..ea7cdd651 100644 --- a/apps/server/src/modules/agent/agent-node-lifecycle.test.ts +++ b/apps/server/src/modules/agent/agent-node-lifecycle.test.ts @@ -3,118 +3,123 @@ import { describe, expect, it, vi } from 'vitest'; -import { - AgentNodeLifecycle, - AgentNodeLifecycleError, -} from './agent-node-lifecycle.js'; +import { AgentNodeLifecycle } from './agent-node-lifecycle.js'; -import type { FixedAgentNodeTarget } from './agent-thread-resolver.js'; -import type { ExecuteOnServerOutput } from '../canvas/canvas-executor.js'; +import type { + AgentNodeProjection, + AgentNodeTransition, +} from './agent-node-lifecycle.js'; +import type { AgentNodeTarget } from './agent-thread-resolver.js'; import type { CanvasNodeId } from '@huabu/shared'; -const TARGET: FixedAgentNodeTarget = { +const TARGET: AgentNodeTarget = { canvasId: 'canvas-a', nodeId: 'node-agent' as CanvasNodeId, threadId: 'thread-a', - agentBinding: { - kind: 'external', - profileId: 'profile-a', - alias: 'Researcher', - }, - status: 'idle', - content: '', }; -function output(applied = true): ExecuteOnServerOutput { - return { - canvasId: 'canvas-a', - fromVersion: 1, - toVersion: 2, - deltas: [], - commands: [], - results: [ - { - command: { type: 'DELETE_NODES', nodeIds: [] }, - applied, - }, - ], - pendingEffects: { - mutatedNodes: [], - deletedNodeIds: [], - contentEditedNodeIds: [], - deferredFitFrameIds: [], +function harness(initial: AgentNodeProjection = {}, hasHistory = false) { + let current: AgentNodeProjection = { content: '', ...initial }; + const transition = vi.fn( + async (_target: AgentNodeTarget, update: AgentNodeTransition) => { + const patch = update(current); + if (patch) current = { ...current, ...patch }; }, - }; + ); + const lifecycle = new AgentNodeLifecycle({ + transition, + hasSubmission: () => hasHistory, + }); + return { lifecycle, transition, current: () => current }; } describe('AgentNodeLifecycle', () => { - it('writes first content once and serializes lifecycle patches', async () => { - const execute = vi.fn().mockResolvedValue(output()); - const lifecycle = new AgentNodeLifecycle({ execute }); + it('initializes fresh submitted content and projects running then unread completion', async () => { + const h = harness(); + await h.lifecycle.start(TARGET, 'First prompt', 'attempt-1'); + expect(h.current()).toMatchObject({ + content: 'First prompt', + status: 'running', + invocationToken: 'attempt-1', + }); + await h.lifecycle.done(TARGET, 'attempt-1'); + expect(h.current()).toMatchObject({ + status: 'done', + errorMessage: '', + viewed: false, + }); + }); - await Promise.all([ - lifecycle.start(TARGET, 'Initial task'), - lifecycle.done(TARGET), - ]); + it.each([ + [{ content: 'User authored' }, false], + [{ invocationToken: 'previous', content: '' }, false], + [{ content: '' }, true], + ])( + 'preserves authored content and prior submission evidence %j', + async (initial, history) => { + const h = harness(initial, history); + await h.lifecycle.start(TARGET, 'Follow-up', 'new'); + expect(h.current().content).toBe(initial.content); + }, + ); - expect(execute).toHaveBeenCalledTimes(2); - expect(execute.mock.calls.map((call) => call[0])).toEqual([ - { - canvasId: 'canvas-a', - commands: [ - { - type: 'MERGE_NODE_DATA', - patches: [ - { - nodeId: 'node-agent', - patch: { - content: 'Initial task', - status: 'running', - errorMessage: '', - }, - }, - ], - }, - ], - originator: { source: 'system' }, - }, - expect.objectContaining({ - commands: [ - { - type: 'MERGE_NODE_DATA', - patches: [ - { - nodeId: 'node-agent', - patch: { status: 'done', errorMessage: '' }, - }, - ], - }, - ], - }), - ]); + it('fills an empty control-only Bound node on its first prompt', async () => { + const h = harness({ bindingState: 'bound' }); + await h.lifecycle.start(TARGET, 'First prompt', 'new'); + expect(h.current().content).toBe('First prompt'); + expect(h.current().bindingState).toBe('bound'); }); - it('preserves first-turn content on follow-up starts', async () => { - const execute = vi.fn().mockResolvedValue(output()); - const lifecycle = new AgentNodeLifecycle({ execute }); + it('fences stale completion, failure and viewed acknowledgements', async () => { + const h = harness(); + await h.lifecycle.start(TARGET, 'First', 'old'); + await h.lifecycle.start(TARGET, 'Second', 'current'); + await h.lifecycle.done(TARGET, 'old'); + await h.lifecycle.error(TARGET, 'Old failure', 'old'); + await h.lifecycle.acknowledge(TARGET, 'old'); + await h.lifecycle.acknowledge(TARGET, 'current'); + expect(h.current()).toMatchObject({ + status: 'running', + invocationToken: 'current', + errorMessage: '', + }); + expect(h.current().viewed).toBeUndefined(); + await h.lifecycle.error(TARGET, 'Current failure', 'current'); + await h.lifecycle.acknowledge(TARGET, 'old'); + expect(h.current().viewed).toBe(false); + await h.lifecycle.acknowledge(TARGET, 'current'); + expect(h.current().viewed).toBe(true); + }); - await lifecycle.start( - { ...TARGET, status: 'done', content: 'Initial task' }, - 'Follow-up', + it('binding does not invent a prompt or alter its previous outcome', async () => { + const h = harness({ + status: 'error', + invocationToken: 'previous', + errorMessage: 'Failed', + }); + await h.lifecycle.bind(TARGET); + expect(h.current()).toEqual({ + content: '', + bindingState: 'bound', + status: 'error', + invocationToken: 'previous', + errorMessage: 'Failed', + }); + await h.lifecycle.bind(TARGET, true); + expect(h.transition).toHaveBeenLastCalledWith( + TARGET, + expect.any(Function), + true, ); - - expect( - execute.mock.calls[0]?.[0].commands[0].patches[0].patch, - ).not.toHaveProperty('content'); }); - it('surfaces rejected Canvas lifecycle updates', async () => { + it('surfaces projection failures without claiming completion', async () => { const lifecycle = new AgentNodeLifecycle({ - execute: vi.fn().mockResolvedValue(output(false)), + transition: vi.fn().mockRejectedValue(new Error('Canvas write failed')), + hasSubmission: () => false, }); - - await expect( - lifecycle.start(TARGET, 'Initial task'), - ).rejects.toBeInstanceOf(AgentNodeLifecycleError); + await expect(lifecycle.start(TARGET, 'First', 'attempt')).rejects.toThrow( + 'Canvas write failed', + ); }); }); diff --git a/apps/server/src/modules/agent/agent-node-lifecycle.ts b/apps/server/src/modules/agent/agent-node-lifecycle.ts index e19ed7b55..93ab6e36d 100644 --- a/apps/server/src/modules/agent/agent-node-lifecycle.ts +++ b/apps/server/src/modules/agent/agent-node-lifecycle.ts @@ -1,20 +1,36 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { executeOnServer } from '../canvas/canvas-executor.js'; +import { agenetes } from './agenetes/drivers.js'; +import { projectAgentNodeStateAlreadyLocked } from '../canvas/agent-node-projection.js'; +import { space, withCanvasMutex } from '../storage/index.js'; +import { canvasAcpNamespace } from '../workspace/paths.js'; -import type { FixedAgentNodeTarget } from './agent-thread-resolver.js'; -import type { CanvasCommand } from '@huabu/shared'; +import type { AgentNodeTarget } from './agent-thread-resolver.js'; +import type { AgentNodeProjection as AgentNodeProjectionWrite } from '@huabu/shared'; +import type { CanvasNode } from '@huabu/shared/canvas-engine'; -interface LifecycleDependencies { - execute: typeof executeOnServer; +export interface AgentNodeProjection { + threadId?: unknown; + content?: unknown; + status?: unknown; + invocationToken?: unknown; + bindingState?: unknown; + [key: string]: unknown; } -const DEFAULT_DEPENDENCIES: LifecycleDependencies = { - execute: executeOnServer, -}; +export type AgentNodeTransition = ( + current: AgentNodeProjection, +) => Record | null; -const patchChains = new Map>(); +interface LifecycleDependencies { + transition: ( + target: AgentNodeTarget, + update: AgentNodeTransition, + alreadyLocked?: boolean, + ) => Promise; + hasSubmission: (target: AgentNodeTarget) => boolean; +} export class AgentNodeLifecycleError extends Error { constructor(message: string) { @@ -23,57 +39,129 @@ export class AgentNodeLifecycleError extends Error { } } +async function transitionAgentNode( + target: AgentNodeTarget, + update: AgentNodeTransition, + alreadyLocked = false, +): Promise { + const apply = async () => { + const handle = space(target.canvasId); + const canvas = await handle.read(); + const node = (canvas?.state.nodes as CanvasNode[] | undefined)?.find( + (candidate) => candidate.id === target.nodeId, + ); + if ( + !node || + node.type !== 'question' || + node.data?.threadId !== target.threadId + ) { + throw new AgentNodeLifecycleError( + `Agent Node ${target.nodeId} no longer owns thread ${target.threadId}`, + ); + } + const content = await handle.nodes.read(target.nodeId); + if (!content) { + throw new AgentNodeLifecycleError( + `Agent Node ${target.nodeId} has no content record`, + ); + } + const patch = update({ ...node.data, content: content.record.content }); + if (!patch) return; + const { content: initialContent, ...metadata } = patch; + const applied = await projectAgentNodeStateAlreadyLocked( + target.canvasId, + target.nodeId, + { + threadId: target.threadId, + ...(typeof node.data.invocationToken === 'string' + ? { expectedInvocationToken: node.data.invocationToken } + : {}), + ...metadata, + ...(typeof initialContent === 'string' ? { initialContent } : {}), + } as AgentNodeProjectionWrite, + ); + if (!applied) { + throw new AgentNodeLifecycleError( + `Agent Node ${target.nodeId} lifecycle update was rejected`, + ); + } + }; + return alreadyLocked ? apply() : withCanvasMutex(target.canvasId, apply); +} + +const DEFAULT_DEPENDENCIES: LifecycleDependencies = { + transition: transitionAgentNode, + hasSubmission: (target) => + agenetes.history(canvasAcpNamespace(target.canvasId), target.threadId, { + withTail: true, + }).turns.length > 0, +}; + +/** Projects decisions only; admission and binding remain owned by their coordinators. */ export class AgentNodeLifecycle { constructor( private readonly dependencies: LifecycleDependencies = DEFAULT_DEPENDENCIES, ) {} - start(target: FixedAgentNodeTarget, prompt: string): Promise { - const firstTurn = - target.status === 'idle' && target.content.trim().length === 0; - return this.patch(target, { - ...(firstTurn ? { content: prompt } : {}), + start( + target: AgentNodeTarget, + prompt: string, + invocationToken: string, + ): Promise { + return this.dependencies.transition(target, (current) => ({ + ...(!current.invocationToken && + typeof current.content === 'string' && + current.content.trim().length === 0 && + !this.dependencies.hasSubmission(target) + ? { content: prompt } + : {}), + invocationToken, status: 'running', errorMessage: '', - }); + })); + } + + done(target: AgentNodeTarget, invocationToken: string): Promise { + return this.terminal(target, invocationToken, 'done', ''); + } + + error( + target: AgentNodeTarget, + message: string, + invocationToken: string, + ): Promise { + return this.terminal(target, invocationToken, 'error', message); } - done(target: FixedAgentNodeTarget): Promise { - return this.patch(target, { status: 'done', errorMessage: '' }); + bind(target: AgentNodeTarget, alreadyLocked = false): Promise { + return this.dependencies.transition( + target, + (current) => + current.bindingState === 'bound' ? null : { bindingState: 'bound' }, + alreadyLocked, + ); } - error(target: FixedAgentNodeTarget, message: string): Promise { - return this.patch(target, { status: 'error', errorMessage: message }); + acknowledge(target: AgentNodeTarget, invocationToken: string): Promise { + return this.dependencies.transition(target, (current) => + current.invocationToken === invocationToken && + (current.status === 'done' || current.status === 'error') + ? { viewed: true } + : null, + ); } - private patch( - target: FixedAgentNodeTarget, - data: Record, + private terminal( + target: AgentNodeTarget, + invocationToken: string, + status: 'done' | 'error', + errorMessage: string, ): Promise { - const key = `${target.canvasId}\0${target.nodeId}`; - const previous = patchChains.get(key) ?? Promise.resolve(); - const current = previous - .catch(() => undefined) - .then(async () => { - const command: CanvasCommand = { - type: 'MERGE_NODE_DATA', - patches: [{ nodeId: target.nodeId, patch: data }], - }; - const output = await this.dependencies.execute({ - canvasId: target.canvasId, - commands: [command], - originator: { source: 'system' }, - }); - if (output.results[0]?.applied !== true) { - throw new AgentNodeLifecycleError( - `Agent Node ${target.nodeId} lifecycle update was rejected`, - ); - } - }); - patchChains.set(key, current); - return current.finally(() => { - if (patchChains.get(key) === current) patchChains.delete(key); - }); + return this.dependencies.transition(target, (current) => + current.invocationToken === invocationToken + ? { status, errorMessage, viewed: false } + : null, + ); } } diff --git a/apps/server/src/modules/agent/agent-thread-resolver.test.ts b/apps/server/src/modules/agent/agent-thread-resolver.test.ts index c21687082..3770d9104 100644 --- a/apps/server/src/modules/agent/agent-thread-resolver.test.ts +++ b/apps/server/src/modules/agent/agent-thread-resolver.test.ts @@ -56,6 +56,8 @@ describe('AgentThreadResolver', () => { canvasId: 'canvas-a', nodeId: 'node-agent', threadId: 'thread-a', + agentBinding: FIXED_NODE.data.agentBinding, + launchOverrides: FIXED_NODE.data.agentLaunchOverrides, }); await expect( createResolver([FIXED_NODE], 'Fixed prompt').resolveAgentNode( @@ -66,6 +68,8 @@ describe('AgentThreadResolver', () => { canvasId: 'canvas-a', nodeId: 'node-agent', threadId: 'thread-a', + agentBinding: FIXED_NODE.data.agentBinding, + launchOverrides: FIXED_NODE.data.agentLaunchOverrides, }); }); diff --git a/apps/server/src/modules/agent/agent-thread-resolver.ts b/apps/server/src/modules/agent/agent-thread-resolver.ts index a7e07e758..98a0b072d 100644 --- a/apps/server/src/modules/agent/agent-thread-resolver.ts +++ b/apps/server/src/modules/agent/agent-thread-resolver.ts @@ -31,6 +31,11 @@ export interface AgentNodeTarget { canvasId: string; nodeId: CanvasNodeId; threadId: string; + agentBinding?: AgentBinding; + launchOverrides?: AgentLaunchOverrides; + agentMode?: 'ask' | 'operate'; + bindingState?: 'editing' | 'bound'; + invocationToken?: string; } export interface FixedAgentNodeTarget extends AgentNodeTarget { @@ -124,9 +129,40 @@ export class AgentThreadResolver { canvasId, nodeId: node.id as CanvasNodeId, threadId, + agentBinding: this.parseBinding(node), + ...(node.data?.agentLaunchOverrides + ? { + launchOverrides: parseAgentLaunchOverrides( + node.data.agentLaunchOverrides, + ), + } + : {}), + ...(node.data?.agentMode === 'ask' || node.data?.agentMode === 'operate' + ? { agentMode: node.data.agentMode } + : {}), + ...(node.data?.bindingState === 'bound' || + node.data?.bindingState === 'editing' + ? { bindingState: node.data.bindingState } + : {}), + ...(typeof node.data?.invocationToken === 'string' + ? { invocationToken: node.data.invocationToken } + : {}), }; } + private parseBinding(node: StoredNode): AgentBinding { + const parsed = agentBindingSchema.safeParse( + node.data?.agentBinding ?? { kind: 'internal' }, + ); + if (!parsed.success) { + throw new AgentThreadResolutionError( + 'invalid_binding', + `Agent Node ${node.id} has an invalid binding`, + ); + } + return parsed.data; + } + async resolveFixedAgentNode( canvasId: string, threadId: string, @@ -195,6 +231,16 @@ export class AgentThreadResolver { ...(launchOverrides ? { launchOverrides } : {}), status: getQuestionNodeStatus(node.data), content, + ...(node.data?.agentMode === 'ask' || node.data?.agentMode === 'operate' + ? { agentMode: node.data.agentMode } + : {}), + ...(node.data?.bindingState === 'bound' || + node.data?.bindingState === 'editing' + ? { bindingState: node.data.bindingState } + : {}), + ...(typeof node.data?.invocationToken === 'string' + ? { invocationToken: node.data.invocationToken } + : {}), }; } } diff --git a/apps/server/src/modules/agent/agent-thread.service.test.ts b/apps/server/src/modules/agent/agent-thread.service.test.ts index f4f0e1c43..54d0a42ce 100644 --- a/apps/server/src/modules/agent/agent-thread.service.test.ts +++ b/apps/server/src/modules/agent/agent-thread.service.test.ts @@ -93,6 +93,7 @@ function createHarness(options?: { persistedBinding?: Extract | null; persistedSpacePrompt?: { realised: boolean; markdown?: string }; collectedSpacePrompt?: string; + canonicalBinding?: AgentBinding; }) { const release = vi.fn(); const startLifecycle = options?.startError @@ -158,7 +159,9 @@ function createHarness(options?: { resolveAgentNode: async () => options && 'agentTarget' in options ? (options.agentTarget ?? null) - : (options?.target ?? TARGET), + : options && 'target' in options + ? (options.target ?? null) + : TARGET, resolveFixedAgentNode: async () => options && 'target' in options ? (options.target ?? null) : TARGET, resolvePersistedExternalBinding: () => @@ -177,6 +180,9 @@ function createHarness(options?: { runExternal, runInternal, closeHandle: vi.fn(), + confirmBinding: options?.canonicalBinding + ? vi.fn().mockResolvedValue(options.canonicalBinding) + : undefined, }); return { service, @@ -200,8 +206,8 @@ function invocationOptions() { envelope: ENVELOPE, requestBinding: { kind: 'external' as const, - profileId: 'profile-request', - alias: 'Request Agent', + profileId: 'profile-fixed', + alias: 'Fixed Agent', }, fixedTarget: TARGET, signal: new AbortController().signal, @@ -210,6 +216,322 @@ function invocationOptions() { } describe('AgentThreadService', () => { + it('keeps slow input preparation inside cancellable admission and never dispatches after stop', async () => { + const h = createHarness(); + let finishPreparation!: () => void; + let started!: () => void; + const preparing = new Promise((resolve) => { + started = resolve; + }); + const preparation = new Promise((resolve) => { + finishPreparation = resolve; + }); + const pending = h.service.invoke({ + ...invocationOptions(), + envelope: async () => { + started(); + await preparation; + return ENVELOPE; + }, + }); + await preparing; + expect(h.startLifecycle).toHaveBeenCalledOnce(); + expect(h.service.stop('thread-a')).toBe(true); + expect(h.release).not.toHaveBeenCalled(); + finishPreparation(); + const invocation = await pending; + for await (const _event of invocation.events) { + /* Drain cancelled preparation. */ + } + expect(h.realizeExternal).not.toHaveBeenCalled(); + expect(h.runExternal).not.toHaveBeenCalled(); + expect(h.finishLifecycle).toHaveBeenCalledOnce(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it('projects input preparation failures with the admitted token and does not realize an execution', async () => { + const h = createHarness(); + await expect( + h.service.invoke({ + ...invocationOptions(), + envelope: async () => { + throw new Error('Input preparation failed'); + }, + }), + ).rejects.toThrow('Input preparation failed'); + expect(h.failLifecycle).toHaveBeenCalledWith( + TARGET, + 'Input preparation failed', + h.startLifecycle.mock.calls[0]?.[2], + ); + expect(h.realizeExternal).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it('admits and installs cancellation before slow external preparation', async () => { + const h = createHarness(); + let finishPreparation!: () => void; + const preparation = new Promise((resolve) => { + finishPreparation = resolve; + }); + let preparationStarted!: () => void; + const started = new Promise((resolve) => { + preparationStarted = resolve; + }); + h.realizeExternal.mockImplementationOnce(async () => { + preparationStarted(); + await preparation; + return { + binding: TARGET.agentBinding as Extract< + AgentBinding, + { kind: 'external' } + >, + fixedTarget: TARGET, + spec: {} as AcpWorkloadSpec, + handle: {} as AcpHandle, + }; + }); + const pending = h.service.invoke(invocationOptions()); + await started; + expect(h.startLifecycle).toHaveBeenCalledOnce(); + expect(h.service.isActive('thread-a', 'canvas-a')).toBe(true); + expect(h.service.stop('thread-a')).toBe(true); + expect(h.release).not.toHaveBeenCalled(); + finishPreparation(); + const invocation = await pending; + for await (const _event of invocation.events) { + /* Drain settlement. */ + } + expect(h.runExternal).not.toHaveBeenCalled(); + expect(h.finishLifecycle).toHaveBeenCalledOnce(); + expect(h.release).toHaveBeenCalledOnce(); + expect(h.service.isActive('thread-a', 'canvas-a')).toBe(false); + }); + + it('settles preparation errors and retains the admitted token', async () => { + const h = createHarness(); + h.realizeExternal.mockRejectedValueOnce(new Error('Profile unavailable')); + await expect(h.service.invoke(invocationOptions())).rejects.toThrow( + 'Profile unavailable', + ); + const token = h.startLifecycle.mock.calls[0]?.[2]; + expect(h.failLifecycle).toHaveBeenCalledWith( + TARGET, + 'Profile unavailable', + token, + ); + expect(h.release).toHaveBeenCalledOnce(); + expect(h.runExternal).not.toHaveBeenCalled(); + }); + + it('treats cancellation unwinding preparation as cancellation, not an HTTP preparation error', async () => { + const h = createHarness(); + h.realizeExternal.mockImplementationOnce(async () => { + h.service.stop('thread-a'); + throw new DOMException('Stopped during preparation', 'AbortError'); + }); + const invocation = await h.service.invoke(invocationOptions()); + expect(invocation.signal.aborted).toBe(true); + for await (const _event of invocation.events) { + /* Drain cancelled preparation. */ + } + expect(h.finishLifecycle).toHaveBeenCalledOnce(); + expect(h.failLifecycle).not.toHaveBeenCalled(); + expect(h.runExternal).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it('rejects stale requested selection without a lifecycle transition or realization', async () => { + const h = createHarness(); + await expect( + h.service.invoke({ + ...invocationOptions(), + requestBinding: { + kind: 'external', + profileId: 'stale', + alias: 'Old selection', + }, + }), + ).rejects.toMatchObject({ code: 'agent_binding_conflict' }); + expect(h.startLifecycle).not.toHaveBeenCalled(); + expect(h.realizeExternal).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it('rejects an internal node whose confirmed execution uses an external binding without a request binding', async () => { + const h = createHarness({ + target: null, + agentTarget: { ...SELECTABLE_TARGET, agentBinding: { kind: 'internal' } }, + canonicalBinding: { + kind: 'external', + profileId: 'other', + alias: 'Other', + }, + }); + await expect( + h.service.invoke({ + ...invocationOptions(), + requestBinding: undefined, + }), + ).rejects.toMatchObject({ code: 'agent_binding_conflict' }); + expect(h.realizeExternal).not.toHaveBeenCalled(); + expect(h.runInternal).not.toHaveBeenCalled(); + expect(h.failLifecycle).toHaveBeenCalledOnce(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it('does not turn a captured preparation failure into success when cancellation arrives before the catch', async () => { + const h = createHarness(); + h.realizeExternal.mockImplementationOnce(async () => { + const failure = new Error('Canonical record invalid'); + queueMicrotask(() => h.service.stop('thread-a')); + throw failure; + }); + await expect(h.service.invoke(invocationOptions())).rejects.toThrow( + 'Canonical record invalid', + ); + expect(h.failLifecycle).toHaveBeenCalledWith( + TARGET, + 'Canonical record invalid', + expect.any(String), + ); + expect(h.finishLifecycle).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it('cannot run a lazy stream after explicit disposal released admission', async () => { + const h = createHarness(); + const invocation = await h.service.invoke(invocationOptions()); + await invocation.dispose(new Error('Transport setup failed')); + for await (const _event of invocation.events) { + /* Drain disposed stream. */ + } + expect(h.runExternal).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it.each([ + { name: 'empty output', stream: [] as AgentStreamEvent[], outcome: 'done' }, + { + name: 'Done after an error', + stream: [ + { type: 'error', data: { error: 'Recoverable' } }, + { type: 'done', data: { message: '' } }, + ] as AgentStreamEvent[], + outcome: 'done', + }, + { + name: 'error after Done', + stream: [ + { type: 'done', data: { message: '' } }, + { type: 'error', data: { error: 'Late error' } }, + ] as AgentStreamEvent[], + outcome: 'done', + }, + { + name: 'a handled tool error', + stream: [ + { + type: 'tool_call', + data: { toolCallId: 'tool', title: 'read', status: 'failed' }, + }, + ] as AgentStreamEvent[], + outcome: 'done', + }, + { + name: 'an unhandled stream error', + stream: [ + { type: 'error', data: { error: 'Failed' } }, + ] as AgentStreamEvent[], + outcome: 'error', + }, + ])('preserves terminal precedence for $name', async ({ stream, outcome }) => { + const h = createHarness({ externalEvents: stream }); + const invocation = await h.service.invoke(invocationOptions()); + for await (const _event of invocation.events) { + /* Drain characterized events. */ + } + expect(h.finishLifecycle).toHaveBeenCalledTimes(outcome === 'done' ? 1 : 0); + expect(h.failLifecycle).toHaveBeenCalledTimes(outcome === 'error' ? 1 : 0); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it.each(['done', 'error'] as const)( + 'a late stop cannot rewrite an established %s fact', + async (outcome) => { + const h = createHarness({ + externalEvents: + outcome === 'done' + ? [{ type: 'done', data: { message: '' } }] + : [{ type: 'error', data: { error: 'Failure' } }], + }); + const controller = new AbortController(); + const invocation = await h.service.invoke({ + ...invocationOptions(), + signal: controller.signal, + }); + const stream = invocation.events; + await stream.next(); + expect(h.service.stop('thread-a')).toBe(false); + controller.abort(); + await stream.next(); + expect(h.failLifecycle).toHaveBeenCalledTimes( + outcome === 'error' ? 1 : 0, + ); + expect(h.finishLifecycle).toHaveBeenCalledTimes( + outcome === 'done' ? 1 : 0, + ); + }, + ); + + it('preserves cancellation when the adapter reports an error after stop', async () => { + const h = createHarness({ + externalEvents: [ + { type: 'text_delta', data: { content: 'Partial' } }, + { type: 'error', data: { error: 'Aborted' } }, + ], + }); + const invocation = await h.service.invoke(invocationOptions()); + await invocation.events.next(); + expect(h.service.stop('thread-a')).toBe(true); + expect(h.release).not.toHaveBeenCalled(); + await invocation.events.next(); + await invocation.events.next(); + expect(h.finishLifecycle).toHaveBeenCalledOnce(); + expect(h.failLifecycle).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it.each(['done', 'error'] as const)( + 'stream disposal retains an established %s outcome', + async (outcome) => { + const h = createHarness({ + externalEvents: + outcome === 'done' + ? [{ type: 'done', data: { message: '' } }] + : [{ type: 'error', data: { error: 'Original failure' } }], + }); + const invocation = await h.service.invoke(invocationOptions()); + await invocation.events.next(); + await invocation.dispose(new Error('Late transport failure')); + await invocation.events.return(); + expect(h.finishLifecycle).toHaveBeenCalledTimes( + outcome === 'done' ? 1 : 0, + ); + expect(h.failLifecycle).toHaveBeenCalledTimes( + outcome === 'error' ? 1 : 0, + ); + if (outcome === 'error') { + expect(h.failLifecycle).toHaveBeenCalledWith( + TARGET, + 'Original failure', + expect.any(String), + ); + } + expect(h.release).toHaveBeenCalledOnce(); + }, + ); + it('validates an external binding from a durable workload spec', () => { expect( externalBindingFromWorkloadSpec({ @@ -280,6 +602,7 @@ describe('AgentThreadService', () => { expect(harness.startLifecycle).toHaveBeenCalledWith( TARGET, 'Investigate this', + expect.any(String), ); expect(harness.runExternal).not.toHaveBeenCalled(); @@ -294,7 +617,10 @@ describe('AgentThreadService', () => { binding: TARGET.agentBinding, }), ); - expect(harness.finishLifecycle).toHaveBeenCalledWith(TARGET); + expect(harness.finishLifecycle).toHaveBeenCalledWith( + TARGET, + expect.any(String), + ); expect(harness.failLifecycle).not.toHaveBeenCalled(); expect(harness.release).toHaveBeenCalledOnce(); }); @@ -335,6 +661,7 @@ describe('AgentThreadService', () => { expect(harness.failLifecycle).toHaveBeenCalledWith( TARGET, 'Agent unavailable', + expect.any(String), ); expect(harness.finishLifecycle).not.toHaveBeenCalled(); expect(harness.release).toHaveBeenCalledOnce(); @@ -352,6 +679,7 @@ describe('AgentThreadService', () => { const invocation = await harness.service.invoke({ ...invocationOptions(), fixedTarget: target, + requestBinding: { kind: 'internal' }, }); for await (const _event of invocation.events) { @@ -393,7 +721,11 @@ describe('AgentThreadService', () => { expect(harness.runInternal).toHaveBeenCalledWith( expect.objectContaining({ spacePrompt: 'Space prompt' }), ); - expect(harness.startLifecycle).not.toHaveBeenCalled(); + expect(harness.startLifecycle).toHaveBeenCalledWith( + SELECTABLE_TARGET, + 'Investigate this', + expect.any(String), + ); }); it('passes a selectable Agent Node to external realization', async () => { @@ -417,7 +749,11 @@ describe('AgentThreadService', () => { fixedTarget: null, }), ); - expect(harness.startLifecycle).not.toHaveBeenCalled(); + expect(harness.startLifecycle).toHaveBeenCalledWith( + SELECTABLE_TARGET, + 'Investigate this', + expect.any(String), + ); }); it('does not collect a Space Prompt for a node-less thread', async () => { @@ -482,6 +818,7 @@ describe('AgentThreadService', () => { expect(harness.failLifecycle).toHaveBeenCalledWith( TARGET, 'SSE setup failed', + expect.any(String), ); expect(harness.release).toHaveBeenCalledOnce(); }); diff --git a/apps/server/src/modules/agent/agent-thread.service.ts b/apps/server/src/modules/agent/agent-thread.service.ts index e720c22ba..dd48981a9 100644 --- a/apps/server/src/modules/agent/agent-thread.service.ts +++ b/apps/server/src/modules/agent/agent-thread.service.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { randomUUID } from 'node:crypto'; + import { emptyAcpOverlay } from '@agenetes/acp-driver'; import { AGENT_SSE_EVENTS, agentBindingSchema } from '@huabu/shared'; @@ -8,6 +10,7 @@ import { AGENT_SSE_EVENTS, agentBindingSchema } from '@huabu/shared'; import { externalAgentRealization } from './acp/external-agent-realization.js'; import { runAcpAgent } from './acp/service.js'; import { agenetes, EXTERNAL_DRIVER_KIND } from './agenetes/drivers.js'; +import { agentNodeBinding } from './agent-node-binding.js'; import { agentNodeLifecycle } from './agent-node-lifecycle.js'; import { agentThreadResolver, @@ -54,7 +57,11 @@ interface AgentThreadServiceDependencies { resolvePersistedSpacePrompt: ( canvasId: string, threadId: string, - ) => { realised: boolean; markdown?: string }; + ) => { + realised: boolean; + markdown?: string; + record?: ReturnType | null; + }; collectSpacePrompt: ( canvasId: string, targetAgentNodeId: string, @@ -70,6 +77,7 @@ interface AgentThreadServiceDependencies { runExternal: typeof runAcpAgent; runInternal: typeof runAgent; closeHandle: (threadId: string) => void; + confirmBinding?: typeof agentNodeBinding.confirm; } export function externalBindingFromWorkloadSpec( @@ -108,20 +116,23 @@ const DEFAULT_DEPENDENCIES: AgentThreadServiceDependencies = { }, resolvePersistedSpacePrompt: (canvasId, threadId) => { const record = agenetes.record(canvasAcpNamespace(canvasId), threadId); - if (!record) return { realised: false }; + if (!record) return { realised: false, record: null }; const markdown = spacePromptFromWorkloadSpec(record.spec.spec); - return markdown ? { realised: true, markdown } : { realised: true }; + return markdown + ? { realised: true, markdown, record } + : { realised: true, record }; }, collectSpacePrompt: resolveSpacePrompt, realizeExternal: (options) => externalAgentRealization.realize(options), waitForTurnRelease: waitForAgentTurnRelease, acquireTurn: acquireAgentTurn, - startLifecycle: agentNodeLifecycle.start.bind(agentNodeLifecycle), - finishLifecycle: agentNodeLifecycle.done.bind(agentNodeLifecycle), - failLifecycle: agentNodeLifecycle.error.bind(agentNodeLifecycle), + startLifecycle: (...args) => agentNodeLifecycle.start(...args), + finishLifecycle: (...args) => agentNodeLifecycle.done(...args), + failLifecycle: (...args) => agentNodeLifecycle.error(...args), runExternal: runAcpAgent, runInternal: runAgent, closeHandle: (threadId) => agenetes.close(threadId), + confirmBinding: (...args) => agentNodeBinding.confirm(...args), }; export class AgentThreadBusyError extends Error { @@ -136,7 +147,8 @@ export interface AgentThreadInvocationOptions { canvasId?: string; content: string; mode: AgentMode; - envelope: ChatEnvelope; + /** Expensive context gathering runs after admission and cancellation tracking. */ + envelope: ChatEnvelope | (() => Promise); /** Canonical durable submission; ordinary chat callers omit it. */ submission?: HuabuSubmission; requestBinding?: AgentBinding; @@ -156,11 +168,13 @@ export interface AgentThreadInvocationOptions { type EffectiveAgentThreadInvocationOptions = Omit< AgentThreadInvocationOptions, - 'signal' + 'signal' | 'envelope' > & { signal: AbortSignal; + envelope?: ChatEnvelope; spacePrompt?: string; externalRealization?: RealizedExternalAgentThread; + onExecutionCreated?: () => Promise; }; export interface AgentThreadInvocation { @@ -181,6 +195,9 @@ interface ActiveAgentInvocation { abortController: AbortController; turnStarted: Promise; resolveTurnStarted: (started: boolean) => void; + phase: 'preparing' | 'executing' | 'stopping' | 'settled'; + outcome?: 'done' | 'error'; + errorMessage?: string; } function buildAgentSystemPrompt(params: { @@ -229,6 +246,10 @@ export class AgentThreadService { ? { binding: fixedTarget.agentBinding, fixedTarget } : null; } + const target = await this.dependencies.resolveAgentNode(canvasId, threadId); + if (target?.agentBinding?.kind === 'external') { + return { binding: target.agentBinding, fixedTarget: null }; + } const binding = this.dependencies.resolvePersistedExternalBinding( canvasId, threadId, @@ -245,46 +266,6 @@ export class AgentThreadService { async invoke( options: AgentThreadInvocationOptions, ): Promise { - const fixedTarget = - options.fixedTarget === undefined - ? await this.resolveFixedTarget(options.canvasId, options.threadId) - : options.fixedTarget; - const agentTarget = - options.agentTarget === undefined - ? (fixedTarget ?? - (options.canvasId - ? await this.dependencies.resolveAgentNode( - options.canvasId, - options.threadId, - ) - : null)) - : options.agentTarget; - const persistedExternalBinding = - !fixedTarget && options.canvasId - ? this.dependencies.resolvePersistedExternalBinding( - options.canvasId, - options.threadId, - ) - : null; - let binding: AgentBinding = fixedTarget?.agentBinding ?? - persistedExternalBinding ?? - options.requestBinding ?? { kind: 'internal' }; - const externalRealization = - binding.kind === 'external' - ? await this.dependencies.realizeExternal({ - threadId: options.threadId, - canvasId: options.canvasId, - requestedBinding: - options.requestBinding?.kind === 'external' - ? options.requestBinding - : undefined, - agentTarget, - fixedTarget, - logger: options.logger, - }) - : undefined; - if (externalRealization) binding = externalRealization.binding; - await this.dependencies.waitForTurnRelease(options.threadId); const releaseTurn = this.dependencies.acquireTurn(options.threadId); if (!releaseTurn) throw new AgentThreadBusyError(options.threadId); @@ -302,16 +283,150 @@ export class AgentThreadService { abortController, turnStarted, resolveTurnStarted, + phase: 'preparing', }; this.activeInvocations.set(options.threadId, active); + const onAbort = () => { + if (active.phase !== 'settled' && !active.outcome) + active.phase = 'stopping'; + }; + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + const invocationToken = randomUUID(); + let agentTarget: AgentNodeTarget | null = null; + let fixedTarget: FixedAgentNodeTarget | null = null; + let binding: AgentBinding = options.requestBinding ?? { kind: 'internal' }; + let externalRealization: RealizedExternalAgentThread | undefined; + let envelope = + typeof options.envelope === 'function' ? undefined : options.envelope; + let projected = false; + let settled = false; + const settle = async ( + terminal: 'done' | 'error', + message?: string, + ): Promise => { + if (settled) return; + settled = true; + terminal = active.outcome ?? terminal; + message = + active.outcome === 'error' ? (active.errorMessage ?? message) : message; + active.phase = 'settled'; + resolveTurnStarted(false); + try { + if (projected && agentTarget) { + if (terminal === 'error') { + await this.dependencies.failLifecycle( + agentTarget, + message ?? 'Internal Error', + invocationToken, + ); + } else { + await this.dependencies.finishLifecycle( + agentTarget, + invocationToken, + ); + } + } + } catch (error) { + options.logger.error( + { err: error, threadId: options.threadId, outcome: terminal }, + 'Agent Node terminal projection failed', + ); + throw error; + } finally { + signal.removeEventListener('abort', onAbort); + if (this.activeInvocations.get(options.threadId) === active) { + this.activeInvocations.delete(options.threadId); + } + releaseTurn(); + } + }; let spacePrompt: string | undefined; + let persistedSpacePrompt: + | ReturnType< + AgentThreadServiceDependencies['resolvePersistedSpacePrompt'] + > + | undefined; try { - if (agentTarget && options.canvasId && binding.kind !== 'external') { - const persisted = this.dependencies.resolvePersistedSpacePrompt( - options.canvasId, - options.threadId, + // Resolve only after admission: a queued request must not execute an old draft. + agentTarget = options.canvasId + ? await this.dependencies.resolveAgentNode( + options.canvasId, + options.threadId, + ) + : null; + fixedTarget = await this.resolveFixedTarget( + options.canvasId, + options.threadId, + ); + if (agentTarget) { + binding = + agentTarget.agentBinding ?? fixedTarget?.agentBinding ?? binding; + agentNodeBinding.assertRequestedBinding( + binding, + options.requestBinding, + ); + await this.dependencies.startLifecycle( + agentTarget, + options.content, + invocationToken, ); + projected = true; + if (binding.kind !== 'external') { + persistedSpacePrompt = this.dependencies.resolvePersistedSpacePrompt( + agentTarget.canvasId, + agentTarget.threadId, + ); + const canonical = await this.dependencies.confirmBinding?.( + agentTarget, + { record: persistedSpacePrompt.record }, + ); + if (canonical) + agentNodeBinding.assertRequestedBinding(canonical, binding); + binding = canonical ?? binding; + agentNodeBinding.assertRequestedBinding( + binding, + options.requestBinding, + ); + } + } else if (options.canvasId) { + binding = + this.dependencies.resolvePersistedExternalBinding( + options.canvasId, + options.threadId, + ) ?? binding; + } + if (!signal.aborted && typeof options.envelope === 'function') { + envelope = await options.envelope(); + } + if (signal.aborted) { + await settle('done'); + } else if (binding.kind === 'external') { + externalRealization = await this.dependencies.realizeExternal({ + threadId: options.threadId, + canvasId: options.canvasId, + requestedBinding: binding, + agentTarget, + fixedTarget, + logger: options.logger, + signal, + turnLeaseHeld: true, + }); + binding = externalRealization.binding; + } + if ( + !signal.aborted && + agentTarget && + options.canvasId && + binding.kind !== 'external' + ) { + const persisted = + persistedSpacePrompt ?? + this.dependencies.resolvePersistedSpacePrompt( + options.canvasId, + options.threadId, + ); if (persisted.realised) { spacePrompt = persisted.markdown; } else { @@ -339,51 +454,40 @@ export class AgentThreadService { } } } - if (fixedTarget) { - await this.dependencies.startLifecycle(fixedTarget, options.content); - } } catch (error) { - resolveTurnStarted(false); - if (this.activeInvocations.get(options.threadId) === active) { - this.activeInvocations.delete(options.threadId); + const cancelled = + signal.aborted && + (error === signal.reason || + (error instanceof Error && error.name === 'AbortError')); + try { + await settle(cancelled ? 'done' : 'error', errorMessage(error)); + } catch (projectionError) { + options.logger.error( + { err: projectionError, preparationError: error }, + 'Preparation failed and its terminal projection also failed', + ); + if (cancelled) throw projectionError; } - releaseTurn(); - throw error; + if (!cancelled) throw error; } const effectiveOptions: EffectiveAgentThreadInvocationOptions = { ...options, agentTarget, + envelope, signal, spacePrompt, externalRealization, - }; - - let settled = false; - const settle = async ( - terminal: 'done' | 'error', - message?: string, - ): Promise => { - if (settled) return; - settled = true; - resolveTurnStarted(false); - if (this.activeInvocations.get(options.threadId) === active) { - this.activeInvocations.delete(options.threadId); - } - try { - if (fixedTarget) { - if (terminal === 'error') { - await this.dependencies.failLifecycle( - fixedTarget, - message ?? 'Internal Error', - ); - } else { - await this.dependencies.finishLifecycle(fixedTarget); + mode: !agentTarget?.invocationToken + ? (agentTarget?.agentMode ?? options.mode) + : options.mode, + onExecutionCreated: agentTarget + ? async () => { + await this.dependencies.confirmBinding?.(agentTarget, { + required: true, + }); } - } - } finally { - releaseTurn(); - } + : undefined, }; return { @@ -394,8 +498,13 @@ export class AgentThreadService { effectiveOptions, binding, fixedTarget, - () => resolveTurnStarted(true), + () => { + if (!signal.aborted) active.phase = 'executing'; + resolveTurnStarted(true); + }, settle, + active, + () => settled, ), dispose: (error) => settle( @@ -406,7 +515,9 @@ export class AgentThreadService { } stop(threadId: string): boolean { - const controller = this.activeInvocations.get(threadId)?.abortController; + const active = this.activeInvocations.get(threadId); + if (active?.outcome || active?.phase === 'settled') return false; + const controller = active?.abortController; if (!controller || controller.signal.aborted) return false; controller.abort(); return true; @@ -435,12 +546,19 @@ export class AgentThreadService { fixedTarget: FixedAgentNodeTarget | null, onTurnStarted: () => void, settle: (terminal: 'done' | 'error', message?: string) => Promise, + active: ActiveAgentInvocation, + isSettled: () => boolean, ): AsyncGenerator { let runError: unknown; let eventError: string | null = null; let sawDone = false; try { + if (isSettled()) return; + if (options.signal.aborted) { + await settle('done'); + return; + } const stream = this.createDispatchStream( options, binding, @@ -449,18 +567,31 @@ export class AgentThreadService { ); try { for await (const event of stream) { - if (event.type === AGENT_SSE_EVENTS.Done) sawDone = true; + if (event.type === AGENT_SSE_EVENTS.Done) { + sawDone = true; + active.outcome = 'done'; + } if (event.type === AGENT_SSE_EVENTS.Error) { eventError = event.data.error || 'Internal Error'; + if (!sawDone && !options.signal.aborted) { + active.outcome = 'error'; + active.errorMessage = eventError; + } } yield event; } } catch (error) { runError = error; + if (!sawDone && !options.signal.aborted && !active.outcome) { + active.outcome = 'error'; + active.errorMessage = errorMessage(error); + } } const failed = - !sawDone && !options.signal.aborted && (runError || eventError); + !sawDone && + (active.outcome === 'error' || + (!options.signal.aborted && (runError || eventError))); await settle( failed ? 'error' : 'done', runError ? errorMessage(runError) : (eventError ?? undefined), @@ -468,7 +599,10 @@ export class AgentThreadService { if (runError) throw runError; } finally { - await settle('error', 'Invocation stream was not drained'); + await settle( + options.signal.aborted ? 'done' : 'error', + 'Invocation stream was not drained', + ); } } @@ -478,6 +612,7 @@ export class AgentThreadService { fixedTarget: FixedAgentNodeTarget | null, onTurnStarted: () => void, ): AsyncGenerator { + if (!options.envelope) throw new Error('Invocation input was not prepared'); if (binding.kind === 'external') { if (!options.externalRealization) { throw new Error( @@ -522,6 +657,7 @@ export class AgentThreadService { canvasId: options.canvasId, mode: options.mode, additionalInitialPreamble: + options.agentTarget?.launchOverrides?.additionalInitialPreamble ?? fixedTarget?.launchOverrides?.additionalInitialPreamble, spacePrompt: options.spacePrompt, }), @@ -536,6 +672,7 @@ export class AgentThreadService { logger: options.logger, debugPrompt: options.debugPrompt, onTurnStarted, + onExecutionCreated: options.onExecutionCreated, }); } } diff --git a/apps/server/src/modules/agent/agent-thread.titles.test.ts b/apps/server/src/modules/agent/agent-thread.titles.test.ts index 94696d76c..b7f080470 100644 --- a/apps/server/src/modules/agent/agent-thread.titles.test.ts +++ b/apps/server/src/modules/agent/agent-thread.titles.test.ts @@ -149,8 +149,8 @@ describe('Question ownership at the real message adapters', () => { } expect(run).toHaveBeenCalledOnce(); - expect(startLifecycle).toHaveBeenCalledTimes(fixed ? 1 : 0); - expect(finishLifecycle).toHaveBeenCalledTimes(fixed ? 1 : 0); + expect(startLifecycle).toHaveBeenCalledTimes(questionOwned ? 1 : 0); + expect(finishLifecycle).toHaveBeenCalledTimes(questionOwned ? 1 : 0); expect(conversationTitleService.initialize).toHaveBeenCalledTimes( questionOwned ? 0 : 1, ); diff --git a/apps/server/src/modules/agent/agent.route.ts b/apps/server/src/modules/agent/agent.route.ts index f22c1942b..f78b884bf 100644 --- a/apps/server/src/modules/agent/agent.route.ts +++ b/apps/server/src/modules/agent/agent.route.ts @@ -29,6 +29,11 @@ import { setChatThreadReasoningEffortRequestSchema, } from '@huabu/shared'; +import { + AgentNodeBindingError, + agentNodeBinding, +} from './agent-node-binding.js'; +import { agentThreadResolver } from './agent-thread-resolver.js'; import conversationTitleRoutes from './conversation-title.route.js'; import { ExternalAgentRealizationError } from '../agent/acp/external-agent-realization.js'; import { agenetes, INTERNAL_DRIVER_KIND } from '../agent/agenetes/drivers.js'; @@ -93,6 +98,27 @@ async function dispatchBuiltinControl( { ok: true } | { ok: false; status: number; message: string; code: string } > { const record = agenetes.record(namespace, threadId); + if (namespace.name) { + const target = await agentThreadResolver.resolveAgentNode( + namespace.name, + threadId, + ); + if (target) { + try { + await agentNodeBinding.confirm(target, { record: record ?? null }); + } catch (error) { + if (error instanceof AgentNodeBindingError) { + return { + ok: false, + status: 409, + message: error.message, + code: error.code, + }; + } + throw error; + } + } + } if (!record) { return { ok: false, @@ -670,15 +696,16 @@ const agentRoutes: FastifyPluginAsync = async ( // message INSIDE the dispatch layer (runAgent / runAcpAgent), so both // backends share one render timing and it never enters the persisted // transcript (it is re-derived from the envelope on reload). - const envelope = await buildChatEnvelope({ - content, - attachments, - selectedNodes: canvasContext?.selectedNodes, - anchorNodeId: fixedTarget?.nodeId ?? anchorNodeId, - invokedSkills, - canvasId: canvasId ?? null, - logger: request.log, - }); + const envelope = () => + buildChatEnvelope({ + content, + attachments, + selectedNodes: canvasContext?.selectedNodes, + anchorNodeId: fixedTarget?.nodeId ?? anchorNodeId, + invokedSkills, + canvasId: canvasId ?? null, + logger: request.log, + }); // Debug-prompt metadata forwarded to the dispatch layer (it assembles // the final prompt). No-op unless HUABU_DEBUG_PROMPT is set. const debugPrompt = { @@ -713,7 +740,10 @@ const agentRoutes: FastifyPluginAsync = async ( code: 'thread_busy', }); } - if (error instanceof ExternalAgentRealizationError) { + if ( + error instanceof ExternalAgentRealizationError || + error instanceof AgentNodeBindingError + ) { return reply.code(409).send({ message: error.message, code: error.code, diff --git a/apps/server/src/modules/agent/agent.service.test.ts b/apps/server/src/modules/agent/agent.service.test.ts index 075037efa..c047cebaa 100644 --- a/apps/server/src/modules/agent/agent.service.test.ts +++ b/apps/server/src/modules/agent/agent.service.test.ts @@ -97,6 +97,7 @@ vi.mock('./conversation/prompt/build-prompt.js', () => ({ ]), })); +import { agenetes } from './agenetes/drivers.js'; import { runAgent, syncDeploymentSystemPrompt } from './agent.service.js'; import type { BuiltinHandle } from './agenetes/drivers.js'; @@ -141,6 +142,102 @@ beforeEach(() => { // ─── Tests ─────────────────────────────────────────────────────────────────── describe('runAgent output delta', () => { + it.each(['Job', 'Deployment'] as const)( + 'awaits the %s binding confirmation before controls or run', + async (workloadType) => { + const order: string[] = []; + let release!: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const enteredConfirmation = new Promise((resolve) => { + entered = resolve; + }); + const control = vi.fn(async () => { + order.push('control'); + return { ok: true }; + }); + const run = vi.fn(() => { + order.push('run'); + return (async function* () { + yield* []; + return []; + })(); + }); + const create = vi.spyOn(agenetes, 'create').mockImplementation(() => { + order.push('create'); + return { control, run } as unknown as ReturnType< + typeof agenetes.create + >; + }); + try { + const pending = drain( + runAgent({ + scope: 'ask', + context: priorContext([]), + threadId: `binding-seam-${workloadType}`, + workloadType, + modelId: 'chosen-model', + onExecutionCreated: async () => { + order.push('confirm'); + entered(); + await ready; + order.push('bound'); + }, + }), + ); + await enteredConfirmation; + expect(order).toEqual(['create', 'confirm']); + expect(control).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + release(); + await pending; + expect(order).toEqual( + workloadType === 'Job' + ? ['create', 'confirm', 'bound', 'run'] + : ['create', 'confirm', 'bound', 'control', 'run'], + ); + } finally { + create.mockRestore(); + } + }, + ); + + it('does not dispatch when cancellation arrives during binding confirmation', async () => { + const controller = new AbortController(); + const onTurnStarted = vi.fn(); + await drain( + runAgent({ + scope: 'ask', + context: priorContext([]), + signal: controller.signal, + onTurnStarted, + onExecutionCreated: async () => { + controller.abort(); + }, + }), + ); + expect(onTurnStarted).not.toHaveBeenCalled(); + }); + + it('surfaces binding projection failure before prompt dispatch', async () => { + const onTurnStarted = vi.fn(); + await expect( + drain( + runAgent({ + scope: 'ask', + context: priorContext([]), + onTurnStarted, + onExecutionCreated: async () => { + throw new Error('Bound write failed'); + }, + }), + ), + ).rejects.toThrow('Bound write failed'); + expect(onTurnStarted).not.toHaveBeenCalled(); + }); + it('with an envelope: returns only the output delta, excluding the rendered user message', async () => { const prior = [ { role: 'user', content: 'earlier question', timestamp: 0 }, diff --git a/apps/server/src/modules/agent/agent.service.ts b/apps/server/src/modules/agent/agent.service.ts index f8e5e897b..6c11d3e6a 100644 --- a/apps/server/src/modules/agent/agent.service.ts +++ b/apps/server/src/modules/agent/agent.service.ts @@ -173,6 +173,8 @@ export interface AgentRunOptions { }; /** Called after Agenetes has synchronously persisted this turn's start. */ onTurnStarted?: () => void; + /** Awaited after canonical create, before any controls or prompt dispatch. */ + onExecutionCreated?: () => Promise; } // ==================== Agent Loop ==================== @@ -217,11 +219,13 @@ export async function* runAgent( onTurnStarted, } = options; + if (signal?.aborted) return []; const rendered = envelope ? await renderInternalAgentInputs(envelope, { canvasId: canvasId ?? null, }) : undefined; + if (signal?.aborted) return []; const submission = suppliedSubmission ?? (envelope ? createChatSubmission(envelope, rendered) : null); @@ -288,6 +292,8 @@ export async function* runAgent( // pi-backed handle. Deployments get-or-create by `threadId`; Jobs mint a // fresh handle. const handle = agenetes.create(spec) as BuiltinHandle; + await options.onExecutionCreated?.(); + if (signal?.aborted) return []; if ( workloadType === 'Deployment' && canvasId && @@ -329,6 +335,7 @@ export async function* runAgent( liveHandle === undefined && durableRecord === undefined, ); } + if (signal?.aborted) return []; const iterator = handle.run(submission, { maxIterations: maxIterations ?? agentCfg.runtime.maxIterations, signal, diff --git a/apps/server/src/modules/agent/conversation-title.conversion.test.ts b/apps/server/src/modules/agent/conversation-title.conversion.test.ts index 8254d1e01..9a85fbc9c 100644 --- a/apps/server/src/modules/agent/conversation-title.conversion.test.ts +++ b/apps/server/src/modules/agent/conversation-title.conversion.test.ts @@ -7,7 +7,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { agenetes } from './agenetes/drivers.js'; +import { agenetes, EXTERNAL_DRIVER_KIND } from './agenetes/drivers.js'; import { CONVERSATION_TITLE_METADATA_KEY, ConversationTitleService, @@ -20,6 +20,7 @@ import { setWorkspacePath } from '../workspace.js'; import type { ThreadRecord } from '@agenetes/agenetes'; import type { ConversationTitle } from '@huabu/shared'; +import type { CanvasNode } from '@huabu/shared/canvas-engine'; const canvasId = 'canvas-conversion'; const threadId = 'thread-conversion'; @@ -51,11 +52,17 @@ async function fixture( let record: ThreadRecord = { driverSchemaVersion: 1, spec: { - kind: 'test', + kind: EXTERNAL_DRIVER_KIND, workloadType: 'Deployment', threadId, namespace: { name: canvasId }, - spec: {}, + spec: { + binding: { + kind: 'external', + profileId: 'profile-conversion', + alias: 'Conversion Agent', + }, + }, }, state: { driverState: {}, @@ -144,8 +151,6 @@ async function fixture( label: title, labelSource, content: 'First user prompt', - status: 'done', - viewed: true, }, }, ], @@ -165,12 +170,22 @@ async function fixture( id: 'node-q', data: expect.objectContaining({ threadId, - status: 'done', - viewed: true, + bindingState: 'bound', + agentBinding: { + kind: 'external', + profileId: 'profile-conversion', + alias: 'Conversion Agent', + }, }), }), ]), ); + const question = (canvas?.state.nodes as CanvasNode[] | undefined)?.find( + (entry) => entry.id === 'node-q', + ); + expect(question?.data).not.toHaveProperty('status'); + expect(question?.data).not.toHaveProperty('viewed'); + expect(question?.data).not.toHaveProperty('invocationToken'); return () => { const reopened = getCanvasStore(canvasId); expect(reopened.readNode('node-q')).toEqual(node); diff --git a/apps/server/src/modules/canvas/agent-node-association.ts b/apps/server/src/modules/canvas/agent-node-association.ts new file mode 100644 index 000000000..75c3dde7e --- /dev/null +++ b/apps/server/src/modules/canvas/agent-node-association.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createId } from '@huabu/shared'; +import { projectAgentNodeEditableData } from '@huabu/shared/canvas-engine'; + +import { initializeAgentNodeCreationAlreadyLocked } from './agent-node-edit.js'; +import { + applyDeltasOnServerAlreadyLocked, + hydrateCanvasNodes, +} from './canvas-executor.js'; +import { publishCanvasUpdate } from './canvas-sync.js'; +import { withCanvasMutex } from './write-coordinator.js'; +import { space } from '../storage/index.js'; + +import type { + AssociateAgentNodeBody, + AssociateAgentNodeResponse, +} from '@huabu/shared'; +import type { CanvasNode } from '@huabu/shared/canvas-engine'; + +export class AgentNodeAssociationError extends Error {} + +/** Explicit identity creation, never an existing-node thread replacement. */ +export async function associateAgentNode( + canvasId: string, + nodeId: string, + body: AssociateAgentNodeBody, +): Promise { + return withCanvasMutex(canvasId, async () => { + const handle = space(canvasId); + const canvas = await handle.read(); + if (!canvas) throw new AgentNodeAssociationError('Space no longer exists'); + const nodes = canvas.state.nodes as CanvasNode[]; + const stored = nodes.find((node) => node.id === nodeId); + if (stored && stored.type !== 'question') + throw new AgentNodeAssociationError('The node is not an Agent Node'); + if (body.kind === 'restore' && body.node.id !== nodeId) + throw new AgentNodeAssociationError( + 'Restored node identity does not match', + ); + if (stored?.data.threadId) { + if (body.kind === 'restore' && stored.data.threadId !== body.threadId) + throw new AgentNodeAssociationError( + 'An existing Agent Node cannot be rebound', + ); + const record = await handle.nodes.read(nodeId); + const node = + hydrateCanvasNodes(record ? new Map([[nodeId, record]]) : new Map(), [ + stored, + ])[0] ?? stored; + return { node, fromVersion: canvas.version, toVersion: canvas.version }; + } + if (body.kind === 'initialize' && !stored) + throw new AgentNodeAssociationError('Agent Node no longer exists'); + if ( + stored?.data.bindingState === 'bound' || + stored?.data.invocationToken || + (stored?.data.status !== undefined && stored.data.status !== 'idle') + ) + throw new AgentNodeAssociationError( + 'Agent Node execution identity is missing', + ); + // A restore cannot attach a supplied thread to an existing legacy node. + if (body.kind === 'restore' && stored) + throw new AgentNodeAssociationError( + 'An existing Agent Node cannot be rebound', + ); + const threadId = + body.kind === 'restore' + ? (body.threadId ?? createId('thread')) + : createId('thread'); + if ( + nodes.some( + (node) => node.type === 'question' && node.data.threadId === threadId, + ) + ) + throw new AgentNodeAssociationError( + 'The thread already belongs to an Agent Node', + ); + const record = stored ? await handle.nodes.read(nodeId) : undefined; + const previous = stored + ? (hydrateCanvasNodes(record ? new Map([[nodeId, record]]) : new Map(), [ + stored, + ])[0] ?? stored) + : undefined; + const source = + previous ?? (body.kind === 'restore' ? body.node : undefined); + if (!source) + throw new AgentNodeAssociationError('Agent Node no longer exists'); + const node = await initializeAgentNodeCreationAlreadyLocked( + canvasId, + { + ...source, + data: { + ...projectAgentNodeEditableData(source.data ?? {}), + threadId, + bindingState: 'editing', + }, + } as CanvasNode, + body.kind === 'restore' && body.requireBinding, + ); + const output = await applyDeltasOnServerAlreadyLocked({ + canvasId, + deltas: previous + ? [{ type: 'REPLACE_NODE', prev: previous, next: node }] + : [{ type: 'INSERT_NODE', node }], + originator: { source: 'ui' }, + agentNodeProjection: true, + }); + publishCanvasUpdate(canvasId, { + type: 'update', + data: { + fromVersion: output.fromVersion, + toVersion: output.toVersion, + deltas: output.deltas, + pendingEffects: output.pendingEffects, + agentNodeProjection: true, + }, + }); + return { + node, + fromVersion: output.fromVersion, + toVersion: output.toVersion, + }; + }); +} diff --git a/apps/server/src/modules/canvas/agent-node-edit.ts b/apps/server/src/modules/canvas/agent-node-edit.ts new file mode 100644 index 000000000..f2b852bed --- /dev/null +++ b/apps/server/src/modules/canvas/agent-node-edit.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { canvasEditableNodeDataSchema } from '@huabu/shared'; +import { + AGENT_NODE_PREPARATION_KEYS, + projectAgentNodeEditableData, +} from '@huabu/shared/canvas-engine'; + +import { agenetes } from '../agent/agenetes/drivers.js'; +import { parseAgentLaunchOverrides } from '../agent/agent-launch-overrides.js'; +import { agentNodeBinding } from '../agent/agent-node-binding.js'; +import { agentThreadResolver } from '../agent/agent-thread-resolver.js'; +import { canvasAcpNamespace } from '../workspace/paths.js'; + +import type { CanvasNodeId } from '@huabu/shared'; +import type { CanvasNode } from '@huabu/shared/canvas-engine'; + +interface EditableAgentNode { + id?: string; + type?: string; + data?: Record; +} + +export class AgentNodeEditError extends Error { + constructor(message: string) { + super(message); + this.name = 'AgentNodeEditError'; + } +} + +export function validateAgentNodeEditableData( + data: Record, +): void { + const parsed = canvasEditableNodeDataSchema.safeParse( + projectAgentNodeEditableData(data), + ); + if (!parsed.success) { + throw new AgentNodeEditError( + parsed.error.issues[0]?.message ?? 'Invalid Agent Node edit', + ); + } + if ( + data.agentLaunchOverrides !== null && + data.agentLaunchOverrides !== undefined + ) { + try { + parseAgentLaunchOverrides(data.agentLaunchOverrides); + } catch (error) { + throw new AgentNodeEditError( + error instanceof Error ? error.message : 'Invalid launch overrides', + ); + } + } +} + +/** Creation/attachment confirms the new owner's thread, not copied lifecycle data. */ +export async function initializeAgentNodeCreationAlreadyLocked( + canvasId: string, + node: CanvasNode, + requireBinding = false, +): Promise { + if (node.type !== 'question' || typeof node.data.threadId !== 'string') + return node; + validateAgentNodeEditableData(node.data); + const target = { + canvasId, + nodeId: node.id as CanvasNodeId, + threadId: node.data.threadId, + }; + const record = agenetes.record(canvasAcpNamespace(canvasId), target.threadId); + if (record) { + const binding = await agentNodeBinding.confirm( + { ...target, bindingState: 'bound' }, + { required: true, alreadyLocked: true, record }, + ); + return { + ...node, + data: { ...node.data, bindingState: 'bound', agentBinding: binding }, + }; + } + await agentNodeBinding.confirm(target, { + alreadyLocked: true, + required: requireBinding, + record: null, + }); + return node; +} + +/** Retain nonblocking admission leases until the caller's Canvas write settles. */ +export async function guardAgentNodeDraftEditsAlreadyLocked( + canvasId: string, + edits: readonly { + current: EditableAgentNode; + patch: Record; + }[], +): Promise<() => void> { + const releases: Array<() => void> = []; + const release = () => { + for (const finish of releases.reverse()) finish(); + }; + try { + for (const { current, patch } of edits) { + if (current.type !== 'question') continue; + validateAgentNodeEditableData(patch); + if ( + !AGENT_NODE_PREPARATION_KEYS.some((key) => + Object.prototype.hasOwnProperty.call(patch, key), + ) + ) + continue; + const threadId = current.data?.threadId; + if (typeof threadId !== 'string' || !threadId) continue; + const target = await agentThreadResolver.resolveAgentNode( + canvasId, + threadId, + ); + if (!target || target.nodeId !== current.id) + throw new Error('Agent Node association changed'); + releases.push(await agentNodeBinding.guardDraftEdit(target, patch)); + } + return release; + } catch (error) { + release(); + throw error; + } +} diff --git a/apps/server/src/modules/canvas/agent-node-ownership.test.ts b/apps/server/src/modules/canvas/agent-node-ownership.test.ts new file mode 100644 index 000000000..662542d84 --- /dev/null +++ b/apps/server/src/modules/canvas/agent-node-ownership.test.ts @@ -0,0 +1,763 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createId } from '@huabu/shared'; + +import { + projectAgentNodeState, + acknowledgeAgentNodeResult, +} from './agent-node-projection.js'; +import { applyDeltasOnServer, executeOnServer } from './canvas-executor.js'; +import { subscribeCanvasUpdates } from './canvas-sync.js'; +import canvasRoutes from './canvas.route.js'; +import { agenetes } from '../agent/agenetes/drivers.js'; +import { createSpace, space } from '../storage/index.js'; +import { + forEachProductProfile, + mountTestWorkspace, + type MountedTestStorage, +} from '../storage/testing.js'; + +import type { CanvasNode } from '@huabu/shared/canvas-engine'; + +forEachProductProfile((profile, label) => { + describe(`Agent Node Canvas ownership (${label})`, () => { + let mounted: MountedTestStorage; + let app: FastifyInstance; + let canvasId: string; + let node: CanvasNode; + + beforeEach(async () => { + mounted = await mountTestWorkspace(profile, 'agent-node-ownership-'); + canvasId = createId('canvas'); + const created = await createSpace(canvasId, 'Agent ownership'); + if (!created.ok) throw new Error('Failed to seed Space'); + node = { + id: 'node-agent', + type: 'question', + position: { x: 0, y: 0 }, + data: { + threadId: 'thread-agent', + bindingState: 'editing', + agentBinding: { kind: 'internal' }, + }, + }; + await space(canvasId).write({ + expectedVersion: 0, + nextRecord: { + ...created.record, + version: 1, + state: { nodes: [node], edges: [] }, + }, + nodeMutations: [ + { + kind: 'put', + nodeId: node.id, + record: { + nodeId: node.id, + type: 'question', + label: 'Agent', + content: '', + }, + authoritativeInsert: true, + }, + ], + }); + app = fastify(); + await app.register(canvasRoutes, { prefix: '/canvas' }); + await app.ready(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await app?.close(); + await mounted?.close(); + }); + + async function currentCanvas() { + const canvas = await space(canvasId).read(); + if (!canvas) throw new Error('Test Space disappeared'); + return canvas; + } + + async function current(): Promise { + return (await currentCanvas()).state.nodes[0] as CanvasNode; + } + + it('initializes a legacy Question once without creating an execution record', async () => { + const canvas = await currentCanvas(); + await space(canvasId).write({ + expectedVersion: canvas.version, + nextRecord: { + ...canvas, + version: canvas.version + 1, + state: { ...canvas.state, nodes: [{ ...node, data: {} }] }, + }, + nodeMutations: [], + }); + const create = vi.spyOn(agenetes, 'create'); + const request = { + method: 'POST' as const, + url: `/canvas/${canvasId}/nodes/${node.id}/association`, + payload: { kind: 'initialize' }, + }; + const first = await app.inject(request); + expect(first.statusCode).toBe(200); + const data = first.json().node.data; + expect(data).toMatchObject({ + bindingState: 'editing', + threadId: expect.stringMatching(/^thread-/), + }); + const second = await app.inject(request); + expect(second.json().node.data.threadId).toBe(data.threadId); + expect(second.json().toVersion).toBe(first.json().toVersion); + expect(create).not.toHaveBeenCalled(); + }); + + it('validates reinsertion and never attaches a different thread to an existing node', async () => { + const restore = { + kind: 'restore', + node: { ...node, data: {} }, + threadId: 'other-thread', + requireBinding: false, + }; + const url = `/canvas/${canvasId}/nodes/${node.id}/association`; + expect( + (await app.inject({ method: 'POST', url, payload: restore })) + .statusCode, + ).toBe(409); + expect( + ( + await app.inject({ + method: 'POST', + url, + payload: { + ...restore, + node: { ...node, data: { status: 'done' } }, + }, + }) + ).statusCode, + ).toBe(400); + expect((await current()).data.threadId).toBe('thread-agent'); + }); + + it('confirms the restored thread and discards historical invocation metadata', async () => { + await executeOnServer({ + canvasId, + originator: { source: 'ui' }, + commands: [ + { type: 'DELETE_NODES', nodeIds: [node.id as `node-${string}`] }, + ], + }); + vi.spyOn(agenetes, 'record').mockReturnValue({ + spec: { + threadId: 'thread-agent', + namespace: { name: canvasId }, + kind: 'internal', + workloadType: 'Deployment', + spec: {}, + }, + state: { driverState: {} }, + driverSchemaVersion: 1, + } as NonNullable>); + const response = await app.inject({ + method: 'POST', + url: `/canvas/${canvasId}/nodes/${node.id}/association`, + payload: { + kind: 'restore', + node: { ...node, data: { content: 'Preserved intent' } }, + threadId: 'thread-agent', + requireBinding: true, + }, + }); + expect(response.statusCode).toBe(200); + expect((await current()).data).toMatchObject({ + threadId: 'thread-agent', + bindingState: 'bound', + agentBinding: { kind: 'internal' }, + }); + expect((await current()).data).not.toHaveProperty('invocationToken'); + expect((await space(canvasId).nodes.read(node.id))?.record.content).toBe( + 'Preserved intent', + ); + }); + + it('rejects resurrection of a Bound node with a missing record', async () => { + await executeOnServer({ + canvasId, + originator: { source: 'ui' }, + commands: [ + { type: 'DELETE_NODES', nodeIds: [node.id as `node-${string}`] }, + ], + }); + const response = await app.inject({ + method: 'POST', + url: `/canvas/${canvasId}/nodes/${node.id}/association`, + payload: { + kind: 'restore', + node: { ...node, data: {} }, + threadId: 'thread-agent', + requireBinding: true, + }, + }); + expect(response.statusCode).toBe(409); + expect((await currentCanvas()).state.nodes).toHaveLength(0); + await expect( + applyDeltasOnServer({ + canvasId, + originator: { source: 'ui' }, + deltas: [ + { + type: 'INSERT_NODE', + node: { + ...node, + data: { + ...node.data, + bindingState: 'bound', + invocationToken: 'old', + status: 'done', + }, + }, + }, + ], + }), + ).rejects.toThrow('no canonical execution record'); + }); + + it('confirms a realized chat attachment and rejects duplicate thread owners', async () => { + vi.spyOn(agenetes, 'record').mockReturnValue({ + spec: { + threadId: 'thread-attached', + namespace: { name: canvasId }, + kind: 'internal', + workloadType: 'Deployment', + spec: {}, + }, + state: { driverState: {} }, + driverSchemaVersion: 1, + } as NonNullable>); + await executeOnServer({ + canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-attached', + nodeType: 'question', + position: { x: 10, y: 0 }, + data: { + threadId: 'thread-attached', + status: 'error', + invocationToken: 'old', + }, + }, + ], + }, + ], + }); + const attached = ( + (await currentCanvas()).state.nodes as CanvasNode[] + ).find((entry) => entry.id === 'node-attached'); + if (!attached) throw new Error('Chat attachment was not created'); + expect(attached.data).toMatchObject({ + threadId: 'thread-attached', + bindingState: 'bound', + agentBinding: { kind: 'internal' }, + }); + expect(attached.data).not.toHaveProperty('status'); + const response = await app.inject({ + method: 'POST', + url: `/canvas/${canvasId}/nodes/node-duplicate/association`, + payload: { + kind: 'restore', + node: { ...node, id: 'node-duplicate', data: {} }, + threadId: 'thread-attached', + requireBinding: true, + }, + }); + expect(response.statusCode).toBe(409); + }); + + it('fences stale terminal writes and acknowledgements, preserving first submitted content', async () => { + const events: unknown[] = []; + const unsubscribe = subscribeCanvasUpdates(canvasId, (event) => + events.push(event), + ); + try { + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + invocationToken: 'first', + status: 'running', + initialContent: 'First intent', + }); + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + expectedInvocationToken: 'first', + status: 'error', + viewed: false, + }); + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + invocationToken: 'second', + status: 'running', + initialContent: 'Do not overwrite', + }); + expect( + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + expectedInvocationToken: 'first', + status: 'done', + }), + ).toBe(false); + expect( + await acknowledgeAgentNodeResult(canvasId, node.id, 'first'), + ).toBe(false); + expect( + await acknowledgeAgentNodeResult(canvasId, node.id, 'second'), + ).toBe(false); + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + expectedInvocationToken: 'second', + status: 'done', + viewed: false, + }); + const acknowledged = await app.inject({ + method: 'POST', + url: `/canvas/${canvasId}/nodes/${node.id}/viewed`, + payload: { invocationToken: 'second' }, + }); + expect(acknowledged.json()).toEqual({ acknowledged: true }); + expect((await current()).data).toMatchObject({ + invocationToken: 'second', + status: 'done', + viewed: true, + }); + expect( + (await space(canvasId).nodes.read(node.id))?.record.content, + ).toBe('First intent'); + expect(events).toHaveLength(5); + expect( + events.every( + (event) => + (event as { data: { agentNodeProjection?: boolean } }).data + .agentNodeProjection, + ), + ).toBe(true); + } finally { + unsubscribe(); + } + }); + + it('acknowledges legacy terminal attention only while no invocation token has superseded it', async () => { + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + status: 'done', + viewed: false, + }); + const url = `/canvas/${canvasId}/nodes/${node.id}/viewed`; + expect( + ( + await app.inject({ + method: 'POST', + url, + payload: { invocationToken: null }, + }) + ).json(), + ).toEqual({ acknowledged: true }); + expect((await current()).data.viewed).toBe(true); + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + invocationToken: 'new', + status: 'done', + viewed: false, + }); + expect( + ( + await app.inject({ + method: 'POST', + url, + payload: { invocationToken: null }, + }) + ).json(), + ).toEqual({ acknowledged: false }); + expect((await current()).data.viewed).toBe(false); + }); + + it('rejects forbidden PUT and MERGE writes but composes omitted metadata from fresh state', async () => { + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + bindingState: 'bound', + invocationToken: 'current', + status: 'done', + }); + const version = (await currentCanvas()).version; + const forged = await app.inject({ + method: 'PUT', + url: `/canvas/${canvasId}`, + payload: { + version, + state: { nodes: [{ ...node, data: { status: 'idle' } }], edges: [] }, + }, + }); + expect(forged.statusCode).toBe(400); + const merged = await executeOnServer({ + canvasId, + originator: { source: 'system' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { + nodeId: 'node-agent', + patch: { threadId: 'other', bindingState: 'editing' }, + }, + ], + }, + ], + }); + expect(merged.results[0]?.applied).toBe(false); + const saved = await app.inject({ + method: 'PUT', + url: `/canvas/${canvasId}`, + payload: { + version, + state: { + nodes: [ + { + id: node.id, + type: 'question', + position: { x: 100, y: 20 }, + data: { label: 'New' }, + }, + ], + edges: [], + }, + }, + }); + expect(saved.statusCode).toBe(200); + expect((await current()).data).toMatchObject({ + bindingState: 'bound', + invocationToken: 'current', + status: 'done', + threadId: 'thread-agent', + }); + }); + + it('validates stored Question ownership when a PUT omits the node type', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/canvas/${canvasId}`, + payload: { + version: (await currentCanvas()).version, + state: { nodes: [{ id: node.id, data: { status: 'done' } }] }, + }, + }); + expect(response.statusCode).toBe(400); + expect((await current()).data).not.toHaveProperty('status'); + }); + + it('preserves unrelated node metadata without carrying it into a new Question identity', async () => { + await executeOnServer({ + canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-note', + nodeType: 'note', + position: { x: 20, y: 20 }, + data: { + content: 'Note', + }, + }, + ], + }, + ], + }); + const result = await executeOnServer({ + canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { + nodeId: 'node-note', + patch: { status: 'updated', threadId: 'reference' }, + }, + ], + }, + ], + }); + expect(result.results[0]?.applied).toBe(true); + const canvas = await currentCanvas(); + const response = await app.inject({ + method: 'PUT', + url: `/canvas/${canvasId}`, + payload: { + version: canvas.version, + state: { + nodes: [ + { id: node.id, type: 'question', data: {} }, + { + id: 'node-note', + type: 'note', + data: { status: 'saved', threadId: 'reference' }, + }, + ], + }, + }, + }); + expect(response.statusCode).toBe(200); + const converted = await app.inject({ + method: 'PUT', + url: `/canvas/${canvasId}`, + payload: { + version: response.json().version, + state: { + nodes: [ + { id: node.id, type: 'question', data: {} }, + { id: 'node-note', type: 'question', data: {} }, + ], + }, + }, + }); + expect(converted.statusCode).toBe(200); + const question = (await currentCanvas()).state.nodes.find( + (entry) => (entry as CanvasNode).id === 'node-note', + ) as CanvasNode; + expect(question.data).toMatchObject({ bindingState: 'editing' }); + expect(question.data).not.toHaveProperty('status'); + expect(question.data.threadId).not.toBe('reference'); + }); + + it('retains live FSM and preparation when reverting an unrelated historical edit', async () => { + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + bindingState: 'bound', + invocationToken: 'current', + status: 'running', + }); + const before = { + ...node, + position: { x: 50, y: 0 }, + data: { ...node.data, content: '', label: 'Agent' }, + }; + const after = { ...before, position: { x: 0, y: 0 } }; + await applyDeltasOnServer({ + canvasId, + originator: { source: 'ui' }, + deltas: [{ type: 'REPLACE_NODE', prev: before, next: after }], + }); + expect((await current()).data).toMatchObject({ + bindingState: 'bound', + invocationToken: 'current', + status: 'running', + }); + await expect( + executeOnServer({ + canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { + nodeId: 'node-agent', + patch: { + agentBinding: { + kind: 'external', + alias: 'Other', + profileId: 'other', + }, + }, + }, + ], + }, + ], + }), + ).rejects.toThrow('after binding'); + }); + + it('distinguishes an omitted prompt from an explicit empty prompt', async () => { + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + invocationToken: 'first', + status: 'error', + initialContent: 'Submitted intent', + }); + const omitted = await app.inject({ + method: 'PUT', + url: `/canvas/${canvasId}/nodes/${node.id}/content`, + payload: { nodeType: 'question', label: 'Renamed' }, + }); + expect(omitted.statusCode).toBe(200); + expect((await space(canvasId).nodes.read(node.id))?.record.content).toBe( + 'Submitted intent', + ); + const cleared = await app.inject({ + method: 'PUT', + url: `/canvas/${canvasId}/nodes/${node.id}/content`, + payload: { nodeType: 'question', content: '' }, + }); + expect(cleared.statusCode).toBe(200); + expect((await space(canvasId).nodes.read(node.id))?.record.content).toBe( + '', + ); + expect((await current()).data.invocationToken).toBe('first'); + }); + + it('does not reset a live identity by deleting and recreating the same node in one command batch', async () => { + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + bindingState: 'bound', + invocationToken: 'current', + status: 'done', + }); + await expect( + executeOnServer({ + canvasId, + originator: { source: 'ui' }, + commands: [ + { type: 'DELETE_NODES', nodeIds: ['node-agent'] }, + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-agent', + nodeType: 'question', + position: { x: 0, y: 0 }, + data: { threadId: 'other' }, + }, + ], + }, + ], + }), + ).rejects.toThrow('cannot be recreated'); + expect((await current()).data).toMatchObject({ + bindingState: 'bound', + threadId: 'thread-agent', + invocationToken: 'current', + }); + }); + + it('promotes an existing canonical record before rejecting a legacy draft edit without reentering the Canvas lock', async () => { + const record = { + spec: { + threadId: 'thread-agent', + namespace: { name: canvasId }, + kind: 'internal', + workloadType: 'Deployment', + spec: {}, + }, + state: { driverState: {} }, + driverSchemaVersion: 1, + } as NonNullable>; + const readRecord = vi.spyOn(agenetes, 'record').mockReturnValue(record); + const response = await app.inject({ + method: 'PUT', + url: `/canvas/${canvasId}`, + payload: { + version: 1, + state: { + nodes: [ + { + id: node.id, + type: 'question', + data: { + agentBinding: { + kind: 'external', + alias: 'Different', + profileId: 'different', + }, + }, + }, + ], + edges: [], + }, + }, + }); + expect(response.statusCode).toBe(409); + expect((await current()).data).toMatchObject({ + bindingState: 'bound', + agentBinding: { kind: 'internal' }, + }); + expect(readRecord).toHaveBeenCalledOnce(); + const saved = await app.inject({ + method: 'PUT', + url: `/canvas/${canvasId}`, + payload: { + version: (await currentCanvas()).version, + state: { + nodes: [ + { + id: node.id, + type: 'question', + position: { x: 10, y: 10 }, + data: {}, + }, + ], + edges: [], + }, + }, + }); + expect(saved.statusCode).toBe(200); + expect(readRecord).toHaveBeenCalledOnce(); + }); + + it('allows an explicit override reset while Editing and rejects its inverse after Bound', async () => { + await executeOnServer({ + canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { + nodeId: 'node-agent', + patch: { agentLaunchOverrides: { workingDirPath: '/work' } }, + }, + ], + }, + ], + }); + const reset = await executeOnServer({ + canvasId, + originator: { source: 'ui' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { nodeId: 'node-agent', patch: { agentLaunchOverrides: null } }, + ], + }, + ], + }); + expect((await current()).data).not.toHaveProperty('agentLaunchOverrides'); + await projectAgentNodeState(canvasId, node.id, { + threadId: 'thread-agent', + bindingState: 'bound', + }); + const delta = reset.deltas.find((item) => item.type === 'REPLACE_NODE'); + if (delta?.type !== 'REPLACE_NODE') + throw new Error('Missing override inverse'); + await expect( + applyDeltasOnServer({ + canvasId, + originator: { source: 'ui' }, + deltas: [ + { type: 'REPLACE_NODE', prev: delta.next, next: delta.prev }, + ], + }), + ).rejects.toThrow('after binding'); + expect((await current()).data).not.toHaveProperty('agentLaunchOverrides'); + }); + }); +}); diff --git a/apps/server/src/modules/canvas/agent-node-projection.ts b/apps/server/src/modules/canvas/agent-node-projection.ts new file mode 100644 index 000000000..2bd2fbef2 --- /dev/null +++ b/apps/server/src/modules/canvas/agent-node-projection.ts @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + agentNodeProjectionSchema, + type AgentNodeProjection, +} from '@huabu/shared'; + +import { + applyDeltasOnServerAlreadyLocked, + hydrateCanvasNodes, +} from './canvas-executor.js'; +import { publishCanvasUpdate } from './canvas-sync.js'; +import { withCanvasMutex } from './write-coordinator.js'; +import { space } from '../storage/index.js'; + +import type { CanvasNode } from '@huabu/shared/canvas-engine'; + +export async function projectAgentNodeState( + canvasId: string, + nodeId: string, + transition: AgentNodeProjection, +): Promise { + return withCanvasMutex(canvasId, () => + projectAgentNodeStateAlreadyLocked(canvasId, nodeId, transition), + ); +} + +/** Caller owns the Canvas mutex; never acquire another lock from this entry. */ +export async function projectAgentNodeStateAlreadyLocked( + canvasId: string, + nodeId: string, + transition: AgentNodeProjection, +): Promise { + const parsed = agentNodeProjectionSchema.safeParse(transition); + if (!parsed.success) throw new Error('Invalid Agent Node projection'); + const handle = space(canvasId); + const canvas = await handle.read(); + const stored = (canvas?.state.nodes as CanvasNode[] | undefined)?.find( + (node) => node.id === nodeId, + ); + if ( + stored?.type !== 'question' || + stored.data.threadId !== transition.threadId + ) + return false; + if ( + transition.expectedInvocationToken !== undefined && + stored.data.invocationToken !== transition.expectedInvocationToken + ) + return false; + if ( + stored.data.bindingState === 'bound' && + transition.bindingState === 'editing' + ) { + throw new Error('An established Agent binding cannot be demoted'); + } + const record = await handle.nodes.read(nodeId); + const node = hydrateCanvasNodes( + record ? new Map([[nodeId, record]]) : new Map(), + [stored], + )[0]; + if (!node) return false; + const { + threadId: _threadId, + expectedInvocationToken: _expected, + initialContent, + ...patch + } = parsed.data; + const data: Record = { ...node.data, ...patch }; + if ( + initialContent !== undefined && + !node.data.invocationToken && + typeof node.data.content === 'string' && + node.data.content.trim().length === 0 + ) + data.content = initialContent; + const output = await applyDeltasOnServerAlreadyLocked({ + canvasId, + deltas: [{ type: 'REPLACE_NODE', prev: node, next: { ...node, data } }], + originator: { source: 'system' }, + agentNodeProjection: true, + }); + if (output.toVersion > output.fromVersion) { + publishCanvasUpdate(canvasId, { + type: 'update', + data: { + fromVersion: output.fromVersion, + toVersion: output.toVersion, + deltas: output.deltas, + pendingEffects: output.pendingEffects, + agentNodeProjection: true, + }, + }); + } + return true; +} + +export async function acknowledgeAgentNodeResult( + canvasId: string, + nodeId: string, + invocationToken: string | null, +): Promise { + return withCanvasMutex(canvasId, async () => { + const canvas = await space(canvasId).read(); + const node = (canvas?.state.nodes as CanvasNode[] | undefined)?.find( + (item) => item.id === nodeId, + ); + if ( + node?.type !== 'question' || + (node.data.invocationToken ?? null) !== invocationToken || + (node.data.status !== 'done' && node.data.status !== 'error') || + typeof node.data.threadId !== 'string' + ) + return false; + return projectAgentNodeStateAlreadyLocked(canvasId, nodeId, { + threadId: node.data.threadId, + ...(invocationToken !== null + ? { expectedInvocationToken: invocationToken } + : {}), + viewed: true, + }); + }); +} diff --git a/apps/server/src/modules/canvas/canvas-executor.ts b/apps/server/src/modules/canvas/canvas-executor.ts index 29951ed9d..771f791d1 100644 --- a/apps/server/src/modules/canvas/canvas-executor.ts +++ b/apps/server/src/modules/canvas/canvas-executor.ts @@ -50,12 +50,21 @@ import { executeCanvasCommands, extractCanvasChanges, nodeRevision, + preserveAgentNodeOwnedData, + projectAgentNodeEditableData, + replayAgentNodeEditableData, + changesAgentNodePreparation, type CanvasChangeRecord, type CanvasEdge, type CanvasNode, type Delta, } from '@huabu/shared/canvas-engine'; +import { + guardAgentNodeDraftEditsAlreadyLocked, + initializeAgentNodeCreationAlreadyLocked, + validateAgentNodeEditableData, +} from './agent-node-edit.js'; import { publishCanvasUpdate } from './canvas-sync.js'; import { importForeignNodeSources } from './import-node-src.js'; import { @@ -598,6 +607,8 @@ export interface ExecuteOnServerInput { computeChanges?: boolean; /** Internal coordination hook; ordinary callers always publish. */ publish?: boolean; + /** Validated cross-Space move retains the original execution identity. */ + agentNodeMoveState?: ReadonlyMap>; } export interface ExecuteOnServerOutput { @@ -719,9 +730,9 @@ export async function executeOnServer( export async function executeOnServerAlreadyLocked( input: ExecuteOnServerInput, ): Promise { - const { canvasId, originator, runId } = input; + const { canvasId } = input; assertCurrentCanvasCommands(input.commands); - let commands = [...input.commands]; + const commands = [...input.commands]; const handle = space(canvasId); const canvas = await handle.read(); @@ -731,8 +742,6 @@ export async function executeOnServerAlreadyLocked( // topology needs its stored content before the engine sees it. const records = await handle.nodes.list(); - const fromVersion = canvas.version; - // Hydrate per-node content from .md sidecars before the engine sees // the prestate — handlers like MERGE_NODE_DATA need the current // `data.content` to merge against, but topology never carries it. @@ -740,7 +749,83 @@ export async function executeOnServerAlreadyLocked( records, canvas.state.nodes as CanvasNode[], ); - const prestateEdges = (canvas.state.edges ?? []) as CanvasEdge[]; + const draftPatches = new Map>(); + for (const command of commands) { + if (command.type === 'CREATE_NODES') { + for (const node of command.nodes) { + if ( + prestateNodes.some( + (current) => current.id === node.id && current.type === 'question', + ) + ) { + throw new Error( + 'An existing Agent Node identity cannot be recreated', + ); + } + if (node.id) + draftPatches.set(node.id, { + ...draftPatches.get(node.id), + ...node.data, + }); + } + } + if (command.type !== 'MERGE_NODE_DATA') continue; + for (const entry of command.patches) { + if ( + prestateNodes.some( + (node) => node.id === entry.nodeId && node.type === 'question', + ) + ) + validateAgentNodeEditableData(entry.patch); + draftPatches.set(entry.nodeId, { + ...draftPatches.get(entry.nodeId), + ...entry.patch, + }); + } + } + const releaseDrafts = await guardAgentNodeDraftEditsAlreadyLocked( + canvasId, + prestateNodes.flatMap((current) => { + const patch = draftPatches.get(current.id); + return patch ? [{ current, patch }] : []; + }), + ); + try { + // Canonical confirmation can persist Bound while this mutex is held. + const confirmedCanvas = await handle.read(); + if (!confirmedCanvas) throw new CanvasNotFoundError(canvasId); + const promoted = confirmedCanvas.version !== canvas.version; + return await executePreparedOnServerAlreadyLocked(input, { + handle, + canvas: confirmedCanvas, + commands, + prestateNodes: promoted + ? hydrateCanvasNodes( + await handle.nodes.list(), + confirmedCanvas.state.nodes as CanvasNode[], + ) + : prestateNodes, + prestateEdges: (confirmedCanvas.state.edges ?? []) as CanvasEdge[], + }); + } finally { + releaseDrafts(); + } +} + +async function executePreparedOnServerAlreadyLocked( + input: ExecuteOnServerInput, + prepared: { + handle: ReturnType; + canvas: CanvasFile; + commands: CanvasCommand[]; + prestateNodes: CanvasNode[]; + prestateEdges: CanvasEdge[]; + }, +): Promise { + const { canvasId, originator, runId } = input; + const { handle, canvas, prestateNodes, prestateEdges } = prepared; + let commands = prepared.commands; + const fromVersion = canvas.version; // Automatic preprocessing can arrive after an agent or user rename. // Filter only its label fields at the actual commit, not at request time. @@ -876,7 +961,37 @@ export async function executeOnServerAlreadyLocked( // Pure host-agnostic cleanups (edge handle reroute) — same path the // web's `executeCommands` runs before its set(). const sharedOut = applySharedPostEffectsFromWriteResult(writeResult); - const finalNodes = writeResult.nodes; + const originalNodes = new Map(prestateNodes.map((node) => [node.id, node])); + const finalNodes = await Promise.all( + writeResult.nodes.map(async (node) => { + const original = originalNodes.get(node.id); + if (original === node) return node; + if (original?.type === 'question') { + if (node.type !== 'question') + throw new Error('An Agent Node cannot change type'); + return { + ...node, + data: preserveAgentNodeOwnedData(node.data, original.data), + }; + } + if (node.type !== 'question' || original) return node; + const moved = input.agentNodeMoveState?.get(node.id); + return moved + ? { ...node, data: preserveAgentNodeOwnedData(node.data, moved) } + : initializeAgentNodeCreationAlreadyLocked(canvasId, node); + }), + ); + const threadOwners = new Set(); + for (const node of finalNodes) { + if (node.type !== 'question' || typeof node.data.threadId !== 'string') + continue; + if (threadOwners.has(node.data.threadId)) + throw new Error('A thread cannot belong to multiple Agent Nodes'); + threadOwners.add(node.data.threadId); + } + pendingEffects.mutatedNodes = pendingEffects.mutatedNodes.map( + (node) => finalNodes.find((candidate) => candidate.id === node.id) ?? node, + ); const finalEdges = sharedOut.edges; const assertResultAllowed = originator.source === 'system' @@ -1200,6 +1315,8 @@ export async function applyDeltasOnServerAlreadyLocked(input: { deltas: readonly Delta[]; originator: ExecuteOriginator; runId?: string; + /** Only the internal lifecycle writer may bypass editable inverse projection. */ + agentNodeProjection?: boolean; }): Promise<{ canvasId: string; fromVersion: number; @@ -1215,55 +1332,238 @@ export async function applyDeltasOnServerAlreadyLocked(input: { const { canvasId, originator, runId } = input; const handle = space(canvasId); - const canvas = await handle.read(); + let canvas = await handle.read(); if (!canvas) throw new CanvasNotFoundError(canvasId); // Executor prestate is whole-Space work: every md-backed node in the // topology needs its stored content before the engine sees it. const records = await handle.nodes.list(); - const fromVersion = canvas.version; - const prestateNodes = hydrateCanvasNodes( + let fromVersion = canvas.version; + let prestateNodes = hydrateCanvasNodes( records, canvas.state.nodes as CanvasNode[], ); const prestateEdges = (canvas.state.edges ?? []) as CanvasEdge[]; + const liveNodes = new Map(prestateNodes.map((node) => [node.id, node])); + const editableDeltas = input.agentNodeProjection + ? input.deltas + : input.deltas.map((delta): Delta => { + if (delta.type !== 'REPLACE_NODE') return delta; + const current = liveNodes.get(delta.next.id); + if (current?.type !== 'question') return delta; + const next = { + ...delta.next, + data: replayAgentNodeEditableData( + current.data, + delta.prev.data, + delta.next.data, + ), + }; + liveNodes.set(next.id, next); + return { ...delta, next }; + }); const final = applyDeltas( { nodes: prestateNodes, edges: prestateEdges }, - input.deltas, + editableDeltas, ); - const finalNodes = final.nodes; - const finalEdges = final.edges; + const previousById = new Map(prestateNodes.map((node) => [node.id, node])); + const replacedNodeIds = new Set( + input.deltas.flatMap((delta) => + delta.type === 'REPLACE_NODE' ? [delta.next.id] : [], + ), + ); + const releaseDrafts = input.agentNodeProjection + ? () => {} + : await guardAgentNodeDraftEditsAlreadyLocked( + canvasId, + final.nodes.flatMap((node) => { + const current = previousById.get(node.id); + return current && replacedNodeIds.has(node.id) + ? [ + { + current, + patch: { + ...node.data, + agentBinding: node.data.agentBinding ?? { + kind: 'internal', + }, + agentLaunchOverrides: + node.data.agentLaunchOverrides ?? null, + }, + }, + ] + : []; + }), + ); + try { + if (!input.agentNodeProjection) { + const confirmedCanvas = await handle.read(); + if (!confirmedCanvas) throw new CanvasNotFoundError(canvasId); + if (confirmedCanvas.version !== canvas.version) { + canvas = confirmedCanvas; + fromVersion = canvas.version; + prestateNodes = hydrateCanvasNodes( + await handle.nodes.list(), + canvas.state.nodes as CanvasNode[], + ); + previousById.clear(); + for (const node of prestateNodes) previousById.set(node.id, node); + } + } + const finalNodes = input.agentNodeProjection + ? final.nodes + : await Promise.all( + final.nodes.map(async (node) => { + const current = previousById.get(node.id); + if (!current && node.type === 'question') { + return initializeAgentNodeCreationAlreadyLocked( + canvasId, + { + ...node, + data: { + ...projectAgentNodeEditableData(node.data), + threadId: node.data.threadId ?? createId('thread'), + bindingState: 'editing', + }, + }, + node.data.bindingState === 'bound', + ); + } + if (current?.type !== 'question') return node; + if ( + current.data.bindingState === 'bound' && + changesAgentNodePreparation(current.data, { + ...node.data, + agentBinding: node.data.agentBinding ?? { kind: 'internal' }, + agentLaunchOverrides: node.data.agentLaunchOverrides ?? null, + }) + ) + throw new Error( + 'Cannot undo execution preparation after binding', + ); + return { + ...node, + type: current.type, + data: preserveAgentNodeOwnedData(node.data, current.data), + }; + }), + ); + const threadOwners = new Set(); + for (const node of finalNodes) { + if (node.type !== 'question' || typeof node.data.threadId !== 'string') + continue; + if (threadOwners.has(node.data.threadId)) + throw new Error('A thread cannot belong to multiple Agent Nodes'); + threadOwners.add(node.data.threadId); + } + const finalEdges = final.edges; + + // Reverts bypass commands, not topology policy. + const liveCanvasIds = isWorldCanvasId(canvasId) + ? await readLiveSpaceIds() + : EMPTY_CANVAS_IDS; + const assertResultAllowed = + originator.source === 'system' + ? assertWorldPreviewResultAllowed + : assertWorldPreviewTopologyAllowed; + assertResultAllowed(canvasId, prestateNodes, finalNodes, liveCanvasIds); + + // Recompute the authoritative diff so the log row and broadcast + // reflect exactly what landed (tolerates already-applied / missing + // targets in the input deltas). + const deltas = diffCanvasState( + { nodes: prestateNodes, edges: prestateEdges }, + { nodes: finalNodes, edges: finalEdges }, + ); - // Reverts bypass commands, not policy. Both validators reject retired - // topology for every source; system compensation keeps its creation path. - const liveCanvasIds = isWorldCanvasId(canvasId) - ? await readLiveSpaceIds() - : EMPTY_CANVAS_IDS; - const assertResultAllowed = - originator.source === 'system' - ? assertWorldPreviewResultAllowed - : assertWorldPreviewTopologyAllowed; - assertResultAllowed(canvasId, prestateNodes, finalNodes, liveCanvasIds); + const mutatedNodes: CanvasNode[] = []; + const deletedNodeIds: string[] = []; + const contentEditedNodeIds: string[] = []; - // Recompute the authoritative diff so the log row and broadcast - // reflect exactly what landed (tolerates already-applied / missing - // targets in the input deltas). - const deltas = diffCanvasState( - { nodes: prestateNodes, edges: prestateEdges }, - { nodes: finalNodes, edges: finalEdges }, - ); + if (deltas.length === 0) { + return { + canvasId, + fromVersion, + toVersion: fromVersion, + deltas, + pendingEffects: { + mutatedNodes, + deletedNodeIds, + contentEditedNodeIds, + deferredFitFrameIds: [], + }, + }; + } + + const toVersion = fromVersion + 1; - const mutatedNodes: CanvasNode[] = []; - const deletedNodeIds: string[] = []; - const contentEditedNodeIds: string[] = []; + for (const d of deltas) { + if (d.type === 'INSERT_NODE' || d.type === 'REPLACE_NODE') { + const node = d.type === 'INSERT_NODE' ? d.node : d.next; + mutatedNodes.push(node); + if (d.type === 'REPLACE_NODE') contentEditedNodeIds.push(node.id); + } else if (d.type === 'DELETE_NODE') { + deletedNodeIds.push(d.node.id); + } + } + const insertedIds = insertedNodeIds(deltas); + const nodeMutations: SpaceNodeMutation[] = []; + for (const d of deltas) { + if (d.type === 'INSERT_NODE' || d.type === 'REPLACE_NODE') { + const node = d.type === 'INSERT_NODE' ? d.node : d.next; + const record = buildNodeContent(node); + if (record) { + nodeMutations.push({ + kind: 'put', + nodeId: record.nodeId, + record, + strictLabel: record['labelSource'] === 'user', + authoritativeInsert: insertedIds.has(record.nodeId), + }); + } + } else if (d.type === 'DELETE_NODE') { + nodeMutations.push({ kind: 'delete', nodeId: d.node.id }); + } + } + + const nextRecord: CanvasFile = { + ...canvas, + version: toVersion, + state: { + ...canvas.state, + nodes: stripNodesForCanvas(finalNodes), + edges: finalEdges, + }, + updatedAt: Date.now(), + }; + const write = await handle.write({ + expectedVersion: fromVersion, + nextRecord, + nodeMutations, + delta: { + version: toVersion, + ts: Date.now(), + ...(runId ? { runId } : {}), + commands: [], + deltas: deltas as unknown[], + originator, + }, + }); + if (!write.ok) { + if (write.reason === 'not-found') { + throw new CanvasNotFoundError(canvasId); + } + throw new Error( + `[canvas-executor] ordered Space write rejected: ${write.reason}`, + ); + } - if (deltas.length === 0) { return { canvasId, fromVersion, - toVersion: fromVersion, + toVersion, deltas, pendingEffects: { mutatedNodes, @@ -1272,81 +1572,7 @@ export async function applyDeltasOnServerAlreadyLocked(input: { deferredFitFrameIds: [], }, }; + } finally { + releaseDrafts(); } - - const toVersion = fromVersion + 1; - - for (const d of deltas) { - if (d.type === 'INSERT_NODE' || d.type === 'REPLACE_NODE') { - const node = d.type === 'INSERT_NODE' ? d.node : d.next; - mutatedNodes.push(node); - if (d.type === 'REPLACE_NODE') contentEditedNodeIds.push(node.id); - } else if (d.type === 'DELETE_NODE') { - deletedNodeIds.push(d.node.id); - } - } - const insertedIds = insertedNodeIds(deltas); - const nodeMutations: SpaceNodeMutation[] = []; - for (const d of deltas) { - if (d.type === 'INSERT_NODE' || d.type === 'REPLACE_NODE') { - const node = d.type === 'INSERT_NODE' ? d.node : d.next; - const record = buildNodeContent(node); - if (record) { - nodeMutations.push({ - kind: 'put', - nodeId: record.nodeId, - record, - strictLabel: record['labelSource'] === 'user', - authoritativeInsert: insertedIds.has(record.nodeId), - }); - } - } else if (d.type === 'DELETE_NODE') { - nodeMutations.push({ kind: 'delete', nodeId: d.node.id }); - } - } - - const nextRecord: CanvasFile = { - ...canvas, - version: toVersion, - state: { - ...canvas.state, - nodes: stripNodesForCanvas(finalNodes), - edges: finalEdges, - }, - updatedAt: Date.now(), - }; - const write = await handle.write({ - expectedVersion: fromVersion, - nextRecord, - nodeMutations, - delta: { - version: toVersion, - ts: Date.now(), - ...(runId ? { runId } : {}), - commands: [], - deltas: deltas as unknown[], - originator, - }, - }); - if (!write.ok) { - if (write.reason === 'not-found') { - throw new CanvasNotFoundError(canvasId); - } - throw new Error( - `[canvas-executor] ordered Space write rejected: ${write.reason}`, - ); - } - - return { - canvasId, - fromVersion, - toVersion, - deltas, - pendingEffects: { - mutatedNodes, - deletedNodeIds, - contentEditedNodeIds, - deferredFitFrameIds: [], - }, - }; } diff --git a/apps/server/src/modules/canvas/canvas.route.ts b/apps/server/src/modules/canvas/canvas.route.ts index 9c71d3dd7..8f42fc9f8 100644 --- a/apps/server/src/modules/canvas/canvas.route.ts +++ b/apps/server/src/modules/canvas/canvas.route.ts @@ -21,11 +21,29 @@ import { moveSelectionBodySchema, preprocessNodeBodySchema, putCanvasBodySchema, + canvasEditableNodeDataSchema, + acknowledgeAgentNodeResultBodySchema, + associateAgentNodeBodySchema, + associateAgentNodeParamsSchema, putNodeContentBodySchema, stripLegacyPortalTopology, } from '@huabu/shared'; import { nodeRevisionOf } from '@huabu/shared/canvas-engine'; +import { + preserveAgentNodeOwnedData, + projectAgentNodeEditableData, + changesAgentNodePreparation, +} from '@huabu/shared/canvas-engine'; +import { + associateAgentNode, + AgentNodeAssociationError, +} from './agent-node-association.js'; +import { + AgentNodeEditError, + guardAgentNodeDraftEditsAlreadyLocked, +} from './agent-node-edit.js'; +import { acknowledgeAgentNodeResult } from './agent-node-projection.js'; import { CanvasNotFoundError, applyDeltasOnServer, @@ -45,7 +63,9 @@ import { WorldPreviewMutationError, } from './world-preview-policy.js'; import { reconcileWorldPreviews } from './world-previews.js'; +import { withCanvasMutex } from './write-coordinator.js'; import { MAX_UPLOAD_BYTES } from '../../upload-limits.js'; +import { AgentNodeBindingError } from '../agent/agent-node-binding.js'; import { ARTIFACT_URL_REGEX } from '../artifact/utils.js'; import { getPreprocessDispatcher, getProfile } from '../preprocessing/index.js'; import { isLabelProtected } from '../preprocessing/label-policy.js'; @@ -722,6 +742,7 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // existing body but still refreshes the frontmatter. const wouldClobber = acceptsBody && + nodeType !== 'question' && incomingContent === '' && typeof existing?.content === 'string' && existing.content.length > 0; @@ -1136,6 +1157,55 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // --- PUT Canvas --- + fastify.post( + '/:canvasId/nodes/:nodeId/association', + async (request, reply) => { + const params = associateAgentNodeParamsSchema.safeParse(request.params); + const body = associateAgentNodeBodySchema.safeParse(request.body); + if (!params.success || !body.success) + return reply + .code(400) + .send({ message: 'Invalid Agent Node association' }); + try { + return reply.send( + await associateAgentNode( + params.data.canvasId, + params.data.nodeId, + body.data, + ), + ); + } catch (error) { + if ( + error instanceof AgentNodeAssociationError || + error instanceof AgentNodeBindingError + ) + return reply.code(409).send({ message: error.message }); + throw error; + } + }, + ); + + fastify.post<{ Params: { canvasId: string; nodeId: string } }>( + '/:canvasId/nodes/:nodeId/viewed', + async (request, reply) => { + const params = associateAgentNodeParamsSchema.safeParse(request.params); + const parsed = acknowledgeAgentNodeResultBodySchema.safeParse( + request.body, + ); + if (!parsed.success || !params.success) + return reply + .code(400) + .send({ message: 'Invalid result acknowledgement' }); + return reply.send({ + acknowledged: await acknowledgeAgentNodeResult( + params.data.canvasId, + params.data.nodeId, + parsed.data.invocationToken, + ), + }); + }, + ); + fastify.put<{ Params: { canvasId: string }; Body: PutCanvasRequest; @@ -1154,123 +1224,221 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { [key: string]: unknown; }; - const structured = getStructuredStore(); - const spaces = structured.spaces(); - const handle = space(canvasId); - const existing = await handle.read(); - const serverVersion = existing?.version ?? 0; - if (clientVersion !== serverVersion) { - return reply.code(409).send({ - code: 'CANVAS_VERSION_CONFLICT', - message: 'Canvas version mismatch', - serverVersion, - } satisfies CanvasConflictResponse); - } - - try { - assertWorldPreviewTopologyAllowed( - canvasId, - (existing?.state.nodes ?? []) as NodeLike[], - incomingState.nodes ?? [], - isWorldCanvasId(canvasId) - ? await readLiveSpaceIds() - : new Set(), - ); - } catch (error) { - if (error instanceof WorldPreviewMutationError) { - return reply.code(409).send({ message: error.message }); + return withCanvasMutex(canvasId, async () => { + const structured = getStructuredStore(); + const spaces = structured.spaces(); + const handle = space(canvasId); + let existing = await handle.read(); + let serverVersion = existing?.version ?? 0; + if (clientVersion !== serverVersion) { + return reply.code(409).send({ + code: 'CANVAS_VERSION_CONFLICT', + message: 'Canvas version mismatch', + serverVersion, + } satisfies CanvasConflictResponse); } - throw error; - } - const previousTitle = existing?.title ?? null; - // The record write below refuses to change the title — addressing is the - // rename operation's business. So the title it carries must be the one - // rename actually installed, not the one the client asked for: the two - // differ whenever the backend reconciles a title against its locator, and - // sending the requested title would make the write throw instead of - // returning a business result the route can answer with. - let nextTitle = title ?? previousTitle; - const titleChange = - typeof title === 'string' && title !== previousTitle - ? { title } - : undefined; - - if (existing !== null && titleChange !== undefined) { - let renamed; try { - renamed = await spaces.rename({ canvasId, ...titleChange }); - } catch (error) { - request.log.error( - { canvasId, err: toMessage(error) }, - 'Failed to rename canvas directory', + assertWorldPreviewTopologyAllowed( + canvasId, + (existing?.state.nodes ?? []) as NodeLike[], + incomingState.nodes ?? [], + isWorldCanvasId(canvasId) + ? await readLiveSpaceIds() + : new Set(), ); - return reply.code(500).send({ message: 'Failed to rename canvas' }); + } catch (error) { + if (error instanceof WorldPreviewMutationError) { + return reply.code(409).send({ message: error.message }); + } + throw error; } - if (!renamed.ok) { - switch (renamed.reason) { - case 'not-found': - return reply.code(404).send({ message: 'Canvas not found' }); - case 'title-conflict': - return reply.code(409).send({ - code: 'CANVAS_TITLE_CONFLICT', - message: `Another canvas already uses the title "${renamed.conflictingTitle ?? ''}"`, - conflictWith: renamed.conflictingTitle ?? '', - } satisfies CanvasConflictResponse); - case 'world-forbidden': - return reply - .code(403) - .send({ message: 'World canvas cannot be renamed' }); + + const previousTitle = existing?.title ?? null; + // The record write below refuses to change the title — addressing is the + // rename operation's business. So the title it carries must be the one + // rename actually installed, not the one the client asked for: the two + // differ whenever the backend reconciles a title against its locator, and + // sending the requested title would make the write throw instead of + // returning a business result the route can answer with. + let nextTitle = title ?? previousTitle; + const titleChange = + typeof title === 'string' && title !== previousTitle + ? { title } + : undefined; + + if (existing !== null && titleChange !== undefined) { + let renamed; + try { + renamed = await spaces.rename({ canvasId, ...titleChange }); + } catch (error) { + request.log.error( + { canvasId, err: toMessage(error) }, + 'Failed to rename canvas directory', + ); + return reply.code(500).send({ message: 'Failed to rename canvas' }); + } + if (!renamed.ok) { + switch (renamed.reason) { + case 'not-found': + return reply.code(404).send({ message: 'Canvas not found' }); + case 'title-conflict': + return reply.code(409).send({ + code: 'CANVAS_TITLE_CONFLICT', + message: `Another canvas already uses the title "${renamed.conflictingTitle ?? ''}"`, + conflictWith: renamed.conflictingTitle ?? '', + } satisfies CanvasConflictResponse); + case 'world-forbidden': + return reply + .code(403) + .send({ message: 'World canvas cannot be renamed' }); + } + } else { + nextTitle = renamed.record.title; } - } else { - nextTitle = renamed.record.title; } - } - const timestamp = nowMs(); - const nextVersion = serverVersion + 1; + const timestamp = nowMs(); - const rawState = incomingState; + const rawState = incomingState; - const slimNodes = stripNodesForCanvas( - (rawState?.nodes ?? []) as NodeLike[], - ); + const currentById = new Map( + ((existing?.state.nodes ?? []) as NodeLike[]).map((node) => [ + node.id, + node, + ]), + ); + let releaseDrafts: () => void; + try { + for (const node of rawState.nodes ?? []) { + if (currentById.get(node.id)?.type !== 'question') continue; + const parsed = canvasEditableNodeDataSchema.safeParse( + node.data ?? {}, + ); + if (!parsed.success) + throw new AgentNodeEditError( + 'Agent Node lifecycle and association are server-owned', + ); + } + releaseDrafts = await guardAgentNodeDraftEditsAlreadyLocked( + canvasId, + (rawState.nodes ?? []).flatMap((node) => { + const current = currentById.get(node.id); + return current ? [{ current, patch: node.data ?? {} }] : []; + }), + ); + } catch (error) { + if (error instanceof AgentNodeEditError) { + return reply + .code(400) + .send({ code: 'INVALID_REQUEST', message: error.message }); + } + if (error instanceof AgentNodeBindingError) { + return reply + .code(409) + .send({ code: error.code, message: error.message }); + } + throw error; + } + try { + // Admission above may have completed a canonical record-to-Bound write. + const confirmedCanvas = await handle.read(); + if (existing !== null && confirmedCanvas === null) { + return reply.code(404).send({ message: 'Canvas not found' }); + } + existing = confirmedCanvas; + serverVersion = existing?.version ?? 0; + const nextVersion = serverVersion + 1; + currentById.clear(); + for (const node of (existing?.state.nodes ?? []) as NodeLike[]) + currentById.set(node.id, node); + const composedNodes: NodeLike[] = []; + for (const node of rawState.nodes ?? []) { + const current = currentById.get(node.id ?? ''); + if (current?.type === 'question') { + if (node.type !== undefined && node.type !== 'question') { + return reply + .code(409) + .send({ message: 'Agent Node type cannot change' }); + } + if ( + current.data?.bindingState === 'bound' && + changesAgentNodePreparation(current.data, node.data ?? {}) + ) + return reply.code(409).send({ + message: 'Agent preparation cannot change after binding', + }); + const composedData = preserveAgentNodeOwnedData( + { ...current.data, ...node.data }, + current.data ?? {}, + ); + if (node.data?.agentLaunchOverrides === null) + delete composedData.agentLaunchOverrides; + composedNodes.push({ + ...current, + ...node, + type: current.type, + data: composedData, + }); + } else { + composedNodes.push({ + ...current, + ...node, + data: { + ...(node.type === 'question' + ? projectAgentNodeEditableData({ + ...current?.data, + ...node.data, + }) + : { ...current?.data, ...node.data }), + ...(node.type === 'question' + ? { bindingState: 'editing', threadId: createId('thread') } + : {}), + }, + }); + } + } + const slimNodes = stripNodesForCanvas(composedNodes); - const canvasFile: CanvasFile = { - canvasId, - title: nextTitle, - version: nextVersion, - state: { - ...rawState, - nodes: slimNodes, - edges: rawState?.edges ?? [], - }, - createdAt: existing?.createdAt ?? timestamp, - updatedAt: timestamp, - }; + const canvasFile: CanvasFile = { + canvasId, + title: nextTitle, + version: nextVersion, + state: { + ...rawState, + nodes: slimNodes, + edges: rawState?.edges ?? [], + }, + createdAt: existing?.createdAt ?? timestamp, + updatedAt: timestamp, + }; - const outcome = await handle.write({ - expectedVersion: serverVersion, - nextRecord: canvasFile, - nodeMutations: [], - allowCreate: existing === null, - }); - if (!outcome.ok) { - switch (outcome.reason) { - case 'not-found': - return reply.code(404).send({ message: 'Canvas not found' }); - case 'version-conflict': - return reply.code(409).send({ - code: 'CANVAS_VERSION_CONFLICT', - message: 'Canvas version mismatch', - serverVersion: outcome.actualVersion, - } satisfies CanvasConflictResponse); - } - } + const outcome = await handle.write({ + expectedVersion: serverVersion, + nextRecord: canvasFile, + nodeMutations: [], + allowCreate: existing === null, + }); + if (!outcome.ok) { + switch (outcome.reason) { + case 'not-found': + return reply.code(404).send({ message: 'Canvas not found' }); + case 'version-conflict': + return reply.code(409).send({ + code: 'CANVAS_VERSION_CONFLICT', + message: 'Canvas version mismatch', + serverVersion: outcome.actualVersion, + } satisfies CanvasConflictResponse); + } + } - return reply.send({ - canvasId, - version: nextVersion, + return reply.send({ + canvasId, + version: nextVersion, + }); + } finally { + releaseDrafts(); + } }); }); @@ -1371,6 +1539,14 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { if (err instanceof WorldPreviewMutationError) { return reply.code(409).send({ message: err.message }); } + if (err instanceof AgentNodeBindingError) { + return reply.code(409).send({ code: err.code, message: err.message }); + } + if (err instanceof AgentNodeEditError) { + return reply + .code(400) + .send({ code: 'INVALID_REQUEST', message: err.message }); + } request.log.error({ canvasId, err }, 'Failed to execute canvas commands'); return reply.code(500).send({ message: 'Failed to execute canvas commands', @@ -1452,6 +1628,9 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { if (err instanceof CanvasNotFoundError) { return reply.code(404).send({ message: 'Canvas not found' }); } + if (err instanceof AgentNodeBindingError) { + return reply.code(409).send({ code: err.code, message: err.message }); + } if (err instanceof WorldPreviewMutationError) { return reply.code(409).send({ message: err.message }); } diff --git a/apps/server/src/modules/canvas/space-move.service.test.ts b/apps/server/src/modules/canvas/space-move.service.test.ts index bf893f901..a02a3a6f5 100644 --- a/apps/server/src/modules/canvas/space-move.service.test.ts +++ b/apps/server/src/modules/canvas/space-move.service.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { executeOnServer } from './canvas-executor.js'; import * as canvasSync from './canvas-sync.js'; import { moveCanvasSelection } from './space-move.service.js'; +import { acquireAgentTurn } from '../agent/turn-lease.js'; import { createCanvas } from '../storage/compatibility/canvas.js'; import { getCanvasStore, resetStorageCache, space } from '../storage/index.js'; import { getStructuredStore } from '../storage/index.js'; @@ -79,6 +80,94 @@ async function seedSource() { } describe('moveCanvasSelection', () => { + it('moves an unbound draft without manufacturing an execution record', async () => { + createCanvas('source', 'Source'); + createCanvas('destination', 'Destination'); + const seeded = await executeOnServer({ + canvasId: 'source', + originator: { source: 'ui' }, + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-draft', + nodeType: 'question', + position: { x: 0, y: 0 }, + data: { + threadId: 'thread-draft', + content: 'Draft', + agentBinding: { kind: 'internal' }, + }, + }, + ], + }, + ], + }); + const result = await moveCanvasSelection('source', { + selectedNodeIds: ['node-draft'], + destination: { kind: 'existing', canvasId: 'destination' }, + createSourcePreview: false, + expectedSourceVersion: seeded.toVersion, + }); + const moved = (await space('destination').read())?.state.nodes[0] as { + data: Record; + }; + expect(moved.data).toMatchObject({ + bindingState: 'editing', + threadId: 'thread-draft', + }); + expect(moved.data).not.toHaveProperty('invocationToken'); + expect(result.movedNodeCount).toBe(1); + }); + + it('does not wait for turn admission while holding both Canvas locks', async () => { + createCanvas('source', 'Source'); + createCanvas('destination', 'Destination'); + const seeded = await executeOnServer({ + canvasId: 'source', + originator: { source: 'ui' }, + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-busy', + nodeType: 'question', + position: { x: 0, y: 0 }, + data: { threadId: 'thread-busy', content: '' }, + }, + ], + }, + ], + }); + const release = acquireAgentTurn('thread-busy'); + expect(release).not.toBeNull(); + try { + await expect( + moveCanvasSelection('source', { + selectedNodeIds: ['node-busy'], + destination: { kind: 'existing', canvasId: 'destination' }, + createSourcePreview: false, + expectedSourceVersion: seeded.toVersion, + }), + ).rejects.toMatchObject({ code: 'MOVE_AGENT_RUNNING' }); + const edited = await executeOnServer({ + canvasId: 'source', + originator: { source: 'ui' }, + commands: [ + { + type: 'SET_NODE_GEOMETRY', + items: [{ nodeId: 'node-busy', position: { x: 20, y: 20 } }], + }, + ], + }); + expect(edited.results[0]?.applied).toBe(true); + } finally { + release?.(); + } + }); + it('compensates both committed Spaces without retaining the source breadcrumb', async () => { const seeded = await seedSource(); const sourceBefore = (await space('source').read())!; diff --git a/apps/server/src/modules/canvas/space-move.service.ts b/apps/server/src/modules/canvas/space-move.service.ts index d56e36df1..279b68fb1 100644 --- a/apps/server/src/modules/canvas/space-move.service.ts +++ b/apps/server/src/modules/canvas/space-move.service.ts @@ -39,6 +39,8 @@ import { EXTERNAL_DRIVER_KIND, INTERNAL_DRIVER_KIND, } from '../agent/agenetes/drivers.js'; +import { agentNodeBinding } from '../agent/agent-node-binding.js'; +import { agentThreadResolver } from '../agent/agent-thread-resolver.js'; import { agentThreadService } from '../agent/agent-thread.service.js'; import { acquireAgentTurn } from '../agent/turn-lease.js'; import { @@ -308,11 +310,36 @@ export async function moveCanvasSelection( const namespace = canvasAcpNamespace(sourceCanvasId); const record = agenetes.record(namespace, threadId); if (!record) { + const target = await agentThreadResolver.resolveAgentNode( + sourceCanvasId, + threadId, + ); + if (!target) + throw new SpaceMoveError( + 'MOVE_AGENT_HISTORY_INVALID', + 'Agent Node is missing', + ); + await agentNodeBinding.confirm(target, { alreadyLocked: true }); + continue; + } + const target = await agentThreadResolver.resolveAgentNode( + sourceCanvasId, + threadId, + ); + if (!target) throw new SpaceMoveError( 'MOVE_AGENT_HISTORY_INVALID', - `Agent conversation ${threadId} has no durable record`, + 'Agent Node is missing', ); - } + await agentNodeBinding.confirm( + { ...target, bindingState: 'bound' }, + { required: true, alreadyLocked: true }, + ); + const movedNode = hydratedSource.find( + (node) => node.data.threadId === threadId, + ); + if (movedNode) + movedNode.data = { ...movedNode.data, bindingState: 'bound' }; threadMoves.push({ threadId, sourceSpec: record.spec, @@ -347,6 +374,19 @@ export async function moveCanvasSelection( commands: plan.commands, originator: { source: 'system' }, publish: false, + agentNodeMoveState: new Map( + rewrittenNodes + .filter((node) => node.type === 'question') + .map((node) => { + const destinationId = plan.nodeIdMap.get(node.id); + if (!destinationId) + throw new SpaceMoveError( + 'MOVE_AGENT_HISTORY_INVALID', + 'Moved Agent Node identity is missing', + ); + return [destinationId, node.data] as const; + }), + ), }); if ( destinationWrite.results.some((result) => !result.applied) || diff --git a/apps/server/src/modules/remote_fs/rfs.route.test.ts b/apps/server/src/modules/remote_fs/rfs.route.test.ts index 3881e2856..070e986c3 100644 --- a/apps/server/src/modules/remote_fs/rfs.route.test.ts +++ b/apps/server/src/modules/remote_fs/rfs.route.test.ts @@ -1484,96 +1484,101 @@ describe('POST /api/rfs/:canvasId/agent', () => { } }); - it('submits a prompt to an existing Agent through AgentThreadService', async () => { - const target: FixedAgentNodeTarget = { - canvasId: 'c1', - nodeId: 'node-fixed' as CanvasNodeId, - threadId: 'thread-fixed', - agentBinding: { - kind: 'external', - profileId: 'profile-fixed', - alias: 'Fixed Agent', - }, - status: 'idle', - content: '', - }; - const store = getCanvasStore('c1'); - store.write({ - canvasId: 'c1', - title: null, - version: 1, - state: { - nodes: [ - { - id: 'node-fixed', - type: 'question', - position: { x: 0, y: 0 }, - data: { - threadId: 'thread-fixed', - agentBindingPolicy: 'fixed', - agentBinding: target.agentBinding, + it.each(['fixed', 'selectable'] as const)( + 'submits a prompt to an existing %s Agent through AgentThreadService', + async (policy) => { + const target: FixedAgentNodeTarget = { + canvasId: 'c1', + nodeId: 'node-fixed' as CanvasNodeId, + threadId: 'thread-fixed', + agentBinding: { + kind: 'external', + profileId: 'profile-fixed', + alias: 'Fixed Agent', + }, + status: 'idle', + content: '', + }; + const store = getCanvasStore('c1'); + store.write({ + canvasId: 'c1', + title: null, + version: 1, + state: { + nodes: [ + { + id: 'node-fixed', + type: 'question', + position: { x: 0, y: 0 }, + data: { + threadId: 'thread-fixed', + agentBindingPolicy: policy, + agentBinding: target.agentBinding, + }, }, - }, - ], - edges: [], - }, - createdAt: Date.now(), - updatedAt: Date.now(), - }); - store.writeNode('node-fixed', { - nodeId: 'node-fixed', - type: 'question', - label: null, - content: '', - }); - - vi.spyOn(agentThreadService, 'resolveFixedTarget').mockResolvedValue( - target, - ); - const dispose = vi.fn().mockResolvedValue(undefined); - const invoke = vi - .spyOn(agentThreadService, 'invoke') - .mockImplementation(async (options) => ({ - binding: target.agentBinding, - fixedTarget: target, - signal: options.signal ?? new AbortController().signal, - dispose, - events: (async function* () { - yield { - type: 'done' as const, - data: { message: 'delegated answer' }, - }; - })(), - })); - - const app = await buildApp(); - try { - const res = await app.inject({ - method: 'POST', - url: '/rfs/c1/agent/thread-fixed/prompt', - headers: { - 'content-type': 'text/plain', + ], + edges: [], }, - payload: 'continue delegated work', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + store.writeNode('node-fixed', { + nodeId: 'node-fixed', + type: 'question', + label: null, + content: '', }); - expect(res.statusCode).toBe(200); - expect(res.body).toContain('data: delegated answer'); - expect(invoke).toHaveBeenCalledWith( - expect.objectContaining({ - threadId: 'thread-fixed', - canvasId: 'c1', - content: 'continue delegated work', - mode: 'operate', - fixedTarget: target, - }), + vi.spyOn(agentThreadService, 'resolveFixedTarget').mockResolvedValue( + policy === 'fixed' ? target : null, ); - expect(agentMocks.get).not.toHaveBeenCalled(); - expect(dispose).not.toHaveBeenCalled(); - } finally { - await app.close(); - } - }); + const dispose = vi.fn().mockResolvedValue(undefined); + const invoke = vi + .spyOn(agentThreadService, 'invoke') + .mockImplementation(async (options) => ({ + binding: target.agentBinding, + fixedTarget: target, + signal: options.signal ?? new AbortController().signal, + dispose, + events: (async function* () { + yield { + type: 'done' as const, + data: { message: 'delegated answer' }, + }; + })(), + })); + + const app = await buildApp(); + try { + const res = await app.inject({ + method: 'POST', + url: '/rfs/c1/agent/thread-fixed/prompt', + headers: { + 'content-type': 'text/plain', + }, + payload: 'continue delegated work', + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toContain('data: delegated answer'); + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: 'thread-fixed', + canvasId: 'c1', + content: 'continue delegated work', + mode: 'operate', + fixedTarget: policy === 'fixed' ? target : null, + agentTarget: expect.objectContaining({ nodeId: target.nodeId }), + envelope: expect.any(Function), + }), + ); + expect(agentMocks.get).not.toHaveBeenCalled(); + expect(dispose).not.toHaveBeenCalled(); + } finally { + await app.close(); + } + }, + ); it('uses text/plain to create and immediately start a Huabu Agent', async () => { seedNote('c1', 'node-anchor', 'Anchor', 'content'); @@ -1731,6 +1736,7 @@ describe('POST /api/rfs/:canvasId/agent', () => { }); it('returns thread_not_found before opening SSE', async () => { + seedNote('c1', 'node-other', 'Other', 'content'); vi.spyOn(agentThreadService, 'resolveFixedTarget').mockResolvedValue(null); const app = await buildApp(); diff --git a/apps/server/src/modules/remote_fs/rfs.route.ts b/apps/server/src/modules/remote_fs/rfs.route.ts index a2fe13a92..03f8e3a7c 100644 --- a/apps/server/src/modules/remote_fs/rfs.route.ts +++ b/apps/server/src/modules/remote_fs/rfs.route.ts @@ -1216,33 +1216,20 @@ const rfsRoutes: FastifyPluginAsync = async (app) => { ), ); } - let envelope; - try { - envelope = await buildChatEnvelope({ - content: initialPrompt, - anchorNodeId: target.nodeId, - canvasId, - logger: request.log, - }); - } catch (error) { - request.log.error( - { - error, + let preparationFailed = false; + const envelope = async () => { + try { + return await buildChatEnvelope({ + content: initialPrompt, + anchorNodeId: target.nodeId, canvasId, - nodeId: created.nodeId, - threadId: created.threadId, - }, - 'rfs created Agent prompt preparation failed', - ); - return reply - .code(500) - .send( - rfsError( - `Agent ${created.nodeId} was created with thread ${created.threadId}, but its first prompt could not be prepared.`, - 'prompt_preparation_failed', - ), - ); - } + logger: request.log, + }); + } catch (error) { + preparationFailed = true; + throw error; + } + }; let invocation; try { invocation = await agentThreadService.invoke({ @@ -1271,8 +1258,12 @@ const rfsRoutes: FastifyPluginAsync = async (app) => { .code(500) .send( rfsError( - `Agent ${created.nodeId} was created with thread ${created.threadId}, but its first turn could not start.`, - 'invocation_failed', + preparationFailed + ? `Agent ${created.nodeId} was created with thread ${created.threadId}, but its first prompt could not be prepared.` + : `Agent ${created.nodeId} was created with thread ${created.threadId}, but its first turn could not start.`, + preparationFailed + ? 'prompt_preparation_failed' + : 'invocation_failed', ), ); } @@ -1341,11 +1332,15 @@ const rfsRoutes: FastifyPluginAsync = async (app) => { } let target; + let fixedTarget; try { - target = await agentThreadService.resolveFixedTarget( + fixedTarget = await agentThreadService.resolveFixedTarget( canvasId, threadId, ); + target = + fixedTarget ?? + (await agentThreadResolver.resolveAgentNode(canvasId, threadId)); } catch (error) { if (error instanceof AgentThreadResolutionError) { return reply @@ -1365,12 +1360,13 @@ const rfsRoutes: FastifyPluginAsync = async (app) => { ); } - const envelope = await buildChatEnvelope({ - content: prompt, - anchorNodeId: target.nodeId, - canvasId, - logger: request.log, - }); + const envelope = () => + buildChatEnvelope({ + content: prompt, + anchorNodeId: target.nodeId, + canvasId, + logger: request.log, + }); let invocation; try { invocation = await agentThreadService.invoke({ @@ -1379,7 +1375,8 @@ const rfsRoutes: FastifyPluginAsync = async (app) => { content: prompt, mode: 'operate', envelope, - fixedTarget: target, + fixedTarget, + agentTarget: target, logger: request.log, }); } catch (error) { diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 7ff8cea67..0909267c8 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -63,6 +63,10 @@ export const routes = { `/canvas/${enc(canvasId)}/nodes/${enc(nodeId)}`, canvasNodeContent: (canvasId: string, nodeId: string) => `/canvas/${enc(canvasId)}/nodes/${enc(nodeId)}/content`, + agentNodeResultViewed: (canvasId: string, nodeId: string) => + `/canvas/${enc(canvasId)}/nodes/${enc(nodeId)}/viewed`, + agentNodeAssociation: (canvasId: string, nodeId: string) => + `/canvas/${enc(canvasId)}/nodes/${enc(nodeId)}/association`, canvasNodePreprocess: (canvasId: string, nodeId: string) => `/canvas/${enc(canvasId)}/nodes/${enc(nodeId)}/preprocess`, canvasRevealNodes: (canvasId: string) => diff --git a/apps/web/src/api/canvas.ts b/apps/web/src/api/canvas.ts index be38d90c0..78d3b3c7f 100644 --- a/apps/web/src/api/canvas.ts +++ b/apps/web/src/api/canvas.ts @@ -5,6 +5,10 @@ import { ApiError, apiFetch, apiUrl } from './_client'; import { routes } from './_routes'; import type { + AcknowledgeAgentNodeResultBody, + AcknowledgeAgentNodeResultResponse, + AssociateAgentNodeBody, + AssociateAgentNodeResponse, ApiErrorBody, CanvasConflictResponse, CanvasErrorCode, @@ -30,6 +34,36 @@ import type { MoveSelectionResponse, } from '@huabu/shared'; +export async function associateAgentNode( + canvasId: string, + nodeId: string, + body: AssociateAgentNodeBody, +): Promise { + return apiFetch( + routes.agentNodeAssociation(canvasId, nodeId), + { + method: 'POST', + json: body, + fallbackMessage: 'Failed to associate Agent conversation', + }, + ); +} + +export async function acknowledgeAgentNodeResult( + canvasId: string, + nodeId: string, + body: AcknowledgeAgentNodeResultBody, +): Promise { + return apiFetch( + routes.agentNodeResultViewed(canvasId, nodeId), + { + method: 'POST', + json: body, + fallbackMessage: 'Failed to mark Agent result viewed', + }, + ); +} + /** * Error thrown when a canvas mutation is rejected by the server with a * structured 409 conflict (`CanvasConflictResponse`). Callers can branch on diff --git a/apps/web/src/components/Nodes/question/QuestionNode.tsx b/apps/web/src/components/Nodes/question/QuestionNode.tsx index ff3e904f0..09a8f467f 100644 --- a/apps/web/src/components/Nodes/question/QuestionNode.tsx +++ b/apps/web/src/components/Nodes/question/QuestionNode.tsx @@ -5,11 +5,12 @@ import { MessageSquare } from 'lucide-react'; import { memo, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { createId, getQuestionNodeStatus } from '@huabu/shared'; +import { getQuestionNodeStatus } from '@huabu/shared'; import './QuestionNode.css'; import { FloatingToolbar } from '@/components/Common/FloatingToolbar.tsx'; +import { toast } from '@/components/Common/Toast'; import { useActivelyViewingQuestionNode } from '@/hooks/useActivelyViewingQuestion'; import { useTextNodeSurface } from '@/hooks/useTextNodeSurface'; import { useAcpProfilesStore } from '@/store/acpProfilesStore.ts'; @@ -22,6 +23,10 @@ import { useChatStore, } from '@/store/chatStore.ts'; import { findPendingPermissionRequestId } from '@/store/chatTypes.ts'; +import { + acknowledgeConversationResult, + resolveConversationAgentBinding, +} from '@/store/conversationOwner'; import { usePreviewWorkspaceStore } from '@/store/previewWorkspace/store'; import { getQuestionFontOpts, @@ -37,6 +42,7 @@ import { NodeWrapper } from '../NodeWrapper'; import { enterQuestionCompose, enterQuestionConversation, + ensureQuestionThread, } from './questionCompose.ts'; import { QuestionTakeoverMark } from './QuestionTakeoverMark.tsx'; import { TextNodeBody } from '../shared/TextNodeBody'; @@ -66,7 +72,6 @@ const STICKY_BG = 'var(--question-bg)'; export const QuestionNode = memo( ({ id, data, selected, width }: NodeProps) => { const { t } = useTranslation(); - const patchNodeSilent = useCanvasStore((state) => state.patchNodeSilent); const textareaRef = useRef(null); // On-canvas anchor text. Prefer the generated `label` (a concise @@ -199,7 +204,23 @@ export const QuestionNode = memo( ); // Mark as viewed only once the run has finished. if (hasRun && !data.viewed) { - patchNodeSilent(id, { viewed: true }); + void acknowledgeConversationResult( + { + presentationAnchor: { canvasId, nodeId: id }, + conversationOwner: { + canvasId, + nodeId: id, + threadId: data.threadId, + }, + }, + { + status: data.status, + viewed: data.viewed, + invocationToken: data.invocationToken, + }, + ).catch((error) => + console.error('Failed to acknowledge Agent result', error), + ); } }, [ @@ -210,7 +231,8 @@ export const QuestionNode = memo( needsApproval, hasRun, canvasId, - patchNodeSilent, + data.invocationToken, + data.status, ], ); @@ -221,22 +243,28 @@ export const QuestionNode = memo( // ------------------------------------------------------------------ const openInCompose = useCallback( (transient = false) => { - let threadId = data.threadId; - if (!threadId) { - threadId = createId('thread'); - patchNodeSilent(id, { threadId }); - } - enterQuestionCompose( - { - presentationAnchor: { canvasId, nodeId: id }, - conversationOwner: { canvasId, nodeId: id, threadId }, - }, - canvasId, - data.agentBinding, - { transient }, - ); + void ensureQuestionThread(canvasId, id) + .then((threadId) => + enterQuestionCompose( + { + presentationAnchor: { canvasId, nodeId: id }, + conversationOwner: { canvasId, nodeId: id, threadId }, + }, + canvasId, + data.agentBinding, + { transient }, + ), + ) + .catch((error) => + toast( + error instanceof Error + ? error.message + : 'Failed to open Agent conversation', + { tone: 'danger' }, + ), + ); }, - [id, data.threadId, data.agentBinding, canvasId, patchNodeSilent], + [id, data.agentBinding, canvasId], ); // ------------------------------------------------------------------ @@ -289,17 +317,16 @@ export const QuestionNode = memo( const isDoneUnviewed = status === 'done' && !viewed; const isErrorUnviewed = status === 'error' && !viewed; - const effectiveBinding = (isOpenForQuestion - ? composeAgentBinding - : data.agentBinding) ?? - data.agentBinding ?? { kind: 'internal' as const }; + const effectiveBinding = resolveConversationAgentBinding( + data, + composeAgentBinding ?? { kind: 'internal' }, + ); const agentPresentation = resolveQuestionAgentPresentation({ binding: effectiveBinding, fallbackIcon: data.agentIcon, profiles: agentProfiles, - agentMode: isOpenForQuestion - ? composeAgentMode - : (data.agentMode ?? 'ask'), + agentMode: + data.agentMode ?? (isOpenForQuestion ? composeAgentMode : 'ask'), }); // `open` is the highest-priority badge state, BUT only while the chat // panel is actually visible: whenever this node's conversation is open in diff --git a/apps/web/src/components/Nodes/question/questionCompose.test.ts b/apps/web/src/components/Nodes/question/questionCompose.test.ts index 2b7f1b153..d678f47eb 100644 --- a/apps/web/src/components/Nodes/question/questionCompose.test.ts +++ b/apps/web/src/components/Nodes/question/questionCompose.test.ts @@ -1,7 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const saveDraft = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const associateNode = vi.hoisted(() => vi.fn()); +vi.mock('@/api/canvas', async (importOriginal) => ({ + ...(await importOriginal()), + associateAgentNode: associateNode, +})); +vi.mock('@/store/conversationOwner', async (importOriginal) => ({ + ...(await importOriginal()), + saveConversationDraft: saveDraft, +})); import useCanvasStore from '@/store/canvasStore'; import { @@ -19,8 +30,12 @@ import { import { enterQuestionCompose, enterQuestionConversation, + ensureQuestionThread, } from './questionCompose'; +import type * as CanvasApi from '@/api/canvas'; +import type * as ConversationOwner from '@/store/conversationOwner'; + const view = { presentationAnchor: { canvasId: 'canvas-1', nodeId: 'question-1' }, conversationOwner: { @@ -31,6 +46,8 @@ const view = { }; beforeEach(() => { + saveDraft.mockClear(); + associateNode.mockReset(); useCanvasStore.getState()._setStateNoAutosave({ canvasId: 'canvas-1', nodes: [], @@ -49,6 +66,62 @@ beforeEach(() => { }); describe('Question conversation presentation', () => { + it('keeps legacy compose by awaiting a server-minted identity', async () => { + useCanvasStore.getState()._setStateNoAutosave({ + version: 1, + nodes: [ + { + id: 'question-1', + type: 'question', + position: { x: 0, y: 0 }, + data: { content: '' }, + }, + ], + }); + associateNode.mockResolvedValue({ + fromVersion: 1, + toVersion: 2, + node: { + id: 'question-1', + type: 'question', + position: { x: 0, y: 0 }, + data: { threadId: 'thread-legacy', bindingState: 'editing' }, + }, + }); + expect(await ensureQuestionThread('canvas-1', 'question-1')).toBe( + 'thread-legacy', + ); + expect(associateNode).toHaveBeenCalledWith('canvas-1', 'question-1', { + kind: 'initialize', + }); + expect(useCanvasStore.getState().nodes[0].data).toMatchObject({ + threadId: 'thread-legacy', + bindingState: 'editing', + }); + expect(await ensureQuestionThread('canvas-1', 'question-1')).toBe( + 'thread-legacy', + ); + expect(associateNode).toHaveBeenCalledOnce(); + }); + + it('does not invent an identity when the association is rejected', async () => { + useCanvasStore.getState()._setStateNoAutosave({ + nodes: [ + { + id: 'question-1', + type: 'question', + position: { x: 0, y: 0 }, + data: {}, + }, + ], + }); + associateNode.mockRejectedValue(new Error('Identity is missing')); + await expect( + ensureQuestionThread('canvas-1', 'question-1'), + ).rejects.toThrow('Identity is missing'); + expect(useCanvasStore.getState().nodes[0].data.threadId).toBeUndefined(); + }); + it('opens an authored Question as a workspace node tab', () => { enterQuestionConversation(view, undefined, 'canvas-1', 'bottom'); @@ -108,5 +181,10 @@ describe('Question conversation presentation', () => { expect(selectThreadBinding(useChatStore.getState(), 'thread-1')).toEqual( binding, ); + expect(saveDraft).toHaveBeenCalledWith(view, { + agentBinding: binding, + agentMode: 'ask', + agentIcon: expect.objectContaining({ shape: expect.any(String) }), + }); }); }); diff --git a/apps/web/src/components/Nodes/question/questionCompose.ts b/apps/web/src/components/Nodes/question/questionCompose.ts index 00627f39c..6a5241688 100644 --- a/apps/web/src/components/Nodes/question/questionCompose.ts +++ b/apps/web/src/components/Nodes/question/questionCompose.ts @@ -12,11 +12,19 @@ */ import { createId } from '@huabu/shared'; +import { associateAgentNode } from '@/api/canvas'; +import { toast } from '@/components/Common/Toast'; +import { useAcpProfilesStore } from '@/store/acpProfilesStore'; import useCanvasStore from '@/store/canvasStore.ts'; import { useChatStore } from '@/store/chatStore.ts'; +import { + resolveConversationOwnerSource, + saveConversationDraft, +} from '@/store/conversationOwner'; import { usePanelStore } from '@/store/panelStore.ts'; import { openPreviewNode } from '@/store/previewWorkspace/actions.ts'; import { usePreviewWorkspaceStore } from '@/store/previewWorkspace/store.ts'; +import { snapshotAgentIcon } from '@/utils/agentIcon'; import type { AddNodeInput } from '@/handler/canvasCommand/uiIntent.ts'; import type { @@ -24,6 +32,55 @@ import type { AgentConversationView, CanvasNodeId, } from '@huabu/shared'; +import type { Node } from '@xyflow/react'; + +/** Legacy Questions acquire an identity on the server before compose opens. */ +export async function ensureQuestionThread( + canvasId: string, + nodeId: string, +): Promise { + const initial = useCanvasStore.getState(); + const existing = initial.nodes.find((node) => node.id === nodeId); + if (initial.canvasId !== canvasId || existing?.type !== 'question') + throw new Error('Agent Node no longer exists'); + if (typeof existing.data.threadId === 'string' && existing.data.threadId) + return existing.data.threadId; + const response = await associateAgentNode(canvasId, nodeId, { + kind: 'initialize', + }); + const current = useCanvasStore.getState(); + const live = current.nodes.find((node) => node.id === nodeId); + if (current.canvasId !== canvasId || live?.type !== 'question') + throw new Error('Agent Node no longer exists'); + const confirmed = response.node as Node; + if (!live.data.threadId) { + current._setStateNoAutosave({ + nodes: current.nodes.map((node) => + node.id === nodeId + ? { + ...node, + data: { + ...node.data, + threadId: confirmed.data.threadId, + bindingState: confirmed.data.bindingState, + agentBinding: + confirmed.data.agentBinding ?? node.data.agentBinding, + }, + } + : node, + ), + ...(current.version === response.fromVersion + ? { version: response.toVersion } + : {}), + }); + } + const threadId = useCanvasStore + .getState() + .nodes.find((node) => node.id === nodeId)?.data.threadId; + if (typeof threadId !== 'string' || !threadId) + throw new Error('Agent Node association was not acknowledged'); + return threadId; +} function initializeQuestionBinding( view: AgentConversationView, @@ -33,12 +90,56 @@ function initializeQuestionBinding( ): void { const chat = useChatStore.getState(); const ownerCanvasId = canvasId ?? view.conversationOwner.canvasId; - const effectiveBinding = - binding ?? - (inheritCanvasDefault ? chat.bindingMap[ownerCanvasId] : undefined); + const effectiveBinding = binding ?? + (inheritCanvasDefault ? chat.bindingMap[ownerCanvasId] : undefined) ?? { + kind: 'internal' as const, + }; + const canvas = useCanvasStore.getState(); + const source = resolveConversationOwnerSource( + canvas.canvasId, + canvas.nodes, + view, + ); if (effectiveBinding) { chat.setAgentBinding(view.conversationOwner.threadId, effectiveBinding); } + const mode = + source?.agentMode ?? + (effectiveBinding.kind === 'internal' ? 'operate' : 'ask'); + chat.setThreadLastAction(view.conversationOwner.threadId, mode); + if ( + inheritCanvasDefault && + !source?.agentBinding && + source?.bindingState !== 'bound' + ) { + const profiles = useAcpProfilesStore.getState().profiles; + const profile = + effectiveBinding.kind === 'external' + ? profiles.find((entry) => entry.id === effectiveBinding.profileId) + : undefined; + const icon = snapshotAgentIcon(effectiveBinding, profiles); + void saveConversationDraft(view, { + agentBinding: + profile && effectiveBinding.kind === 'external' + ? { ...effectiveBinding, alias: profile.alias } + : effectiveBinding, + agentMode: mode, + ...(icon ? { agentIcon: icon } : {}), + }) + .then(() => + chat.makeThreadMetadataEphemeral(view.conversationOwner.threadId, { + preserveSettings: true, + }), + ) + .catch((error) => + toast( + error instanceof Error + ? error.message + : 'Failed to save Agent selection', + { tone: 'danger' }, + ), + ); + } } /** Open an authored Question conversation in the active presentation mode. */ diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx index e7cf8dc9d..8e3e62e60 100644 --- a/apps/web/src/components/Panels/ChatPanel/index.tsx +++ b/apps/web/src/components/Panels/ChatPanel/index.tsx @@ -26,6 +26,7 @@ import { PermissionTray } from '@/components/Messages/AIMessage/PermissionCard'; import { useAcpProfiles } from '@/hooks/useAcpProfiles'; import { useAcpSessionMeta } from '@/hooks/useAcpSessionMeta'; import { useAcpSlashCommands } from '@/hooks/useAcpSlashCommands'; +import { useActivelyViewingQuestionNode } from '@/hooks/useActivelyViewingQuestion'; import { useBuiltinThreadSettings } from '@/hooks/useBuiltinThreadSettings'; import { ChatSessionProvider, type ChatSession } from '@/hooks/useChatSession'; import { useInternalSlashCommands } from '@/hooks/useInternalSlashCommands'; @@ -43,6 +44,9 @@ import { } from '@/store/chatStore'; import { findPendingPermissionRequest } from '@/store/chatTypes'; import { + acknowledgeConversationResult, + awaitConversationDraft, + saveConversationDraft, resolveConversationAgentBinding, resolveConversationOwnerSource, } from '@/store/conversationOwner'; @@ -155,7 +159,31 @@ export const ChatPanel = ({ return d.agentBinding?.kind === 'external' ? 'ask' : (d.agentMode ?? 'ask'); })(); const viewingQuestionBindingIsFixed = - conversationOwnerSource?.agentBindingPolicy === 'fixed'; + conversationOwnerSource?.agentBindingPolicy === 'fixed' || + conversationOwnerSource?.bindingState === 'bound'; + const [savingAgentDraft, setSavingAgentDraft] = useState(false); + const activelyViewingOwner = useActivelyViewingQuestionNode( + activeConversationView?.presentationAnchor.nodeId ?? '', + ); + const observedToken = conversationOwnerSource?.invocationToken; + const observedStatus = conversationOwnerSource?.status; + const observedViewed = conversationOwnerSource?.viewed; + useEffect(() => { + if (!activeConversationView || !activelyViewingOwner) return; + void acknowledgeConversationResult(activeConversationView, { + invocationToken: observedToken, + status: observedStatus, + viewed: observedViewed, + }).catch((error) => + console.error('Failed to acknowledge Agent result', error), + ); + }, [ + activeConversationView, + activelyViewingOwner, + observedToken, + observedStatus, + observedViewed, + ]); // Bind-time avatar snapshot of the viewing question node, used as the // fallback icon in the agent chip when the bound external Profile no // longer exists — mirrors how the canvas node preserves its identity. @@ -316,7 +344,9 @@ export const ChatPanel = ({ useEffect(() => { if (viewingQuestionBindingIsFixed) return; if (!isHistoryLoaded) return; - if (messages.length > 0) return; + // Node-backed selection is never silently rebound after a Profile vanishes. + // An Editing node can have failed preparation text, and Bound can be empty. + if (activeConversationView || messages.length > 0) return; if (!acpProfilesLoaded) return; if (agentBinding.kind !== 'external') return; const profileExists = acpProfiles.some( @@ -326,6 +356,7 @@ export const ChatPanel = ({ setAgentBinding(threadId, { kind: 'internal' }, canvasId || undefined); }, [ isHistoryLoaded, + activeConversationView, messages.length, acpProfilesLoaded, agentBinding, @@ -452,7 +483,9 @@ export const ChatPanel = ({ provider: llmConfig?.provider, defaultModelId: llmConfig?.model, enabled: ownerScopeReady && agentBinding.kind !== 'external', - threadHasMessages: messages.length > 0, + threadHasMessages: activeConversationView + ? conversationOwnerSource?.bindingState === 'bound' + : messages.length > 0, }); // Three-state connection summary for the header badge, derived from @@ -525,6 +558,8 @@ export const ChatPanel = ({ selection: { id: MODE_SELECTION_ID, value: modeId }, }); try { + if (activeConversationView) + await awaitConversationDraft(activeConversationView); if (!acpControlTarget.binding) return; await setAcpSessionMode(threadId, { modeId, @@ -549,6 +584,7 @@ export const ChatPanel = ({ threadId, applyAcpSessionMetaOptimistic, acpControlTarget, + activeConversationView, refreshAcpSessionMeta, onCommit, t, @@ -564,6 +600,8 @@ export const ChatPanel = ({ selection: { id: MODEL_SELECTION_ID, value: modelId }, }); try { + if (activeConversationView) + await awaitConversationDraft(activeConversationView); if (!acpControlTarget.binding) return; await setAcpSessionModel(threadId, { modelId, @@ -588,6 +626,7 @@ export const ChatPanel = ({ threadId, applyAcpSessionMetaOptimistic, acpControlTarget, + activeConversationView, refreshAcpSessionMeta, onCommit, t, @@ -602,6 +641,8 @@ export const ChatPanel = ({ selection: { id: optionId, value }, }); try { + if (activeConversationView) + await awaitConversationDraft(activeConversationView); if (!acpControlTarget.binding) return; await setAcpSessionConfigOption(threadId, { configOptionId: optionId, @@ -627,6 +668,7 @@ export const ChatPanel = ({ threadId, applyAcpSessionMetaOptimistic, acpControlTarget, + activeConversationView, refreshAcpSessionMeta, onCommit, t, @@ -774,19 +816,52 @@ export const ChatPanel = ({ // place; it never mints a new thread. const threadHasUserMessage = messages.some((m) => m.role === 'user'); const agentSelectorEditable = - !viewingQuestionBindingIsFixed && !threadHasUserMessage && !isLoading; + !viewingQuestionBindingIsFixed && + (activeConversationView + ? conversationOwnerSource?.bindingState !== 'bound' + : !threadHasUserMessage) && + !savingAgentDraft && + !isLoading; const handleSelectAgent = useCallback( - (choice: AgentChoice) => { + async (choice: AgentChoice) => { // Agent binding is immutable once a turn starts (1 thread = 1 binding). // The selector is already read-only then; keep this guard as defense in // depth in case a stale menu event arrives during the transition. - if (isLoading || viewingQuestionBindingIsFixed) return; + if (isLoading || savingAgentDraft || viewingQuestionBindingIsFixed) + return; + if (activeConversationView) { + setSavingAgentDraft(true); + try { + await saveConversationDraft(activeConversationView, { + agentBinding: choice.binding, + agentMode: choice.mode, + agentIcon: snapshotAgentIcon( + choice.binding, + useAcpProfilesStore.getState().profiles, + ), + }); + makeThreadMetadataEphemeral(threadId, { preserveSettings: true }); + } catch (error) { + toast( + error instanceof Error + ? error.message + : 'Failed to save Agent selection', + { tone: 'danger' }, + ); + return; + } finally { + setSavingAgentDraft(false); + } + } setAgentBinding(threadId, choice.binding, canvasId || undefined); setThreadLastAction(threadId, choice.mode); onCommit?.(); }, [ isLoading, + savingAgentDraft, + activeConversationView, + makeThreadMetadataEphemeral, onCommit, viewingQuestionBindingIsFixed, setAgentBinding, @@ -817,8 +892,6 @@ export const ChatPanel = ({ data: { type: 'question', content, - status: 'done', - viewed: true, threadId, agentBinding, agentIcon: snapshotAgentIcon( diff --git a/apps/web/src/handler/canvasCommand/postEffects.web.ts b/apps/web/src/handler/canvasCommand/postEffects.web.ts index ad4a1f815..3e740d77b 100644 --- a/apps/web/src/handler/canvasCommand/postEffects.web.ts +++ b/apps/web/src/handler/canvasCommand/postEffects.web.ts @@ -57,6 +57,7 @@ export interface RunWebPostEffectsInput { * a back-import cycle with the canvas store. */ forgetNodeContent: (nodeId: string) => void; + getPendingCreation?: (nodeId: string) => Promise | undefined; waitForNodeContent?: () => Promise; /** Remove Preview Workspace tabs whose node targets were deleted. */ validatePreviewNodes: (liveNodeIds: ReadonlySet) => void; @@ -79,6 +80,7 @@ export function runWebPostEffects(input: RunWebPostEffectsInput): void { setNodes, triggerPreprocessing, forgetNodeContent, + getPendingCreation, validatePreviewNodes, } = input; @@ -115,6 +117,7 @@ export function runWebPostEffects(input: RunWebPostEffectsInput): void { canvasId, nodeId, input.waitForNodeContent?.(), + getPendingCreation?.(nodeId), ); // Release the node's per-node save-queue state so a long session of // create/delete churn doesn't leak bookkeeping keyed by dead ids. diff --git a/apps/web/src/handler/canvasCommand/resolvers/resolveAddNodes.ts b/apps/web/src/handler/canvasCommand/resolvers/resolveAddNodes.ts index 6e66d8809..67e0a814a 100644 --- a/apps/web/src/handler/canvasCommand/resolvers/resolveAddNodes.ts +++ b/apps/web/src/handler/canvasCommand/resolvers/resolveAddNodes.ts @@ -163,7 +163,12 @@ function materializeAddNode( node: { id: nodeId, nodeType: input.nodeType, - data: input.data as never, + data: (input.nodeType === 'question' + ? { + ...input.data, + threadId: input.data?.threadId ?? createId('thread'), + } + : input.data) as never, position, ...(size && { size }), ...(parentId && { parentId }), diff --git a/apps/web/src/handler/canvasCommand/resolvers/resolvePasteClipboard.ts b/apps/web/src/handler/canvasCommand/resolvers/resolvePasteClipboard.ts index 0c17c6ad3..87172e8b9 100644 --- a/apps/web/src/handler/canvasCommand/resolvers/resolvePasteClipboard.ts +++ b/apps/web/src/handler/canvasCommand/resolvers/resolvePasteClipboard.ts @@ -121,16 +121,21 @@ export default function resolvePasteClipboard( // Reset question node runtime state so the copy starts fresh - // UNLESS it's a forked copy (`__forkConversation`), in which case - // `pasteNodes` already assigned a new threadId + status and queued a + // `pasteNodes` already assigned a new threadId and queued a // server-side history fork, so we keep the conversation pointer and // only drop the transient marker. - if (clonedData.type === 'question') { + if (nodeType === 'question') { + delete clonedData.bindingState; + delete clonedData.invocationToken; + delete clonedData.viewed; + delete clonedData.status; + delete clonedData.errorMessage; if (clonedData.__forkConversation) { delete clonedData.__forkConversation; } else { delete clonedData.status; delete clonedData.runAt; - delete clonedData.threadId; + clonedData.threadId = createId('thread'); delete clonedData.errorMessage; delete clonedData.responseSummary; } diff --git a/apps/web/src/hooks/useAgentStream.ts b/apps/web/src/hooks/useAgentStream.ts index 3992822a5..ac981f11e 100644 --- a/apps/web/src/hooks/useAgentStream.ts +++ b/apps/web/src/hooks/useAgentStream.ts @@ -16,9 +16,8 @@ import { import { agentApi } from '@/api/agent'; import { toast } from '@/components/Common/Toast'; -import { isActivelyViewingQuestion } from '@/hooks/useActivelyViewingQuestion'; import { i18n } from '@/i18n'; -import { useAcpProfilesStore } from '@/store/acpProfilesStore'; +import { useAcpThreadChangesStore } from '@/store/acpThreadChangesStore'; import useCanvasStore from '@/store/canvasStore'; import { selectThreadBinding, @@ -31,8 +30,7 @@ import { import { conversationRequestScope, ConversationIntegrityError, - filterClientOwnedQuestionPatch, - patchConversationOwnerNode, + awaitConversationDraft, resolveConversationAgentBinding, resolveConversationOwnerSource, shouldComposeConversationOwner, @@ -45,7 +43,6 @@ import { } from '@/store/conversationTitleStore'; import { useGesturePreviewStore } from '@/store/gesturePreviewStore'; import { usePreviewWorkspaceStore } from '@/store/previewWorkspace/store'; -import { snapshotAgentIcon } from '@/utils/agentIcon'; import { isPageUnloading } from '@/utils/pageLifecycle'; import { @@ -704,6 +701,7 @@ export function useAgentStream( if (conversationView) { try { + await awaitConversationDraft(conversationView); await validateConversationView(conversationView); } catch (error) { if (error instanceof ConversationIntegrityError) { @@ -712,7 +710,13 @@ export function useAgentStream( }); return; } - throw error; + toast( + error instanceof Error + ? error.message + : 'Failed to save Agent selection', + { tone: 'danger' }, + ); + return; } // Validation is async, so confirm this renderer still owns the same // tab. Closing or replacing it invalidates the pending send. @@ -751,6 +755,15 @@ export function useAgentStream( const requestScope = conversationRequestScope(conversationView, canvasId); const anchorQuestionNodeId = conversationView?.conversationOwner.nodeId ?? null; + const refreshAfterLifecycle = async () => { + if (!conversationView) return; + await useAcpThreadChangesStore + .getState() + .load( + conversationView.conversationOwner.canvasId, + conversationView.conversationOwner.threadId, + ); + }; // Selected node ids are still recorded on the persisted user // message so the UI can re-render the selection chip after a @@ -841,22 +854,8 @@ export function useAgentStream( streamClaim.release(); }; - // ── Question-node follow-up bookkeeping ───────────────────────── - // - // When this session renders a selectable Question Node, this hook keeps - // its client-authored lifecycle honest across follow-up turns. Fixed - // Agent Nodes route content/status/error through the server; this hook - // writes only their client presentation state (`viewed`). - // - // We also track whether a successful `done` event was observed so - // a late cap-out `error` event (`Agent loop exceeded maximum - // iterations`) emitted *after* a complete answer doesn't flip the - // node to `error` (issue 3 — tool failures during a successful - // agent run should not poison the final status). - const questionNodeId = conversationView?.conversationOwner.nodeId ?? null; - let sawDone = false; - let serverOwnsQuestionLifecycle = false; - let isComposingQuestion = false; + // Canvas Sync owns all node lifecycle projection. This stream only + // maintains request feedback and the conversation transcript. let serverSettingsConfirmed = false; const canvasState = useCanvasStore.getState(); const ownerSource = conversationView @@ -867,84 +866,8 @@ export function useAgentStream( ) : undefined; - if (questionNodeId && conversationView) { - // First send of a freshly-composed question node ⇔ the node is still - // `idle` (never authored/run). Derived from the node's own status so - // there is no stored `compose` flag to keep in sync. On that first - // send we author the node's `content` and lock in the agent the user - // picked in the inline selector (binding + built-in mode); follow-up - // turns skip both. - const isCompose = shouldComposeConversationOwner(ownerSource); - isComposingQuestion = isCompose; - serverOwnsQuestionLifecycle = - ownerSource?.agentBindingPolicy === 'fixed'; - if (isCompose && !serverOwnsQuestionLifecycle) { - // Author content through the intent pipeline so it gets a - // markdown sidecar save + server-side label preprocessing — - // matching how the inline editor used to commit the prompt. - useCanvasStore - .getState() - .updateNodeData(questionNodeId, { content: prompt }); - } - const selectedBinding = resolveConversationAgentBinding( - ownerSource, - selectThreadBinding(useChatStore.getState(), threadId), - ); - const selectedProfile = - selectedBinding.kind === 'external' - ? useAcpProfilesStore - .getState() - .profiles.find( - (profile) => profile.id === selectedBinding.profileId, - ) - : undefined; - const snapshotBinding = - selectedBinding.kind === 'external' && selectedProfile - ? { ...selectedBinding, alias: selectedProfile.alias } - : selectedBinding; - const composeBinding = - isCompose && !serverOwnsQuestionLifecycle - ? { - agentBinding: snapshotBinding, - agentIcon: snapshotAgentIcon( - selectedBinding, - useAcpProfilesStore.getState().profiles, - ), - agentMode, - } - : {}; - // Reset `viewed` so the layer-panel dot + on-canvas "done · unread" - // glow re-appear when the follow-up answer lands. Completion marks it - // viewed again if the user is still in the thread. - const startPatch = filterClientOwnedQuestionPatch(ownerSource, { - status: 'running', - errorMessage: undefined, - viewed: false, - ...composeBinding, - }); - try { - if (startPatch) { - await patchConversationOwnerNode(conversationView, startPatch); - if (isCompose) { - useChatStore.getState().makeThreadMetadataEphemeral(threadId, { - preserveSettings: true, - }); - } - } - } catch (error) { - const message = - error instanceof Error ? error.message : 'Unknown error'; - setThreadLoading(threadId, false); - releaseAbort(); - addMessage(threadId, { - id: createId('status'), - role: 'status', - status: 'error', - detail: message, - }); - return; - } - } + const isComposingQuestion = + !!conversationView && shouldComposeConversationOwner(ownerSource); // Make sure any buffered behavioural events have hit the server // before the agent builds its request context. Failures are @@ -990,7 +913,6 @@ export function useAgentStream( serverSettingsConfirmed = true; useChatStore.getState().makeThreadMetadataEphemeral(threadId); } - if (event.type === 'done') sawDone = true; handleStreamEvent(event, { threadId, assistantId, @@ -1001,46 +923,9 @@ export function useAgentStream( if (isPageUnloading() || errorHandled) return; errorHandled = true; console.error(`${agentMode} error:`, err); - // Question-node follow-up: only flip to `error` if no - // useful final `done` event ever arrived. A cap-out error - // emitted after a successful answer is treated as success. - if (questionNodeId) { - const stillViewing = isActivelyViewingQuestion({ - nodeId: questionNodeId, - }); - const terminalPatch = filterClientOwnedQuestionPatch( - serverOwnsQuestionLifecycle - ? { agentBindingPolicy: 'fixed' } - : undefined, - { - status: sawDone ? 'done' : 'error', - errorMessage: sawDone ? undefined : err.message, - ...(stillViewing ? { viewed: true } : {}), - }, - ); - if (conversationView && terminalPatch) { - void patchConversationOwnerNode( - conversationView, - terminalPatch, - ) - .catch((error) => - console.error( - '[useAgentStream] failed to persist owner error', - error, - ), - ) - .finally(() => { - setThreadLoading(threadId, false); - releaseAbort(); - }); - } else { - setThreadLoading(threadId, false); - releaseAbort(); - } - } else { - setThreadLoading(threadId, false); - releaseAbort(); - } + setThreadLoading(threadId, false); + releaseAbort(); + void refreshAfterLifecycle().catch(console.error); addMessage(threadId, { id: createId('status'), role: 'status', @@ -1049,47 +934,9 @@ export function useAgentStream( }); }, onComplete: () => { - if (questionNodeId) { - // If the user is still actively viewing this question - // thread at completion, count it as read — they watched - // the answer stream. Otherwise leave `viewed: false` so - // the layer-panel dot stays "unread" until they open it. - const stillViewing = isActivelyViewingQuestion({ - nodeId: questionNodeId, - }); - const terminalPatch = filterClientOwnedQuestionPatch( - serverOwnsQuestionLifecycle - ? { agentBindingPolicy: 'fixed' } - : undefined, - { - status: 'done', - errorMessage: undefined, - ...(stillViewing ? { viewed: true } : {}), - }, - ); - if (conversationView && terminalPatch) { - void patchConversationOwnerNode( - conversationView, - terminalPatch, - ) - .catch((error) => - console.error( - '[useAgentStream] failed to persist owner completion', - error, - ), - ) - .finally(() => { - setThreadLoading(threadId, false); - releaseAbort(); - }); - } else { - setThreadLoading(threadId, false); - releaseAbort(); - } - } else { - setThreadLoading(threadId, false); - releaseAbort(); - } + setThreadLoading(threadId, false); + releaseAbort(); + void refreshAfterLifecycle().catch(console.error); }, }, { @@ -1124,31 +971,7 @@ export function useAgentStream( if (abortController.signal.aborted) { setThreadLoading(threadId, false); releaseAbort(); - if (questionNodeId) { - // User stopped the stream while in the thread — count as - // viewed; otherwise leave unread so the dot reappears. - const stillViewing = isActivelyViewingQuestion({ - nodeId: questionNodeId, - }); - if (conversationView) { - const terminalPatch = filterClientOwnedQuestionPatch( - serverOwnsQuestionLifecycle - ? { agentBindingPolicy: 'fixed' } - : undefined, - { - status: 'done', - errorMessage: undefined, - ...(stillViewing ? { viewed: true } : {}), - }, - ); - if (terminalPatch) { - await patchConversationOwnerNode( - conversationView, - terminalPatch, - ); - } - } - } + void refreshAfterLifecycle().catch(console.error); return; } // Page unloading — don't persist error @@ -1157,27 +980,7 @@ export function useAgentStream( if (errorHandled) return; errorHandled = true; console.error(`${agentMode} failed:`, err); - if (questionNodeId) { - const message = err instanceof Error ? err.message : 'Unknown error'; - const stillViewing = isActivelyViewingQuestion({ - nodeId: questionNodeId, - }); - if (conversationView) { - const terminalPatch = filterClientOwnedQuestionPatch( - serverOwnsQuestionLifecycle - ? { agentBindingPolicy: 'fixed' } - : undefined, - { - status: sawDone ? 'done' : 'error', - errorMessage: sawDone ? undefined : message, - ...(stillViewing ? { viewed: true } : {}), - }, - ); - if (terminalPatch) { - await patchConversationOwnerNode(conversationView, terminalPatch); - } - } - } + void refreshAfterLifecycle().catch(console.error); setThreadLoading(threadId, false); releaseAbort(); addMessage(threadId, { diff --git a/apps/web/src/hooks/useChatHistory.test.tsx b/apps/web/src/hooks/useChatHistory.test.tsx index 2f07bac9f..1d00e58e7 100644 --- a/apps/web/src/hooks/useChatHistory.test.tsx +++ b/apps/web/src/hooks/useChatHistory.test.tsx @@ -25,8 +25,13 @@ import type { AgentStreamAttachResult } from '@/api/agent'; const apiMocks = vi.hoisted(() => ({ fetchHistoryPage: vi.fn(), reconnectStream: vi.fn( - async (): Promise => ({ status: 'inactive' }), + async ( + _threadId: string, + _canvasId: string, + _callbacks: { onComplete: () => void }, + ): Promise => ({ status: 'inactive' }), ), + patchOwner: vi.fn(), })); const canvasMock = vi.hoisted(() => ({ @@ -66,7 +71,7 @@ vi.mock('@/store/conversationOwner', () => ({ filterClientOwnedQuestionPatch: vi.fn( (_source: unknown, patch: Record) => patch, ), - patchConversationOwnerNode: vi.fn(), + patchConversationOwnerNode: apiMocks.patchOwner, resolveConversationOwnerSource: vi.fn(() => undefined), validateConversationView: vi.fn(async () => {}), })); @@ -75,6 +80,12 @@ vi.mock('@/hooks/useActivelyViewingQuestion', () => ({ isActivelyViewingQuestion: vi.fn(() => false), })); +vi.mock('@/store/acpThreadChangesStore', () => ({ + useAcpThreadChangesStore: { + getState: () => ({ load: vi.fn().mockResolvedValue(undefined) }), + }, +})); + const THREAD_ID = 'thread-1'; const CANVAS_ID = 'canvas-1'; @@ -157,6 +168,8 @@ beforeEach(() => { apiMocks.reconnectStream.mockReset(); apiMocks.reconnectStream.mockResolvedValue({ status: 'inactive' }); canvasMock.state.nodes = []; + canvasMock.state.patchNodeSilent.mockClear(); + apiMocks.patchOwner.mockClear(); latestLoadOlderHistory = undefined; }); @@ -283,50 +296,59 @@ describe('useChatHistory reconnect', () => { ); }); - it('attaches when an already-loaded Agent Node becomes running', async () => { - seedStore(false); - useChatStore.getState().setMessages(THREAD_ID, [ - { - id: 'prior-answer', - role: 'assistant', - segments: [{ kind: 'text', text: 'Previous answer' }], - }, - ]); - canvasMock.state.nodes = [ - { - id: 'node-agent', - type: 'question', - data: { - threadId: THREAD_ID, - status: 'running', - agentBindingPolicy: 'fixed', + it.each([undefined, 'selectable', 'fixed'])( + 'observes a running %s Agent Node without history-based repair', + async (policy) => { + seedStore(false); + useChatStore.getState().setMessages(THREAD_ID, [ + { + id: 'prior-answer', + role: 'assistant', + segments: [{ kind: 'text', text: 'Previous answer' }], }, - }, - ]; - const session: ChatSession = { - ...SESSION, - conversationView: { - presentationAnchor: { - canvasId: CANVAS_ID, - nodeId: 'node-agent', + ]); + canvasMock.state.nodes = [ + { + id: 'node-agent', + type: 'question', + data: { + threadId: THREAD_ID, + status: 'running', + agentBindingPolicy: policy, + }, }, - conversationOwner: { - canvasId: CANVAS_ID, - nodeId: 'node-agent', - threadId: THREAD_ID, + ]; + const session: ChatSession = { + ...SESSION, + conversationView: { + presentationAnchor: { + canvasId: CANVAS_ID, + nodeId: 'node-agent', + }, + conversationOwner: { + canvasId: CANVAS_ID, + nodeId: 'node-agent', + threadId: THREAD_ID, + }, }, - }, - }; - - await renderHarness(session); - - expect(apiMocks.fetchHistoryPage).toHaveBeenCalledWith( - THREAD_ID, - CANVAS_ID, - 3, - ); - expect(apiMocks.reconnectStream).toHaveBeenCalledTimes(1); - }); + }; + + await renderHarness(session); + + expect(apiMocks.fetchHistoryPage).toHaveBeenCalledWith( + THREAD_ID, + CANVAS_ID, + 3, + ); + expect(apiMocks.reconnectStream).toHaveBeenCalledTimes(1); + await act(async () => + apiMocks.reconnectStream.mock.calls[0][2].onComplete(), + ); + expect(canvasMock.state.nodes[0].data.status).toBe('running'); + expect(canvasMock.state.patchNodeSilent).not.toHaveBeenCalled(); + expect(apiMocks.patchOwner).not.toHaveBeenCalled(); + }, + ); it('reconnects when the newest page marks an assistant tail active', async () => { seedStore(false); diff --git a/apps/web/src/hooks/useChatHistory.ts b/apps/web/src/hooks/useChatHistory.ts index ab7d7ff07..cc90e5bdd 100644 --- a/apps/web/src/hooks/useChatHistory.ts +++ b/apps/web/src/hooks/useChatHistory.ts @@ -7,7 +7,7 @@ import { createId } from '@huabu/shared'; import { ApiError } from '@/api/_client'; import { agentApi } from '@/api/agent'; -import { isActivelyViewingQuestion } from '@/hooks/useActivelyViewingQuestion'; +import { useAcpThreadChangesStore } from '@/store/acpThreadChangesStore'; import useCanvasStore from '@/store/canvasStore'; import { useChatPreferencesStore } from '@/store/chatPreferencesStore'; import { @@ -19,9 +19,6 @@ import { } from '@/store/chatStore'; import { ConversationIntegrityError, - filterClientOwnedQuestionPatch, - patchConversationOwnerNode, - resolveConversationOwnerSource, validateConversationView, } from '@/store/conversationOwner'; import { @@ -459,72 +456,15 @@ export function useChatHistory( const assistantId = createId('message'); // Flag set to true once we know the server has an active run let streaming = false; - // Track whether a usable final `done` event arrived so a late - // cap-out error after a complete answer terminalizes as `done`. - let sawDone = false; - - // Drive the question node that owns the reconnected thread to a - // terminal status. Resolves the node by `data.threadId` so it - // works regardless of which thread is currently visible. Only - // rescues a still-live node (`running` / `pending`): never - // overrides a terminal status the originating run already wrote, - // nor resurrects a user cancel (`idle`). - const rescueQuestionNode = ( - forThreadId: string, - patch: Record, - ) => { - if ( - ownerView?.conversationOwner.threadId === forThreadId && - ownerView.conversationOwner.canvasId === ownerCanvasId - ) { - const canvas = useCanvasStore.getState(); - const ownerPatch = filterClientOwnedQuestionPatch( - resolveConversationOwnerSource( - canvas.canvasId, - canvas.nodes, - ownerView, - ), - patch, - ); - if (!ownerPatch) return; - void patchConversationOwnerNode(ownerView, ownerPatch).catch( - (error) => - console.error( - '[useChatHistory] failed to persist owner lifecycle', - error, - ), - ); - return; - } - const node = useCanvasStore - .getState() - .nodes.find( - (n) => - n.type === 'question' && - (n.data as Record | undefined)?.threadId === - forThreadId, - ); - if (!node) return; - const bindingPolicy = ( - node.data as { agentBindingPolicy?: unknown } | undefined - )?.agentBindingPolicy; - const ownerPatch = filterClientOwnedQuestionPatch( - bindingPolicy === 'fixed' || bindingPolicy === 'selectable' - ? { agentBindingPolicy: bindingPolicy } - : undefined, - patch, + const refreshObservation = () => { + if (!ownerView) return; + void Promise.all([ + useAcpThreadChangesStore + .getState() + .load(ownerCanvasId, ownerThreadId), + ]).catch((error) => + console.error('[useChatHistory] observation refresh failed', error), ); - if (!ownerPatch) return; - const curStatus = (node.data as Record | undefined) - ?.status; - if ( - bindingPolicy !== 'fixed' && - curStatus !== 'running' && - curStatus !== 'pending' - ) { - return; - } - useCanvasStore.getState().patchNodeSilent(node.id, ownerPatch); }; // The reconnect event buffer replays the active Tier-1 projection. @@ -549,7 +489,6 @@ export function useChatHistory( { onEvent: (event: AgentStreamEvent) => { if (cancelled) return; - if (event.type === 'done') sawDone = true; if (!streaming) { streaming = true; if (!effectiveConversationView) @@ -577,35 +516,14 @@ export function useChatHistory( detail: err.message, }); setIsLoading(ownerThreadId, false); - // A reconnected run that errors must still terminalize the - // owning question node — otherwise it stalls at `running`. - rescueQuestionNode( - ownerThreadId, - sawDone - ? { status: 'done', errorMessage: undefined } - : { status: 'error', errorMessage: err.message }, - ); + refreshObservation(); }, onComplete: () => { if (cancelled) return; if (!effectiveConversationView) refreshConversationTitleAfterStream(ownerCanvasId, ownerThreadId); setIsLoading(ownerThreadId, false); - // When the reconnect stream is the consumer that sees the run - // finish, the originating `useQuestionRunner` callback may - // never fire (its POST stream was superseded / dropped). Drive - // the question node to `done` here so the status badge + chat - // affordance reappear. Count it as viewed only if the user is - // actively watching — this thread is open AND the chat panel is - // expanded; a collapsed panel leaves the answer unread. - const stillViewing = isActivelyViewingQuestion({ - threadId: ownerThreadId, - }); - rescueQuestionNode(ownerThreadId, { - status: 'done', - errorMessage: undefined, - ...(stillViewing ? { viewed: true } : {}), - }); + refreshObservation(); }, }, claim.signal, diff --git a/apps/web/src/store/canvasHistoryManager.test.ts b/apps/web/src/store/canvasHistoryManager.test.ts index 06b0766e4..2fb0e2fcb 100644 --- a/apps/web/src/store/canvasHistoryManager.test.ts +++ b/apps/web/src/store/canvasHistoryManager.test.ts @@ -17,6 +17,52 @@ function node(id: string, x: number): Node { } describe('CanvasHistoryRegistry', () => { + it('undoes editable Question content and geometry while retaining the current FSM', () => { + const history = new CanvasHistoryRegistry(); + const before: Node = { + ...node('question', 0), + type: 'question', + data: { + content: 'Before', + bindingState: 'editing', + status: 'idle', + threadId: 'thread', + }, + }; + history.activate('canvas'); + history.takeSnapshot([before], []); + const current = { + ...before, + position: { x: 20, y: 0 }, + data: { + ...before.data, + content: 'After', + bindingState: 'bound', + status: 'done', + invocationToken: 'new', + viewed: false, + }, + }; + const restored = history.undo([current], [])?.nodes[0]; + expect(restored).toMatchObject({ + position: { x: 0, y: 0 }, + data: { + content: 'Before', + bindingState: 'bound', + status: 'done', + invocationToken: 'new', + viewed: false, + threadId: 'thread', + }, + }); + if (!restored) throw new Error('Question was not restored'); + expect(history.redo([restored], [])?.nodes[0]?.data).toMatchObject({ + content: 'After', + invocationToken: 'new', + bindingState: 'bound', + }); + }); + it('ignores legacy topology on undo and redo without losing ordinary child geometry', () => { const history = new CanvasHistoryRegistry(); history.activate('canvas-world'); diff --git a/apps/web/src/store/canvasHistoryManager.ts b/apps/web/src/store/canvasHistoryManager.ts index 9287a0b92..8f407fc47 100644 --- a/apps/web/src/store/canvasHistoryManager.ts +++ b/apps/web/src/store/canvasHistoryManager.ts @@ -6,12 +6,16 @@ import { stripTransientNodeFields, stripTransientEdgeFields, TRANSIENT_NODE_FIELDS, + preserveAgentNodeOwnedData, + projectAgentNodeEditableData, + AGENT_NODE_PREPARATION_KEYS, } from '@huabu/shared/canvas-engine'; import { ApiError, deleteNode } from '../api'; import { toast } from '../components/Common/Toast'; import type { RecentAction } from '@huabu/shared'; +import type { Delta } from '@huabu/shared/canvas-engine'; import type { Node, Edge } from '@xyflow/react'; const MAX_HISTORY = 50; @@ -90,7 +94,15 @@ function snapshotsEqual(a: CanvasSnapshot, b: CanvasSnapshot): boolean { if (a.nodes.length !== b.nodes.length || a.edges.length !== b.edges.length) return false; for (let i = 0; i < a.nodes.length; i++) { - if (JSON.stringify(a.nodes[i]) !== JSON.stringify(b.nodes[i])) return false; + const editable = (node: Node) => + node.type === 'question' + ? { ...node, data: projectAgentNodeEditableData(node.data) } + : node; + if ( + JSON.stringify(editable(a.nodes[i])) !== + JSON.stringify(editable(b.nodes[i])) + ) + return false; } for (let i = 0; i < a.edges.length; i++) { if (JSON.stringify(a.edges[i]) !== JSON.stringify(b.edges[i])) return false; @@ -98,27 +110,8 @@ function snapshotsEqual(a: CanvasSnapshot, b: CanvasSnapshot): boolean { return true; } -/** - * Question nodes own a conversational `data` payload (`content`, - * `threadId`, `status`, `viewed`, `agentBinding`, `agentMode`, - * `errorMessage`, `responseSummary`, plus the `label` derived from - * `content`). That payload is entirely system-driven — authored on - * send and mutated by the agent runner via `patchNodeSilent` — never a - * deliberate canvas edit. Undo/redo therefore restores a question - * node's geometry (position / size / parent, all top-level props) but - * must NOT rewind its `data` to a stale snapshot value: undoing a move - * should not wipe the thread binding or answer the node already holds. - * - * So for every question node that still exists in the live canvas we - * keep its current `data` and take only the structural props from the - * restored snapshot. Question nodes absent from the live canvas (undo - * is resurrecting a deleted node) fall back to the snapshot's `data` — - * the only source available, and the correct pre-deletion value. - * - * Direction-neutral: both undo and redo pop a target snapshot and own - * the live `currentNodes`, so the same merge applies to either. - */ -function preserveLiveQuestionData( +/** Restore editable effects while preserving live execution state and Bound preparation. */ +export function preserveLiveQuestionData( restoredNodes: Node[], currentNodes: Node[], ): Node[] { @@ -128,7 +121,14 @@ function preserveLiveQuestionData( const live = liveById.get(node.id); // Resurrection (no live node) → snapshot data is the correct source. if (!live) return node; - return { ...node, data: live.data }; + const data = preserveAgentNodeOwnedData(node.data, live.data); + if (live.data.bindingState === 'bound') { + for (const key of AGENT_NODE_PREPARATION_KEYS) { + if (key in live.data) data[key] = live.data[key]; + else delete data[key]; + } + } + return { ...node, data }; }); } @@ -181,6 +181,10 @@ class CanvasHistoryManager { // A fetch abort is not a server-side cancellation. Keep completion fences. private inflightDeletes = new Map>(); + waitForDeletion(nodeId: string): Promise { + return this.inflightDeletes.get(nodeId) ?? Promise.resolve(); + } + // ---- Gesture snapshot tracking ---- /** True when `beginGesture` has been called but the resulting command * batch has not yet been executed. Used by the executor to verify @@ -263,6 +267,44 @@ class CanvasHistoryManager { return true; } + /** First submission is a server effect, not an editable empty-content undo. */ + rebaseAgentInitialContent(deltas: readonly Delta[]): void { + for (const delta of deltas) { + if ( + delta.type !== 'REPLACE_NODE' || + delta.next.type !== 'question' || + delta.prev.data.invocationToken || + !delta.next.data.invocationToken || + typeof delta.prev.data.content !== 'string' || + delta.prev.data.content.trim() !== '' || + delta.prev.data.content === delta.next.data.content + ) + continue; + for (const snapshot of [...this.undoStack, ...this.redoStack]) { + snapshot.nodes = snapshot.nodes.map((node) => + node.id === delta.next.id && + node.type === 'question' && + !node.data.invocationToken && + node.data.content === delta.prev.data.content + ? { + ...node, + data: { ...node.data, content: delta.next.data.content }, + } + : node, + ); + } + } + } + + discardNodes(nodeIds: ReadonlySet): void { + for (const snapshot of [...this.undoStack, ...this.redoStack]) { + snapshot.nodes = snapshot.nodes.filter((node) => !nodeIds.has(node.id)); + snapshot.edges = snapshot.edges.filter( + (edge) => !nodeIds.has(edge.source) && !nodeIds.has(edge.target), + ); + } + } + // ---------- Undo / Redo ---------- /** @@ -327,13 +369,19 @@ class CanvasHistoryManager { prevNodes: Node[], restoredNodes: Node[], beforeDelete: () => Promise, + getPendingCreation?: (nodeId: string) => Promise | undefined, ): void { const restoredIds = new Set(restoredNodes.map((n) => n.id)); // Nodes that disappear after undo/redo for (const node of prevNodes) { if (!restoredIds.has(node.id)) { - this.trackDelete(canvasId, node.id, beforeDelete()); + this.trackDelete( + canvasId, + node.id, + beforeDelete(), + getPendingCreation?.(node.id), + ); } } } @@ -347,9 +395,17 @@ class CanvasHistoryManager { canvasId: string, nodeId: string, beforeDelete = Promise.resolve(), + pendingCreation?: Promise, ): void { const previous = this.inflightDeletes.get(nodeId); - const pending = Promise.all([previous, beforeDelete]) + // A failed creation acknowledgement may still have committed server-side. + const ready = pendingCreation + ? pendingCreation.then( + () => beforeDelete, + () => beforeDelete, + ) + : beforeDelete; + const pending = Promise.all([previous, ready]) .then(async () => { await deleteNode(canvasId, nodeId); }) @@ -362,7 +418,6 @@ class CanvasHistoryManager { this.inflightDeletes.delete(nodeId); } }); - this.inflightDeletes.set(nodeId, pending); } @@ -431,6 +486,14 @@ export class CanvasHistoryRegistry { return this.active.takeSnapshot(nodes, edges); } + rebaseAgentInitialContent(deltas: readonly Delta[]): void { + this.active.rebaseAgentInitialContent(deltas); + } + + discardNodes(canvasId: string, nodeIds: ReadonlySet): void { + this.managers.get(canvasId)?.discardNodes(nodeIds); + } + undo(nodes: Node[], edges: Edge[]): CanvasSnapshot | null { return this.active.undo(nodes, edges); } @@ -452,12 +515,14 @@ export class CanvasHistoryRegistry { prevNodes: Node[], restoredNodes: Node[], beforeDelete: () => Promise, + getPendingCreation?: (nodeId: string) => Promise | undefined, ): void { this.active.syncServerAfterRestore( canvasId, prevNodes, restoredNodes, beforeDelete, + getPendingCreation, ); } @@ -465,8 +530,15 @@ export class CanvasHistoryRegistry { canvasId: string, nodeId: string, beforeDelete?: Promise, + pendingCreation?: Promise, ): void { - this.active.trackDelete(canvasId, nodeId, beforeDelete); + this.active.trackDelete(canvasId, nodeId, beforeDelete, pendingCreation); + } + + waitForDeletion(canvasId: string, nodeId: string): Promise { + return ( + this.managers.get(canvasId)?.waitForDeletion(nodeId) ?? Promise.resolve() + ); } async waitForDeletes(canvasId: string): Promise { diff --git a/apps/web/src/store/canvasStore.agentDeltaConflict.test.ts b/apps/web/src/store/canvasStore.agentDeltaConflict.test.ts index c7dc7f9a8..05b5b9c8d 100644 --- a/apps/web/src/store/canvasStore.agentDeltaConflict.test.ts +++ b/apps/web/src/store/canvasStore.agentDeltaConflict.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { canvasHistoryManager } from './canvasHistoryManager'; import useCanvasStore from './canvasStore'; import type { Delta } from '@huabu/shared/canvas-engine'; @@ -51,6 +52,7 @@ function roundTripReplacement( beforeEach(() => { vi.useFakeTimers(); + canvasHistoryManager.activate('canvas-1', true); useCanvasStore.getState()._setStateNoAutosave({ canvasId: 'canvas-1', nodes: [question('', 'idle')], @@ -66,6 +68,64 @@ afterEach(() => { }); describe('agent delta conflict protection', () => { + it('keeps first submitted intent out of undo and rebases earlier geometry snapshots', () => { + const before = question('', 'idle'); + canvasHistoryManager.takeSnapshot([before], []); + const moved = { ...before, position: { x: 30, y: 0 } }; + useCanvasStore.getState()._setStateNoAutosave({ nodes: [moved] }); + const started = { + ...moved, + data: { + ...moved.data, + content: 'First intent', + status: 'running', + invocationToken: 'first', + }, + }; + useCanvasStore + .getState() + .applyDeltasFromAgent( + [{ type: 'REPLACE_NODE', prev: moved, next: started }], + 2, + pendingEffects, + true, + ); + const undone = canvasHistoryManager.undo( + useCanvasStore.getState().nodes, + [], + ); + expect(undone?.nodes[0]).toMatchObject({ + position: { x: 0, y: 0 }, + data: { + content: 'First intent', + status: 'running', + invocationToken: 'first', + }, + }); + expect(canvasHistoryManager.canUndo).toBe(false); + }); + + it('does not put FSM-only broadcasts into ordinary undo', () => { + useCanvasStore.getState().applyDeltasFromAgent( + [ + { + type: 'REPLACE_NODE', + prev: question('', 'idle'), + next: { + ...question('', 'running'), + data: { + ...question('', 'running').data, + bindingState: 'bound', + invocationToken: 'current', + }, + }, + }, + ], + 2, + pendingEffects, + ); + expect(canvasHistoryManager.canUndo).toBe(false); + }); it.each([ { name: 'keywords', @@ -164,7 +224,10 @@ describe('agent delta conflict protection', () => { .applyDeltasFromAgent([delta], 2, pendingEffects); expect(skipped).toEqual(['question-1']); - expect(useCanvasStore.getState().nodes[0]).toEqual(before); + expect(useCanvasStore.getState().nodes[0]).toEqual({ + ...before, + data: { ...before.data, status: 'running' }, + }); expect(useCanvasStore.getState().version).toBe(2); }, ); @@ -208,8 +271,49 @@ describe('agent delta conflict protection', () => { expect(skipped).toEqual(['question-1']); expect(useCanvasStore.getState().nodes[0]?.data).toMatchObject({ content: 'Pending prompt', - status: 'idle', + status: 'running', }); + expect(useCanvasStore.getState().version).toBe(2); }); + + it('accepts mixed FSM metadata without replacing dirty content or local geometry', () => { + useCanvasStore + .getState() + .patchNodeSilent('question-1', { content: 'Pending prompt' }); + const local = useCanvasStore.getState().nodes[0]; + const next = { + ...question('Remote prompt', 'done'), + position: { x: 500, y: 500 }, + data: { + ...question('Remote prompt', 'done').data, + bindingState: 'bound', + invocationToken: 'invocation-new', + viewed: false, + threadId: 'thread-1', + }, + }; + expect( + useCanvasStore + .getState() + .applyDeltasFromAgent( + [{ type: 'REPLACE_NODE', prev: question('', 'idle'), next }], + 2, + pendingEffects, + ), + ).toEqual(['question-1']); + expect(useCanvasStore.getState().nodes[0]).toMatchObject({ + position: local.position, + data: { + content: 'Pending prompt', + status: 'done', + bindingState: 'bound', + invocationToken: 'invocation-new', + viewed: false, + }, + }); + expect(useCanvasStore.getState().pendingContentNodeIds()).toContain( + 'question-1', + ); + }); }); diff --git a/apps/web/src/store/canvasStore.questionRestore.test.ts b/apps/web/src/store/canvasStore.questionRestore.test.ts new file mode 100644 index 000000000..2ed5138d1 --- /dev/null +++ b/apps/web/src/store/canvasStore.questionRestore.test.ts @@ -0,0 +1,416 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { nodeRevisionOf } from '@huabu/shared/canvas-engine'; + +const { associateNode, deleteNode, putCanvas, postCanvasExecute, forkThread } = + vi.hoisted(() => ({ + associateNode: vi.fn(), + deleteNode: vi.fn().mockResolvedValue({ success: true }), + putCanvas: vi.fn(), + postCanvasExecute: vi.fn(), + forkThread: vi.fn(), + })); +vi.mock('../api', async (importOriginal) => ({ + ...(await importOriginal()), + associateAgentNode: associateNode, + deleteNode, + putCanvas, + postCanvasExecute, + postCanvasEvents: vi.fn().mockResolvedValue({ success: true }), +})); +vi.mock('../api/canvas', async (importOriginal) => ({ + ...(await importOriginal()), + putNodeContent: vi.fn( + async ( + _canvasId: string, + nodeId: string, + request: PutNodeContentRequest, + ) => ({ + nodeId, + label: null, + rev: nodeRevisionOf({ content: request.content }), + }), + ), + preprocessNode: vi.fn().mockResolvedValue({ success: true }), +})); +vi.mock('../api/agent', () => ({ agentApi: { forkThread } })); + +import { canvasHistoryManager } from './canvasHistoryManager'; +import useCanvasStore, { awaitQuestionCreation } from './canvasStore'; + +import type * as CanvasApi from '../api'; +import type { PutNodeContentRequest } from '@huabu/shared'; +import type { Node } from '@xyflow/react'; + +const question: Node = { + id: 'node-restore', + type: 'question', + position: { x: 10, y: 20 }, + data: { + content: 'Original intent', + threadId: 'thread-restore', + bindingState: 'bound', + status: 'done', + invocationToken: 'historical-token', + viewed: false, + agentBinding: { kind: 'internal' }, + }, +}; + +beforeEach(() => { + vi.useFakeTimers(); + associateNode.mockReset(); + deleteNode.mockClear(); + putCanvas.mockReset(); + postCanvasExecute.mockReset(); + forkThread.mockReset(); + canvasHistoryManager.activate('canvas-restore', true); + useCanvasStore.getState()._setStateNoAutosave({ + canvasId: 'canvas-restore', + nodes: [], + edges: [], + version: 1, + isLoading: true, + isSaving: false, + pendingSave: false, + versionConflict: false, + }); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +const pendingEffects = { + mutatedNodes: [], + deletedNodeIds: [], + contentEditedNodeIds: [], + deferredFitFrameIds: [], +}; + +function createQuestion(id: `node-${string}`): Node { + useCanvasStore.getState().executeCommands([ + { + type: 'CREATE_NODES', + nodes: [ + { + id, + nodeType: 'question', + position: { x: 0, y: 0 }, + data: { content: '', threadId: `thread-${id}` }, + }, + ], + }, + ]); + const node = useCanvasStore.getState().nodes.find((node) => node.id === id); + if (!node) throw new Error('Question was not created optimistically'); + return node; +} + +describe('Question creation settlement', () => { + it('rolls back an unsupported conversation fork without poisoning unrelated saves or undo', async () => { + const error = new Error('Fork not supported (501)'); + forkThread.mockRejectedValue(error); + useCanvasStore.getState().pasteNodes({ x: 0, y: 0 }, [question]); + const pasted = useCanvasStore.getState().nodes[0]; + expect(pasted).toBeDefined(); + useCanvasStore.getState().addNode({ + nodeType: 'note', + placementPoint: { x: 100, y: 100 }, + data: { content: 'Unrelated' }, + }); + await expect( + awaitQuestionCreation('canvas-restore', pasted.id), + ).rejects.toThrow('501'); + expect(useCanvasStore.getState().nodes.map((node) => node.type)).toEqual([ + 'note', + ]); + expect(postCanvasExecute).not.toHaveBeenCalled(); + putCanvas.mockResolvedValue({ canvasId: 'canvas-restore', version: 2 }); + await useCanvasStore.getState().saveCanvas(); + expect(putCanvas).toHaveBeenCalledTimes(1); + expect( + putCanvas.mock.calls[0][1].state.nodes.map((node: Node) => node.type), + ).toEqual(['note']); + await useCanvasStore.getState().undo(); + await useCanvasStore.getState().redo(); + expect( + useCanvasStore.getState().nodes.some((node) => node.type === 'question'), + ).toBe(false); + expect(associateNode).not.toHaveBeenCalled(); + }); + + it.each(['http-first', 'sse-first'] as const)( + 'keeps an undone pending creation removed with %s acknowledgement', + async (order) => { + let acknowledge!: (response: unknown) => void; + postCanvasExecute.mockReturnValue( + new Promise((resolve) => { + acknowledge = resolve; + }), + ); + const node = createQuestion(`node-delayed-${order}`); + await vi.waitFor(() => + expect(postCanvasExecute).toHaveBeenCalledTimes(1), + ); + await useCanvasStore.getState().undo(); + expect(useCanvasStore.getState().nodes).toEqual([]); + expect(deleteNode).not.toHaveBeenCalled(); + putCanvas.mockResolvedValue({ canvasId: 'canvas-restore', version: 3 }); + const removalSave = useCanvasStore.getState().saveCanvas(); + expect(putCanvas).not.toHaveBeenCalled(); + const delta = { type: 'INSERT_NODE' as const, node }; + const response = { + canvasId: 'canvas-restore', + fromVersion: 1, + toVersion: 2, + results: [{ applied: true }], + deltas: [delta], + pendingEffects, + }; + if (order === 'sse-first') { + useCanvasStore + .getState() + .applyDeltasFromAgent([delta], 2, pendingEffects); + expect(useCanvasStore.getState().nodes).toEqual([]); + expect(canvasHistoryManager.canUndo).toBe(false); + } + acknowledge(response); + await awaitQuestionCreation('canvas-restore', node.id); + await canvasHistoryManager.waitForDeletion('canvas-restore', node.id); + await removalSave; + expect(putCanvas).toHaveBeenCalledTimes(1); + expect(putCanvas.mock.calls[0][1].state.nodes).toEqual([]); + if (order === 'http-first') { + useCanvasStore + .getState() + .applyDeltasFromAgent([delta], 2, pendingEffects); + } + expect(useCanvasStore.getState().nodes).toEqual([]); + expect(canvasHistoryManager.canUndo).toBe(false); + expect(deleteNode).toHaveBeenCalledTimes(1); + expect(deleteNode).toHaveBeenCalledWith('canvas-restore', node.id); + useCanvasStore.getState().addNode({ + nodeType: 'note', + placementPoint: { x: 100, y: 100 }, + data: { content: 'Saved after undo' }, + }); + putCanvas.mockResolvedValue({ canvasId: 'canvas-restore', version: 4 }); + await useCanvasStore.getState().saveCanvas(); + expect(putCanvas).toHaveBeenCalledTimes(2); + expect( + putCanvas.mock.calls[1][1].state.nodes.map((item: Node) => item.type), + ).toEqual(['note']); + // A genuinely newer server restoration is not the stale create echo. + useCanvasStore + .getState() + .applyDeltasFromAgent([delta], 5, pendingEffects); + expect( + useCanvasStore.getState().nodes.some((item) => item.id === node.id), + ).toBe(true); + }, + ); + + it('orders explicit deletion after a pending creation too', async () => { + let acknowledge!: (response: unknown) => void; + postCanvasExecute.mockReturnValue( + new Promise((resolve) => { + acknowledge = resolve; + }), + ); + const node = createQuestion('node-delete-pending'); + await vi.waitFor(() => expect(postCanvasExecute).toHaveBeenCalledTimes(1)); + useCanvasStore.getState().deleteNodes([node.id]); + expect(useCanvasStore.getState().nodes).toEqual([]); + expect(deleteNode).not.toHaveBeenCalled(); + acknowledge({ + canvasId: 'canvas-restore', + fromVersion: 1, + toVersion: 2, + results: [{ applied: true }], + deltas: [{ type: 'INSERT_NODE', node }], + pendingEffects, + }); + await awaitQuestionCreation('canvas-restore', node.id); + await canvasHistoryManager.waitForDeletion('canvas-restore', node.id); + expect(useCanvasStore.getState().nodes).toEqual([]); + expect(deleteNode).toHaveBeenCalledTimes(1); + }); + + it('can redo while a structure save awaits creation and its compensating deletion', async () => { + let acknowledge!: (response: unknown) => void; + postCanvasExecute.mockReturnValue( + new Promise((resolve) => { + acknowledge = resolve; + }), + ); + const node = createQuestion('node-pending-redo'); + await vi.waitFor(() => expect(postCanvasExecute).toHaveBeenCalledTimes(1)); + await useCanvasStore.getState().undo(); + putCanvas.mockResolvedValue({ canvasId: 'canvas-restore', version: 4 }); + const removalSave = useCanvasStore.getState().saveCanvas(); + associateNode.mockResolvedValue({ node, fromVersion: 2, toVersion: 3 }); + await useCanvasStore.getState().redo(); + expect(associateNode).not.toHaveBeenCalled(); + acknowledge({ + canvasId: 'canvas-restore', + fromVersion: 1, + toVersion: 2, + results: [{ applied: true }], + deltas: [{ type: 'INSERT_NODE', node }], + pendingEffects, + }); + await awaitQuestionCreation('canvas-restore', node.id); + expect(deleteNode).toHaveBeenCalledTimes(1); + expect(associateNode).toHaveBeenCalledTimes(1); + expect(deleteNode.mock.invocationCallOrder[0]).toBeLessThan( + associateNode.mock.invocationCallOrder[0], + ); + expect(useCanvasStore.getState().nodes[0].data.threadId).toBe( + node.data.threadId, + ); + await removalSave; + expect(putCanvas).toHaveBeenCalledTimes(1); + expect(associateNode.mock.invocationCallOrder[0]).toBeLessThan( + putCanvas.mock.invocationCallOrder[0], + ); + }); + + it('does not resurrect a pending undo reinsertion removed by redo', async () => { + const node = { ...question, id: 'node-delayed-restore' }; + canvasHistoryManager.takeSnapshot([node], []); + let acknowledge!: (response: unknown) => void; + associateNode.mockReturnValue( + new Promise((resolve) => { + acknowledge = resolve; + }), + ); + await useCanvasStore.getState().undo(); + await vi.waitFor(() => expect(associateNode).toHaveBeenCalledTimes(1)); + await useCanvasStore.getState().redo(); + expect(deleteNode).not.toHaveBeenCalled(); + const delta = { type: 'INSERT_NODE' as const, node }; + useCanvasStore.getState().applyDeltasFromAgent([delta], 2, pendingEffects); + acknowledge({ node, fromVersion: 1, toVersion: 2 }); + await awaitQuestionCreation('canvas-restore', node.id); + await canvasHistoryManager.waitForDeletion('canvas-restore', node.id); + expect(useCanvasStore.getState().nodes).toEqual([]); + expect(deleteNode).toHaveBeenCalledTimes(1); + }); + + it('preserves the PDF cover-clear helper and ordinary editable undo', async () => { + useCanvasStore.getState()._setStateNoAutosave({ + nodes: [ + { + id: 'node-pdf', + type: 'pdf', + position: { x: 0, y: 0 }, + data: { coverUrl: 'old-cover', label: 'PDF' }, + }, + ], + }); + useCanvasStore + .getState() + .updateNodeData('node-pdf', { coverUrl: undefined }); + expect(useCanvasStore.getState().nodes[0].data.coverUrl).toBeUndefined(); + expect(useCanvasStore.getState().nodes[0].data.label).toBe('PDF'); + await useCanvasStore.getState().undo(); + expect(useCanvasStore.getState().nodes[0].data.coverUrl).toBe('old-cover'); + await useCanvasStore.getState().redo(); + expect(useCanvasStore.getState().nodes[0].data.coverUrl).toBeUndefined(); + }); +}); + +describe('Question undo identity reinsertion', () => { + it('restores the UI immediately but waits for an issued topology PUT before association', async () => { + let finishStructure!: (response: unknown) => void; + putCanvas.mockReturnValueOnce( + new Promise((resolve) => { + finishStructure = resolve; + }), + ); + const saving = useCanvasStore.getState().saveCanvas(); + await vi.waitFor(() => expect(putCanvas).toHaveBeenCalledTimes(1)); + canvasHistoryManager.takeSnapshot([question], []); + associateNode.mockResolvedValue({ + node: question, + fromVersion: 2, + toVersion: 3, + }); + + await useCanvasStore.getState().undo(); + expect(useCanvasStore.getState().nodes[0].id).toBe(question.id); + expect(associateNode).not.toHaveBeenCalled(); + + finishStructure({ canvasId: 'canvas-restore', version: 2 }); + await saving; + await awaitQuestionCreation('canvas-restore', question.id); + expect(associateNode).toHaveBeenCalledTimes(1); + expect(useCanvasStore.getState().version).toBe(3); + }); + + it('awaits deletion and validates the old thread before autosave, without replaying old FSM', async () => { + let finishDelete!: () => void; + deleteNode.mockReturnValueOnce( + new Promise((resolve) => { + finishDelete = resolve; + }), + ); + canvasHistoryManager.trackDelete('canvas-restore', question.id); + canvasHistoryManager.takeSnapshot([question], []); + const confirmed = { + ...question, + data: { + content: 'Original intent', + threadId: 'thread-restore', + bindingState: 'bound', + agentBinding: { kind: 'internal' }, + }, + }; + associateNode.mockResolvedValue({ + node: confirmed, + fromVersion: 1, + toVersion: 2, + }); + await useCanvasStore.getState().undo(); + expect(associateNode).not.toHaveBeenCalled(); + finishDelete(); + await awaitQuestionCreation('canvas-restore', question.id); + expect(associateNode).toHaveBeenCalledWith('canvas-restore', question.id, { + kind: 'restore', + node: { + ...question, + data: { + content: 'Original intent', + agentBinding: { kind: 'internal' }, + }, + }, + threadId: 'thread-restore', + requireBinding: true, + }); + const restored = useCanvasStore.getState().nodes[0]; + expect(restored.data.threadId).toBe('thread-restore'); + expect(restored.data.bindingState).toBe('bound'); + expect(restored.data).not.toHaveProperty('invocationToken'); + expect(restored.data).not.toHaveProperty('status'); + expect(useCanvasStore.getState().version).toBe(2); + }); + + it('does not let ordinary saving invent a replacement identity after failed restore', async () => { + canvasHistoryManager.takeSnapshot([question], []); + associateNode.mockRejectedValue(new Error('Canonical record is missing')); + await useCanvasStore.getState().undo(); + await expect( + awaitQuestionCreation('canvas-restore', question.id), + ).rejects.toThrow('Canonical record is missing'); + expect(useCanvasStore.getState().nodes).toEqual([]); + putCanvas.mockResolvedValue({ canvasId: 'canvas-restore', version: 2 }); + await useCanvasStore.getState().saveCanvas(); + expect(putCanvas).toHaveBeenCalledTimes(1); + expect(putCanvas.mock.calls[0][1].state.nodes).toEqual([]); + }); +}); diff --git a/apps/web/src/store/canvasStore.ts b/apps/web/src/store/canvasStore.ts index 69c2321f7..c8421ddea 100644 --- a/apps/web/src/store/canvasStore.ts +++ b/apps/web/src/store/canvasStore.ts @@ -28,6 +28,9 @@ import { } from '@huabu/shared'; import { COMMAND_META, + preserveAgentNodeOwnedData, + projectAgentNodeEditableData, + stripTransientNodeFields, applyDeltas, applySharedPostEffectsFromWriteResult, executeCanvasCommands, @@ -86,7 +89,12 @@ import { } from '@/handler/snap/snapSession'; import { canvasHistoryManager } from './canvasHistoryManager'; -import { getCanvas, putCanvas } from '../api'; +import { + getCanvas, + postCanvasExecute, + associateAgentNode, + putCanvas, +} from '../api'; import { agentApi } from '../api/agent'; import { cloneArtifactToCanvas, resolveArtifactUrl } from '../api/artifact'; import { CanvasConflictError } from '../api/canvas'; @@ -371,7 +379,10 @@ export function dismissVersionConflictToast(): void { */ function stripNodeContentForStructurePut(nodes: readonly Node[]): Node[] { return nodes.map((node) => { - const data = node.data; + const data = + node.type === 'question' + ? projectAgentNodeEditableData(node.data) + : node.data; if (!data) return node; let mutated = false; const slim: Record = {}; @@ -382,7 +393,7 @@ function stripNodeContentForStructurePut(nodes: readonly Node[]): Node[] { } slim[k] = v; } - return mutated ? { ...node, data: slim } : node; + return mutated || data !== node.data ? { ...node, data: slim } : node; }); } @@ -855,6 +866,7 @@ type RFState = { contentEditedNodeIds: string[]; deferredFitFrameIds: string[]; }, + agentNodeProjection?: boolean, ) => string[]; /** * Ids of nodes with un-persisted local content edits (pending debounced @@ -1244,6 +1256,123 @@ function makeBuildSelectedDetail( return build; } +const questionCreations = new Map< + string, + { promise: Promise; toVersion?: number } +>(); +const questionForks = new Map>(); +const inflightStructureWrites = new Map>(); + +export async function awaitQuestionCreation( + canvasId: string, + nodeId: string, +): Promise { + await questionCreations.get(`${canvasId}\0${nodeId}`)?.promise; +} + +function scheduleAcknowledgedNodePreprocessing(node: Node): void { + const { canvasId } = useCanvasStore.getState(); + const creation = questionCreations.get(`${canvasId}\0${node.id}`)?.promise; + if (!creation) { + preprocessQueue.schedule(node); + return; + } + void creation.then( + () => { + const current = useCanvasStore.getState(); + if (current.canvasId !== canvasId) return; + const live = current.nodes.find((candidate) => candidate.id === node.id); + if (live) preprocessQueue.schedule(live); + }, + () => {}, + ); +} + +function rollbackFailedQuestions(canvasId: string, nodes: Node[]): void { + const failedIds = new Set(nodes.map((node) => node.id)); + canvasHistoryManager.discardNodes(canvasId, failedIds); + const current = useCanvasStore.getState(); + if (current.canvasId !== canvasId) return; + for (const id of failedIds) nodeContentQueue.forgetNode(id); + useCanvasStore.setState({ + nodes: current.nodes.filter((node) => !failedIds.has(node.id)), + edges: current.edges.filter( + (edge) => !failedIds.has(edge.source) && !failedIds.has(edge.target), + ), + }); + usePreviewWorkspaceStore + .getState() + .validate(new Set(useCanvasStore.getState().nodes.map((node) => node.id))); +} + +/** Reinsertion must establish its identity before ordinary autosave can omit it. */ +function restoreQuestionAssociations( + canvasId: string, + previous: Node[], + restored: Node[], +): void { + const previousIds = new Set(previous.map((node) => node.id)); + for (const node of restored) { + if (node.type !== 'question' || previousIds.has(node.id)) continue; + // Wait only for already-issued writes, not saves that need this association. + // saveCanvas owns transport errors; association validates the resulting state. + const creation = Promise.allSettled([ + inflightStructureWrites.get(canvasId), + canvasHistoryManager.waitForDeletion(canvasId, node.id), + ]).then(async () => { + const response = await associateAgentNode(canvasId, node.id, { + kind: 'restore', + node: { + ...stripTransientNodeFields(node), + type: 'question', + data: projectAgentNodeEditableData(node.data), + }, + threadId: + typeof node.data.threadId === 'string' + ? node.data.threadId + : undefined, + requireBinding: node.data.bindingState === 'bound', + }); + const pending = questionCreations.get(`${canvasId}\0${node.id}`); + if (pending?.promise === creation) pending.toVersion = response.toVersion; + const current = useCanvasStore.getState(); + if (current.canvasId !== canvasId) return; + const confirmed = response.node as Node; + current._setStateNoAutosave({ + nodes: current.nodes.map((live) => + live.id === node.id && current.version <= response.toVersion + ? { + ...live, + data: { + ...preserveAgentNodeOwnedData(live.data, confirmed.data), + agentBinding: + confirmed.data.agentBinding ?? live.data.agentBinding, + }, + } + : live, + ), + ...(current.version === response.fromVersion + ? { version: response.toVersion } + : {}), + }); + const live = useCanvasStore + .getState() + .nodes.find((item) => item.id === node.id); + if (live) preprocessQueue.schedule(live); + }); + questionCreations.set(`${canvasId}\0${node.id}`, { promise: creation }); + void creation.catch((error) => { + rollbackFailedQuestions(canvasId, [node]); + toast( + error instanceof Error + ? error.message + : 'Failed to restore Agent conversation', + { tone: 'danger' }, + ); + }); + } +} + const useCanvasStore = create()( autoSaveMiddleware((set, get) => ({ nodes: [], @@ -1365,6 +1494,73 @@ const useCanvasStore = create()( // Only commit if at least one command was applied. if (!commandResults.some((r) => r.applied)) return; + const createdQuestions = writeResult.nodes.filter( + (node) => + node.type === 'question' && + !state.nodes.some((old) => old.id === node.id), + ); + if (createdQuestions.length > 0 && resolvedSource === 'ui') { + const creation = Promise.all( + createdQuestions.map((node) => + questionForks.get(`${state.canvasId}\0${node.data.threadId}`), + ), + ) + .then(() => + postCanvasExecute(state.canvasId, { + commands, + originator: { source: 'ui' }, + }), + ) + .then((response) => { + for (const node of createdQuestions) { + const pending = questionCreations.get( + `${state.canvasId}\0${node.id}`, + ); + if (pending?.promise === creation) + pending.toVersion = response.toVersion; + } + for (const node of createdQuestions) { + const index = commands.findIndex( + (command) => + command.type === 'CREATE_NODES' && + command.nodes.some((entry) => entry.id === node.id), + ); + if (index < 0 || !response.results[index]?.applied) { + throw new Error('Question creation was not acknowledged'); + } + } + const current = get(); + if ( + current.canvasId === response.canvasId && + current.version === response.fromVersion + ) { + current.applyDeltasFromAgent( + response.deltas as Delta[], + response.toVersion, + response.pendingEffects as Parameters< + typeof current.applyDeltasFromAgent + >[2], + ); + } + }); + for (const node of createdQuestions) { + questionCreations.set(`${state.canvasId}\0${node.id}`, { + promise: creation, + }); + } + void creation.catch((error) => { + // A failed fork/creation is not a fresh conversation. Remove only + // its optimistic Questions, including copies in later undo entries. + rollbackFailedQuestions(state.canvasId, createdQuestions); + toast( + error instanceof Error + ? error.message + : 'Failed to create Question', + { tone: 'danger' }, + ); + }); + } + // Guard: verify that 'caller' snapshot commands were preceded by beginGesture. // Skip for agent-originated commands (no UI gesture involved). const hasCallerSnapshot = commands.some( @@ -1415,8 +1611,10 @@ const useCanvasStore = create()( getNodes: () => get().nodes, getEdges: () => get().edges, setNodes: (nodes) => set({ nodes }), - triggerPreprocessing: preprocessQueue.schedule, + triggerPreprocessing: scheduleAcknowledgedNodePreprocessing, forgetNodeContent: nodeContentQueue.forgetNode, + getPendingCreation: (nodeId) => + questionCreations.get(`${state.canvasId}\0${nodeId}`)?.promise, waitForNodeContent: waitForSidecarWrites, validatePreviewNodes: (liveNodeIds) => usePreviewWorkspaceStore.getState().validate(liveNodeIds), @@ -1435,7 +1633,12 @@ const useCanvasStore = create()( * autosave PUTs against the right baseline. * 3. Snapshot the pre-batch state for ordinary undo. */ - applyDeltasFromAgent: (deltas, toVersion, pendingEffects) => { + applyDeltasFromAgent: ( + deltas, + toVersion, + pendingEffects, + agentNodeProjection = false, + ) => { const reconcileIncomingVersion = (): void => { const current = get(); const reconciled = reconcileCanvasVersion( @@ -1458,6 +1661,37 @@ const useCanvasStore = create()( } }; + // A locally removed optimistic Question must not reappear when its + // delayed creation arrives over HTTP or SSE. Its tracked DELETE waits + // for that same creation, so the server converges to the removal too. + const removedQuestionIds = new Set( + deltas.flatMap((delta) => { + if (delta.type !== 'INSERT_NODE' || delta.node.type !== 'question') + return []; + const creation = questionCreations.get( + `${get().canvasId}\0${delta.node.id}`, + ); + return creation && + (creation.toVersion === undefined || + toVersion <= creation.toVersion) && + !get().nodes.some((node) => node.id === delta.node.id) + ? [delta.node.id] + : []; + }), + ); + deltas = deltas.filter( + (delta) => + !( + delta.type === 'INSERT_NODE' && + removedQuestionIds.has(delta.node.id) + ) && + !( + delta.type === 'INSERT_EDGE' && + (removedQuestionIds.has(delta.edge.source) || + removedQuestionIds.has(delta.edge.target)) + ), + ); + // Never let an incoming agent write clobber a // node the user is mid-editing. Skip REPLACE/DELETE deltas that // target a node with un-persisted local content edits (INSERT is a @@ -1503,6 +1737,7 @@ const useCanvasStore = create()( return [ { ...d, + prev: local, next: { ...d.next, data: mergedData }, }, ]; @@ -1510,6 +1745,20 @@ const useCanvasStore = create()( } skippedNodeIds.push(d.next.id); skippedRemoteNodes.push(d.next as unknown as Node); + const local = localNodesById.get(d.next.id); + if (local?.type === 'question') { + preservedPendingNodeIds.add(d.next.id); + return [ + { + ...d, + prev: local, + next: { + ...local, + data: preserveAgentNodeOwnedData(local.data, nextData), + }, + }, + ]; + } return []; } if (d.type === 'DELETE_NODE' && dirty.has(d.node.id)) { @@ -1542,7 +1791,40 @@ const useCanvasStore = create()( const prevNodes = get().nodes; const prevEdges = get().edges; const canvasId = get().canvasId; - canvasHistoryManager.takeSnapshot(prevNodes, prevEdges); + if (agentNodeProjection) { + canvasHistoryManager.rebaseAgentInitialContent(safeDeltas); + } else if ( + safeDeltas.some((delta) => { + if (delta.type === 'INSERT_NODE' && delta.node.type === 'question') { + const live = prevNodes.find((node) => node.id === delta.node.id); + if (!live) return true; + return !deepEqual( + { + ...stripTransientNodeFields(live), + data: projectAgentNodeEditableData(live.data), + }, + { + ...stripTransientNodeFields(delta.node), + data: projectAgentNodeEditableData(delta.node.data ?? {}), + }, + ); + } + if (delta.type !== 'REPLACE_NODE' || delta.next.type !== 'question') + return true; + return !deepEqual( + { + ...delta.prev, + data: projectAgentNodeEditableData(delta.prev.data ?? {}), + }, + { + ...delta.next, + data: projectAgentNodeEditableData(delta.next.data ?? {}), + }, + ); + }) + ) { + canvasHistoryManager.takeSnapshot(prevNodes, prevEdges); + } // Replay the structural diff. The shared helper tolerates // missing targets (REPLACE/DELETE against an already-absent @@ -1588,7 +1870,7 @@ const useCanvasStore = create()( // Post-effects must not run for nodes whose delta we skipped — they // were not actually mutated locally, so preprocessing / fit them is // wrong. - const skipped = new Set(skippedNodeIds); + const skipped = new Set([...skippedNodeIds, ...removedQuestionIds]); runWebPostEffects({ effects: { mutatedNodes: @@ -1611,8 +1893,10 @@ const useCanvasStore = create()( getNodes: () => get().nodes, getEdges: () => get().edges, setNodes: (nodes) => get()._setStateNoAutosave({ nodes }), - triggerPreprocessing: preprocessQueue.schedule, + triggerPreprocessing: scheduleAcknowledgedNodePreprocessing, forgetNodeContent: nodeContentQueue.forgetNode, + getPendingCreation: (nodeId) => + questionCreations.get(`${canvasId}\0${nodeId}`)?.promise, waitForNodeContent: waitForSidecarWrites, validatePreviewNodes: (liveNodeIds) => usePreviewWorkspaceStore.getState().validate(liveNodeIds), @@ -1995,11 +2279,25 @@ const useCanvasStore = create()( set({ isSaving: true }); const savingCanvasId = get().canvasId; let saveSucceeded = false; + let structureWrite: ReturnType | undefined; try { - // DELETE is not cancellable once admitted server-side. Wait for its - // actual completion, then read the latest topology (undo/redo may have - // changed again while waiting). No content flush is started here. - if (canvasHistoryManager.hasPendingDeletes(savingCanvasId)) { + const acknowledgedCreations = new Set>(); + while (get().canvasId === savingCanvasId) { + const creating = get().nodes.flatMap((node) => { + const creation = questionCreations.get( + `${savingCanvasId}\0${node.id}`, + )?.promise; + return creation && !acknowledgedCreations.has(creation) + ? [creation] + : []; + }); + const deleting = + canvasHistoryManager.hasPendingDeletes(savingCanvasId); + if (creating.length === 0 && !deleting) break; + await Promise.all(creating); + for (const creation of creating) acknowledgedCreations.add(creation); + // Re-read identities after waiting: undo/redo may have restored a + // different Question while its previous creation/delete was pending. await canvasHistoryManager.waitForDeletes(savingCanvasId); } if (get().canvasId !== savingCanvasId) return; @@ -2012,7 +2310,7 @@ const useCanvasStore = create()( // Viewport is intentionally omitted: it's local UI state mirrored // into `localStorage`, not canvas data. const slimNodes = stripNodeContentForStructurePut(nodes); - const response = await putCanvas( + structureWrite = putCanvas( canvasId, { version, @@ -2021,6 +2319,8 @@ const useCanvasStore = create()( }, { keepalive: options?.keepalive }, ); + inflightStructureWrites.set(canvasId, structureWrite); + const response = await structureWrite; if (get().canvasId !== canvasId) return; nodeContentQueue.acknowledgeRestores(canvasId, restoreTokens); const reconciled = reconcileCanvasVersion( @@ -2077,6 +2377,9 @@ const useCanvasStore = create()( } console.error('Failed to save canvas:', error); } finally { + if (inflightStructureWrites.get(savingCanvasId) === structureWrite) { + inflightStructureWrites.delete(savingCanvasId); + } if (get().canvasId === savingCanvasId) set({ isSaving: false }); const { pendingSave } = get(); @@ -3604,7 +3907,10 @@ const useCanvasStore = create()( const hasConversation = isQuestion && !!threadId && - (status === 'done' || status === 'error' || status === 'running'); + (data.bindingState === 'bound' || + status === 'done' || + status === 'error' || + status === 'running'); if (hasConversation) { const dstThreadId = createId('thread'); forkTasks.push({ srcThreadId: threadId, dstThreadId }); @@ -3613,8 +3919,11 @@ const useCanvasStore = create()( data: { ...data, threadId: dstThreadId, - status: 'done', + status: undefined, errorMessage: undefined, + bindingState: undefined, + invocationToken: undefined, + viewed: undefined, __forkConversation: true, }, }); @@ -3650,25 +3959,23 @@ const useCanvasStore = create()( set({ pendingForkThreadIds: next }); }; - void Promise.all( - forkTasks.map((t) => - agentApi - .forkThread( - t.srcThreadId, - t.dstThreadId, - sourceCanvasId, - dstCanvasId, - ) - .catch((err) => { - console.warn( - '[paste] Failed to fork question conversation', - err, - ); - toast('Failed to copy a conversation', { tone: 'danger' }); - }) - .finally(() => clearPending(t.dstThreadId)), - ), - ); + for (const t of forkTasks) { + const fork = agentApi + .forkThread( + t.srcThreadId, + t.dstThreadId, + sourceCanvasId, + dstCanvasId, + ) + .catch((err) => { + console.warn('[paste] Failed to fork question conversation', err); + toast('Failed to copy a conversation', { tone: 'danger' }); + throw err; + }) + .finally(() => clearPending(t.dstThreadId)); + questionForks.set(`${dstCanvasId}\0${t.dstThreadId}`, fork); + void fork.catch(() => undefined); + } }; // Same-canvas pastes leave artifact keys as-is (the artifact is @@ -3715,8 +4022,8 @@ const useCanvasStore = create()( // Fast path: nothing to clone — preserve the prior synchronous // behaviour so simple intra-canvas pastes feel instant. if (!needsClone || !srcCanvasId) { - dispatch(clipboardNodes); runForks(); + dispatch(clipboardNodes); return; } @@ -3803,15 +4110,15 @@ const useCanvasStore = create()( // for the user to switch Spaces — dropping the paste beats // landing it on the wrong canvas. if (get().canvasId !== dstCanvasId) return; - dispatch(remapped); runForks(); + dispatch(remapped); })(); }, canUndo: false, canRedo: false, - undo: () => { + undo: async () => { const { nodes, edges, canvasId } = get(); const snapshot = canvasHistoryManager.undo(nodes, edges); if (!snapshot) return; @@ -3825,6 +4132,7 @@ const useCanvasStore = create()( useGesturePreviewStore.getState().resetCanvasScopedTransients(); const action: RecentAction = { action: 'canvas_undone' }; + restoreQuestionAssociations(canvasId, nodes, snapshot.nodes); nodeContentQueue.holdRestoredNodes( canvasId, snapshot.nodes.filter( @@ -3843,19 +4151,20 @@ const useCanvasStore = create()( nodes, snapshot.nodes, waitForSidecarWrites, + (nodeId) => questionCreations.get(`${canvasId}\0${nodeId}`)?.promise, ); }, - redo: () => { + redo: async () => { const { nodes, edges, canvasId } = get(); const snapshot = canvasHistoryManager.redo(nodes, edges); if (!snapshot) return; - // See `undo`: a redo is the same authoritative geometry swap, so // discard the floating stroke selection for the same reason. useGesturePreviewStore.getState().resetCanvasScopedTransients(); const action: RecentAction = { action: 'canvas_redone' }; + restoreQuestionAssociations(canvasId, nodes, snapshot.nodes); nodeContentQueue.holdRestoredNodes( canvasId, snapshot.nodes.filter( @@ -3874,6 +4183,7 @@ const useCanvasStore = create()( nodes, snapshot.nodes, waitForSidecarWrites, + (nodeId) => questionCreations.get(`${canvasId}\0${nodeId}`)?.promise, ); }, })), diff --git a/apps/web/src/store/canvasSyncStore.ts b/apps/web/src/store/canvasSyncStore.ts index b5a1ae947..8ffb608d3 100644 --- a/apps/web/src/store/canvasSyncStore.ts +++ b/apps/web/src/store/canvasSyncStore.ts @@ -197,6 +197,7 @@ export const useCanvasSyncStore = create((set, get) => ({ deltas as Delta[], toVersion, pendingEffects as SyncPendingEffects, + event.data.agentNodeProjection, ); } else if (toVersion > canvasStore.version) { // Gap (missed an earlier update). A blind `loadCanvas` would diff --git a/apps/web/src/store/conversationOwner.test.ts b/apps/web/src/store/conversationOwner.test.ts index c1985c822..bb0b0a76f 100644 --- a/apps/web/src/store/conversationOwner.test.ts +++ b/apps/web/src/store/conversationOwner.test.ts @@ -3,13 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { postCanvasExecute } = vi.hoisted(() => ({ +const { postCanvasExecute, acknowledgeAgentNodeResult } = vi.hoisted(() => ({ postCanvasExecute: vi.fn(), + acknowledgeAgentNodeResult: vi.fn(), })); vi.mock('@/api/canvas', async (importOriginal) => ({ ...(await importOriginal()), postCanvasExecute, + acknowledgeAgentNodeResult, })); import useCanvasStore from './canvasStore'; @@ -24,6 +26,9 @@ import { resolveConversationOwnerSource, shouldComposeConversationOwner, validateConversationView, + saveConversationDraft, + awaitConversationDraft, + acknowledgeConversationResult, } from './conversationOwner'; import type * as CanvasApi from '@/api/canvas'; @@ -50,6 +55,9 @@ beforeEach(() => { }, }); postCanvasExecute.mockReset(); + acknowledgeAgentNodeResult + .mockReset() + .mockResolvedValue({ acknowledged: true }); postCanvasExecute.mockResolvedValue({ canvasId: 'canvas-source', fromVersion: 1, @@ -88,6 +96,216 @@ beforeEach(() => { }); describe('conversation owner routing', () => { + it('acknowledges only the observed terminal invocation, never a viewed merge', async () => { + const view = { + ...ownerView, + presentationAnchor: { canvasId: 'canvas-source', nodeId: 'node-source' }, + }; + await acknowledgeConversationResult(view, { + status: 'running', + invocationToken: 'run', + }); + expect(acknowledgeAgentNodeResult).not.toHaveBeenCalled(); + await acknowledgeConversationResult(view, { + status: 'done', + invocationToken: 'result', + viewed: false, + }); + expect(acknowledgeAgentNodeResult).toHaveBeenCalledWith( + 'canvas-source', + 'node-source', + { invocationToken: 'result' }, + ); + expect(postCanvasExecute).not.toHaveBeenCalled(); + }); + + it('preserves legacy viewed interaction with an explicit absent-token acknowledgement', async () => { + const view = { + ...ownerView, + presentationAnchor: { canvasId: 'canvas-source', nodeId: 'node-source' }, + }; + await acknowledgeConversationResult(view, { + status: 'done', + viewed: false, + }); + expect(acknowledgeAgentNodeResult).toHaveBeenCalledWith( + 'canvas-source', + 'node-source', + { invocationToken: null }, + ); + expect(postCanvasExecute).not.toHaveBeenCalled(); + }); + + it('keeps a rejected draft as a send failure instead of using cached configuration', async () => { + const view = { + presentationAnchor: { canvasId: 'draft-failure', nodeId: 'draft-node' }, + conversationOwner: { + canvasId: 'draft-failure', + nodeId: 'draft-node', + threadId: 'draft-thread', + }, + }; + postCanvasExecute.mockResolvedValueOnce({ results: [{ applied: false }] }); + await expect( + saveConversationDraft(view, { + agentBinding: { kind: 'internal' }, + agentMode: 'operate', + }), + ).rejects.toThrow('could not be updated'); + await expect(awaitConversationDraft(view)).rejects.toThrow( + 'could not be updated', + ); + }); + + it('uses the acknowledged saved draft after the thread cache is lost', async () => { + const view = { + presentationAnchor: { + canvasId: 'canvas-source', + nodeId: 'node-saved-draft', + }, + conversationOwner: { + canvasId: 'canvas-source', + nodeId: 'node-saved-draft', + threadId: 'thread-draft', + }, + }; + const binding = { + kind: 'external' as const, + profileId: 'chosen', + alias: 'Chosen', + }; + const before = { + id: 'node-saved-draft', + type: 'question', + position: { x: 0, y: 0 }, + data: { + type: 'question', + threadId: 'thread-draft', + bindingState: 'editing', + }, + }; + const after = { + ...before, + data: { ...before.data, agentBinding: binding, agentMode: 'ask' }, + }; + useCanvasStore.getState()._setStateNoAutosave({ + canvasId: 'canvas-source', + version: 1, + nodes: [before], + isLoading: true, + }); + postCanvasExecute.mockResolvedValueOnce({ + canvasId: 'canvas-source', + fromVersion: 1, + toVersion: 2, + deltas: [{ type: 'REPLACE_NODE', prev: before, next: after }], + results: [{ applied: true }], + pendingEffects: { + mutatedNodes: [], + deletedNodeIds: [], + contentEditedNodeIds: [], + deferredFitFrameIds: [], + }, + }); + await saveConversationDraft(view, { + agentBinding: binding, + agentMode: 'ask', + }); + await awaitConversationDraft(view); + expect( + resolveConversationAgentBinding(useCanvasStore.getState().nodes[0].data, { + kind: 'internal', + }), + ).toEqual(binding); + expect( + postCanvasExecute.mock.calls[0][1].commands[0].patches[0].patch, + ).toEqual({ + agentBinding: binding, + agentMode: 'ask', + }); + }); + + it('waits for actual creation application before sending the initial draft', async () => { + const view = { + presentationAnchor: { canvasId: 'create-canvas', nodeId: 'node-create' }, + conversationOwner: { + canvasId: 'create-canvas', + nodeId: 'node-create', + threadId: 'create-thread', + }, + }; + useCanvasStore.getState()._setStateNoAutosave({ + canvasId: 'create-canvas', + nodes: [], + edges: [], + version: 1, + isLoading: true, + }); + let rejectCreation: ((error: Error) => void) | undefined; + postCanvasExecute.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectCreation = reject; + }), + ); + useCanvasStore.getState().executeCommands([ + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-create', + nodeType: 'question', + position: { x: 0, y: 0 }, + data: { threadId: 'create-thread' }, + }, + ], + }, + ]); + const draft = saveConversationDraft(view, { + agentBinding: { kind: 'internal' }, + agentMode: 'operate', + }); + await vi.waitFor(() => expect(postCanvasExecute).toHaveBeenCalledTimes(1)); + expect(postCanvasExecute.mock.calls[0][1].commands[0].type).toBe( + 'CREATE_NODES', + ); + rejectCreation?.(new Error('creation failed')); + await expect(draft).rejects.toThrow('creation failed'); + expect(postCanvasExecute).toHaveBeenCalledTimes(1); + }); + + it('rejects acknowledgement of a draft superseded by newer server selection', async () => { + const view = { + presentationAnchor: { canvasId: 'draft-conflict', nodeId: 'draft-node' }, + conversationOwner: { + canvasId: 'draft-conflict', + nodeId: 'draft-node', + threadId: 'draft-thread', + }, + }; + useCanvasStore.getState()._setStateNoAutosave({ + canvasId: 'draft-conflict', + version: 3, + nodes: [ + { + id: 'draft-node', + type: 'question', + position: { x: 0, y: 0 }, + data: { + agentBinding: { kind: 'external', profileId: 'new', alias: 'New' }, + agentMode: 'ask', + }, + }, + ], + }); + await expect( + saveConversationDraft(view, { + agentBinding: { kind: 'internal' }, + agentMode: 'operate', + }), + ).rejects.toThrow('selection changed'); + }); + it('uses the durable owner binding when a refreshed send still has the cache default', () => { const externalBinding = { kind: 'external' as const, @@ -106,7 +324,7 @@ describe('conversation owner routing', () => { ).toEqual({ kind: 'internal' }); }); - it('limits fixed Agent Node client patches to viewed state', () => { + it('omits server-owned fields regardless of binding policy', () => { expect( filterClientOwnedQuestionPatch( { agentBindingPolicy: 'fixed' }, @@ -117,7 +335,7 @@ describe('conversation owner routing', () => { viewed: false, }, ), - ).toEqual({ viewed: false }); + ).toEqual({ content: 'Prompt' }); expect( filterClientOwnedQuestionPatch( { agentBindingPolicy: 'fixed' }, @@ -129,7 +347,7 @@ describe('conversation owner routing', () => { { agentBindingPolicy: 'selectable' }, { status: 'done' }, ), - ).toEqual({ status: 'done' }); + ).toBeNull(); }); it('routes ordinary Question and unbound Chat requests with Canvas selection', () => { @@ -149,8 +367,7 @@ describe('conversation owner routing', () => { const before = useCanvasStore.getState().nodes[0]; await patchConversationOwnerNode(ownerView, { - status: 'running', - viewed: false, + agentMode: 'operate', }); expect(useCanvasStore.getState().nodes[0]).toBe(before); @@ -161,7 +378,7 @@ describe('conversation owner routing', () => { patches: [ { nodeId: 'node-source', - patch: { status: 'running', viewed: false }, + patch: { agentMode: 'operate' }, }, ], }, @@ -170,7 +387,7 @@ describe('conversation owner routing', () => { }); }); - it('persists ordinary same-Canvas question lifecycle updates', async () => { + it('rejects browser lifecycle writes without optimistic mutation', async () => { const view: AgentConversationView = { presentationAnchor: { canvasId: 'canvas-source', @@ -194,18 +411,12 @@ describe('conversation owner routing', () => { ], }); - await patchConversationOwnerNode(view, { status: 'running' }); + await expect( + patchConversationOwnerNode(view, { status: 'running' }), + ).rejects.toThrow('server-owned'); - expect(useCanvasStore.getState().nodes[0]?.data.status).toBe('running'); - expect(postCanvasExecute).toHaveBeenCalledWith('canvas-source', { - commands: [ - { - type: 'MERGE_NODE_DATA', - patches: [{ nodeId: 'node-source', patch: { status: 'running' } }], - }, - ], - originator: { source: 'ui' }, - }); + expect(useCanvasStore.getState().nodes[0]?.data.status).toBe('idle'); + expect(postCanvasExecute).not.toHaveBeenCalled(); }); it('resolves only ordinary Question nodes with a thread', () => { @@ -308,13 +519,13 @@ describe('conversation owner routing', () => { }; }); - await patchConversationOwnerNode(ownerView, { status: 'running' }); + await patchConversationOwnerNode(ownerView, { agentMode: 'operate' }); expect(useCanvasStore.getState().version).toBe(2); expect(useCanvasStore.getState().nodes[0]?.data.status).toBe('running'); }); - it('serializes lifecycle writes for the same source owner', async () => { + it('serializes draft writes for the same source owner', async () => { let releaseFirst: (() => void) | undefined; postCanvasExecute .mockImplementationOnce( @@ -352,9 +563,9 @@ describe('conversation owner routing', () => { }, }); - const done = patchConversationOwnerNode(ownerView, { status: 'done' }); + const done = patchConversationOwnerNode(ownerView, { agentMode: 'ask' }); const running = patchConversationOwnerNode(ownerView, { - status: 'running', + agentMode: 'operate', }); await vi.waitFor(() => expect(postCanvasExecute).toHaveBeenCalledTimes(1)); @@ -365,9 +576,40 @@ describe('conversation owner routing', () => { expect(postCanvasExecute.mock.calls[1]?.[1]).toMatchObject({ commands: [ { - patches: [{ patch: { status: 'running' } }], + patches: [{ patch: { agentMode: 'operate' } }], + }, + ], + }); + }); + + it('does not replay a delayed command patch after a newer SSE update', async () => { + const view = { + ...ownerView, + presentationAnchor: { canvasId: 'canvas-source', nodeId: 'node-source' }, + }; + useCanvasStore.getState()._setStateNoAutosave({ + canvasId: 'canvas-source', + version: 3, + nodes: [ + { + id: 'node-source', + type: 'question', + position: { x: 0, y: 0 }, + data: { + type: 'question', + agentMode: 'operate', + bindingState: 'bound', + invocationToken: 'new', + }, }, ], }); + await patchConversationOwnerNode(view, { agentMode: 'ask' }); + expect(useCanvasStore.getState().version).toBe(3); + expect(useCanvasStore.getState().nodes[0]?.data).toMatchObject({ + agentMode: 'operate', + bindingState: 'bound', + invocationToken: 'new', + }); }); }); diff --git a/apps/web/src/store/conversationOwner.ts b/apps/web/src/store/conversationOwner.ts index 5c57129a5..f8e440dc6 100644 --- a/apps/web/src/store/conversationOwner.ts +++ b/apps/web/src/store/conversationOwner.ts @@ -2,9 +2,10 @@ // Licensed under the MIT license. import { getQuestionNodeStatus } from '@huabu/shared'; +import { projectAgentNodeEditableData } from '@huabu/shared/canvas-engine'; -import { postCanvasExecute } from '@/api/canvas'; -import useCanvasStore from '@/store/canvasStore'; +import { acknowledgeAgentNodeResult, postCanvasExecute } from '@/api/canvas'; +import useCanvasStore, { awaitQuestionCreation } from '@/store/canvasStore'; import type { AgentBinding, AgentConversationView } from '@huabu/shared'; import type { Delta } from '@huabu/shared/canvas-engine'; @@ -19,6 +20,8 @@ export type ConversationOwnerSource = { agentMode?: 'ask' | 'operate'; agentBinding?: AgentBinding; agentBindingPolicy?: 'selectable' | 'fixed'; + bindingState?: 'editing' | 'bound'; + invocationToken?: string; content?: unknown; }; @@ -80,13 +83,13 @@ export function resolveConversationAgentBinding( return source?.agentBinding ?? cachedBinding; } -/** Keep client writes to fixed Agent Nodes limited to presentation state. */ +/** Lifecycle and association are never ordinary browser edits. */ export function filterClientOwnedQuestionPatch( - source: ConversationOwnerSource | undefined, + _source: ConversationOwnerSource | undefined, patch: Record, ): Record | null { - if (source?.agentBindingPolicy !== 'fixed') return patch; - return typeof patch.viewed === 'boolean' ? { viewed: patch.viewed } : null; + const editable = projectAgentNodeEditableData(patch); + return Object.keys(editable).length > 0 ? editable : null; } export async function validateConversationView( @@ -126,22 +129,13 @@ async function applyConversationOwnerPatch( patch: Record, ): Promise { const owner = view.conversationOwner; - const active = useCanvasStore.getState(); - const ownerIsActive = - active.canvasId === owner.canvasId && - active.nodes.some((node) => node.id === owner.nodeId); - if (ownerIsActive) { - // Reflect lifecycle changes immediately, but still persist them through - // the canonical executor below. A local-only status disappears on reload; - // load-time code cannot safely infer success from conversation existence. - active.patchNodeSilent(owner.nodeId, patch); + await awaitQuestionCreation(owner.canvasId, owner.nodeId); + const editable = filterClientOwnedQuestionPatch(undefined, patch); + if (!editable || Object.keys(editable).length !== Object.keys(patch).length) { + throw new Error('Agent Node lifecycle and association are server-owned'); } - const wirePatch = Object.fromEntries( - Object.entries(patch).map(([key, value]) => [ - key, - value === undefined ? '' : value, - ]), + Object.entries(editable).filter(([, value]) => value !== undefined), ); const response = await postCanvasExecute(owner.canvasId, { commands: [ @@ -168,14 +162,79 @@ async function applyConversationOwnerPatch( typeof current.applyDeltasFromAgent >[2], ); - } else if (ownerIsActive && current.canvasId === owner.canvasId) { - // A Canvas-sync broadcast may have advanced the version before this - // response returned. The optimistic patch is already visible; the server - // command above is the durable source of truth. - current.patchNodeSilent(owner.nodeId, patch); } } +const draftSaves = new Map>(); + +function draftKey(view: AgentConversationView): string { + return `${view.conversationOwner.canvasId}\0${view.conversationOwner.nodeId}`; +} + +export function saveConversationDraft( + view: AgentConversationView, + patch: { + agentBinding: AgentBinding; + agentMode: 'ask' | 'operate'; + agentIcon?: unknown; + }, +): Promise { + const save = patchConversationOwnerNode(view, patch).then(async () => { + if (draftSaves.get(draftKey(view)) !== save) return; + const state = useCanvasStore.getState(); + const source = resolveConversationOwnerSource( + state.canvasId, + state.nodes, + view, + ); + const actual = source?.agentBinding; + if (!source && state.canvasId !== view.conversationOwner.canvasId) return; + if ( + !actual || + actual.kind !== patch.agentBinding.kind || + (actual.kind === 'external' && + patch.agentBinding.kind === 'external' && + actual.profileId !== patch.agentBinding.profileId) || + source?.agentMode !== patch.agentMode + ) { + throw new ConversationIntegrityError( + 'Agent selection changed before the draft was acknowledged', + ); + } + }); + draftSaves.set(draftKey(view), save); + // Keep a rejected save available to the send guard until an explicit retry. + void save.catch(() => undefined); + return save; +} + +export async function awaitConversationDraft( + view: AgentConversationView, +): Promise { + await awaitQuestionCreation( + view.conversationOwner.canvasId, + view.conversationOwner.nodeId, + ); + await draftSaves.get(draftKey(view)); +} + +export async function acknowledgeConversationResult( + view: AgentConversationView, + source: ConversationOwnerSource | undefined, +): Promise { + if ( + !source || + source.viewed || + (source.status !== 'done' && source.status !== 'error') + ) + return; + await acknowledgeAgentNodeResult( + view.conversationOwner.canvasId, + view.conversationOwner.nodeId, + { invocationToken: source.invocationToken ?? null }, + ); +} + export function patchConversationOwnerNode( view: AgentConversationView, patch: Record, diff --git a/docs/README.md b/docs/README.md index 6f5aca3a6..541281a7f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,29 +77,30 @@ docs/ ### Active -| Doc | Status | Summary | -| -------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------ | -| [active-space-external-note-watcher.md](./proposals/active-space-external-note-watcher.md) | Proposed | Scope external-note watchers to Spaces with active SSE subscribers. | -| [agent-node-freshness-cas-plan.md](./proposals/agent-node-freshness-cas-plan.md) | In-Progress | Read/write revision freshness across agent and web paths. | -| [agent-space-change-auto-accept.md](./proposals/agent-space-change-auto-accept.md) | Proposed | Global General setting to suppress routine Agent Space Change Review records. | -| [agent-turn-realtime-sync.md](./proposals/agent-turn-realtime-sync.md) | Proposed | Live attachment and durable event replay for UI, RFS, and Headless turns. | -| [canvas-checkpoint-plan.md](./proposals/canvas-checkpoint-plan.md) | Proposed | Canvas checkpoint and restoration design. | -| [canvas-realtime-sync-plan.md](./proposals/canvas-realtime-sync-plan.md) | In-Progress | Roadmap from multi-agent sync to multi-user co-editing. | -| [content-before-ai-design.md](./proposals/content-before-ai-design.md) | Needs review | Block-level and inline authorship provenance. | -| [credential-storage-hardening-followups.md](./proposals/credential-storage-hardening-followups.md) | Draft | Follow-up credential storage hardening. | -| [direct-space-operations.md](./proposals/direct-space-operations.md) | In-Progress | #348 deterministic RFS query and mutation operations for external agents. | -| [external-agent-capability-cache-and-realization.md](./proposals/external-agent-capability-cache-and-realization.md) | Accepted | #160/#162 GET-only capability discovery and canonical first-interaction realization. | -| [headless-executor-plan.md](./proposals/headless-executor-plan.md) | Partly shipped | Server-side headless canvas executor and structure/content sync. | -| [interactive-agent-views.md](./proposals/interactive-agent-views.md) | In-Progress | Capability-bound HTML views for persistent external-Agent interaction. | -| [long-horizon-tasks.md](./proposals/long-horizon-tasks.md) | Partly shipped | Canvas-scoped recursive Agent creation, invocation, and handoff pipeline. | -| [managed-acp-harness.md](./proposals/managed-acp-harness.md) | Draft | Resource-first Agent Team Profile compilation. | -| [managed-agent-teams.md](./proposals/managed-agent-teams.md) | In-Progress | Huabu-managed discovery, configuration, preparation, and runtime. | -| [milkdown-custom-toolbar-plan.md](./proposals/milkdown-custom-toolbar-plan.md) | In-Progress | Huabu-owned Milkdown toolbar and semantic editor commands. | -| [model-role-routing.md](./proposals/model-role-routing.md) | Proposed | Model selection by runtime role. | -| [move-selected-nodes-between-spaces.md](./proposals/move-selected-nodes-between-spaces.md) | Proposed | #142 selected-node and Frame-subtree moves between Spaces with bounded compensation. | -| [multi-backend-storage.md](./proposals/multi-backend-storage.md) | Partly shipped | Phases 1–3: Blob, structured repositories, catalogue, and bounded reads. | -| [note-auto-height-stable-geometry.md](./proposals/note-auto-height-stable-geometry.md) | Proposed | Revision-aware offscreen Note measurement and stable auto-height geometry. | -| [space-prompt-topology-scoping.md](./proposals/space-prompt-topology-scoping.md) | Shipped | Topology-derived global/direct-Agent targeting for Prompt Frames. | +| Doc | Status | Summary | +| -------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------ | +| [active-space-external-note-watcher.md](./proposals/active-space-external-note-watcher.md) | Proposed | Scope external-note watchers to Spaces with active SSE subscribers. | +| [agent-node-freshness-cas-plan.md](./proposals/agent-node-freshness-cas-plan.md) | In-Progress | Read/write revision freshness across agent and web paths. | +| [agent-node-fsm.md](./proposals/agent-node-fsm.md) | Implemented (pending merge) | #163 server-owned Agent Node FSM, execution binding, and prompt lifecycle. | +| [agent-space-change-auto-accept.md](./proposals/agent-space-change-auto-accept.md) | Proposed | Global General setting to suppress routine Agent Space Change Review records. | +| [agent-turn-realtime-sync.md](./proposals/agent-turn-realtime-sync.md) | Proposed | Live attachment and durable event replay for UI, RFS, and Headless turns. | +| [canvas-checkpoint-plan.md](./proposals/canvas-checkpoint-plan.md) | Proposed | Canvas checkpoint and restoration design. | +| [canvas-realtime-sync-plan.md](./proposals/canvas-realtime-sync-plan.md) | In-Progress | Roadmap from multi-agent sync to multi-user co-editing. | +| [content-before-ai-design.md](./proposals/content-before-ai-design.md) | Needs review | Block-level and inline authorship provenance. | +| [credential-storage-hardening-followups.md](./proposals/credential-storage-hardening-followups.md) | Draft | Follow-up credential storage hardening. | +| [direct-space-operations.md](./proposals/direct-space-operations.md) | In-Progress | #348 deterministic RFS query and mutation operations for external agents. | +| [external-agent-capability-cache-and-realization.md](./proposals/external-agent-capability-cache-and-realization.md) | Accepted | #160/#162 GET-only capability discovery and canonical first-interaction realization. | +| [headless-executor-plan.md](./proposals/headless-executor-plan.md) | Partly shipped | Server-side headless canvas executor and structure/content sync. | +| [interactive-agent-views.md](./proposals/interactive-agent-views.md) | In-Progress | Capability-bound HTML views for persistent external-Agent interaction. | +| [long-horizon-tasks.md](./proposals/long-horizon-tasks.md) | Partly shipped | Canvas-scoped recursive Agent creation, invocation, and handoff pipeline. | +| [managed-acp-harness.md](./proposals/managed-acp-harness.md) | Draft | Resource-first Agent Team Profile compilation. | +| [managed-agent-teams.md](./proposals/managed-agent-teams.md) | In-Progress | Huabu-managed discovery, configuration, preparation, and runtime. | +| [milkdown-custom-toolbar-plan.md](./proposals/milkdown-custom-toolbar-plan.md) | In-Progress | Huabu-owned Milkdown toolbar and semantic editor commands. | +| [model-role-routing.md](./proposals/model-role-routing.md) | Proposed | Model selection by runtime role. | +| [move-selected-nodes-between-spaces.md](./proposals/move-selected-nodes-between-spaces.md) | Proposed | #142 selected-node and Frame-subtree moves between Spaces with bounded compensation. | +| [multi-backend-storage.md](./proposals/multi-backend-storage.md) | Partly shipped | Phases 1–3: Blob, structured repositories, catalogue, and bounded reads. | +| [note-auto-height-stable-geometry.md](./proposals/note-auto-height-stable-geometry.md) | Proposed | Revision-aware offscreen Note measurement and stable auto-height geometry. | +| [space-prompt-topology-scoping.md](./proposals/space-prompt-topology-scoping.md) | Shipped | Topology-derived global/direct-Agent targeting for Prompt Frames. | ### Shipped diff --git a/docs/architecture/agent-architecture.md b/docs/architecture/agent-architecture.md index 982ef67fd..8181cbf87 100644 --- a/docs/architecture/agent-architecture.md +++ b/docs/architecture/agent-architecture.md @@ -1,7 +1,7 @@ # Agent Architecture > Runtime architecture of the server-side agent: runtime, entry points, tools, skills, external agents, persistence. -> Last updated: 2026-09-14 +> Last updated: 2026-09-15 Module root: [apps/server/src/modules/agent](../../apps/server/src/modules/agent) · prompt root: [apps/server/src/prompt](../../apps/server/src/prompt) @@ -26,7 +26,9 @@ Key runtime characteristics: - **Built-in chat is a Deployment**: `POST /api/agent` reuses one live `PiAgentHandle` per `threadId` (get-or-create by Agenetes). On restart, Agenetes supplies durable materialized history through `AgentCreateContext`; that history contains completed Tier-2 turns plus an optional read-time incomplete turn projected from the Tier-1 `turn_start` and event suffix. pi-driver lowers that history through its `materializeHistory` port and seeds the result through pi-agent-core's native `initialState.messages`. Huabu implements the port in [history-replay.ts](../../apps/server/src/modules/agent/agenetes/history-replay.ts) on top of `rebuildTurnMessages`, whose job is to restore the context the live handle would still be holding: each turn replays the canonical `rendered` input array persisted with its submission, so role attribution, `toolCall`/`toolResult` pairing, and images as real vision parts all come back byte-identical to what the model saw. The folded transcript is projected one round at a time, so a multi-round turn replays as `assistant → toolResult → assistant` instead of collapsing into a single block, and a tool call folded with `status: 'failed'` replays as an error result. Only records written before `rendered` existed fall back to re-rendering the stored envelope, and that path drops the neighbourhood, whose point-in-time snapshot would otherwise differ on every rebuild and break the provider's prefix cache. Replay deliberately does not trim: context growth belongs to the conversation, and budgeting only on recovery would make a recovered thread quietly forget what a never-restarted one remembers. Because the payload is not the durable record, the driver reports the materialized `estimatedSize` to `authorizeHistoryLoad`; the mounted `AutoRecoverPolicy` limit is `HISTORY_LOAD_SANITY_LIMIT`, a corruption guard sitting far above any genuine conversation, not a context budget. The route no longer rebuilds transcript context or persists turns. The workload's `initialPreamble` is mapped to pi-agent-core's native `systemPrompt`; later prompt changes use native `set_context`. The pi driver also re-resolves the symbolic `{ type: 'host', id: 'active' }` model ref at every turn boundary. - **RFS Agent creation and prompting are separate**: `POST /agent` creates a visible Agent Node and may start its first turn, while `POST /agent/:threadId/prompt` addresses an existing conversation. Both Huabu and configured Agent Profiles use the same node-backed invocation service; turns continue draining after the RFS socket disconnects and remain stoppable through the shared explicit stop path. - **Deployment turns are mutually exclusive**: `AgentThreadService` owns the shared per-`threadId` turn lease, abort controller, process-local active-invocation registry, and durable-turn-start barrier for UI, RFS, and Interactive View invocation. The lease remains held until the run settles, including when a client disconnects. `GET /api/agent/stream/:threadId` validates the active invocation's owner Canvas and independently tails Agenetes Tier 1, so an RFS response and multiple Web tabs can observe one turn without draining each other. History reads include the uncovered Tier-1 suffix and wait for turn start when the matching invocation is active. -- **UI invocation is service-owned**: `POST /api/agent` delegates dispatch and lease ownership to `AgentThreadService`. When `(canvasId, threadId)` resolves to a fixed Agent Node, the persisted external binding overrides request binding data, the node's launch overrides feed first ACP realization, and the service owns first content plus running/done/error Canvas patches. When the pair resolves to any Agent Node, independently of binding policy, the service compiles the Space's recognized Prompt Frames into a bounded user-authored preamble on first realization and persists that snapshot in the workload (`hostContext.spacePrompt` for the built-in driver, a dedicated `initialPreamble` fragment for ACP); later turns reuse the snapshot, including the intentional absence of a prompt on an already-realized legacy thread. Selectable Question Nodes retain their existing request binding and Web lifecycle paths, while ordinary node-less Canvas Chat does not trigger Prompt Frame collection. +- **Node-backed invocation is service-owned**: `POST /api/agent`, direct RFS, and Interactive View use `AgentThreadService`. After admission it resolves fresh node identity regardless of `agentBindingPolicy`, rejects a conflicting requested Profile, registers cancellation before slow preparation (including deferred Web/RFS envelope construction), and projects `running` with a fresh `invocationToken` before dispatch. `AgentNodeLifecycle` fills only freshly read empty never-submitted content, using prior token/history evidence, and projects terminal `done`/`error` plus unread attention under the Canvas mutex. Terminal writes check the current token and release admission only after settlement cleanup. Ordinary node-less Chat has no node FSM. +- **Binding is a separate monotonic axis**: `AgentNodeBindingCoordinator` confirms the validated `agenetes.record(namespace, threadId)` and persists `bindingState: bound`. A first external control can bind without a prompt, content write, or invocation token. Internal `runAgent` exposes an awaited `onExecutionCreated` seam after canonical create and before settings controls or `run`; thread-associated Jobs use the same confirmation without changing their existing execution strategy or same-driver spec semantics. A failed Bound write cannot roll back canonical binding; the next guarded operation completes promotion. Missing records on Bound nodes are explicit execution errors, never permission to rebind. No native Session ID or live process is required to establish Bound. +- **Prompt Frame snapshots**: for every Agent Node, the service compiles recognized Space Prompt Frames into a bounded user-authored preamble on first realization and persists it in the workload (`hostContext.spacePrompt` for built-in, a dedicated `initialPreamble` fragment for ACP). Later turns reuse that snapshot, including the intentional absence on realized legacy threads. Node-less Canvas Chat does not collect Prompt Frames. - **Abort**: route `signal` → `agent.abort()`; pi-agent-core writes a final message with `stopReason: 'aborted'`. ACP turns check the same signal both before and after session bootstrap, so stopping during process startup never dispatches the pending `session/prompt`. A replacement `/api/agent` request waits (bounded) for any in-flight turn on the same thread to release its lease before acquiring — this absorbs the cancel-then-resend race where the client's fire-and-forget `/stop` has not yet reached the server. A turn that never releases within the timeout, or a genuinely concurrent turn, still receives `409 thread_busy`. - **Anchored conversations**: ordinary Question previews retain `AgentConversationView`, with presentation and owner identifying the same active Canvas/node and the owner carrying the thread. History, reconnect, `/api/agent`, tools, and lifecycle patches use that owner. World `nodeRef` source-conversation presentation is retired; this does not remove server-side RFS or background Agent execution. @@ -39,10 +41,10 @@ Three built-in agents, each with a declares `tools` / `skillScope` / `runtime`; loader in [agents/loader.ts](../../apps/server/src/prompt/agents/loader.ts)): -| Agent | Entry point | Notes | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `ask` / `operate` | `POST /api/agent` ([agent.route.ts](../../apps/server/src/modules/agent/agent.route.ts) → [AgentThreadService](../../apps/server/src/modules/agent/agent-thread.service.ts)) | Main chat path; ask is read-only, operate can write. The service dispatches built-in or ACP Deployments and owns fixed Agent Node invocation. | -| `memory` | [memory/](../../apps/server/src/modules/agent/memory) background curator | Triggered by the op-counter; see [agent-memory.md](./agent-memory.md). | +| Agent | Entry point | Notes | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ask` / `operate` | `POST /api/agent` ([agent.route.ts](../../apps/server/src/modules/agent/agent.route.ts) → [AgentThreadService](../../apps/server/src/modules/agent/agent-thread.service.ts)) | Main chat path; ask is read-only, operate can write. The service dispatches built-in or ACP Deployments and owns node-backed invocation regardless of binding policy. | +| `memory` | [memory/](../../apps/server/src/modules/agent/memory) background curator | Triggered by the op-counter; see [agent-memory.md](./agent-memory.md). | **External / ACP agents**: when a chat request carries a `binding` field it routes through [acp/](../../apps/server/src/modules/agent/acp) (§6) instead of the built-in `runAgent`. @@ -140,7 +142,7 @@ The [shared title contracts](../../packages/shared/src/types/api/conversation-ti [acp/](../../apps/server/src/modules/agent/acp) is the integration layer for external agents. Its trusted built-in catalogue detects and launches GitHub Copilot, Claude Agent, Gemini, Codex, Qwen Code, Kimi Code CLI, OpenCode, Cursor, CodeBuddy, and Hermes Agent; Manual setup remains available for other ACP-compatible agents and advanced launch commands. Presets with official argument-based full-auto modes expose an auto-approve toggle whose structured recipe controls both the arguments and whether global options precede the ACP subcommand; agents that require environment variables, configuration, or ACP session modes do not expose this launch-command toggle. -- [external-agent-realization.ts](../../apps/server/src/modules/agent/acp/external-agent-realization.ts) is the sole first-interaction realization boundary for external threads. The first message or mode/model/config control resolves the Agent Node from Canvas state, collects its Space Prompt, applies fixed-node Profile/cwd conflict checks when applicable, calls `buildAcpWorkloadSpec()`, and persists one complete immutable WorkloadSpec through Agenetes. A namespace-and-thread single-flight makes simultaneous first interactions converge on that same spec; later interactions reuse the persisted spec without recollecting instructions. +- [external-agent-realization.ts](../../apps/server/src/modules/agent/acp/external-agent-realization.ts) is the sole first-interaction realization boundary for external threads. The first message or mode/model/config control resolves the Agent Node from Canvas state, collects its Space Prompt, applies acknowledged node Profile/cwd conflict checks when applicable, calls `buildAcpWorkloadSpec()`, and persists one complete immutable WorkloadSpec through Agenetes. A namespace-and-thread single-flight makes simultaneous first interactions converge on that same spec; later interactions reuse the persisted spec without recollecting instructions. - [service.ts](../../apps/server/src/modules/agent/acp/service.ts) owns `buildAcpWorkloadSpec()` and `runAcpAgent()`. The builder snapshots the unified Agent Profile, explicit placement, reachback environment, effective cwd, mandatory Huabu bootstrap, frozen Space Prompt, and node-specific instructions. `runAcpAgent()` receives the already-realized handle and drives only the message turn. Agenetes keeps an already persisted WorkloadSpec authoritative, so later calls cannot mutate launch identity or Space instructions. - [preprocessor.ts](../../apps/server/src/modules/agent/acp/preprocessor.ts) renders the shared `ChatEnvelope` into canonical `AgentInput[]`. Slash commands become one exclusive `AgentCommandInput`; selection and attachments ride its `context`. - [`@agenetes/agentlet-host`](../../external/agenetes/packages/agentlet-host) mounts the durably stateless [`@agenetes/agentlet-gateway`](../../external/agenetes/packages/agentlet-gateway), supervises the local agentlet daemon, and injects host-owned authentication. The Gateway owns only live control/session connections, pending RPCs, reconnect buffers, and bounded pre-attach buffering; durable workload and conversation state remains in Agenetes. Ordinary control RPCs time out after 60 seconds, while `server/spawn` has a separate 240-second deadline because it includes ACP `initialize` plus session lifecycle bootstrap, whose two sequential requests may each take up to 90 seconds. @@ -218,3 +220,14 @@ To add / change a skill: - [sketch-node.md](./sketch-node.md) — sketch nodes. - [agent-reachback.md](./agent-reachback.md) — the reachback channel external agents use to read/write the canvas. + +## Code entry points + +| File | Responsibility | +| ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| [agent-thread.service.ts](../../apps/server/src/modules/agent/agent-thread.service.ts) | Admitted Preparing / Executing / Stopping / Settled lifetime, cancellation, outcome precedence, and lease cleanup | +| [agent-node-binding.ts](../../apps/server/src/modules/agent/agent-node-binding.ts) | Canonical record confirmation and Editing / Bound preparation guard | +| [agent-node-lifecycle.ts](../../apps/server/src/modules/agent/agent-node-lifecycle.ts) | Fresh-state first-content and token-guarded terminal projection | +| [agent.service.ts](../../apps/server/src/modules/agent/agent.service.ts) | Internal lazy create, awaited binding confirmation, then controls/run | +| [external-agent-realization.ts](../../apps/server/src/modules/agent/acp/external-agent-realization.ts) | External first-interaction single-flight and record-before-Bound-before-session ordering | +| [conversation-stores.ts](../../apps/server/src/modules/agent/agenetes/conversation-stores.ts) | Portable existing Disk/SQLite conversation-store dispatch | diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md index c0ec3c42c..e968e2396 100644 --- a/docs/architecture/api-design.md +++ b/docs/architecture/api-design.md @@ -1,6 +1,6 @@ # API Design Spec -> Authoritative · Last updated 2026-09-14 +> Authoritative · Last updated 2026-09-15 How every HTTP / SSE endpoint is defined and consumed across `apps/server` and `apps/web`. Deviations require updating this file in the same PR. @@ -112,6 +112,12 @@ export async function postEcho(body: EchoBody): Promise { - **Error message**: take from the first zod issue, fall back to a fixed string. Never ship `error.format()` — it leaks zod internals. +## Agent Node owner-specific writes + +[`agent-node.ts`](../../packages/shared/src/types/api/agent-node.ts) defines the bounded editable node schema, launch overrides, association bodies, result acknowledgements, and internal projection shape. `PUT /api/canvas/:canvasId` accepts editable fields, not a complete replacement read model: omitted fields remain unchanged, Question FSM fields cannot be submitted, and the server composes from current state under the Canvas mutex. The same ownership guard applies to ordinary `MERGE_NODE_DATA`, regardless of the submitted originator. It does not reserve unrelated node types' `status` or thread-reference metadata. + +`POST /api/canvas/:canvasId/nodes/:nodeId/association` initializes a legacy Question or validates undo reinsertion; it cannot replace an existing thread association. `POST /api/canvas/:canvasId/nodes/:nodeId/viewed` accepts `{ invocationToken }` and returns `{ acknowledged }`, where false means the observed token is no longer the current terminal result. A null acknowledgement token is the bounded legacy case: it succeeds only for a terminal node whose token is still absent, and cannot acknowledge any newly admitted result. Both use shared schemas and `safeParse`. Trusted FSM projection is an in-process business writer, not an HTTP endpoint or client-controlled originator privilege. Canvas Sync may emit `agentNodeProjection` to keep these server effects out of editable undo. + ## Agent history paging `GET /api/agent/history/:threadId/page` is the bounded display-history endpoint. Its canonical contract is [`agent-history.ts`](../../packages/shared/src/types/api/agent-history.ts): `threadId` and required `canvasId` are non-empty, `limit` is an integer from 1 through 20, and optional `before` is an opaque exclusive cursor. The server validates params and query with `safeParse` before resolving a namespace. @@ -139,3 +145,12 @@ grep -l 'ZodObject\|safeParse' dist/assets/*.js && echo LEAK || echo OK ``` `OK` is the only acceptable output. + +## Code entry points + +| File | Responsibility | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| [types/api/](../../packages/shared/src/types/api) | Canonical wire schemas and inferred types | +| [agent-node.ts](../../packages/shared/src/types/api/agent-node.ts) | Bounded Question edit, association, result-acknowledgement, and projection contracts | +| [canvas.route.ts](../../apps/server/src/modules/canvas/canvas.route.ts) | Runtime input validation and owner-specific Canvas operations | +| [canvas.ts](../../apps/web/src/api/canvas.ts) | Type-only web contract imports and Canvas HTTP helpers | diff --git a/docs/architecture/canvas-command-architecture.md b/docs/architecture/canvas-command-architecture.md index 3984fe7b4..24872871a 100644 --- a/docs/architecture/canvas-command-architecture.md +++ b/docs/architecture/canvas-command-architecture.md @@ -133,6 +133,16 @@ The Frame itself is not resized during the preview; its projected size continues Frame resize previews capture the current gutter plan at gesture start. Each animation-frame tick scales those frozen X/Y sizes with the child geometry and does not recompute label measurements or lane assignments; the authoritative resize-end command omits the override and recomputes the plan from the final graph. A multi-selection treats every selected Frame as a scaling root and transforms its complete descendant subtree in the same coordinate space, so the Frame continues to contain nested Frames and ordinary children; a nested selected Frame is handled by its outermost selected ancestor to avoid double scaling. Multi-selection movement uses preview geometry and performs one authoritative geometry commit on completion; text fitting and height-commit suspension follow the single-node resize lifecycle. The override is executor-local transient state and is never persisted in a command or canvas document. +### Agent Node field ownership + +An Agent Node's complete read model is not an ordinary write DTO. Structure PUT and `MERGE_NODE_DATA` edit layout, authored content, and presentation, while `bindingState`, `invocationToken`, invocation `status` / `errorMessage`, `viewed`, and `threadId` belong to server business paths. The shared Agent ownership projection omits these fields from browser saves; server validation rejects explicit writes, including requests labelled `originator.source: system`. This bounded rule applies to Question nodes, not similarly named fields on unrelated node types. For Question patches, omission or `undefined` preserves a field; clearing is explicit (for example empty authored text or `agentLaunchOverrides: null`). Other node types retain their existing local shallow-merge reset semantics, including clearing PDF `coverUrl` with `undefined`. + +Editing preparation (`agentBinding` driver/Profile identity and explicit launch overrides) is guarded against first realization using the existing turn lease. Bound preparation cannot change; aliases, ordinary display data, and internal ask/operate mode are not execution identity. Draft guards already inside the non-reentrant Canvas mutex acquire a turn lease nonblockingly and retain it through persistence; they never wait for a running invocation while holding the Canvas lock. Canonical promotion uses an already-locked projection entry and reloads the resulting version before composing the remaining edit. Unchanged preparation echoed by a layout save can pass while an admitted prompt owns confirmation; an actual preparation change still conflicts. No Canvas lock spans model execution. + +Creation initializes Editing and fresh invocation state; attaching a realized chat or reinserting an undone Question confirms that owner's canonical thread. Ordinary edits cannot replace an existing association. Plain copies receive a new thread without Bound/token/result metadata; conversation forks confirm the fork's own record before creation. Cross-Space Move preserves the original thread and execution metadata via an internal validated move input, uses nonblocking turn acquisition with sorted Canvas locks, and retains its existing compensation boundaries. + +Question creation and undo reinsertion remain optimistic, but preprocessing and ordinary structure saves await their acknowledgement. Failed creation, fork, or restoration removes the optimistic Question and its incident edges from live state and history while preserving the explicit rejection; unrelated edits can still save. Unsupported conversation forks never fall back to a fresh thread. Undo or deletion removes the local node immediately and orders the tracked server DELETE after pending creation settles; late creation HTTP/SSE inserts cannot resurrect the locally removed node. Structure saves await tracked deletions too: DELETE removes the sidecar, while the subsequent Canvas PUT must remove the topology after any delayed creation. + ### Command Catalog See `packages/shared/src/types/canvas/command.ts` for the full discriminated union. Summary: @@ -273,6 +283,9 @@ The parallel `IntentAction` union is gone: `RecentAction` ([context.ts](../../pa | [`packages/shared/src/canvas-engine/autoLayout/gridLayout.ts`](../../packages/shared/src/canvas-engine/autoLayout/gridLayout.ts) | Solve structured tracks, edge-aware gutters, and resize-time frozen spacing. | | [`apps/web/src/store/canvasStore/slices/resizePreview.ts`](../../apps/web/src/store/canvasStore/slices/resizePreview.ts) | Capture and scale frozen structured gutter plans during Frame resize. | | [`apps/web/src/store/canvasStore/slices/structuredReflow.ts`](../../apps/web/src/store/canvasStore/slices/structuredReflow.ts) | Apply and reverse the live structured drop reflow preview. | +| [agent-node-edit.ts](../../apps/server/src/modules/canvas/agent-node-edit.ts) | Validate and coordinate Question preparation edits and creation confirmation | +| [agent-node-projection.ts](../../apps/server/src/modules/canvas/agent-node-projection.ts) | Trusted token-guarded FSM projection under the Canvas mutex | +| [agentNodeOwnership.ts](../../packages/shared/src/canvas-engine/agentNodeOwnership.ts) | Shared bounded editable projection and inverse-effect replay | | [`apps/server/src/modules/canvas/canvas-executor.ts`](../../apps/server/src/modules/canvas/canvas-executor.ts) | Execute commands against the addressed Canvas and validate resulting topology. | | [`apps/server/src/modules/canvas/world-preview-policy.ts`](../../apps/server/src/modules/canvas/world-preview-policy.ts) | Protect managed preview identity and validate canonical command and node types. | | [`apps/server/src/modules/canvas/world-previews.ts`](../../apps/server/src/modules/canvas/world-previews.ts) | Reconcile canonical previews through ordinary system command batches. | diff --git a/docs/architecture/canvas-realtime-sync.md b/docs/architecture/canvas-realtime-sync.md index 49681025c..8c61da6a4 100644 --- a/docs/architecture/canvas-realtime-sync.md +++ b/docs/architecture/canvas-realtime-sync.md @@ -58,16 +58,17 @@ The wire event is defined once in [canvas-sync.ts](../../packages/shared/src/types/api/canvas-sync.ts) (zod schema + `z.infer`, web imports as `import type` only): -| Event | When | Payload | -| ---------- | --------------------------- | --------------------------------------------------------------------------------------------- | -| `snapshot` | once, on SSE connect | `{ version }` — lets a tab that connected _after_ a mutation detect the gap and `loadCanvas`. | -| `update` | after every persisted batch | `{ fromVersion, toVersion, deltas, pendingEffects, threadId?, changes? }` | +| Event | When | Payload | +| ---------- | --------------------------- | ----------------------------------------------------------------------------------------------- | +| `snapshot` | once, on SSE connect | `{ version }` — lets a tab that connected _after_ a mutation detect the gap and `loadCanvas`. | +| `update` | after every persisted batch | `{ fromVersion, toVersion, deltas, pendingEffects, threadId?, changes?, agentNodeProjection? }` | - `deltas` / `pendingEffects.mutatedNodes` are `unknown` on the wire (they mirror the loosely-typed `PostCanvasExecuteResponse`; the engine `Delta` / `CanvasNode` shapes live in the canvas-engine module, not the API layer). The client casts. - `threadId` + `changes` are present **only** for thread-attributed batches; they feed the originating conversation's review card. +- `agentNodeProjection` identifies trusted server binding/lifecycle/association effects for browser undo handling; it is output metadata, not client authorization to submit those writes. ## One write path, one broadcast @@ -92,7 +93,7 @@ un-persisted local content edits (`nodeContentQueue.pendingNodeIds()` — debounced-but-unsaved plus in-flight PUTs): ``` -INSERT_NODE (new id) → always apply (fresh ids never collide) +INSERT_NODE (new id) → apply unless a local pending Question creation was undone/deleted REPLACE_NODE changing content / DELETE_NODE on a dirty id → SKIP (keep the human's unsaved edit) REPLACE_NODE changing only non-content fields on a dirty id → apply while preserving local content fields otherwise → apply @@ -117,6 +118,7 @@ otherwise → apply - Structure-save acknowledgements reconcile monotonically with Canvas Sync: a delayed HTTP success cannot lower the local version, and a delayed 409 whose reported server version has already arrived over SSE is retried against that fresh baseline instead of opening the global conflict state. If the 409 arrives first, the client records its server version; a later SSE update that reaches that version clears the warning and schedules the latest structure for retry. - **Scope:** content only. Same-node _structure_ conflicts (geometry / parent) stay coarse — there is no per-node structure-dirty tracking yet. +- **Mixed Question content/FSM deltas:** when a dirty node rejects incoming authored content, its authoritative binding, invocation, attention, and association metadata still applies. The local content, existing amber conflict notice, skipped change-card state, and content-CAS baseline rebase remain intact. Geometry and deletion conflict policy is unchanged; this is not general field-level merging. Skipped ids flow back through `canvasSyncStore` into `acpThreadChangesStore` (`conflictedByThread`) and surface as: @@ -197,24 +199,30 @@ is deferred — see the plan. ## Undo interaction +Editable broadcast batches take **one** undo snapshot (via `applyDeltasFromAgent`). Trusted Agent Node projection batches and FSM-only replacements do not create ordinary undo entries. The server's first-submitted-content effect rebases matching empty Question content in existing snapshots, so undoing an earlier drag cannot erase that submitted intent. + **Restore persistence is ordered, not delay-dependent.** Before undo/redo publishes reappearing nodes, the existing `nodeContentQueue` holds their sidecar writes behind generation tokens. Deleted-node content bookkeeping is forgotten immediately, while the preprocessing queue remembers interrupted work for history resurrection. Already-issued content and preprocessing requests settle before the history manager sends DELETE; DELETE promises are tracked to completion rather than aborted, because aborting fetch does not cancel an admitted server mutation. `saveCanvas` waits for outstanding deletes, reads the latest topology, and releases only tokens captured by that successfully acknowledged structure PUT. A later undo/redo invalidates earlier tokens, so neither an old acknowledgement nor a queued body resurrects a node removed again. Ordinary content edits retain their independent debounce and revision baseline; only an acknowledged resurrection starts with the absent-sidecar revision, still subject to the existing content CAS. History reports node changes to the existing preprocessing queue rather than independently cancelling or resuming tasks. The queue owns selective input reconciliation, waiting demand, and client callback validity under the authoritative [client responsibility boundary](./node-preprocessing.md#client-responsibility-boundary). The content queue signals readiness only after restored content successfully persists. These client guarantees do not establish server-side freshness of derived-content writes. Structure failures and unresolved version conflicts leave restored bodies held and dirty; ordinary autosave, the existing save-error Retry action, and SSE version reconciliation all reuse `saveCanvas`. A released body's content failure or content conflict also stays dirty until saved or explicitly resolved. Route navigation, `switchCanvas`, and explicit Canvas loads drain the same queues and refuse to discard an unresolved restored body; Retry or explicitly removing the node lets the user continue. Delayed callbacks check the captured Canvas and restore generation before applying acknowledgements. A snapshot with `contentMissing` or without a text-bearing node's string body never invents an empty sidecar; it remains a missing-file placeholder. Unload remains best-effort: it cannot guarantee a multi-request restore after the browser terminates, and it never bypasses the restore hold to write content early. Backend tombstones, version checks, endpoints, and debounce delays are unchanged. -Broadcast applies take **one** undo snapshot per batch (via `applyDeltasFromAgent`). Host-side refinements keep undo coherent with sync: +Host-side refinements keep undo coherent with sync: - **Transient-field parity.** `diff.ts` and the web snapshotter share one canonical `TRANSIENT_NODE_FIELDS` / `TRANSIENT_EDGE_FIELDS` list (`selected` / `dragging` / `measured` / `resizing`) so a pure selection flip never diffs into a phantom REPLACE, and undo/redo re-applies the live transient fields instead of clearing selection. -- **Question-node data preservation.** Undo/redo restores a question node's - geometry but keeps its **live** `data` (thread binding, answer) — that payload - is system-driven, so rewinding a move must not wipe it. +- **Question-node ownership preservation.** Undo/redo restores editable geometry/content/presentation while preserving live FSM fields and Bound preparation. Server change-card inverse replay applies only the editable data fields changed by that recorded effect, not an old complete Node. Undo reinsertion awaits deletion and confirms the restored thread through the association endpoint before ordinary autosave; a failed confirmation does not silently create a fresh binding. +- **Pending Question removal.** Undo/deletion removes optimistic Questions immediately, filters their late creation inserts (and incident inserted edges) from both HTTP and SSE reconciliation, and delays the tracked server DELETE until creation settles. Failed creation/fork/restore rolls back its optimistic Question and history references rather than leaving a rejected promise that blocks every later structure save. + +Question restoration preserves main's immediate local undo/redo behavior and content-queue restore barriers. Only the server association waits for already-issued topology PUTs and the node's tracked DELETE; it does not flush or await new saves that depend on that association. Structure saving rechecks Question creation acknowledgements after asynchronous waits before submitting the latest editable topology. + - **Retired topology filtering.** Undo/redo applies the shared `stripLegacyPortalTopology()` helper before restoring a snapshot, so old Portal/Pin nodes and their incident edges cannot reappear and ordinary children retain rebased positions. The former Portal-specific history invalidation path is removed; this filtering does not migrate or clean up stored files. +The auto-accept preference still suppresses new Agent review records only. It does not suppress broadcasts or editable undo snapshots, and it does not weaken FSM ownership during manual revert or undo. + ## Stream reliability The sync route subscribes before reading the initial Canvas version, buffers updates committed during that read, sends the snapshot first, and then flushes the buffered updates. This closes the snapshot/subscribe loss window while preserving snapshot-before-update ordering. diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index e1e1dee83..01c56d66b 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -1,6 +1,6 @@ # Canvas Storage Architecture -> Last updated: 2026-09-14 +> Last updated: 2026-09-15 ## 1. Overview @@ -203,6 +203,10 @@ A completed Run stores immutable `completion.completedAt` and an optional trimme ## 4. Portable write seam and application ordering +Agent Node FSM metadata (`bindingState`, `invocationToken`, `status`, `errorMessage`, `viewed`) and association (`threadId`) live in the Space node record, separate from authored Markdown content. On Disk these are node data in `space.json`; SQLite persists them through its existing structured record adapter. `AgentNodeBindingCoordinator` confirms the canonical workload with `agenetes.record()` through the existing Disk/SQLite conversation-store dispatch; application FSM modules use the portable `space(canvasId)` facade and never name `.history/threads.json` or a database table. + +Canonical create persists a ThreadRecord before the Canvas projection acknowledges Bound. These are ordered writes, not a cross-store transaction. If the second write fails, the next guarded edit or interaction recognizes the record and completes promotion; no background recovery scan or automatic invocation replay is added. Bound remains monotonic even when the record later goes missing. Existing in-process persistence restoration and crash limitations below are unchanged. + Web undo/redo resurrection must acknowledge the restored topology before writing its sidecar: a tombstoned, structurally absent node correctly suppresses late content writes with a benign response. The web uses its existing structure save and content queue, holds restored bodies until the matching structure acknowledgement, and waits for actual in-flight DELETE completion instead of treating fetch abort as cancellation. Only restored nodes receive the absent-sidecar content revision baseline; normal content CAS remains unchanged. Failed restores retain their in-memory bodies and expose Retry rather than permitting navigation to discard them. See [Undo interaction](./canvas-realtime-sync.md#undo-interaction) for generation, failure, and unload guarantees. The content queue's generation-checked restored-content success callback notifies the preprocessing queue that waiting work may proceed. Structure acknowledgement alone is not sufficient: preprocessing remains blocked during the restored content PUT and after a failed write until Retry succeeds. The preprocessing queue owns whether interrupted or newly requested work is pending; storage does not decide this or automatically reprocess completed nodes. See the authoritative [client responsibility boundary](./node-preprocessing.md#client-responsibility-boundary). @@ -232,6 +236,8 @@ The mutex is single-process application policy. It is not advertised as a backend transaction or distributed lock; an adapter supplies its own CAS and may be stronger than the common contract. +Ordinary structure PUT composes supported editable fields with current Agent metadata under this same Canvas mutex. Trusted projections and editable inverse replay use already-locked writer entries to avoid recursive acquisition; draft acceptance holds a nonblocking turn lease through its actual Canvas write. Space Move retains sorted multi-Canvas locking and nonblocking turn acquisition rather than waiting for an invocation while holding a Canvas lock. + ### 4.1 Executor persistence restoration For a node/delta batch, Disk's ordered writer changes multiple files: affected node sidecars, `space.json`, and the append-only delta log. Its existing `runCanvasPersistenceTransaction()` helper now lives inside the Disk adapter. It captures raw bytes for `space.json` and affected sidecars, plus the delta log's existence and byte length. If a normal in-process write throws, rollback restores the sidecars and record bytes and truncates or removes the delta log back to its captured state before the rejection returns. A rejected node → record → delta batch therefore does not expose a completed prefix. @@ -239,3 +245,14 @@ For a node/delta batch, Disk's ordered writer changes multiple files: affected n `CanvasStore.withValidatedNodeMutationTransaction()` validates `space.json` once, snapshots adapter-local tombstones for affected node ids, and grants the authoritative inserted-id set a tombstone bypass until the full commit succeeds. Rollback restores `space.json` through `writeNodeMutationRollback()` without inferring another tombstone transition, then restores the captured in-memory tombstones. The normal rejection path returns only after the persisted and in-memory prestate has been restored. An explicit title rename is resolved before the protected node → record → delta batch and retains the old ordered, best-effort behavior; a later batch rejection does not promise to undo that rename. Artifact import happens before the Canvas mutex and is also outside this restoration boundary. Post-write change-review persistence happens afterwards. Process termination, power loss, an unknown remote outcome, and uncoordinated multi-process access can still leave an unknown or partial result: Phase 4 adds no filesystem WAL, commit marker, startup recovery, durable tombstone, idempotency record, or outbox. SQLite/Postgres may satisfy the in-process batch guarantee with a native transaction, but callers cannot infer any of those additional guarantees from it. + +## Code entry points + +| File | Responsibility | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| [storage/index.ts](../../apps/server/src/modules/storage/index.ts) | Portable Space facade and storage composition exports | +| [structured.ts](../../apps/server/src/modules/storage/ports/structured.ts) | Space, node, and ordered-write ports | +| [write-coordinator.ts](../../apps/server/src/modules/canvas/write-coordinator.ts) | Per-Canvas non-reentrant application mutex | +| [canvas-executor.ts](../../apps/server/src/modules/canvas/canvas-executor.ts) | Editable command/inverse composition and ordered persistence | +| [agent-node-binding.ts](../../apps/server/src/modules/agent/agent-node-binding.ts) | Canonical record confirmation before Bound acknowledgement | +| [conversation-stores.ts](../../apps/server/src/modules/agent/agenetes/conversation-stores.ts) | Existing file/SQLite Agenetes persistence dispatch | diff --git a/docs/architecture/question-node.md b/docs/architecture/question-node.md index 82a10803e..bf1e8a539 100644 --- a/docs/architecture/question-node.md +++ b/docs/architecture/question-node.md @@ -43,16 +43,18 @@ Like sketch nodes, a question node has two independent relationships with AI: | `content` | sidecar | The question text; stored in `nodes/.md` body like text/note (`TEXT_BEARING_NODE_TYPES`), stripped from the structure PUT | | `status` | ✅ | Optional sparse status: absent means `idle`; non-default values are `running` / `done` / `error` | | `threadId` | ✅ | Owns one chat thread; minted on first compose | -| `agentBinding` | ✅ | Internal or external agent, locked on first send | +| `agentBinding` | ✅ | Acknowledged preparation draft; driver/Profile identity becomes immutable after canonical execution binding | | `agentBindingPolicy` | ✅ | Optional `selectable` / `fixed`; absent means selectable, while service-created Agent Nodes use fixed before first send | | `agentIcon` | ✅ | External Agent's bind-time avatar fallback; current Profile icon wins while that Profile still exists | | `agentLaunchOverrides` | ✅ | Optional bounded cwd and additional-initial-preamble overrides for a service-created external Agent Node | | `agentMode` | ✅ | `operate` (default) / `ask` for the internal agent | | `errorMessage` | ✅ | Set on `status === 'error'` | | `viewed` | ✅ | Drives unread terminal-state attention on the Agent avatar | +| `bindingState` | ✅ | Server-owned `editing` / `bound`; Bound acknowledges a validated canonical Agenetes record and never demotes | +| `invocationToken` | ✅ | Server-owned current or last admitted prompt identity; fences terminal writes and viewed acknowledgements | | `responseSummary` | reserved | Teaser field; not yet written by the runner | -Not persisted: the in-flight `AbortController` (module-level in `useAgentStream`). +Not persisted: the server invocation phase and cancellation controller, plus the browser's stream controller and request feedback. The complete Node is a read model, not a writable snapshot: ordinary Canvas PUT, commands, and undo omit or preserve the server-owned fields and cannot replace the thread association. Question nodes are content nodes: their `content` runs through preprocessing's `generate_label` (LLM) to auto-name the node — but the profile has no `persist_source`, so they do **not** enter the knowledge base. They are still @@ -70,13 +72,13 @@ Saving a panel Chat as a Question uses [saveChatAsQuestion](../../apps/web/src/c Created like any node via `CREATE_NODES` ([resolveAddNodes.ts](../../apps/web/src/handler/canvasCommand/resolvers/resolveAddNodes.ts)) with `nodeType: 'question'` and empty `content`. Missing `status` is the idle state, and nothing fires automatically. From there: - **Idle** → double-click opens compose (§5). -- An idle node with `agentBindingPolicy: fixed` opens compose with its persisted binding and a read-only Agent selector; ordinary nodes with an absent or `selectable` policy retain the existing pre-send picker. +- An idle node with `agentBindingPolicy: fixed` opens compose with its persisted binding and a read-only Agent selector. Ordinary Editing nodes retain the picker; Bound nodes cannot switch execution identity even when a first control created no messages. - After sending: **running → done / error**. -- For fixed Agent Nodes, `AgentThreadService` resolves the persisted binding, writes first content and `status` / `errorMessage` through the server Canvas executor, and applies launch overrides before the first ACP realization. The Web client writes only `viewed`; selectable Question Nodes retain the existing client-authored lifecycle. -- Selectable Question Node lifecycle patches are optimistically reflected in the active Canvas and persisted through the canonical Canvas executor. Loading never infers `done` merely from `threadId` plus authored content because a persisted conversation may terminate in `error`; legacy nodes with an unknown status stay neutral but remain reopenable. +- `AgentThreadService` owns lifecycle for every node-backed invocation, regardless of policy. Admission publishes a new token and `running` before dispatch, installs cancellation before slow preparation, and keeps the turn lease through settlement. `AgentNodeLifecycle` fills only freshly read empty, never-submitted content and projects the matching terminal result. Existing content, previous submission tokens, and legacy conversation history prevent follow-ups from replacing authored text. +- Loading and reconnect observe server state; they never infer success from old history or repair status in the browser. A persisted running node without live tracking after restart does not establish an outcome, introduce a new badge, or trigger replay. Existing retry/admission behavior remains unchanged. - Running uses the bound Agent identity with a flowing information ring; an external Agent avatar body rotates while the built-in Huabu logo remains still. - A live unresolved ACP permission request temporarily overrides every other badge state, stops working motion, and shows a static warning ring with a shield satellite; resolving or cancelling the request restores the underlying run state. -- Done, error, and conflict attention styling appears only while `viewed === false`; opening the finished thread marks it viewed and returns the avatar to a quiet neutral ring. +- Done, error, and conflict attention styling appears only while `viewed === false`; opening or actively watching the finished thread sends its `invocationToken` to the viewed endpoint. The server acknowledges only the matching current terminal result, returning the avatar to a quiet neutral ring. Legacy terminal nodes use an explicit null-token acknowledgement that succeeds only while no new invocation token exists. - Move / delete / resize / re-frame all go through the normal node flow; a stale pasted copy strips transient state so it starts fresh. - **Create-time selection**: a question node does **not** auto-select when born @@ -117,7 +119,7 @@ Activating a `conversation` result row ([CanvasSearchResults.tsx](../../apps/web Double-click the node → `openInCompose()` ([QuestionNode.tsx](../../apps/web/src/components/Nodes/question/QuestionNode.tsx)). Creating a question through the toolbar placement flow or the connected-node picker also mints the thread and opens compose immediately. [`questionCompose.ts`](../../apps/web/src/components/Nodes/question/questionCompose.ts) opens the Question's Preview Workspace node tab and directs the input-focus request to that thread. -- mints a `threadId` if missing, opens the chat panel in **compose mode**, and defaults the built-in Huabu Agent to `operate` +- confirms server-acknowledged creation (or initializes a legacy node's missing thread association), opens the chat panel in **compose mode**, and defaults the built-in Huabu Agent to `operate` - inherits the canvas's last-used agent binding; user can switch agent - user types the question, hits send → first send writes `content` back to the node @@ -134,19 +136,21 @@ additional chat context. The chat panel header is the question node's rename surface in both compose and replay modes. Clicking the title (or focusing it and pressing Enter/Space) opens the same inline editor used by expanded content nodes; blur/Enter commits through `canvasStore.tryRename('node', ...)`, Escape cancels, and the shared rename path owns collision detection, persistence, and rollback. A fresh compose view continues to show the neutral “New question” title until the user assigns a name. -The Agent avatar above the node is also its run-status surface. Compose shows the currently selected Agent inside a question bubble; running uses the flowing ring; unread done/error/conflict outcomes use their semantic ring, glow, and low-frequency attention nudge. Existing Profiles are resolved live, so alias and icon edits update old Question nodes. For an external Agent, the first send also stores the current alias in `agentBinding` and the effective icon in `agentIcon`; if the Profile is later deleted or unavailable, those bind-time values preserve the historical identity without copying the Profile's full `customData` bag into the node. The built-in Agent uses the Huabu brand logo directly and does not persist an avatar snapshot. +The Agent avatar above the node is also its run-status surface. Compose shows the currently selected Agent inside a question bubble; running uses the flowing ring; unread done/error/conflict outcomes use their semantic ring, glow, and low-frequency attention nudge. Existing Profiles are resolved live, so alias and icon edits update old Question nodes. Saving an external selection stores its alias in `agentBinding` and effective icon in `agentIcon`; those values preserve historical identity if the Profile disappears without copying its complete `customData`. The built-in Agent uses the Huabu brand logo directly and does not persist an avatar snapshot. As the canvas zooms out, a question node's agent mark **takes over** as the node's stand-in **continuously** ([QuestionTakeoverMark](../../apps/web/src/components/Nodes/question/QuestionTakeoverMark.tsx)): the mark's size and position are a smooth (smoothstep-eased) function of the node's on-screen width, so the badge glides from the readable card's top-left corner into a centred stand-in mark and resizes in lock-step with the zoom gesture — there is no discrete stage swap and no one-shot animation. At full zoom it is the sticky card plus a corner badge that scales with the card; as the node shrinks the badge moves corner → centre and resizes; once the node is too small to read, the card fades out (a single binary `data-lod-body` signal) and only the centred mark remains. The mark's glyph is size-driven: a full agent avatar down to a few px, then a solid identity dot (via [AgentAvatarMark](../../apps/web/src/components/Common/AgentAvatarMark.tsx)), so a field of zoomed-out question nodes reads as tidy colour-coded dots. An idle (never-asked) node shows a quiet neutral dot instead of borrowing an agent's identity colour. When the mark can open an existing conversation, it renders as a labelled, keyboard-focusable shared button; non-interactive marks remain hidden from the accessibility tree. Because the collapsed mark hides the node's tiny footprint, dragging the node from the mark is handled by the takeover layer ([useTakeoverMarkDrag](../../apps/web/src/hooks/useTakeoverMarkDrag.ts)) rather than React Flow's native node-drag: a short press still opens the conversation, while a press that crosses the drag-activation distance moves the node (through the normal drag lifecycle) and suppresses the trailing open click. The morph is driven by the takeover engine ([useNodeTakeover](../../apps/web/src/hooks/useNodeTakeover.ts) / [NodeTakeoverLayer](../../apps/web/src/components/Nodes/NodeTakeoverLayer.tsx)); the `open` chat bubble is the shared [QuestionAgentBubble](../../apps/web/src/components/Nodes/question/QuestionAgentBubble.tsx) and status colour is shared via [questionBadgeChrome.ts](../../apps/web/src/components/Nodes/question/questionBadgeChrome.ts). See [canvas-zoom-rendering.md#31-continuous-zoom-takeover-question-node](./canvas-zoom-rendering.md#31-continuous-zoom-takeover-question-node) and [proposals/question-node-zoom-lod-avatar.md](../proposals/question-node-zoom-lod-avatar.md). ### 5.2 Dispatch -All questions run through `/api/agent` ([agent.ts](../../apps/web/src/api/agent.ts) → [intent](../../apps/server/src/modules/canvas/node-neighbourhood.ts)). On first send `useAgentStream` ([useAgentStream.ts](../../apps/web/src/hooks/useAgentStream.ts)) locks `agentBinding` + `agentMode` onto the node: +All questions run through `/api/agent` ([agent.ts](../../apps/web/src/api/agent.ts) → [AgentThreadService](../../apps/server/src/modules/agent/agent-thread.service.ts)). `useAgentStream` awaits acknowledged node creation and the latest draft save before resolving the owner-first binding and dispatching: - **internal**: built-in Huabu Agent, `agentMode` = `operate` (default) / `ask` - **external**: ACP agent resolved server-side from `profileId` `anchorNodeId` is sent so the server attaches spatial context (§5.3). +Binding is independent of invocation: first actual prompt or external control realizes a canonical execution record; `AgentNodeBindingCoordinator` validates `agenetes.record(namespace, threadId)` and persists Bound before controls/run. Creating a node, opening compose, and cached capability reads do not realize an execution. Bound survives session closure, preparation failure, stop, and missing records. A failed Canvas promotion leaves the canonical record authoritative; the next guarded edit or interaction completes promotion rather than rebinding. Internal ask/operate remains separate from driver/Profile identity. + ### 5.3 Spatial context (server-side) Resolved entirely on the server — no spatial geometry crosses the wire. `renderNodeNeighbourhoodMarkdown(canvasId, anchorNodeId)` ([node-neighbourhood.ts](../../apps/server/src/modules/canvas/node-neighbourhood.ts)) serialises a bounded, priority-tiered neighbourhood into the agent's preamble: @@ -172,7 +176,7 @@ idle ──double-click──▶ compose (no status change) Conversation replay: `openPreviewNode` activates the Question's semantic target, and [`PreviewRenderer.tsx`](../../apps/web/src/components/Panels/PreviewWorkspace/PreviewRenderer.tsx) resolves the live node into a required `ChatSession`. The node is the single source of truth for agent mode. An unresolved permission renders one actionable tray above ChatInput while its original MessageList position remains a passive history record. Messages, loading, drafts, binding, settings, and pending attachments are keyed by the session's thread, so two Question tabs can remain mounted without sharing presentation state. -Before the first send, the thread's binding, mode, and built-in settings remain in the persisted Chat compose cache so an idle Question survives reload. Once the first send locks binding and mode onto the Question node, their cached mirrors are removed; on replay and after refresh, the Chat panel synchronously restores the binding from the conversation owner before rendering agent settings or dispatching a follow-up turn. Built-in settings remain cached until the first server event confirms that the durable thread now owns them. Conversation history and settings for an established thread remain server-owned. +The owner node persists the initial inherited selection and subsequent Editing draft changes through acknowledged Canvas commands, including mode. `resolveConversationAgentBinding` is shared by panel and send and reads the owner before the ChatStore cache. Saves require an applied command and version-aware reconciliation; a failed or conflicting save prevents dispatch, and a delayed response cannot replay an optimistic patch over newer SSE state. Built-in model/reasoning settings remain cached until a durable thread owns them. The browser owns unsent input and request feedback, not node lifecycle or history-based repair. ### 5.5 Conversation ownership @@ -182,28 +186,30 @@ Ordinary Question previews retain `AgentConversationView`, whose presentation an ## 6. Code entry points -| Concern | File | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Component + toolbar | [QuestionNode.tsx](../../apps/web/src/components/Nodes/question/QuestionNode.tsx) | -| Agent status mark | [QuestionTakeoverMark.tsx](../../apps/web/src/components/Nodes/question/QuestionTakeoverMark.tsx) renders the readable corner badge and the zoomed-out collapsed mark in one component; zoom morph via [NodeTakeoverLayer.tsx](../../apps/web/src/components/Nodes/NodeTakeoverLayer.tsx) + [useNodeTakeover.ts](../../apps/web/src/hooks/useNodeTakeover.ts) | -| Compose / replay | [questionCompose.ts](../../apps/web/src/components/Nodes/question/questionCompose.ts) and [PreviewRenderer.tsx](../../apps/web/src/components/Panels/PreviewWorkspace/PreviewRenderer.tsx) open the node target and resolve its renderer-local session | -| Conversation owner | [conversationOwner.ts](../../apps/web/src/store/conversationOwner.ts) resolves and validates ordinary Question ownership and serializes owner lifecycle patches | -| Server-side creation | [agent-node.service.ts](../../apps/server/src/modules/agent/agent-node.service.ts) validates a selectable external Profile and anchor, then creates the fixed-binding Question Node and lineage edge through the canonical Canvas executor | -| Fixed-thread lookup | [agent-thread-resolver.ts](../../apps/server/src/modules/agent/agent-thread-resolver.ts) is the thin Canvas-scan boundary for resolving a fixed Agent Node by thread; a Workspace-global DB index will replace only its storage lookup after issue #60 | -| Server invocation | [agent-thread.service.ts](../../apps/server/src/modules/agent/agent-thread.service.ts) resolves fixed identity for UI and RFS, owns shared lease/stop/dispatch, and wraps runs with [agent-node-lifecycle.ts](../../apps/server/src/modules/agent/agent-node-lifecycle.ts) first-content, running, done, and error patches through the canonical Canvas executor | -| RFS Agent access | [rfs.route.ts](../../apps/server/src/modules/remote_fs/rfs.route.ts) creates visible Agents through `POST /agent` and submits later turns through `POST /agent/:threadId/prompt`; optional parent edges are best effort | -| Open scroll target | [MessageList.tsx](../../apps/web/src/components/Messages/MessageList.tsx) + [messageListScroll.ts](../../apps/web/src/components/Messages/messageListScroll.ts) | -| Send + state writes | [useAgentStream.ts](../../apps/web/src/hooks/useAgentStream.ts) owns selectable-node lifecycle and client `viewed` state; it does not write fixed-node content, status, or errors | -| Create path | [resolveAddNodes.ts](../../apps/web/src/handler/canvasCommand/resolvers/resolveAddNodes.ts) | -| Dispatch API | [agent.ts](../../apps/web/src/api/agent.ts) `streamMessage` | -| Spatial context | [node-neighbourhood.ts](../../apps/server/src/modules/canvas/node-neighbourhood.ts) | -| Shared types | [node.ts](../../packages/shared/src/types/canvas/node.ts) `QuestionNodeData` · [acp.ts](../../packages/shared/src/types/api/acp.ts) `AgentBinding` | +| Concern | File | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Component + toolbar | [QuestionNode.tsx](../../apps/web/src/components/Nodes/question/QuestionNode.tsx) | +| Agent status mark | [QuestionTakeoverMark.tsx](../../apps/web/src/components/Nodes/question/QuestionTakeoverMark.tsx) renders the readable corner badge and the zoomed-out collapsed mark in one component; zoom morph via [NodeTakeoverLayer.tsx](../../apps/web/src/components/Nodes/NodeTakeoverLayer.tsx) + [useNodeTakeover.ts](../../apps/web/src/hooks/useNodeTakeover.ts) | +| Compose / replay | [questionCompose.ts](../../apps/web/src/components/Nodes/question/questionCompose.ts) and [PreviewRenderer.tsx](../../apps/web/src/components/Panels/PreviewWorkspace/PreviewRenderer.tsx) open the node target and resolve its renderer-local session | +| Conversation owner | [conversationOwner.ts](../../apps/web/src/store/conversationOwner.ts) validates ordinary Question ownership, serializes editable draft patches, and sends token-specific viewed acknowledgements | +| Server-side creation | [agent-node.service.ts](../../apps/server/src/modules/agent/agent-node.service.ts) validates a selectable external Profile and anchor, then creates the fixed-binding Question Node and lineage edge through the canonical Canvas executor | +| Thread lookup | [agent-thread-resolver.ts](../../apps/server/src/modules/agent/agent-thread-resolver.ts) resolves node identity and acknowledged preparation regardless of policy | +| Execution binding | [agent-node-binding.ts](../../apps/server/src/modules/agent/agent-node-binding.ts) confirms canonical records and guards preparation edits | +| Server invocation | [agent-thread.service.ts](../../apps/server/src/modules/agent/agent-thread.service.ts) owns shared admission, cancellation, dispatch, and settlement; [agent-node-lifecycle.ts](../../apps/server/src/modules/agent/agent-node-lifecycle.ts) projects token-guarded transitions | +| RFS Agent access | [rfs.route.ts](../../apps/server/src/modules/remote_fs/rfs.route.ts) creates visible Agents through `POST /agent` and submits later turns through `POST /agent/:threadId/prompt`; optional parent edges are best effort | +| Open scroll target | [MessageList.tsx](../../apps/web/src/components/Messages/MessageList.tsx) + [messageListScroll.ts](../../apps/web/src/components/Messages/messageListScroll.ts) | +| Send + observation | [useAgentStream.ts](../../apps/web/src/hooks/useAgentStream.ts) awaits drafts and owns transcript/request feedback; [conversationOwner.ts](../../apps/web/src/store/conversationOwner.ts) sends token-specific viewed acknowledgements | +| Trusted projection | [agent-node-projection.ts](../../apps/server/src/modules/canvas/agent-node-projection.ts) composes current FSM state under the Canvas mutex; [agent-node-association.ts](../../apps/server/src/modules/canvas/agent-node-association.ts) validates legacy initialization and undo reinsertion | +| Create path | [resolveAddNodes.ts](../../apps/web/src/handler/canvasCommand/resolvers/resolveAddNodes.ts) | +| Dispatch API | [agent.ts](../../apps/web/src/api/agent.ts) `streamMessage` | +| Spatial context | [node-neighbourhood.ts](../../apps/server/src/modules/canvas/node-neighbourhood.ts) | +| Shared types | [node.ts](../../packages/shared/src/types/canvas/node.ts) `QuestionNodeData` · [acp.ts](../../packages/shared/src/types/api/acp.ts) `AgentBinding` | --- ## 7. Open questions - `responseSummary` is reserved but not yet written — node shows no answer teaser. -- Stale `running` on reload: needs sanitisation back to `idle` on `loadCanvas`. +- Crash recovery and durable invocation-to-turn correlation are outside the node FSM; reads never sanitize a persisted `running` result. - Vision channel (screenshot of neighbourhood) deferred. - Re-run cleanup of previously created nodes is undecided. diff --git a/docs/proposals/agent-node-fsm.md b/docs/proposals/agent-node-fsm.md new file mode 100644 index 000000000..bee8a4ed2 --- /dev/null +++ b/docs/proposals/agent-node-fsm.md @@ -0,0 +1,312 @@ +# Server-Owned Agent Node State Machine + +Status: Implemented (pending merge) +Last updated: 2026-09-15 +Issue: [#163](https://github.com/microsoft/Huabu/issues/163) + +## Context and scope + +Before this change, Huabu used `agentBindingPolicy` both for pre-message Profile selection and as a proxy for lifecycle ownership: fixed Agent Nodes received server-authored lifecycle updates, while selectable Question Nodes retained browser-authored updates. Binding mutability must not decide who owns execution state. + +This approved design defines one Huabu Server-owned Agent Node FSM, with two separate dimensions: **execution binding** and **prompt invocation**. It targets a small personal-use application with one Huabu Server, not a distributed orchestration system. The implementation is complete on `fix/issue-163` pending merge; current behavior is documented in [Question Node](../architecture/question-node.md), [Agent architecture](../architecture/agent-architecture.md), [Canvas commands](../architecture/canvas-command-architecture.md), and [Canvas sync](../architecture/canvas-realtime-sync.md). The sections below preserve the approved contract and implementation plan. + +Reuse `AgentThreadService`, canonical realization, `AgentNodeLifecycle`, and existing Canvas persistence and synchronization. Do not introduce another Agent runtime, FSM library, event journal, or automatic recovery subsystem. + +## Terminology and ownership + +| Concept | Meaning | Authority | +| --------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Execution-preparation draft | Selected Profile and explicit launch overrides saved before execution binding is established | Huabu Canvas Node | +| Execution binding | Monotonic Editing-to-Bound business state persisted on the node after confirming a canonical execution record | Huabu owns the node state; Agenetes ThreadStore supplies the binding fact | +| Prompt invocation | One admitted prompt, from preparation through settlement | Huabu `AgentThreadService` | +| Execution facts | Workload realization, run events/results, control acknowledgements and existing history | Agenetes, interpreted by Huabu | +| Browser-local state | Unsent input, pending edits, saving, request progress and connection indicators | Browser | +| Result attention | Whether the user has viewed the current terminal result | User acknowledgement, validated by Huabu | + +A node may already have a `threadId` without an execution binding. Conversely, an established binding does not mean a process is resident or a prompt is running. Avoid the term "configuration identity": configuration is data; execution binding is the relationship established from it. + +## 1. Execution-binding FSM + +```mermaid +stateDiagram-v2 + direction LR + state "Editing: execution-preparation draft" as Editing + state "Bound: confirmed execution binding" as Bound + + [*] --> Editing: Create a new thread-backed node + Editing --> Editing: Save draft after confirming no existing binding + Editing --> Editing: Open panel or read capabilities + Editing --> Editing: Preparation fails before commitment + Editing --> Bound: Confirm persisted ThreadRecord / save bindingState + Bound --> Bound: Later prompt or supported control + Bound --> Bound: Prompt completes, fails, or stops + Bound --> Bound: Runtime session closes +``` + +This diagram describes a new node. A persisted Bound node stays Bound when loaded; it does not recreate a draft. An Editing or legacy node may already have a canonical execution record and must confirm that fact before accepting a configuration change or first interaction. Missing records with existing execution history are inconsistent evidence, not permission to silently bind a different Agent. + +The source of truth for establishing Bound is a validated, persisted Agenetes ThreadRecord for the node's namespace and thread. Huabu records that confirmed fact as `bindingState: bound` on the node. Neither sending a browser request, allocating a thread ID, receiving the first token, nor obtaining a native Session ID is the binding criterion. If realization succeeds but session startup or control later fails, the binding remains established. + +There is no Bound-to-Editing transition for the same execution binding. Supported runtime settings may still change through existing controls; this does not mean every setting is frozen. Internal thread-associated Jobs retain their existing execution strategy and do not become a separate Agent Node type. Do not assume every workload is an immutable Deployment. + +### Binding authority, query and persistence + +Use the existing public Agenetes interface: + +```typescript +agenetes.record(namespace, threadId): ThreadRecord | undefined +``` + +This is an in-process library call, not an HTTP/ACP request to the Agent. It delegates to `threadStore.get(namespace, threadId)` and validates an existing record before returning it. It is independent of a live handle and does not create a session or send a prompt/control. The existing external realization path already uses this interface. + +The binding coordinator confirms that the returned record belongs to the intended thread/namespace and carries a supported canonical execution identity. Record presence must not bypass existing driver/binding validation. A native Session ID is neither persisted into the node for this purpose nor exposed as its state criterion. + +| Data | Persistence on the current Disk backend | Role | +| -------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | +| `bindingState: editing \| bound` (proposed) | `space.json`, in the corresponding node's `data` | Huabu's durable, monotonic acknowledgement of binding | +| `threadId`, selected `agentBinding`, explicit `agentLaunchOverrides` | `space.json`, in node `data` | Node association and execution-preparation configuration | +| Canonical ThreadRecord / WorkloadSpec | `.history/threads.json`, owned by Agenetes ThreadStore | Source of truth for confirming binding and for actual execution configuration | +| Node text and content metadata | `nodes/