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 new file mode 100644 index 000000000..292c6549d --- /dev/null +++ b/apps/server/src/modules/agent/acp/external-agent-realization.test.ts @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../workspace/paths.js', () => ({ + canvasAcpNamespace: (canvasId: string) => ({ + name: canvasId, + storage: { root: `/spaces/${canvasId}/.history` }, + }), +})); + +import { ExternalAgentRealizationService } from './external-agent-realization.js'; + +import type { ExternalAgentRealizationError } from './external-agent-realization.js'; +import type { AcpHandle, AcpWorkloadSpec } from '../agenetes/drivers.js'; +import type { + AgentNodeTarget, + FixedAgentNodeTarget, +} from '../agent-thread-resolver.js'; +import type { AcpSessionEntry } from '@agenetes/acp-driver'; +import type { ThreadRecord } from '@agenetes/agenetes'; +import type { CanvasNodeId } from '@huabu/shared'; +import type { FastifyBaseLogger } from 'fastify'; + +const logger = { + warn: vi.fn(), +} as unknown as FastifyBaseLogger; + +const target: FixedAgentNodeTarget = { + canvasId: 'canvas-1', + nodeId: 'node-1' as CanvasNodeId, + threadId: 'thread-1', + agentBinding: { + kind: 'external', + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + launchOverrides: { + workingDirPath: '/fixed/work', + additionalInitialPreamble: 'Node instructions', + }, + status: 'idle', + content: '', +}; +const targetBinding = target.agentBinding as Extract< + typeof target.agentBinding, + { kind: 'external' } +>; +const selectableTarget: AgentNodeTarget = { + canvasId: target.canvasId, + nodeId: 'node-selectable' as CanvasNodeId, + threadId: target.threadId, +}; + +function createHarness(options?: { + agentTarget?: AgentNodeTarget | null; + record?: ThreadRecord; + collect?: () => Promise<{ + markdown: string; + diagnostics: { + includedFrameIds: string[]; + includedNodeIds: string[]; + omittedUnsupportedIds: string[]; + omittedEmptyTextIds: string[]; + omittedMissingIds: string[]; + omittedBudgetNodeIds: string[]; + truncatedNoteIds: string[]; + truncated: boolean; + }; + } | null>; +}) { + const handle = { + control: vi.fn().mockResolvedValue({ ok: true }), + } as unknown as AcpHandle; + const createHandle = vi.fn(() => handle); + const buildSpec = vi.fn( + ({ + binding, + threadId, + canvasId, + launchOverrides, + spacePrompt, + cwd, + }: { + binding: { alias: string; profileId: string }; + threadId: string; + canvasId?: string; + cwd?: string; + launchOverrides?: { + workingDirPath?: string; + additionalInitialPreamble?: string; + }; + spacePrompt?: string; + }): AcpWorkloadSpec => ({ + threadId, + namespace: { + name: canvasId ?? '', + storage: { root: `/spaces/${canvasId ?? ''}/.history` }, + }, + kind: 'external', + workloadType: 'Deployment', + spec: { + binding, + agentletId: 'agentlet-1', + cwd: launchOverrides?.workingDirPath ?? cwd, + recipe: null, + initialPreamble: [ + 'Huabu bootstrap', + ...(spacePrompt ? [spacePrompt] : []), + ...(launchOverrides?.additionalInitialPreamble + ? [launchOverrides.additionalInitialPreamble] + : []), + ], + }, + }), + ); + const ensureSession = vi.fn().mockResolvedValue({ + profileId: 'profile-fixed', + configOptions: [], + } as unknown as AcpSessionEntry); + const collectSpacePrompt = + options?.collect ?? + vi.fn().mockResolvedValue({ + markdown: 'Space rules', + diagnostics: { + includedFrameIds: [], + includedNodeIds: [], + omittedUnsupportedIds: [], + omittedEmptyTextIds: [], + omittedMissingIds: [], + omittedBudgetNodeIds: [], + truncatedNoteIds: [], + truncated: false, + }, + }); + const service = new ExternalAgentRealizationService({ + resolveAgentNode: vi + .fn() + .mockResolvedValue( + options && 'agentTarget' in options + ? (options.agentTarget ?? null) + : target, + ), + resolveFixedAgentNode: vi.fn().mockResolvedValue(target), + collectSpacePrompt, + readRecord: vi.fn(() => options?.record), + createHandle, + buildSpec, + subscribeProfileCache: vi.fn(), + ensureSession, + }); + return { + service, + handle, + createHandle, + buildSpec, + collectSpacePrompt, + ensureSession, + }; +} + +describe('ExternalAgentRealizationService', () => { + it('realizes first control with the fixed Space Prompt and node instructions', async () => { + const harness = createHarness(); + const realized = await harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: targetBinding, + fixedTarget: target, + logger, + }); + + await harness.service.ensureSession(realized, logger); + await realized.handle.control({ + type: 'set_mode', + data: { modeId: 'plan' }, + }); + + expect(realized.spec.spec).toMatchObject({ + binding: { + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + cwd: '/fixed/work', + initialPreamble: [ + 'Huabu bootstrap', + 'Space rules', + 'Node instructions', + ], + }); + expect(harness.ensureSession).toHaveBeenCalledWith(realized, logger); + expect(harness.handle.control).toHaveBeenCalledOnce(); + }); + + it('captures the Space Prompt for a selectable Agent Node', async () => { + const harness = createHarness({ agentTarget: selectableTarget }); + const realized = await harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: { + kind: 'external', + alias: 'Selectable Agent', + profileId: 'profile-selectable', + }, + fixedTarget: null, + logger, + }); + + expect(harness.collectSpacePrompt).toHaveBeenCalledWith('canvas-1'); + expect(realized.spec.spec.initialPreamble).toEqual([ + 'Huabu bootstrap', + 'Space rules', + ]); + }); + + it('does not capture a Space Prompt for a node-less external thread', async () => { + const harness = createHarness({ agentTarget: null }); + const realized = await harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: { + kind: 'external', + alias: 'Standalone Agent', + profileId: 'profile-standalone', + }, + fixedTarget: null, + logger, + }); + + expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); + expect(realized.spec.spec.initialPreamble).toEqual(['Huabu bootstrap']); + }); + + it('rejects a fixed Profile mismatch before creating a workload', async () => { + const harness = createHarness(); + + await expect( + harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: { + kind: 'external', + alias: 'Other', + profileId: 'profile-other', + }, + fixedTarget: target, + logger, + }), + ).rejects.toMatchObject({ + code: 'external_binding_conflict', + } satisfies Partial); + expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); + expect(harness.createHandle).not.toHaveBeenCalled(); + }); + + it('rejects a fixed working-directory mismatch before creating a workload', async () => { + const harness = createHarness(); + + await expect( + harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: targetBinding, + requestedCwd: '/client/override', + fixedTarget: target, + logger, + }), + ).rejects.toMatchObject({ + code: 'external_working_directory_conflict', + } satisfies Partial); + expect(harness.createHandle).not.toHaveBeenCalled(); + }); + + it('reuses the persisted canonical workload without recollecting Prompt Frames', async () => { + const persisted: AcpWorkloadSpec = { + threadId: 'thread-1', + namespace: { + name: 'canvas-1', + storage: { root: '/spaces/canvas-1/.history' }, + }, + kind: 'external', + workloadType: 'Deployment', + spec: { + binding: { alias: 'Fixed Agent', profileId: 'profile-fixed' }, + agentletId: 'agentlet-1', + cwd: '/fixed/work', + recipe: null, + initialPreamble: [ + 'Huabu bootstrap', + 'Original rules', + 'Node instructions', + ], + }, + }; + const harness = createHarness({ + record: { + driverSchemaVersion: 1, + spec: persisted, + state: { driverState: { initialPreambleDelivered: false } }, + }, + }); + + const realized = await harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: targetBinding, + fixedTarget: target, + logger, + }); + + expect(realized.spec).toBe(persisted); + expect(harness.buildSpec).not.toHaveBeenCalled(); + expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); + }); + + it('single-flights simultaneous first interactions', async () => { + let releaseCollection!: () => void; + const collectionGate = new Promise((resolve) => { + releaseCollection = resolve; + }); + const collect = vi.fn(async () => { + await collectionGate; + return { + markdown: 'Space rules', + diagnostics: { + includedFrameIds: [], + includedNodeIds: [], + omittedUnsupportedIds: [], + omittedEmptyTextIds: [], + omittedMissingIds: [], + omittedBudgetNodeIds: [], + truncatedNoteIds: [], + truncated: false, + }, + }; + }); + const harness = createHarness({ collect }); + const options = { + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: targetBinding, + fixedTarget: target, + logger, + }; + + const first = harness.service.realize(options); + const second = harness.service.realize(options); + await Promise.resolve(); + expect(collect).toHaveBeenCalledOnce(); + + releaseCollection(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(firstResult.spec).toBe(secondResult.spec); + expect(harness.buildSpec).toHaveBeenCalledOnce(); + expect(harness.createHandle).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/server/src/modules/agent/acp/external-agent-realization.ts b/apps/server/src/modules/agent/acp/external-agent-realization.ts new file mode 100644 index 000000000..b49995db7 --- /dev/null +++ b/apps/server/src/modules/agent/acp/external-agent-realization.ts @@ -0,0 +1,365 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + AcpServiceError, + ensureAcpSession, + resolveAcpAgentletId, +} from '@agenetes/acp-driver'; + +import { canvasAcpNamespace } from '../../workspace/paths.js'; +import { + acpRuntimePolicy, + agenetes, + EXTERNAL_DRIVER_KIND, + type AcpHandle, + type AcpWorkloadSpec, +} from '../agenetes/drivers.js'; +import { + agentThreadResolver, + type AgentNodeTarget, + type FixedAgentNodeTarget, +} from '../agent-thread-resolver.js'; +import { resolveSpacePrompt } from '../space-instruction-frames.js'; +import { ensureProfileCacheSubscription } from './profile-cache-port.js'; +import { getExternalAgentRuntimeConfig } from './runtime-config.js'; +import { buildAcpWorkloadSpec } from './service.js'; + +import type { AcpSessionEntry } from '@agenetes/acp-driver'; +import type { Namespace } from '@agenetes/protocol'; +import type { AgentBinding } from '@huabu/shared'; +import type { FastifyBaseLogger } from 'fastify'; + +type ExternalBinding = Extract; + +export type ExternalAgentRealizationErrorCode = + | 'external_binding_required' + | 'external_binding_conflict' + | 'external_working_directory_conflict' + | 'external_thread_kind_conflict'; + +export class ExternalAgentRealizationError extends Error { + constructor( + public readonly code: ExternalAgentRealizationErrorCode, + message: string, + ) { + super(message); + this.name = 'ExternalAgentRealizationError'; + } +} + +export interface RealizeExternalAgentThreadOptions { + threadId: string; + canvasId?: string; + requestedBinding?: ExternalBinding; + requestedCwd?: string; + agentTarget?: AgentNodeTarget | null; + fixedTarget?: FixedAgentNodeTarget | null; + logger: FastifyBaseLogger; +} + +export interface RealizedExternalAgentThread { + binding: ExternalBinding; + fixedTarget: FixedAgentNodeTarget | null; + spec: AcpWorkloadSpec; + handle: AcpHandle; +} + +interface RealizationDependencies { + resolveAgentNode: ( + canvasId: string, + threadId: string, + ) => Promise; + resolveFixedAgentNode: ( + canvasId: string, + threadId: string, + ) => Promise; + collectSpacePrompt: typeof resolveSpacePrompt; + readRecord: ( + namespace: Namespace, + threadId: string, + ) => ReturnType; + createHandle: (spec: AcpWorkloadSpec) => AcpHandle; + buildSpec: typeof buildAcpWorkloadSpec; + subscribeProfileCache: typeof ensureProfileCacheSubscription; + ensureSession: ( + realized: RealizedExternalAgentThread, + logger: FastifyBaseLogger, + ) => Promise; +} + +function bindingFromSpec(spec: AcpWorkloadSpec): ExternalBinding { + return { + kind: 'external', + alias: spec.spec.binding.alias, + profileId: spec.spec.binding.profileId, + }; +} + +async function ensureSessionFromCanonicalSpec( + realized: RealizedExternalAgentThread, + logger: FastifyBaseLogger, +): Promise { + const { spec } = realized; + const resolvedEnvironment = + await acpRuntimePolicy.resolveRuntimeEnvironment?.(spec.spec); + const env = + resolvedEnvironment || spec.spec.env + ? { ...resolvedEnvironment, ...spec.spec.env } + : undefined; + const record = agenetes.record(spec.namespace, spec.threadId); + return ensureAcpSession({ + agentletId: resolveAcpAgentletId(spec), + threadId: spec.threadId, + binding: spec.spec.binding, + namespace: spec.namespace, + ...(spec.spec.cwd !== undefined && { cwd: spec.spec.cwd }), + ...(spec.spec.recipe !== undefined && { recipe: spec.spec.recipe }), + ...(env !== undefined && { env }), + ...(record?.state !== undefined && { + priorState: record.state as Parameters< + typeof ensureAcpSession + >[0]['priorState'], + }), + ...(spec.spec.initialPreferences !== undefined && { + initialPreferences: spec.spec.initialPreferences, + }), + idleTimeoutSecs: getExternalAgentRuntimeConfig().idleTimeoutSecs, + logger, + }); +} + +const DEFAULT_DEPENDENCIES: RealizationDependencies = { + resolveAgentNode: (canvasId, threadId) => + agentThreadResolver.resolveAgentNode(canvasId, threadId), + resolveFixedAgentNode: (canvasId, threadId) => + agentThreadResolver.resolveFixedAgentNode(canvasId, threadId), + collectSpacePrompt: resolveSpacePrompt, + readRecord: (namespace, threadId) => agenetes.record(namespace, threadId), + createHandle: (spec) => agenetes.create(spec) as AcpHandle, + buildSpec: buildAcpWorkloadSpec, + subscribeProfileCache: ensureProfileCacheSubscription, + ensureSession: ensureSessionFromCanonicalSpec, +}; + +export class ExternalAgentRealizationService { + private readonly inFlight = new Map< + string, + Promise + >(); + + constructor( + private readonly dependencies: RealizationDependencies = DEFAULT_DEPENDENCIES, + ) {} + + async realize( + options: RealizeExternalAgentThreadOptions, + ): Promise { + const namespace = canvasAcpNamespace(options.canvasId ?? ''); + const key = `${namespace.name}\u0000${namespace.storage?.root ?? ''}\u0000${options.threadId}`; + let pending = this.inFlight.get(key); + if (!pending) { + pending = this.realizeOnce(options, namespace); + this.inFlight.set(key, pending); + } + try { + const realized = await pending; + this.validateRequest(realized, options); + return realized; + } finally { + if (this.inFlight.get(key) === pending) this.inFlight.delete(key); + } + } + + ensureSession( + realized: RealizedExternalAgentThread, + logger: FastifyBaseLogger, + ): Promise { + return this.dependencies.ensureSession(realized, logger); + } + + private async realizeOnce( + options: RealizeExternalAgentThreadOptions, + namespace: Namespace, + ): Promise { + const fixedTarget = + options.fixedTarget === undefined + ? options.canvasId + ? await this.dependencies.resolveFixedAgentNode( + options.canvasId, + options.threadId, + ) + : null + : options.fixedTarget; + const agentTarget = + options.agentTarget === undefined + ? (fixedTarget ?? + (options.canvasId + ? await this.dependencies.resolveAgentNode( + options.canvasId, + options.threadId, + ) + : null)) + : options.agentTarget; + const record = this.dependencies.readRecord(namespace, options.threadId); + + if (record) { + if (record.spec.kind !== EXTERNAL_DRIVER_KIND) { + throw new ExternalAgentRealizationError( + 'external_thread_kind_conflict', + `Thread ${options.threadId} is already realized with a non-external agent`, + ); + } + const spec = record.spec as AcpWorkloadSpec; + const binding = bindingFromSpec(spec); + const realized = { + binding, + fixedTarget, + spec, + handle: this.dependencies.createHandle(spec), + }; + this.dependencies.subscribeProfileCache( + options.threadId, + binding.profileId, + ); + return realized; + } + + const binding = fixedTarget?.agentBinding ?? options.requestedBinding; + if (!binding || binding.kind !== 'external') { + throw new ExternalAgentRealizationError( + 'external_binding_required', + `Thread ${options.threadId} has no external Agent binding`, + ); + } + + if ( + fixedTarget && + options.requestedBinding && + options.requestedBinding.profileId !== binding.profileId + ) { + throw new ExternalAgentRealizationError( + 'external_binding_conflict', + `Thread ${options.threadId} is fixed to Profile ${binding.profileId}`, + ); + } + + const collected = agentTarget + ? await this.dependencies.collectSpacePrompt(agentTarget.canvasId) + : null; + if ( + collected && + (collected.diagnostics.truncated || + collected.diagnostics.truncatedNoteIds.length > 0 || + collected.diagnostics.omittedUnsupportedIds.length > 0 || + collected.diagnostics.omittedEmptyTextIds.length > 0 || + collected.diagnostics.omittedMissingIds.length > 0) + ) { + options.logger.warn( + { + canvasId: agentTarget?.canvasId, + threadId: options.threadId, + spacePromptDiagnostics: collected.diagnostics, + }, + 'Space Prompt collection completed with diagnostics', + ); + } + + const spec = this.dependencies.buildSpec({ + binding, + threadId: options.threadId, + canvasId: options.canvasId, + cwd: fixedTarget ? undefined : options.requestedCwd, + ...(fixedTarget?.launchOverrides + ? { launchOverrides: fixedTarget.launchOverrides } + : {}), + spacePrompt: collected?.markdown, + }); + if ( + fixedTarget && + options.requestedCwd !== undefined && + options.requestedCwd !== spec.spec.cwd + ) { + throw new ExternalAgentRealizationError( + 'external_working_directory_conflict', + `Thread ${options.threadId} is fixed to working directory ${spec.spec.cwd ?? '(profile default)'}`, + ); + } + + const realized = { + binding, + fixedTarget, + spec, + handle: this.dependencies.createHandle(spec), + }; + this.dependencies.subscribeProfileCache( + options.threadId, + binding.profileId, + ); + return realized; + } + + private validateRequest( + realized: RealizedExternalAgentThread, + options: RealizeExternalAgentThreadOptions, + ): void { + const fixedBinding = realized.fixedTarget?.agentBinding; + if ( + fixedBinding && + (fixedBinding.kind !== 'external' || + fixedBinding.profileId !== realized.binding.profileId) + ) { + throw new ExternalAgentRealizationError( + 'external_binding_conflict', + `Fixed Agent Node for thread ${options.threadId} does not match its realized Profile`, + ); + } + const fixedCwd = realized.fixedTarget?.launchOverrides?.workingDirPath; + if (fixedCwd !== undefined && fixedCwd !== realized.spec.spec.cwd) { + throw new ExternalAgentRealizationError( + 'external_working_directory_conflict', + `Fixed Agent Node for thread ${options.threadId} does not match its realized working directory`, + ); + } + if ( + options.requestedBinding && + options.requestedBinding.profileId !== realized.binding.profileId + ) { + throw new ExternalAgentRealizationError( + 'external_binding_conflict', + `Thread ${options.threadId} is realized with Profile ${realized.binding.profileId}`, + ); + } + if ( + options.requestedCwd !== undefined && + options.requestedCwd !== realized.spec.spec.cwd + ) { + throw new ExternalAgentRealizationError( + 'external_working_directory_conflict', + `Thread ${options.threadId} is realized with working directory ${realized.spec.spec.cwd ?? '(profile default)'}`, + ); + } + } +} + +export function realizationHttpError(error: unknown): { + status: 409 | 503; + body: { message: string; code: string }; +} { + if (error instanceof ExternalAgentRealizationError) { + return { + status: 409, + body: { message: error.message, code: error.code }, + }; + } + const message = error instanceof Error ? error.message : String(error); + return { + status: 503, + body: { + message, + code: error instanceof AcpServiceError ? error.code : 'internal', + }, + }; +} + +export const externalAgentRealization = new ExternalAgentRealizationService(); diff --git a/apps/server/src/modules/agent/acp/profile-schema-cache.ts b/apps/server/src/modules/agent/acp/profile-schema-cache.ts index 1d1f865cb..23aea2c8e 100644 --- a/apps/server/src/modules/agent/acp/profile-schema-cache.ts +++ b/apps/server/src/modules/agent/acp/profile-schema-cache.ts @@ -7,22 +7,17 @@ * * ### Motivation * - * The per-`(canvasId, threadId)` cache in `session-store` requires - * spawning the agent at least once per thread before the toolbar - * selectors (model / mode / config option) can populate. But for any - * given profile (e.g. "Copilot @ ~/projects/foo"), the schema portion - * of the meta — `availableModels`, `availableModes`, `configOptions` - * shape — is **identical across every thread bound to that profile**. + * The per-thread durable snapshot exists only after realization. This cache + * lets unopened threads render the last observed Profile capability catalogue + * without spawning ACP or creating a WorkloadSpec. * The `current*` values are per-thread state and are retained only so an * already-associated thread snapshot can be reconstructed elsewhere. They * are never authoritative for a brand-new thread. * - * By caching the most recent push from any session of a profile, the - * cache can still identify a known profile catalogue. A brand-new command - * thread opens a real session before rendering active values; a manifest - * thread waits for its first unified turn. This prevents another thread's - * last-known auto-approve value from being presented as the new session's - * effective policy. + * By caching the most recent push from any session of a Profile, the cache can + * identify a known catalogue. Mode/model values may be displayed as last + * observed, while generic config-option values remain unconfirmed until the + * current thread reports them or records a successful explicit selection. * * ### What gets cached * @@ -36,7 +31,7 @@ * agent's slash-command catalogue is effectively static per profile * (e.g. Copilot CLI advertises the same ~34 commands across every * session), so caching the last-seen list lets a brand-new thread - * paint its `/` menu instantly on warm spawn. The agent's authoritative + * paint its `/` menu without a warm spawn. The agent's authoritative * `available_commands_update` push silently overwrites the cached * list once it arrives, so any per-session drift (e.g. a `/load` * variant exposed only on resumed sessions) self-corrects on the @@ -315,8 +310,8 @@ export function invalidateProfileSchemaCache(profileId: string): void { * `available_commands_update` replaces the cached list wholesale on the * next session, so any per-session drift self-corrects. * - * The cache is what `/cached-meta` falls back to when a brand-new thread - * has no per-thread durable record — see `threads.route.ts`. + * The cache is what the GET-only `/cached-meta` route falls back to when a + * brand-new thread has no per-thread durable record. */ export function foldMetadataIntoProfileCache( profileId: string, diff --git a/apps/server/src/modules/agent/acp/service.ts b/apps/server/src/modules/agent/acp/service.ts index 2437b4659..f97ecd111 100644 --- a/apps/server/src/modules/agent/acp/service.ts +++ b/apps/server/src/modules/agent/acp/service.ts @@ -26,14 +26,12 @@ import { } from '@agenetes/agentlet-host'; import { renderExternalAgentInputs } from './preprocessor.js'; -import { ensureProfileCacheSubscription } from './profile-cache-port.js'; import { getProfileSessionPreferences } from './profile-session-preferences.js'; import { getProfile as getLegacyProfile } from './profile-store.js'; import { buildReachbackEnv } from './reachback-env.js'; import { renderExternalAgentSystemPreamble } from '../../../prompt/external-agent/system-preamble.js'; import { canvasAcpNamespace } from '../../workspace/paths.js'; import { - agenetes, EXTERNAL_DRIVER_KIND, type AcpHandle, type AcpWorkloadSpec, @@ -49,6 +47,8 @@ import type { AgentLaunchOverrides, AgentStreamEvent } from '@huabu/shared'; import type { FastifyBaseLogger } from 'fastify'; export interface RunAcpAgentOptions { + /** Canonically realized handle shared by message and control paths. */ + handle: AcpHandle; /** * External binding for the active thread. `profileId` references a * user-configured spawn recipe (see `./profile-store.ts`); the @@ -101,8 +101,6 @@ export interface RunAcpAgentOptions { * stranded at the filesystem root). */ cwd?: string; - /** Per-node spawn overrides applied when the workload is first created. */ - launchOverrides?: AgentLaunchOverrides; /** Cancellation signal \u2014 wired through to `session/cancel`. */ signal?: AbortSignal; logger: FastifyBaseLogger; @@ -193,11 +191,17 @@ function applyWorkingDirectoryOverride( }; } +export interface BuildAcpWorkloadSpecOptions { + binding: { alias: string; profileId: string }; + threadId: string; + canvasId?: string; + cwd?: string; + launchOverrides?: AgentLaunchOverrides; + spacePrompt?: string; +} + export function buildAcpWorkloadSpec( - opts: Pick< - RunAcpAgentOptions, - 'binding' | 'threadId' | 'canvasId' | 'cwd' | 'launchOverrides' - >, + opts: BuildAcpWorkloadSpecOptions, ): AcpWorkloadSpec { const { binding, threadId } = opts; const canvasId = opts.canvasId ?? ''; @@ -244,6 +248,7 @@ export function buildAcpWorkloadSpec( spec: { initialPreamble: [ renderExternalAgentSystemPreamble(), + ...(opts.spacePrompt ? [opts.spacePrompt] : []), ...(opts.launchOverrides?.additionalInitialPreamble ? [opts.launchOverrides.additionalInitialPreamble] : []), @@ -261,7 +266,7 @@ export function buildAcpWorkloadSpec( export async function* runAcpAgent( opts: RunAcpAgentOptions, ): AsyncGenerator { - const { binding, threadId, overlay, signal, logger } = opts; + const { binding, overlay, signal, logger, handle } = opts; const canvasId = opts.canvasId ?? ''; const submission = opts.submission ?? @@ -275,13 +280,6 @@ export async function* runAcpAgent( }), ); - // Bake this thread's WorkloadSpec (I9.6). The ACP handle self-resolves - // (opens or reuses) its live session per turn from these fields — L1 no - // longer opens the session out-of-band. Agenetes keeps an existing - // persisted spec authoritative when recovering a previously created - // workload. - const spec = buildAcpWorkloadSpec(opts); - // Optional developer aid: dump the exact text payload handed to ACP // `session/prompt` (the serialized prompt, NOT pi-ai messages — the // external agent keeps its own session history). No-op unless @@ -305,15 +303,9 @@ export async function* runAcpAgent( } : undefined; - // Get-or-create the long-lived ACP handle for this thread (I9.3) and - // drive one turn. The handle self-resolves its session inside `run`, so - // session-open failures surface on the generator's first `next()`. - // Static DriverMap construction guarantees that `external` is ACP. - const handle = agenetes.create(spec) as AcpHandle; - // Fold this thread's up-reported metadata into the L1 profile cache - // (I9.7). Idempotent per thread — subscribing before `run()` so the - // handle's initial state up-report is captured. - ensureProfileCacheSubscription(threadId, binding.profileId); + // The shared realization service has already created the complete durable + // workload and subscribed its metadata before either message or control + // dispatch reaches this point. const iterator = handle.run(submission, { overlay, signal, diff --git a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts index f9aede8df..f428540fe 100644 --- a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts +++ b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts @@ -118,4 +118,30 @@ describe('buildAcpWorkloadSpec', () => { }, }); }); + + it('places Space instructions between bootstrap and node constraints', () => { + mocks.profile = { + id: 'profile-a', + alias: 'Researcher', + agentletId: 'agentlet-a', + workingDirPath: '/profile/work', + launch: { kind: 'acp-command', command: 'copilot --acp' }, + }; + + const workload = buildAcpWorkloadSpec({ + binding: { profileId: 'profile-a', alias: 'Researcher' }, + threadId: 'thread-a', + canvasId: 'canvas-a', + spacePrompt: 'Space rules', + launchOverrides: { + additionalInitialPreamble: 'Node constraints', + }, + }); + + expect(workload.spec.initialPreamble).toEqual([ + 'Mandatory preamble', + 'Space rules', + 'Node constraints', + ]); + }); }); diff --git a/apps/server/src/modules/agent/acp/threads.route.test.ts b/apps/server/src/modules/agent/acp/threads.route.test.ts new file mode 100644 index 000000000..022ee67f6 --- /dev/null +++ b/apps/server/src/modules/agent/acp/threads.route.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + live: undefined as unknown, + record: undefined as unknown, + profileCache: undefined as unknown, + realize: vi.fn(), + ensureSession: vi.fn(), + control: vi.fn(), + create: vi.fn(), +})); + +vi.mock('@agenetes/acp-driver', () => ({ + acpSessionRegistry: { get: () => mocks.live }, +})); + +vi.mock('@agenetes/agentlet-host', () => ({ + getSupervisedAgentletId: () => 'agentlet-1', +})); + +vi.mock('./external-agent-realization.js', () => ({ + externalAgentRealization: { + realize: mocks.realize, + ensureSession: mocks.ensureSession, + }, + realizationHttpError: (error: unknown) => ({ + status: 503, + body: { message: String(error), code: 'internal' }, + }), +})); + +vi.mock('./profile-schema-cache.js', () => ({ + getProfileSchemaCache: () => mocks.profileCache, +})); + +vi.mock('./profile-session-preferences.js', () => ({ + rememberProfileConfigPreference: vi.fn(), + rememberProfileSessionPreference: vi.fn(), +})); + +vi.mock('../../workspace/paths.js', () => ({ + canvasAcpNamespace: (canvasId: string) => ({ name: canvasId }), +})); + +vi.mock('../agenetes/index.js', () => ({ + agenetes: { + record: () => mocks.record, + get: vi.fn(), + create: mocks.create, + }, +})); + +import acpThreadsRoutes from './threads.route.js'; + +let app: FastifyInstance | undefined; + +afterEach(async () => { + await app?.close(); + app = undefined; + mocks.live = undefined; + mocks.record = undefined; + mocks.profileCache = undefined; + mocks.realize.mockReset(); + mocks.ensureSession.mockReset(); + mocks.control.mockReset(); + mocks.create.mockReset(); +}); + +async function createApp(): Promise { + app = Fastify({ logger: false }); + await app.register(acpThreadsRoutes, { prefix: '/api/acp' }); + return app; +} + +describe('ACP cached capability route', () => { + it('projects commands and selector catalogues from the Profile cache', async () => { + mocks.profileCache = { + availableCommands: [{ name: 'review', description: 'Review changes' }], + commandsUpdatedAt: 11, + availableModes: [{ id: 'plan', name: 'Plan' }], + currentModeId: 'plan', + availableModels: [{ modelId: 'model-1', name: 'Model 1' }], + currentModelId: 'model-1', + configOptions: [ + { + id: 'allow_all', + name: 'Auto approve', + type: 'boolean', + currentValue: true, + }, + ], + metaUpdatedAt: 12, + }; + const server = await createApp(); + + const response = await server.inject({ + method: 'GET', + url: '/api/acp/threads/thread-1/cached-meta?canvasId=canvas-1&profileId=profile-1', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + source: 'profile', + availableCommands: [{ name: 'review' }], + commandsUpdatedAt: 11, + sessionMeta: { + currentModeId: 'plan', + currentModelId: 'model-1', + selections: {}, + updatedAt: 12, + }, + }); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('returns a successful empty observation on a cold cache', async () => { + const server = await createApp(); + + const response = await server.inject({ + method: 'GET', + url: '/api/acp/threads/thread-1/cached-meta?canvasId=canvas-1&profileId=profile-1', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + source: 'none', + availableCommands: [], + commandsUpdatedAt: 0, + sessionMeta: { updatedAt: 0 }, + }); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('returns Profile commands when the agent has not published session metadata', async () => { + mocks.profileCache = { + availableCommands: [{ name: 'review', description: 'Review changes' }], + commandsUpdatedAt: 11, + metaUpdatedAt: 0, + }; + const server = await createApp(); + + const response = await server.inject({ + method: 'GET', + url: '/api/acp/threads/thread-1/cached-meta?canvasId=canvas-1&profileId=profile-1', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + source: 'profile', + availableCommands: [{ name: 'review' }], + commandsUpdatedAt: 11, + sessionMeta: { updatedAt: 0 }, + }); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('realizes and ensures the canonical workload before a first control', async () => { + const realized = { + binding: { + kind: 'external', + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + fixedTarget: null, + spec: { spec: { initialPreamble: ['Bootstrap', 'Space', 'Node'] } }, + handle: { control: mocks.control }, + }; + mocks.realize.mockResolvedValue(realized); + mocks.ensureSession.mockResolvedValue({ + profileId: 'profile-fixed', + configOptions: [], + }); + mocks.control.mockResolvedValue({ ok: true }); + const server = await createApp(); + + const response = await server.inject({ + method: 'POST', + url: '/api/acp/threads/thread-1/mode', + payload: { + modeId: 'plan', + binding: { + kind: 'external', + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + canvasId: 'canvas-1', + }, + }); + + expect(response.statusCode).toBe(200); + expect(mocks.realize).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: { + kind: 'external', + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + }), + ); + expect(mocks.ensureSession).toHaveBeenCalledWith( + realized, + expect.any(Object), + ); + expect(mocks.control).toHaveBeenCalledWith({ + type: 'set_mode', + data: { modeId: 'plan' }, + }); + }); +}); diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts index ea773135a..a577b1d1b 100644 --- a/apps/server/src/modules/agent/acp/threads.route.ts +++ b/apps/server/src/modules/agent/acp/threads.route.ts @@ -1,61 +1,28 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -/** - * `POST /api/acp/threads/:threadId/session` — eagerly open (or reuse) the - * per-thread ACP session so the web client can pull slash commands BEFORE - * the user submits their first prompt. - * - * `GET /api/acp/threads/:threadId/commands` — return the cached - * `available_commands_update` snapshot for an existing session (404 if - * no session has been opened for this thread yet). - * - * Why a dedicated route family (instead of widening `agents.route.ts`): - * - These endpoints are thread-scoped, not agent-scoped. - * - They mutate (or read) per-thread session state that lives in - * `acpSessionRegistry`. Keeping that surface separate makes the - * read-only `agents` list easier to reason about. - * - * Wire contracts (`EnsureAcpSessionRequest` / `EnsureAcpSessionResponse` - * / `AcpThreadCommandsResponse`) live in `@huabu/shared`; this route - * validates every body with `safeParse` per docs/architecture/api-design.md. - * - * Auth: relies on the global Basic-Auth gate (app.ts). No additional - * per-route check — the agentlet bridge itself is gated by - * `token-store.ts`. - */ - import { acpSessionRegistry } from '@agenetes/acp-driver'; -import { AcpServiceError } from '@agenetes/acp-driver'; -import { ensureAcpSession } from '@agenetes/acp-driver'; import { getSupervisedAgentletId } from '@agenetes/agentlet-host'; import { acpPermissionDecisionSchema, - acpThreadCommandsQuerySchema, - ensureAcpSessionRequestSchema, + acpThreadCachedMetaQuerySchema, setAcpSessionConfigOptionRequestSchema, setAcpSessionModeRequestSchema, setAcpSessionModelRequestSchema, } from '@huabu/shared'; -import { ensureProfileCacheSubscription } from './profile-cache-port.js'; +import { + externalAgentRealization, + realizationHttpError, +} from './external-agent-realization.js'; import { getProfileSchemaCache } from './profile-schema-cache.js'; import { - getProfileSessionPreferences, rememberProfileConfigPreference, rememberProfileSessionPreference, } from './profile-session-preferences.js'; -import { buildReachbackEnv } from './reachback-env.js'; -import { getExternalAgentRuntimeConfig } from './runtime-config.js'; -import { resolveBindingRecipe } from './service.js'; -import { renderExternalAgentSystemPreamble } from '../../../prompt/external-agent/system-preamble.js'; import { canvasAcpNamespace } from '../../workspace/paths.js'; -import { - agenetes, - EXTERNAL_DRIVER_KIND, - type AcpWorkloadSpec, -} from '../agenetes/index.js'; +import { agenetes } from '../agenetes/index.js'; import type { AcpProfileSchemaCacheEntry } from './profile-schema-cache.js'; import type { AcpSessionEntry } from '@agenetes/acp-driver'; @@ -63,10 +30,8 @@ import type { AgentMetadata } from '@agenetes/protocol'; import type { AcpPermissionDecisionResponse, AcpSessionMetaSnapshot, - AcpThreadCommandsQuery, + AcpThreadCachedMetaQuery, AcpThreadCachedMetaResponse, - AcpThreadCommandsResponse, - EnsureAcpSessionResponse, SetAcpSessionConfigOptionResponse, SetAcpSessionModelResponse, SetAcpSessionModeResponse, @@ -85,6 +50,38 @@ function controlFailureCode(operation: string, code?: string): string { return code === 'session_suspended' ? code : `acp_${operation}_failed`; } +async function realizeControlThread( + threadId: string, + target: { + binding: { kind: 'external'; alias: string; profileId: string }; + canvasId?: string; + cwd?: string; + }, + logger: FastifyBaseLogger, +) { + try { + const realized = await externalAgentRealization.realize({ + threadId, + canvasId: target.canvasId, + requestedBinding: target.binding, + requestedCwd: target.cwd, + logger, + }); + const entry = await externalAgentRealization.ensureSession( + realized, + logger, + ); + return { ok: true as const, realized, entry }; + } catch (error) { + const failure = realizationHttpError(error); + logger.warn( + { threadId, code: failure.body.code, err: failure.body.message }, + '[acp/threads] canonical realization for set-RPC failed', + ); + return { ok: false as const, ...failure }; + } +} + function resolveThreadAgentletId(threadId: string, canvasId?: string): string { if (canvasId) { const record = agenetes.record(canvasAcpNamespace(canvasId), threadId); @@ -100,111 +97,6 @@ function resolveThreadAgentletId(threadId: string, canvasId?: string): string { return getSupervisedAgentletId(); } -/** - * Resolve the live session entry for a set-RPC (mode / model / config - * option), opening it on-demand when none exists yet. - * - * The selector dropdowns are seeded from the no-spawn `/cached-meta` - * snapshot, so the user can switch a value BEFORE the session has ever - * been spawned. Per the `/cached-meta` contract a real ensure-session - * is expected on "any set-RPC" — so rather than 404 when the registry - * is cold, we spawn (or reuse) the session using the `profileId` the - * client supplies, then let the caller apply the actual switch. - * - * Returns either the resolved entry or a ready-to-send error envelope: - * • 404 `session_not_found` — no live session AND no `profileId` to - * spawn with (legacy callers that didn't send spawn context). - * • 503 — the on-demand spawn failed; `code` mirrors the ensure - * route's `AcpEnsureErrorCode`. - */ -async function resolveSetRpcEntry( - threadId: string, - ctx: { profileId?: string; canvasId?: string; cwd?: string }, - logger: FastifyBaseLogger, -): Promise< - | { ok: true; entry: AcpSessionEntry; spec: AcpWorkloadSpec } - | { ok: false; status: number; body: { message: string; code: string } } -> { - const agentletId = resolveThreadAgentletId(threadId, ctx.canvasId); - const existing = acpSessionRegistry.get(agentletId, threadId); - if (!ctx.profileId) { - if (existing) { - ensureProfileCacheSubscription(threadId, existing.profileId); - return { - ok: true, - entry: existing, - // A live session with no profileId in the request: rebuild the - // spec from the entry so the handle can be (re)created for the - // control op. `binding.alias` falls back to the profileId. - spec: { - threadId, - kind: EXTERNAL_DRIVER_KIND, - workloadType: 'Deployment', - namespace: existing.namespace, - spec: { - initialPreamble: [renderExternalAgentSystemPreamble()], - agentletId: existing.agentletId, - binding: { - alias: existing.profileId, - profileId: existing.profileId, - }, - cwd: existing.cwd, - recipe: existing.bindingRecipe, - }, - }, - }; - } - return { - ok: false, - status: 404, - body: { - message: 'No ACP session for this thread', - code: 'session_not_found', - }, - }; - } - const spec: AcpWorkloadSpec = { - threadId, - kind: EXTERNAL_DRIVER_KIND, - workloadType: 'Deployment', - namespace: canvasAcpNamespace(ctx.canvasId ?? ''), - spec: { - initialPreamble: [renderExternalAgentSystemPreamble()], - initialPreferences: getProfileSessionPreferences(ctx.profileId), - agentletId, - binding: { alias: ctx.profileId, profileId: ctx.profileId }, - env: buildReachbackEnv(threadId, ctx.canvasId ?? ''), - ...(ctx.cwd !== undefined && { cwd: ctx.cwd }), - recipe: resolveBindingRecipe(ctx.profileId), - }, - }; - ensureProfileCacheSubscription(threadId, ctx.profileId); - if (existing) return { ok: true, entry: existing, spec }; - try { - const entry = await ensureAcpSession({ - agentletId, - threadId: spec.threadId, - binding: spec.spec.binding, - namespace: spec.namespace, - env: spec.spec.env, - ...(spec.spec.cwd !== undefined && { cwd: spec.spec.cwd }), - recipe: spec.spec.recipe, - initialPreferences: spec.spec.initialPreferences, - idleTimeoutSecs: getExternalAgentRuntimeConfig().idleTimeoutSecs, - logger, - }); - return { ok: true, entry, spec }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const code = err instanceof AcpServiceError ? err.code : 'internal'; - logger.warn( - { threadId, code, err: message }, - '[acp/threads] on-demand ensureAcpSession for set-RPC failed', - ); - return { ok: false, status: 503, body: { message, code } }; - } -} - /** * Project the mutable session-meta fields cached on the entry into the * wire-shape clients consume. Pure; safe to call on every response. @@ -289,135 +181,15 @@ function snapshotMetaFromProfileCache( } const acpThreadsRoutes: FastifyPluginAsync = async (app) => { - /** - * Open (or reuse) the per-thread ACP session. Idempotent: repeated - * calls with the same `{threadId, profileId, canvasId}` triple - * return the same session id. Response always includes the latest - * cached `availableCommands`; an empty array means the agent has - * not yet pushed its list (caller should poll - * `/threads/:threadId/commands` after a short delay). - */ - app.post<{ - Params: ThreadParams; - Reply: EnsureAcpSessionResponse | { message: string; code?: string }; - }>('/threads/:threadId/session', async (request, reply) => { - const { threadId } = request.params; - if (!threadId || threadId.length === 0) { - return reply - .status(400) - .send({ message: 'threadId is required', code: 'bad_request' }); - } - - const parsed = ensureAcpSessionRequestSchema.safeParse(request.body); - if (!parsed.success) { - request.log.warn( - { threadId, issues: parsed.error.issues }, - '[acp/threads] invalid session request body', - ); - return reply.status(400).send({ - message: 'Invalid request body', - code: 'validation_failed', - }); - } - - try { - const agentletId = resolveThreadAgentletId( - threadId, - parsed.data.canvasId, - ); - const entry = await ensureAcpSession({ - agentletId, - threadId, - binding: { - // Alias is purely a display hint at this stage \u2014 there's no - // wire field for it on EnsureAcpSessionRequest, so we fall - // back to the profileId itself. Real callers (chat panel) - // also fetch the profile to render the picker label. - alias: parsed.data.profileId, - profileId: parsed.data.profileId, - }, - namespace: canvasAcpNamespace(parsed.data.canvasId ?? ''), - env: buildReachbackEnv(threadId, parsed.data.canvasId ?? ''), - cwd: parsed.data.cwd, - recipe: resolveBindingRecipe(parsed.data.profileId), - initialPreferences: getProfileSessionPreferences(parsed.data.profileId), - idleTimeoutSecs: getExternalAgentRuntimeConfig().idleTimeoutSecs, - logger: request.log, - }); - return { - sessionId: entry.sessionId, - availableCommands: entry.availableCommands, - updatedAt: entry.commandsUpdatedAt, - sessionMeta: snapshotSessionMeta(entry), - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - // Surface the categorical code when the service layer threw an - // `AcpServiceError` — the web client switches on it to render - // a remediation-specific tooltip / CTA. Unrecognised throws - // collapse to `'internal'` so the client can still tell them - // apart from the categorised failures. - const code = err instanceof AcpServiceError ? err.code : 'internal'; - request.log.warn( - { threadId, code, err: message }, - '[acp/threads] ensureAcpSession failed', - ); - return reply.status(503).send({ message, code }); - } - }); - - /** - * Read the cached slash-command snapshot for an existing session. - * Returns 404 when no session has been opened for `threadId` yet — - * the caller should POST `/threads/:threadId/session` first. - * - * `updatedAt` is `0` when the session exists but the agent has not - * pushed `available_commands_update` yet. The web client uses this - * to decide whether to schedule a delayed re-fetch. - */ - app.get<{ - Params: ThreadParams; - Querystring: AcpThreadCommandsQuery; - Reply: AcpThreadCommandsResponse | { message: string; code?: string }; - }>('/threads/:threadId/commands', async (request, reply) => { - const { threadId } = request.params; - const parsed = acpThreadCommandsQuerySchema.safeParse(request.query); - if (!parsed.success) { - request.log.warn( - { threadId, issues: parsed.error.issues }, - '[acp/threads] invalid commands query', - ); - return reply.status(400).send({ - message: 'Invalid query', - code: 'validation_failed', - }); - } - const agentletId = resolveThreadAgentletId(threadId, parsed.data.canvasId); - const entry = acpSessionRegistry.get(agentletId, threadId); - if (!entry) { - return reply.status(404).send({ - message: 'No ACP session for this thread', - code: 'session_not_found', - }); - } - return { - sessionId: entry.sessionId, - availableCommands: entry.availableCommands, - updatedAt: entry.commandsUpdatedAt, - sessionMeta: snapshotSessionMeta(entry), - }; - }); - /** * Read-only **no-spawn** meta snapshot for a thread. * - * Unlike `POST /threads/:threadId/session`, this route NEVER - * contacts the agentlet — it returns whatever the server already - * has cached, in priority order: + * This route never contacts the agentlet. It returns cached commands and + * selector metadata in priority order: * * 1. Live entry in `acpSessionRegistry` (some prior call already * opened the session this lifetime) → freshest state. - * 2. Per-thread persisted record (`session-store`) → last known + * 2. Per-thread Agenetes record → last known * state of THIS thread (includes per-thread `current*` choices * and per-session `sessionInfo` / `usage`). * 3. Per-profile schema cache (`profile-schema-cache`) → schema @@ -430,23 +202,36 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { * * Designed for the web's `useAcpSessionMeta` hydrate-on-mount path: * opening an existing thread can populate dropdowns from its own cache - * without paying the agentlet cold-start tax. A profile-only hit is not - * presented as current state: command Profiles ensure immediately, while - * manifest Profiles wait for their first unified turn. + * without paying the agentlet cold-start tax. A profile-only hit is + * observational: mode/model may be displayed as last observed, while + * generic config values remain unconfirmed until this thread reports or + * records an explicit selection. * * Always responds 200 — absence of cache is a normal state. */ app.get<{ Params: ThreadParams; - Querystring: { canvasId?: string; profileId?: string }; - Reply: AcpThreadCachedMetaResponse; - }>('/threads/:threadId/cached-meta', async (request) => { + Querystring: AcpThreadCachedMetaQuery; + Reply: AcpThreadCachedMetaResponse | { message: string; code?: string }; + }>('/threads/:threadId/cached-meta', async (request, reply) => { const { threadId } = request.params; - const { canvasId, profileId } = request.query; + const parsed = acpThreadCachedMetaQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.status(400).send({ + message: 'Invalid query', + code: 'validation_failed', + }); + } + const { canvasId, profileId } = parsed.data; const agentletId = resolveThreadAgentletId(threadId, canvasId); const live = acpSessionRegistry.get(agentletId, threadId); if (live) { - return { source: 'thread', sessionMeta: snapshotSessionMeta(live) }; + return { + source: 'thread', + availableCommands: live.availableCommands, + commandsUpdatedAt: live.commandsUpdatedAt, + sessionMeta: snapshotSessionMeta(live), + }; } if (canvasId) { const record = agenetes.record(canvasAcpNamespace(canvasId), threadId); @@ -454,20 +239,33 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { if (persistedMeta) { return { source: 'thread', + availableCommands: persistedMeta.availableCommands ?? [], + commandsUpdatedAt: persistedMeta.commandsUpdatedAt ?? 0, sessionMeta: snapshotMetaFromPersisted(persistedMeta), }; } } if (profileId) { const profileCache = getProfileSchemaCache(profileId); - if (profileCache && (profileCache.metaUpdatedAt ?? 0) > 0) { + if ( + profileCache && + ((profileCache.metaUpdatedAt ?? 0) > 0 || + (profileCache.commandsUpdatedAt ?? 0) > 0) + ) { return { source: 'profile', + availableCommands: profileCache.availableCommands ?? [], + commandsUpdatedAt: profileCache.commandsUpdatedAt ?? 0, sessionMeta: snapshotMetaFromProfileCache(profileCache), }; } } - return { source: 'none', sessionMeta: emptySessionMetaSnapshot() }; + return { + source: 'none', + availableCommands: [], + commandsUpdatedAt: 0, + sessionMeta: emptySessionMetaSnapshot(), + }; }); /** @@ -535,10 +333,14 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { // response is therefore best treated as "request accepted" — the // authoritative state is the one carried by the next SSE event. // + // The first request realizes the complete canonical workload, ensures its + // ACP session from that same spec, and then applies the control. Later + // requests reuse the persisted workload. + // // Failure modes: - // • 404 — no session for this thread (caller must POST `/session` - // first). // • 400 — body failed `safeParse`. + // • 409 — requested binding/cwd conflicts with the canonical thread. + // • 503 — workload realization or session creation failed. // • 502 — agent rejected the RPC (unknown id, capability missing, // transport error). The user-visible message comes from the // agent's rejection. @@ -559,23 +361,15 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { code: 'validation_failed', }); } - const resolved = await resolveSetRpcEntry( + const resolved = await realizeControlThread( threadId, - { - profileId: parsed.data.profileId, - canvasId: parsed.data.canvasId, - cwd: parsed.data.cwd, - }, + parsed.data, request.log, ); if (!resolved.ok) { return reply.status(resolved.status).send(resolved.body); } - // Fold onto the long-lived handle's control plane (M3). L1 keeps the - // spawn orchestration (resolveSetRpcEntry get-or-create with spec); the - // set-RPC goes through `handle.control()`, which resolves the same entry - // by threadId and records the selection on it before returning. - const ack = await agenetes.create(resolved.spec).control({ + const ack = await resolved.realized.handle.control({ type: 'set_mode', data: { modeId: parsed.data.modeId }, }); @@ -608,19 +402,15 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { code: 'validation_failed', }); } - const resolved = await resolveSetRpcEntry( + const resolved = await realizeControlThread( threadId, - { - profileId: parsed.data.profileId, - canvasId: parsed.data.canvasId, - cwd: parsed.data.cwd, - }, + parsed.data, request.log, ); if (!resolved.ok) { return reply.status(resolved.status).send(resolved.body); } - const ack = await agenetes.create(resolved.spec).control({ + const ack = await resolved.realized.handle.control({ type: 'set_model', data: { modelId: parsed.data.modelId }, }); @@ -662,19 +452,15 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { code: 'validation_failed', }); } - const resolved = await resolveSetRpcEntry( + const resolved = await realizeControlThread( threadId, - { - profileId: parsed.data.profileId, - canvasId: parsed.data.canvasId, - cwd: parsed.data.cwd, - }, + parsed.data, request.log, ); if (!resolved.ok) { return reply.status(resolved.status).send(resolved.body); } - const ack = await agenetes.create(resolved.spec).control({ + const ack = await resolved.realized.handle.control({ type: 'set_config_option', data: { optionId: parsed.data.configOptionId, diff --git a/apps/server/src/modules/agent/agenetes/drivers.ts b/apps/server/src/modules/agent/agenetes/drivers.ts index e514dffcc..28c49c418 100644 --- a/apps/server/src/modules/agent/agenetes/drivers.ts +++ b/apps/server/src/modules/agent/agenetes/drivers.ts @@ -21,7 +21,7 @@ import { HISTORY_LOAD_SANITY_LIMIT } from './history-replay.js'; import { huabuPiDriverPorts } from './pi-driver.js'; import { getExternalAgentRuntimeConfig } from '../acp/runtime-config.js'; -import type { AcpSpec } from '@agenetes/acp-driver'; +import type { AcpRuntimePolicy, AcpSpec } from '@agenetes/acp-driver'; import type { Agenetes } from '@agenetes/agenetes'; import type { PiWorkloadSpec } from '@agenetes/pi-driver'; import type { AgentHandle as RuntimeAgentHandle } from '@agenetes/runtime'; @@ -36,7 +36,7 @@ export type AcpHandle = AgentHandle; export type BuiltinHandle = AgentHandle; export type AgenetesHandle = RuntimeAgentHandle; -const externalDriver = acpDriverFactory({ +export const acpRuntimePolicy: AcpRuntimePolicy = { getIdleTimeoutSecs: () => getExternalAgentRuntimeConfig().idleTimeoutSecs, resolveRuntimeEnvironment: async (spec: AcpSpec) => { const agentTeam = spec.recipe?.agentTeam; @@ -55,7 +55,9 @@ const externalDriver = acpDriverFactory({ }); return runtime.environment; }, -}); +}; + +const externalDriver = acpDriverFactory(acpRuntimePolicy); export const agenetes: Agenetes = mountAgenetes({ drivers: { diff --git a/apps/server/src/modules/agent/agenetes/pi-driver.ts b/apps/server/src/modules/agent/agenetes/pi-driver.ts index 29a4788bd..8dc50d2ae 100644 --- a/apps/server/src/modules/agent/agenetes/pi-driver.ts +++ b/apps/server/src/modules/agent/agenetes/pi-driver.ts @@ -42,6 +42,7 @@ interface HuabuPiHostContext { readonly origin?: NodeOrigin; readonly modelRole?: ModelRole; readonly hasImage?: boolean; + readonly spacePrompt?: string; } interface BuildHuabuPiWorkloadSpecOptions { @@ -58,6 +59,7 @@ interface BuildHuabuPiWorkloadSpecOptions { readonly origin?: NodeOrigin; readonly modelRole?: ModelRole; readonly hasImage?: boolean; + readonly spacePrompt?: string; } function getHuabuHostContext( @@ -80,6 +82,8 @@ function getHuabuHostContext( ? (obj.modelRole as ModelRole) : undefined, hasImage: typeof obj.hasImage === 'boolean' ? obj.hasImage : undefined, + spacePrompt: + typeof obj.spacePrompt === 'string' ? obj.spacePrompt : undefined, }; } @@ -174,6 +178,7 @@ export function buildHuabuPiWorkloadSpec( ...(options.hasImage !== undefined ? { hasImage: options.hasImage } : {}), + ...(options.spacePrompt ? { spacePrompt: options.spacePrompt } : {}), }, }, }; 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 21c3dbe5e..c21687082 100644 --- a/apps/server/src/modules/agent/agent-thread-resolver.test.ts +++ b/apps/server/src/modules/agent/agent-thread-resolver.test.ts @@ -41,6 +41,34 @@ const FIXED_NODE = { }; describe('AgentThreadResolver', () => { + it('resolves selectable and fixed Agent Nodes without applying binding policy', async () => { + const selectable = { + ...FIXED_NODE, + data: { ...FIXED_NODE.data, agentBindingPolicy: 'selectable' }, + }; + + await expect( + createResolver([selectable], 'Selectable prompt').resolveAgentNode( + 'canvas-a', + 'thread-a', + ), + ).resolves.toEqual({ + canvasId: 'canvas-a', + nodeId: 'node-agent', + threadId: 'thread-a', + }); + await expect( + createResolver([FIXED_NODE], 'Fixed prompt').resolveAgentNode( + 'canvas-a', + 'thread-a', + ), + ).resolves.toEqual({ + canvasId: 'canvas-a', + nodeId: 'node-agent', + threadId: 'thread-a', + }); + }); + it('resolves a fixed external Agent Node from Canvas storage', async () => { const target = await createResolver( [FIXED_NODE], diff --git a/apps/server/src/modules/agent/agent-thread-resolver.ts b/apps/server/src/modules/agent/agent-thread-resolver.ts index f1a44ecb7..a7e07e758 100644 --- a/apps/server/src/modules/agent/agent-thread-resolver.ts +++ b/apps/server/src/modules/agent/agent-thread-resolver.ts @@ -27,10 +27,13 @@ interface ResolverDependencies { readNodeContent: (canvasId: string, nodeId: string) => Promise; } -export interface FixedAgentNodeTarget { +export interface AgentNodeTarget { canvasId: string; nodeId: CanvasNodeId; threadId: string; +} + +export interface FixedAgentNodeTarget extends AgentNodeTarget { agentBinding: AgentBinding; launchOverrides?: AgentLaunchOverrides; status: QuestionNodeStatus; @@ -64,7 +67,7 @@ const DEFAULT_DEPENDENCIES: ResolverDependencies = { }; /** - * Resolve the current Canvas-backed fixed Agent Node for one thread. + * Resolve the current Canvas-backed Agent Node for one thread. * * This intentionally stays thin: issue #60 replaces only its storage lookup * with the future Workspace-global thread index. @@ -96,6 +99,34 @@ export class AgentThreadResolver { return node?.type === 'question' ? (node.id as CanvasNodeId) : null; } + async resolveAgentNode( + canvasId: string, + threadId: string, + ): Promise { + const nodes = await this.dependencies.readCanvasNodes(canvasId); + if (!nodes) { + throw new AgentThreadResolutionError( + 'canvas_not_found', + `Canvas ${canvasId} does not exist`, + ); + } + const matches = nodes.filter((node) => node.data?.threadId === threadId); + if (matches.length > 1) { + throw new AgentThreadResolutionError( + 'duplicate_thread', + `Thread ${threadId} is bound to multiple Canvas nodes`, + ); + } + const node = matches[0]; + if (!node || node.type !== 'question') return null; + + return { + canvasId, + nodeId: node.id as CanvasNodeId, + threadId, + }; + } + async resolveFixedAgentNode( canvasId: string, threadId: string, 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 3d5232558..29b73449d 100644 --- a/apps/server/src/modules/agent/agent-thread.service.test.ts +++ b/apps/server/src/modules/agent/agent-thread.service.test.ts @@ -12,9 +12,14 @@ import { AgentThreadBusyError, AgentThreadService, externalBindingFromWorkloadSpec, + spacePromptFromWorkloadSpec, } from './agent-thread.service.js'; -import type { FixedAgentNodeTarget } from './agent-thread-resolver.js'; +import type { AcpHandle, AcpWorkloadSpec } from './agenetes/drivers.js'; +import type { + AgentNodeTarget, + FixedAgentNodeTarget, +} from './agent-thread-resolver.js'; import type { runAgent } from './agent.service.js'; import type { ChatEnvelope } from './conversation/envelope.js'; import type { @@ -54,6 +59,12 @@ const TARGET: FixedAgentNodeTarget = { content: '', }; +const SELECTABLE_TARGET: AgentNodeTarget = { + canvasId: 'canvas-a', + nodeId: 'node-selectable' as CanvasNodeId, + threadId: 'thread-a', +}; + const logger = { debug: vi.fn(), info: vi.fn(), @@ -73,12 +84,15 @@ async function* events( } function createHarness(options?: { + agentTarget?: AgentNodeTarget | null; target?: FixedAgentNodeTarget | null; busy?: boolean; externalEvents?: AgentStreamEvent[]; startError?: Error; finishError?: Error; persistedBinding?: Extract | null; + persistedSpacePrompt?: { realised: boolean; markdown?: string }; + collectedSpacePrompt?: string; }) { const release = vi.fn(); const startLifecycle = options?.startError @@ -105,13 +119,56 @@ function createHarness(options?: { } return emptyInternalStream(); }); + const collectSpacePrompt = vi.fn().mockResolvedValue({ + markdown: options?.collectedSpacePrompt ?? 'Space prompt', + diagnostics: { + includedFrameIds: ['frame-prompt'], + includedNodeIds: ['text-prompt'], + omittedUnsupportedIds: [], + omittedEmptyTextIds: [], + omittedMissingIds: [], + omittedBudgetNodeIds: [], + truncatedNoteIds: [], + truncated: false, + }, + }); + const realizeExternal = vi.fn( + async ({ + requestedBinding, + fixedTarget, + }: { + agentTarget?: AgentNodeTarget | null; + requestedBinding?: Extract; + fixedTarget?: FixedAgentNodeTarget | null; + }) => { + const binding = + fixedTarget?.agentBinding.kind === 'external' + ? fixedTarget.agentBinding + : requestedBinding; + if (!binding) throw new Error('Missing external binding'); + return { + binding, + fixedTarget: fixedTarget ?? null, + spec: {} as AcpWorkloadSpec, + handle: {} as AcpHandle, + }; + }, + ); const service = new AgentThreadService({ + resolveAgentNode: async () => + options && 'agentTarget' in options + ? (options.agentTarget ?? null) + : (options?.target ?? TARGET), resolveFixedAgentNode: async () => options && 'target' in options ? (options.target ?? null) : TARGET, resolvePersistedExternalBinding: () => options && 'persistedBinding' in options ? (options.persistedBinding ?? null) : null, + resolvePersistedSpacePrompt: () => + options?.persistedSpacePrompt ?? { realised: false }, + collectSpacePrompt, + realizeExternal, waitForTurnRelease: vi.fn().mockResolvedValue(undefined), acquireTurn: vi.fn(() => (options?.busy ? null : release)), startLifecycle, @@ -129,6 +186,8 @@ function createHarness(options?: { failLifecycle, runExternal, runInternal, + collectSpacePrompt, + realizeExternal, }; } @@ -161,9 +220,32 @@ describe('AgentThreadService', () => { profileId: 'profile-a', alias: 'Researcher', }); + expect(externalBindingFromWorkloadSpec({ binding: {} })).toBeNull(); }); + it('reads built-in Space Prompt snapshots without inferring from ACP preambles', () => { + expect( + spacePromptFromWorkloadSpec({ + hostContext: { + spacePrompt: 'Internal', + }, + }), + ).toBe('Internal'); + expect( + spacePromptFromWorkloadSpec({ + initialPreamble: [ + 'Bootstrap', + 'External', + 'Node constraints', + ], + }), + ).toBeUndefined(); + expect( + spacePromptFromWorkloadSpec({ initialPreamble: ['Bootstrap'] }), + ).toBeUndefined(); + }); + it('resolves a persisted external Thread without a fixed Agent Node', async () => { const binding = { kind: 'external' as const, @@ -208,8 +290,8 @@ describe('AgentThreadService', () => { expect(emitted.map((event) => event.type)).toEqual(['text_delta', 'done']); expect(harness.runExternal).toHaveBeenCalledWith( expect.objectContaining({ + handle: expect.any(Object), binding: TARGET.agentBinding, - launchOverrides: TARGET.launchOverrides, }), ); expect(harness.finishLifecycle).toHaveBeenCalledWith(TARGET); @@ -283,10 +365,74 @@ describe('AgentThreadService', () => { 'Review before making changes.', ), }), + spacePrompt: 'Space prompt', }), ); }); + it('collects a Space Prompt for a selectable built-in Agent Node', async () => { + const harness = createHarness({ + agentTarget: SELECTABLE_TARGET, + target: null, + }); + const invocation = await harness.service.invoke({ + ...invocationOptions(), + requestBinding: { kind: 'internal' }, + agentTarget: SELECTABLE_TARGET, + fixedTarget: null, + }); + + for await (const _event of invocation.events) { + // Drain the canonical invocation stream. + } + + expect(harness.collectSpacePrompt).toHaveBeenCalledWith('canvas-a'); + expect(harness.runInternal).toHaveBeenCalledWith( + expect.objectContaining({ spacePrompt: 'Space prompt' }), + ); + expect(harness.startLifecycle).not.toHaveBeenCalled(); + }); + + it('passes a selectable Agent Node to external realization', async () => { + const harness = createHarness({ + agentTarget: SELECTABLE_TARGET, + target: null, + }); + const invocation = await harness.service.invoke({ + ...invocationOptions(), + agentTarget: SELECTABLE_TARGET, + fixedTarget: null, + }); + + for await (const _event of invocation.events) { + // Drain the canonical invocation stream. + } + + expect(harness.realizeExternal).toHaveBeenCalledWith( + expect.objectContaining({ + agentTarget: SELECTABLE_TARGET, + fixedTarget: null, + }), + ); + expect(harness.startLifecycle).not.toHaveBeenCalled(); + }); + + it('does not collect a Space Prompt for a node-less thread', async () => { + const harness = createHarness({ agentTarget: null, target: null }); + const invocation = await harness.service.invoke({ + ...invocationOptions(), + requestBinding: { kind: 'internal' }, + agentTarget: null, + fixedTarget: null, + }); + + for await (const _event of invocation.events) { + // Drain the canonical invocation stream. + } + + expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); + }); + it('does not dispatch when the start lifecycle patch fails', async () => { const harness = createHarness({ startError: new Error('Canvas update failed'), @@ -356,8 +502,17 @@ describe('AgentThreadService', () => { return events([{ type: 'done', data: { message: 'Done' } }]); }); const service = new AgentThreadService({ + resolveAgentNode: async () => TARGET, resolveFixedAgentNode: async () => TARGET, resolvePersistedExternalBinding: () => null, + resolvePersistedSpacePrompt: () => ({ realised: false }), + collectSpacePrompt: vi.fn().mockResolvedValue(undefined), + realizeExternal: vi.fn().mockResolvedValue({ + binding: TARGET.agentBinding, + fixedTarget: TARGET, + spec: {} as AcpWorkloadSpec, + handle: {} as AcpHandle, + }), waitForTurnRelease: vi.fn().mockResolvedValue(undefined), acquireTurn: vi.fn(() => vi.fn()), startLifecycle, diff --git a/apps/server/src/modules/agent/agent-thread.service.ts b/apps/server/src/modules/agent/agent-thread.service.ts index 64075e3de..1fc961ca1 100644 --- a/apps/server/src/modules/agent/agent-thread.service.ts +++ b/apps/server/src/modules/agent/agent-thread.service.ts @@ -5,23 +5,31 @@ import { emptyAcpOverlay } from '@agenetes/acp-driver'; 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 { agentNodeLifecycle } from './agent-node-lifecycle.js'; import { agentThreadResolver, + type AgentNodeTarget, type FixedAgentNodeTarget, } from './agent-thread-resolver.js'; import { runAgent } from './agent.service.js'; import { envelopeHasImage } from './conversation/envelope.js'; import { readWorkspaceMemory } from './memory/index.js'; import { planSkillDispatch } from './skill-model-routing.js'; +import { resolveSpacePrompt } from './space-instruction-frames.js'; import { acquireAgentTurn, waitForAgentTurnRelease } from './turn-lease.js'; import { loadAgent } from '../../prompt/index.js'; import { canvasAcpNamespace } from '../workspace/paths.js'; +import type { + RealizedExternalAgentThread, + RealizeExternalAgentThreadOptions, +} from './acp/external-agent-realization.js'; import type { HuabuSubmission } from './agenetes/handle.js'; import type { ChatEnvelope } from './conversation/envelope.js'; +import type { RenderedSpacePrompt } from './space-instruction-frames.js'; import type { AgentBinding, AgentMode, @@ -31,6 +39,10 @@ import type { import type { FastifyBaseLogger } from 'fastify'; interface AgentThreadServiceDependencies { + resolveAgentNode: ( + canvasId: string, + threadId: string, + ) => Promise; resolveFixedAgentNode: ( canvasId: string, threadId: string, @@ -39,6 +51,14 @@ interface AgentThreadServiceDependencies { canvasId: string, threadId: string, ) => Extract | null; + resolvePersistedSpacePrompt: ( + canvasId: string, + threadId: string, + ) => { realised: boolean; markdown?: string }; + collectSpacePrompt: (canvasId: string) => Promise; + realizeExternal: ( + options: RealizeExternalAgentThreadOptions, + ) => Promise; waitForTurnRelease: typeof waitForAgentTurnRelease; acquireTurn: typeof acquireAgentTurn; startLifecycle: typeof agentNodeLifecycle.start; @@ -62,7 +82,20 @@ export function externalBindingFromWorkloadSpec( return parsed.success && parsed.data.kind === 'external' ? parsed.data : null; } +export function spacePromptFromWorkloadSpec(spec: unknown): string | undefined { + if (!spec || typeof spec !== 'object') return undefined; + const value = spec as Record; + const hostContext = value.hostContext; + if (hostContext && typeof hostContext === 'object') { + const prompt = (hostContext as Record).spacePrompt; + if (typeof prompt === 'string') return prompt; + } + return undefined; +} + const DEFAULT_DEPENDENCIES: AgentThreadServiceDependencies = { + resolveAgentNode: (canvasId, threadId) => + agentThreadResolver.resolveAgentNode(canvasId, threadId), resolveFixedAgentNode: (canvasId, threadId) => agentThreadResolver.resolveFixedAgentNode(canvasId, threadId), resolvePersistedExternalBinding: (canvasId, threadId) => { @@ -70,6 +103,14 @@ const DEFAULT_DEPENDENCIES: AgentThreadServiceDependencies = { if (!record || record.spec.kind !== EXTERNAL_DRIVER_KIND) return null; return externalBindingFromWorkloadSpec(record.spec.spec); }, + resolvePersistedSpacePrompt: (canvasId, threadId) => { + const record = agenetes.record(canvasAcpNamespace(canvasId), threadId); + if (!record) return { realised: false }; + const markdown = spacePromptFromWorkloadSpec(record.spec.spec); + return markdown ? { realised: true, markdown } : { realised: true }; + }, + collectSpacePrompt: resolveSpacePrompt, + realizeExternal: (options) => externalAgentRealization.realize(options), waitForTurnRelease: waitForAgentTurnRelease, acquireTurn: acquireAgentTurn, startLifecycle: agentNodeLifecycle.start.bind(agentNodeLifecycle), @@ -96,6 +137,7 @@ export interface AgentThreadInvocationOptions { /** Canonical durable submission; ordinary chat callers omit it. */ submission?: HuabuSubmission; requestBinding?: AgentBinding; + agentTarget?: AgentNodeTarget | null; fixedTarget?: FixedAgentNodeTarget | null; modelId?: string; reasoningEffort?: ReasoningEffort; @@ -112,7 +154,11 @@ export interface AgentThreadInvocationOptions { type EffectiveAgentThreadInvocationOptions = Omit< AgentThreadInvocationOptions, 'signal' -> & { signal: AbortSignal }; +> & { + signal: AbortSignal; + spacePrompt?: string; + externalRealization?: RealizedExternalAgentThread; +}; export interface AgentThreadInvocation { binding: AgentBinding; @@ -138,15 +184,16 @@ function buildAgentSystemPrompt(params: { canvasId: string | undefined; mode: Parameters[0]; additionalInitialPreamble?: string; + spacePrompt?: string; }): string { const agentCfg = loadAgent(params.mode, { canvasId: params.canvasId }); const workspaceMemory = readWorkspaceMemory(); const base = workspaceMemory ? `${agentCfg.systemPrompt}\n\n\n${workspaceMemory}\n` : agentCfg.systemPrompt; - return params.additionalInitialPreamble - ? `${base}\n\n${params.additionalInitialPreamble}` - : base; + return [base, params.spacePrompt, params.additionalInitialPreamble] + .filter((part): part is string => Boolean(part)) + .join('\n\n'); } function errorMessage(error: unknown): string { @@ -199,8 +246,41 @@ export class AgentThreadService { options.fixedTarget === undefined ? await this.resolveFixedTarget(options.canvasId, options.threadId) : options.fixedTarget; - const binding: AgentBinding = fixedTarget?.agentBinding ?? + 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); @@ -222,7 +302,39 @@ export class AgentThreadService { }; this.activeInvocations.set(options.threadId, active); + let spacePrompt: string | undefined; try { + if (agentTarget && options.canvasId && binding.kind !== 'external') { + const persisted = this.dependencies.resolvePersistedSpacePrompt( + options.canvasId, + options.threadId, + ); + if (persisted.realised) { + spacePrompt = persisted.markdown; + } else { + const collected = await this.dependencies.collectSpacePrompt( + options.canvasId, + ); + spacePrompt = collected?.markdown; + if ( + collected && + (collected.diagnostics.truncated || + collected.diagnostics.truncatedNoteIds.length > 0 || + collected.diagnostics.omittedUnsupportedIds.length > 0 || + collected.diagnostics.omittedEmptyTextIds.length > 0 || + collected.diagnostics.omittedMissingIds.length > 0) + ) { + options.logger.warn( + { + canvasId: options.canvasId, + threadId: options.threadId, + spacePromptDiagnostics: collected.diagnostics, + }, + 'Space Prompt collection completed with diagnostics', + ); + } + } + } if (fixedTarget) { await this.dependencies.startLifecycle(fixedTarget, options.content); } @@ -238,6 +350,8 @@ export class AgentThreadService { const effectiveOptions: EffectiveAgentThreadInvocationOptions = { ...options, signal, + spacePrompt, + externalRealization, }; let settled = false; @@ -360,16 +474,19 @@ export class AgentThreadService { onTurnStarted: () => void, ): AsyncGenerator { if (binding.kind === 'external') { + if (!options.externalRealization) { + throw new Error( + `External thread ${options.threadId} was not canonically realized`, + ); + } return this.dependencies.runExternal({ + handle: options.externalRealization.handle, binding, threadId: options.threadId, canvasId: options.canvasId, envelope: options.envelope, submission: options.submission, overlay: emptyAcpOverlay(), - ...(fixedTarget?.launchOverrides - ? { launchOverrides: fixedTarget.launchOverrides } - : {}), signal: options.signal, logger: options.logger, debugPrompt: options.debugPrompt, @@ -399,11 +516,13 @@ export class AgentThreadService { mode: options.mode, additionalInitialPreamble: fixedTarget?.launchOverrides?.additionalInitialPreamble, + spacePrompt: options.spacePrompt, }), messages: [], tools: [], }, modelId: options.modelId, + spacePrompt: options.spacePrompt, reasoningEffort: options.reasoningEffort, maxIterations: 20, signal: options.signal, diff --git a/apps/server/src/modules/agent/agent.route.ts b/apps/server/src/modules/agent/agent.route.ts index 1ce5a34e2..5fea14ef8 100644 --- a/apps/server/src/modules/agent/agent.route.ts +++ b/apps/server/src/modules/agent/agent.route.ts @@ -22,8 +22,8 @@ import { setChatThreadReasoningEffortRequestSchema, } from '@huabu/shared'; -import { agenetes } from '../agent/agenetes/drivers.js'; -import { INTERNAL_DRIVER_KIND } from '../agent/agenetes/drivers.js'; +import { ExternalAgentRealizationError } from '../agent/acp/external-agent-realization.js'; +import { agenetes, INTERNAL_DRIVER_KIND } from '../agent/agenetes/drivers.js'; import { AgentThreadBusyError, agentThreadService, @@ -621,6 +621,12 @@ const agentRoutes: FastifyPluginAsync = async ( code: 'thread_busy', }); } + if (error instanceof ExternalAgentRealizationError) { + return reply.code(409).send({ + message: error.message, + code: error.code, + }); + } throw error; } diff --git a/apps/server/src/modules/agent/agent.service.ts b/apps/server/src/modules/agent/agent.service.ts index c5e9b3845..a21330352 100644 --- a/apps/server/src/modules/agent/agent.service.ts +++ b/apps/server/src/modules/agent/agent.service.ts @@ -125,6 +125,8 @@ export interface AgentRunOptions { modelRole?: ModelRole; /** Whether this workload may send image content to the selected model. */ hasImage?: boolean; + /** Frozen Space Prompt captured when a fixed Agent Node is first realised. */ + spacePrompt?: string; /** * Per-thread model override id carried with this turn (built-in chat). * Applied to the thread before the run, so a model picked before the @@ -201,6 +203,7 @@ export async function* runAgent( origin, modelRole, hasImage, + spacePrompt, modelId, reasoningEffort, maxIterations, @@ -275,6 +278,7 @@ export async function* runAgent( origin, modelRole, hasImage, + spacePrompt, }); // Static DriverMap construction guarantees that `internal` is the diff --git a/apps/server/src/modules/agent/space-instruction-frames.test.ts b/apps/server/src/modules/agent/space-instruction-frames.test.ts new file mode 100644 index 000000000..9cfa89e04 --- /dev/null +++ b/apps/server/src/modules/agent/space-instruction-frames.test.ts @@ -0,0 +1,455 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { + renderSpacePrompt, + renderSpaceSkill, + SPACE_PROMPT_MAX_BYTES, + SPACE_PROMPT_NOTE_MAX_BYTES, +} from './space-instruction-frames.js'; + +import type { CanvasFile, NodeContent } from '../storage/index.js'; +import type { NodeSnapshot } from '../storage/ports/structured.js'; + +function canvas(nodes: CanvasFile['state']['nodes']): CanvasFile { + return { + canvasId: 'canvas-a', + title: 'Canvas A', + version: 2, + state: { nodes, edges: [] }, + createdAt: 1, + updatedAt: 2, + } as CanvasFile; +} + +function records( + values: Array, +): Map { + return new Map( + values.map((record) => [ + record.nodeId, + { record, revision: `storage-${record.nodeId}` }, + ]), + ); +} + +describe('renderSpacePrompt', () => { + it('recognises explicitly authored prompt modules only', () => { + const result = renderSpacePrompt( + canvas([ + { + id: 'frame-user', + type: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'frame-agent', + type: 'frame', + position: { x: 0, y: 200 }, + data: {}, + }, + { + id: 'frame-auto', + type: 'frame', + position: { x: 0, y: 400 }, + data: {}, + }, + { + id: 'text-user', + type: 'text', + parentId: 'frame-user', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'text-agent', + type: 'text', + parentId: 'frame-agent', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'text-auto', + type: 'text', + parentId: 'frame-auto', + position: { x: 0, y: 0 }, + data: {}, + }, + ]), + records([ + { + nodeId: 'frame-user', + type: 'frame', + label: ' Prompt ', + labelSource: 'user', + content: '', + }, + { + nodeId: 'frame-agent', + type: 'frame', + label: 'PROMPT: Review', + labelSource: 'agent', + content: '', + }, + { + nodeId: 'frame-auto', + type: 'frame', + label: 'prompt: ignored', + labelSource: 'auto', + content: '', + }, + { + nodeId: 'text-user', + type: 'text', + label: null, + content: 'User module', + }, + { + nodeId: 'text-agent', + type: 'text', + label: null, + content: 'Agent module', + }, + { + nodeId: 'text-auto', + type: 'text', + label: null, + content: 'Must not appear', + }, + ]), + ); + + expect(result?.markdown).toContain('User module'); + expect(result?.markdown).toContain('Agent module'); + expect(result?.markdown).not.toContain('Must not appear'); + expect(result?.diagnostics.includedFrameIds).toEqual([ + 'frame-user', + 'frame-agent', + ]); + }); + + describe('renderSpaceSkill', () => { + it('renders only explicitly authored Skill Frames through the shared compiler', () => { + const topology = canvas([ + { + id: 'frame-skill', + type: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'frame-prompt', + type: 'frame', + position: { x: 0, y: 200 }, + data: {}, + }, + { + id: 'text-skill', + type: 'text', + parentId: 'frame-skill', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'text-prompt', + type: 'text', + parentId: 'frame-prompt', + position: { x: 0, y: 0 }, + data: {}, + }, + ]); + const snapshots = records([ + { + nodeId: 'frame-skill', + type: 'frame', + label: 'skill: Research', + labelSource: 'user', + content: '', + }, + { + nodeId: 'frame-prompt', + type: 'frame', + label: 'prompt', + labelSource: 'user', + content: '', + }, + { + nodeId: 'text-skill', + type: 'text', + label: null, + content: 'Use primary sources. ', + }, + { + nodeId: 'text-prompt', + type: 'text', + label: null, + content: 'Prompt-only instruction.', + }, + ]); + + const skill = renderSpaceSkill(topology, snapshots); + const prompt = renderSpacePrompt(topology, snapshots); + + expect(skill?.markdown).toContain('# Space-specific Skills'); + expect(skill?.markdown).toContain('Use primary sources.'); + expect(skill?.markdown).toContain('</space_skill>'); + expect(skill?.markdown.match(/<\/space_skill>/g)).toHaveLength(1); + expect(skill?.markdown).not.toContain('Prompt-only instruction.'); + expect(prompt?.markdown).toContain('Prompt-only instruction.'); + expect(prompt?.markdown).not.toContain('Use primary sources.'); + }); + + it('keeps Note bodies lazy', () => { + const result = renderSpaceSkill( + canvas([ + { + id: 'frame', + type: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'note', + type: 'note', + parentId: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + ]), + records([ + { + nodeId: 'frame', + type: 'frame', + label: 'skill', + labelSource: 'user', + content: '', + }, + { + nodeId: 'note', + type: 'note', + label: 'Reference', + content: 'Lazy Skill body', + }, + ]), + ); + + expect(result?.markdown).toMatch( + //, + ); + expect(result?.markdown).not.toContain('Lazy Skill body'); + }); + }); + + it('renders direct Text and inline Note bodies in stable reading order', () => { + const result = renderSpacePrompt( + canvas([ + { + id: 'frame', + type: 'frame', + position: { x: 10, y: 10 }, + data: {}, + }, + { + id: 'note-b', + type: 'note', + parentId: 'frame', + position: { x: 10, y: 10 }, + data: {}, + }, + { + id: 'text-a', + type: 'text', + parentId: 'frame', + position: { x: 10, y: 10 }, + data: {}, + }, + { + id: 'nested-frame', + type: 'frame', + parentId: 'frame', + position: { x: 0, y: 20 }, + data: {}, + }, + { + id: 'nested-text', + type: 'text', + parentId: 'nested-frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'image', + type: 'image', + parentId: 'frame', + position: { x: 0, y: 30 }, + data: {}, + }, + ]), + records([ + { + nodeId: 'frame', + type: 'frame', + label: 'prompt: Module', + labelSource: 'user', + content: '', + }, + { + nodeId: 'text-a', + type: 'text', + label: null, + content: 'First instruction', + }, + { + nodeId: 'note-b', + type: 'note', + label: 'Reference & guide', + content: 'Inline Note instruction ', + }, + { + nodeId: 'nested-frame', + type: 'frame', + label: 'Nested', + content: '', + }, + { + nodeId: 'nested-text', + type: 'text', + label: null, + content: 'Nested content', + }, + { + nodeId: 'image', + type: 'image', + label: 'Image', + content: '', + }, + ]), + ); + + expect(result).not.toBeNull(); + if (!result) throw new Error('Expected a rendered Space Prompt'); + expect(result.markdown.indexOf('/, + ); + expect(result.markdown).toContain('Inline Note instruction'); + expect(result.markdown).toContain('</note>'); + expect(result.markdown.match(/<\/note>/g)).toHaveLength(1); + expect(result.markdown).not.toContain('Nested content'); + expect(result.diagnostics.omittedUnsupportedIds).toEqual([ + 'nested-frame', + 'image', + ]); + }); + + it('bounds each inline Note without splitting Unicode code points', () => { + const result = renderSpacePrompt( + canvas([ + { + id: 'frame', + type: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'note', + type: 'note', + parentId: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + ]), + records([ + { + nodeId: 'frame', + type: 'frame', + label: 'prompt', + labelSource: 'user', + content: '', + }, + { + nodeId: 'note', + type: 'note', + label: 'Long Note', + content: '🙂'.repeat(SPACE_PROMPT_NOTE_MAX_BYTES), + }, + ]), + ); + + if (!result) throw new Error('Expected a rendered Space Prompt'); + expect(result.markdown).not.toContain('\uFFFD'); + expect(result.markdown).toContain( + '[Note truncated at the 10 KiB per-note limit.]', + ); + expect(result.markdown).toContain(''); + expect(result.diagnostics.truncatedNoteIds).toEqual(['note']); + expect(result.diagnostics.truncated).toBe(false); + }); + + it('bounds the complete prompt without splitting Unicode code points', () => { + const result = renderSpacePrompt( + canvas([ + { + id: 'frame', + type: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'text', + type: 'text', + parentId: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'text-later', + type: 'text', + parentId: 'frame', + position: { x: 0, y: 100 }, + data: {}, + }, + ]), + records([ + { + nodeId: 'frame', + type: 'frame', + label: 'prompt', + labelSource: 'user', + content: '', + }, + { + nodeId: 'text', + type: 'text', + label: null, + content: "LEAD$'MID$`TAIL$&" + '🙂'.repeat(10_000), + }, + { + nodeId: 'text-later', + type: 'text', + label: null, + content: 'This later instruction does not fit.', + }, + ]), + ); + + if (!result) throw new Error('Expected a rendered Space Prompt'); + expect(Buffer.byteLength(result.markdown, 'utf8')).toBeLessThanOrEqual( + SPACE_PROMPT_MAX_BYTES, + ); + expect(result.markdown).not.toContain('\uFFFD'); + expect(result.markdown).toContain('</space_prompt>'); + expect(result.markdown.match(/<\/space_prompt>/g)).toHaveLength(1); + expect(result.markdown).toContain( + 'Space Prompt truncated at the 32 KiB module limit', + ); + expect(result.diagnostics.omittedBudgetNodeIds).toEqual(['text-later']); + expect(result.diagnostics.includedNodeIds).not.toContain('text-later'); + expect(result.diagnostics.truncated).toBe(true); + }); +}); diff --git a/apps/server/src/modules/agent/space-instruction-frames.ts b/apps/server/src/modules/agent/space-instruction-frames.ts new file mode 100644 index 000000000..32102c4cf --- /dev/null +++ b/apps/server/src/modules/agent/space-instruction-frames.ts @@ -0,0 +1,476 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { classifySpaceInstructionFrame } from '@huabu/shared'; +import { nodeRevisionOf } from '@huabu/shared/canvas-engine'; + +import { buildAgentNodeRef } from './node-ref.js'; +import { renderPromptFile } from '../../prompt/agents/loader.js'; +import { buildSpatialBundle } from '../canvas/canvas-spatial.js'; +import { space } from '../storage/index.js'; + +import type { CanvasFile, NodeContent } from '../storage/index.js'; +import type { SpaceInstructionFrameKind } from '@huabu/shared'; +import type { CanvasNode } from '@huabu/shared/canvas-engine'; + +export const SPACE_PROMPT_MAX_BYTES = 32 * 1024; +export const SPACE_PROMPT_NOTE_MAX_BYTES = 10 * 1024; +export const SPACE_SKILL_MAX_BYTES = 16 * 1024; + +export interface SpacePromptDiagnostics { + readonly includedFrameIds: readonly string[]; + readonly includedNodeIds: readonly string[]; + readonly omittedUnsupportedIds: readonly string[]; + readonly omittedEmptyTextIds: readonly string[]; + readonly omittedMissingIds: readonly string[]; + readonly omittedBudgetNodeIds: readonly string[]; + readonly truncatedNoteIds: readonly string[]; + readonly truncated: boolean; +} + +export interface RenderedSpacePrompt { + readonly markdown: string; + readonly diagnostics: SpacePromptDiagnostics; +} + +export type RenderedSpaceSkill = RenderedSpacePrompt; + +interface InstructionFrameConfig { + readonly kind: SpaceInstructionFrameKind; + readonly template: string; + readonly byteLimit: number; + readonly displayName: 'Space Prompt' | 'Space Skill'; + readonly placeholder: string; + readonly noteRendering: 'inline' | 'reference'; +} + +const INSTRUCTION_FRAME_CONFIG: Record< + SpaceInstructionFrameKind, + InstructionFrameConfig +> = { + prompt: { + kind: 'prompt', + template: 'space-prompt.md', + byteLimit: SPACE_PROMPT_MAX_BYTES, + displayName: 'Space Prompt', + placeholder: '{{SPACE_PROMPT_CONTENT}}', + noteRendering: 'inline', + }, + skill: { + kind: 'skill', + template: 'space-skill.md', + byteLimit: SPACE_SKILL_MAX_BYTES, + displayName: 'Space Skill', + placeholder: '{{SPACE_SKILL_CONTENT}}', + noteRendering: 'reference', + }, +}; + +interface OrderedNode { + readonly raw: CanvasNode; + readonly x: number; + readonly y: number; +} + +interface RenderedEntry { + readonly nodeId: string; + readonly markdown: string; +} + +interface RenderedSection { + readonly heading: string; + readonly entries: readonly RenderedEntry[]; +} + +interface EntryRange { + readonly nodeId: string; + readonly start: number; +} + +function compareReadingOrder(a: OrderedNode, b: OrderedNode): number { + return ( + a.y - b.y || + a.x - b.x || + (a.raw.id < b.raw.id ? -1 : a.raw.id > b.raw.id ? 1 : 0) + ); +} + +function recordLabel(record: NodeContent): string { + return typeof record.label === 'string' ? record.label : ''; +} + +function quoteAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +function neutralizeInstructionTags(value: string): string { + return value.replace( + /<\/?space_(?:prompt|skill)>/gi, + (tag) => `<${tag.slice(1)}`, + ); +} + +function neutralizeNoteTags(value: string): string { + return value.replace( + /<\/?note(?:\s[^>]*)?>/gi, + (tag) => `<${tag.slice(1)}`, + ); +} + +function renderNoteReference(node: CanvasNode, record: NodeContent): string { + const ref = buildAgentNodeRef({ + id: node.id, + type: 'note', + label: recordLabel(record), + }); + const rev = nodeRevisionOf({ + content: record.content, + ...(typeof record.src === 'string' ? { src: record.src } : {}), + }); + return ``; +} + +function truncateUtf8(value: string, byteLimit: number): string { + if (Buffer.byteLength(value, 'utf8') <= byteLimit) return value; + let used = 0; + let output = ''; + for (const char of value) { + const bytes = Buffer.byteLength(char, 'utf8'); + if (used + bytes > byteLimit) break; + output += char; + used += bytes; + } + return output; +} + +function renderPromptNote( + node: CanvasNode, + record: NodeContent, +): { markdown: string; truncated: boolean } { + const ref = buildAgentNodeRef({ + id: node.id, + type: 'note', + label: recordLabel(record), + }); + const rev = nodeRevisionOf({ + content: record.content, + ...(typeof record.src === 'string' ? { src: record.src } : {}), + }); + const content = neutralizeNoteTags(neutralizeInstructionTags(record.content)); + const bounded = truncateUtf8(content, SPACE_PROMPT_NOTE_MAX_BYTES); + const truncated = bounded !== content; + const marker = truncated + ? '\n\n[Note truncated at the 10 KiB per-note limit.]' + : ''; + return { + markdown: `\n${bounded}${marker}\n`, + truncated, + }; +} + +function renderWithinBudget( + content: string, + entryRanges: readonly EntryRange[], + diagnostics: Omit< + SpacePromptDiagnostics, + 'omittedBudgetNodeIds' | 'truncated' + >, + config: InstructionFrameConfig, +): RenderedSpacePrompt { + const renderDiagnostics = (omittedBudgetNodeIds: readonly string[]) => + [ + diagnostics.omittedUnsupportedIds.length > 0 + ? `- Omitted unsupported direct children: ${diagnostics.omittedUnsupportedIds.length}.` + : '', + diagnostics.omittedEmptyTextIds.length > 0 + ? `- Omitted empty Text nodes: ${diagnostics.omittedEmptyTextIds.length}.` + : '', + diagnostics.omittedMissingIds.length > 0 + ? `- Omitted missing node records: ${diagnostics.omittedMissingIds.length}.` + : '', + diagnostics.truncatedNoteIds.length > 0 + ? `- Truncated Note nodes at the 10 KiB per-note limit: ${diagnostics.truncatedNoteIds.join(', ')}.` + : '', + omittedBudgetNodeIds.length > 0 + ? `- Omitted nodes after the total budget was exhausted: ${omittedBudgetNodeIds.join(', ')}.` + : '', + ] + .filter(Boolean) + .join('\n'); + const omissionDiagnostics = renderDiagnostics([]); + const complete = renderPromptFile(config.template, { + content, + diagnostics: omissionDiagnostics, + }); + if (Buffer.byteLength(complete, 'utf8') <= config.byteLimit) { + return { + markdown: complete, + diagnostics: { + ...diagnostics, + omittedBudgetNodeIds: [], + truncated: false, + }, + }; + } + + const marker = `\n\n[${config.displayName} truncated at the ${config.byteLimit / 1024} KiB module limit.]`; + let omittedBudgetNodeIds: string[] = []; + let boundedContent = ''; + for (let attempt = 0; attempt <= entryRanges.length; attempt += 1) { + const truncatedDiagnostics = [ + renderDiagnostics(omittedBudgetNodeIds), + `- Some ${config.displayName} Frame content was truncated.`, + ] + .filter(Boolean) + .join('\n'); + const shell = renderPromptFile(config.template, { + content: config.placeholder, + diagnostics: truncatedDiagnostics, + }); + const available = + config.byteLimit - + Buffer.byteLength(shell.replace(config.placeholder, '') + marker); + boundedContent = truncateUtf8(content, Math.max(0, available)); + const nextOmitted = entryRanges + .filter((range) => range.start >= boundedContent.length) + .map((range) => range.nodeId); + if ( + nextOmitted.length === omittedBudgetNodeIds.length && + nextOmitted.every((id, index) => id === omittedBudgetNodeIds[index]) + ) { + break; + } + omittedBudgetNodeIds = nextOmitted; + } + const truncatedDiagnostics = [ + renderDiagnostics(omittedBudgetNodeIds), + `- Some ${config.displayName} Frame content was truncated.`, + ] + .filter(Boolean) + .join('\n'); + const shell = renderPromptFile(config.template, { + content: config.placeholder, + diagnostics: truncatedDiagnostics, + }); + return { + markdown: shell.replace( + config.placeholder, + () => `${boundedContent}${marker}`, + ), + diagnostics: { + ...diagnostics, + includedNodeIds: diagnostics.includedNodeIds.filter( + (id) => !omittedBudgetNodeIds.includes(id), + ), + omittedBudgetNodeIds, + truncated: true, + }, + }; +} + +function renderSections(sections: readonly RenderedSection[]): { + content: string; + entryRanges: EntryRange[]; +} { + let content = ''; + const entryRanges: EntryRange[] = []; + const append = (value: string) => { + content += value; + }; + for (const [sectionIndex, section] of sections.entries()) { + if (sectionIndex > 0) append('\n\n'); + append(`## ${section.heading}`); + for (const entry of section.entries) { + append('\n\n'); + entryRanges.push({ nodeId: entry.nodeId, start: content.length }); + append(entry.markdown); + } + } + return { content, entryRanges }; +} + +function renderSpaceInstructionFrames( + canvas: CanvasFile, + records: ReadonlyMap, + kind: SpaceInstructionFrameKind, +): RenderedSpacePrompt | null { + const config = INSTRUCTION_FRAME_CONFIG[kind]; + const bundle = buildSpatialBundle(canvas); + const frames = bundle.spatialNodes + .filter((node) => { + const raw = bundle.rawById.get(node.id); + const record = records.get(node.id)?.record; + return ( + raw?.type === 'frame' && + classifySpaceInstructionFrame(record?.label, record?.labelSource) === + kind + ); + }) + .flatMap((node) => { + const raw = bundle.rawById.get(node.id); + return raw ? [{ raw, x: node.rect.x, y: node.rect.y }] : []; + }) + .sort(compareReadingOrder); + + if (frames.length === 0) return null; + + const includedNodeIds: string[] = []; + const omittedUnsupportedIds: string[] = []; + const omittedEmptyTextIds: string[] = []; + const omittedMissingIds: string[] = []; + const truncatedNoteIds: string[] = []; + const sections: RenderedSection[] = []; + + for (const frame of frames) { + const frameRecord = records.get(frame.raw.id)?.record; + if (!frameRecord) continue; + const children = [...bundle.rawById.values()] + .filter((node) => node.parentId === frame.raw.id) + .map((raw) => ({ + raw, + x: raw.position.x, + y: raw.position.y, + })) + .sort(compareReadingOrder); + const entries: RenderedEntry[] = []; + + for (const child of children) { + if (child.raw.type !== 'text' && child.raw.type !== 'note') { + omittedUnsupportedIds.push(child.raw.id); + continue; + } + const record = records.get(child.raw.id)?.record; + if (!record) { + omittedMissingIds.push(child.raw.id); + continue; + } + if (child.raw.type === 'text') { + if (!record.content.trim()) { + omittedEmptyTextIds.push(child.raw.id); + continue; + } + entries.push({ + nodeId: child.raw.id, + markdown: neutralizeInstructionTags(record.content), + }); + } else { + if (config.noteRendering === 'inline') { + const rendered = renderPromptNote(child.raw, record); + entries.push({ + nodeId: child.raw.id, + markdown: rendered.markdown, + }); + if (rendered.truncated) truncatedNoteIds.push(child.raw.id); + } else { + entries.push({ + nodeId: child.raw.id, + markdown: renderNoteReference(child.raw, record), + }); + } + } + includedNodeIds.push(child.raw.id); + } + + if (entries.length > 0) { + sections.push({ + heading: + neutralizeInstructionTags(recordLabel(frameRecord)) || + config.displayName, + entries, + }); + } + } + + if (sections.length === 0) return null; + const rendered = renderSections(sections); + + return renderWithinBudget( + rendered.content, + rendered.entryRanges, + { + includedFrameIds: frames.map((frame) => frame.raw.id), + includedNodeIds, + omittedUnsupportedIds, + omittedEmptyTextIds, + omittedMissingIds, + truncatedNoteIds, + }, + config, + ); +} + +export function renderSpacePrompt( + canvas: CanvasFile, + records: ReadonlyMap, +): RenderedSpacePrompt | null { + return renderSpaceInstructionFrames(canvas, records, 'prompt'); +} + +export function renderSpaceSkill( + canvas: CanvasFile, + records: ReadonlyMap, +): RenderedSpaceSkill | null { + return renderSpaceInstructionFrames(canvas, records, 'skill'); +} + +async function resolveSpaceInstructionFrames( + canvasId: string, + kind: SpaceInstructionFrameKind, +): Promise { + const handle = space(canvasId); + const canvas = await handle.read(); + if (!canvas) { + throw new Error(`[space-${kind}] Space not found: ${canvasId}`); + } + const rawNodes = (canvas.state.nodes ?? []) as CanvasNode[]; + const frameIds = rawNodes + .filter((node) => node.type === 'frame') + .map((node) => node.id); + if (frameIds.length === 0) return null; + + const frameRecords = await handle.nodes.readMany(frameIds); + const matchingFrameIds = new Set( + frameIds.filter((frameId) => { + const record = frameRecords.get(frameId)?.record; + return ( + classifySpaceInstructionFrame(record?.label, record?.labelSource) === + kind + ); + }), + ); + if (matchingFrameIds.size === 0) return null; + + const childIds = rawNodes + .filter( + (node) => + typeof node.parentId === 'string' && + matchingFrameIds.has(node.parentId) && + (node.type === 'text' || node.type === 'note'), + ) + .map((node) => node.id); + const childRecords = await handle.nodes.readMany(childIds); + const records = new Map([...frameRecords, ...childRecords]); + return renderSpaceInstructionFrames(canvas as CanvasFile, records, kind); +} + +export function resolveSpacePrompt( + canvasId: string, +): Promise { + return resolveSpaceInstructionFrames(canvasId, 'prompt'); +} + +export function resolveSpaceSkill( + canvasId: string, +): Promise { + return resolveSpaceInstructionFrames(canvasId, 'skill'); +} 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 b31a5724b..3bf6ba5d4 100644 --- a/apps/server/src/modules/remote_fs/rfs.route.test.ts +++ b/apps/server/src/modules/remote_fs/rfs.route.test.ts @@ -116,6 +116,55 @@ function seedNote( return `nodes/${toSafeFilename(label, id)}.md`; } +function seedInstructionFrame( + canvasId: string, + kind: 'prompt' | 'skill', + content: string, +): void { + const frameId = `frame-${kind}`; + const textId = `text-${kind}`; + const label = `${kind}: Workspace`; + const store = getCanvasStore(canvasId); + store.write({ + canvasId, + title: null, + version: 1, + state: { + nodes: [ + { + id: frameId, + type: 'frame', + position: { x: 0, y: 0 }, + data: { label }, + }, + { + id: textId, + type: 'text', + parentId: frameId, + position: { x: 0, y: 0 }, + data: {}, + }, + ], + edges: [], + }, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + store.writeNode(frameId, { + nodeId: frameId, + type: 'frame', + label, + labelSource: 'user', + content: '', + }); + store.writeNode(textId, { + nodeId: textId, + type: 'text', + label: null, + content, + }); +} + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'huabu-rfs-')); setWorkspacePath(tmp); @@ -164,6 +213,23 @@ describe('GET /api/rfs/:canvasId/skill', () => { } }); + it('keeps the authenticated root guide available for a missing Space', async () => { + const app = await buildApp(); + try { + const response = await app.inject({ + method: 'GET', + url: '/rfs/missing/skill', + headers: { authorization: '******' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatch(/Accessing this Huabu Space/i); + expect(response.body).not.toContain('# Space-specific Skills'); + } finally { + await app.close(); + } + }); + it('returns only the bundled root guide without authorization', async () => { seedNote('c1', 'node-1', 'Anchor', 'content'); writeFileSync( @@ -191,6 +257,54 @@ describe('GET /api/rfs/:canvasId/skill', () => { } }); + it('appends live Skill Frames to the authenticated Space guide only', async () => { + seedInstructionFrame('c1', 'skill', 'Prefer primary sources.'); + const app = await buildApp(); + try { + const anonymous = await app.inject({ + method: 'GET', + url: '/rfs/c1/skill', + }); + const authenticated = await app.inject({ + method: 'GET', + url: '/rfs/c1/skill', + headers: { authorization: '******' }, + }); + + expect(anonymous.body).toMatch(/Accessing this Huabu Space/); + expect(anonymous.body).not.toContain('Prefer primary sources.'); + expect(authenticated.body).toMatch(/Accessing this Huabu Space/); + expect(authenticated.body).toContain('# Space-specific Skills'); + expect(authenticated.body).toContain('Prefer primary sources.'); + } finally { + await app.close(); + } + }); + + it('appends Skill Frames after a legacy Space guide override', async () => { + seedInstructionFrame('c1', 'skill', 'Use the team glossary.'); + writeFileSync( + join(diskDirOf('c1'), 'skill.md'), + '# Legacy Space Guide', + 'utf8', + ); + const app = await buildApp(); + try { + const response = await app.inject({ + method: 'GET', + url: '/rfs/c1/skill', + headers: { authorization: '******' }, + }); + + expect(response.body).toContain('# Legacy Space Guide'); + expect(response.body).toContain('# Space-specific Skills'); + expect(response.body).toContain('Use the team glossary.'); + expect(response.body).not.toMatch(/Accessing this Huabu Space/i); + } finally { + await app.close(); + } + }); + it('serves only known advanced skills', async () => { const app = await buildApp(); try { diff --git a/apps/server/src/modules/remote_fs/skill.ts b/apps/server/src/modules/remote_fs/skill.ts index 7de40554a..e77af7159 100644 --- a/apps/server/src/modules/remote_fs/skill.ts +++ b/apps/server/src/modules/remote_fs/skill.ts @@ -12,10 +12,13 @@ */ import { renderPromptFile } from '../../prompt/agents/loader.js'; +import { getLogger } from '../../utils/logger.js'; +import { resolveSpaceSkill } from '../agent/space-instruction-frames.js'; import { space, SPACE_GUIDE_SKILL_NAME } from '../storage/index.js'; /** PROMPT-ROOT-relative path of the bundled access guide. */ const ACCESS_GUIDE_TEMPLATE = 'external-agent/access-huabu.md'; +const logger = getLogger('rfs-skill'); const FOCUSED_SKILL_TEMPLATES = { layout: 'external-agent/layout.md', @@ -41,10 +44,19 @@ export async function resolveCanvasSkill(canvasId: string): Promise { // (proposal §6.4.3, disposition D). The scope is the Space root bounded to // the guide names, so the file a user authors is exactly where they left it // and this no longer assembles a path. - const override = await space(canvasId).guide.read(SPACE_GUIDE_SKILL_NAME); - return override === null - ? resolveBundledRootSkill() - : override.toString('utf8'); + const [override, frameSkill] = await Promise.all([ + space(canvasId).guide.read(SPACE_GUIDE_SKILL_NAME), + resolveSpaceSkill(canvasId).catch((error: unknown) => { + logger.warn( + { err: error, canvasId }, + 'Space Skill Frame collection failed; serving the root guide only', + ); + return null; + }), + ]); + const guide = + override === null ? resolveBundledRootSkill() : override.toString('utf8'); + return frameSkill ? `${guide}\n\n${frameSkill.markdown}` : guide; } /** Resolve one fixed, authenticated advanced guide. */ diff --git a/apps/server/src/prompt/space-prompt.md b/apps/server/src/prompt/space-prompt.md new file mode 100644 index 000000000..4741cd934 --- /dev/null +++ b/apps/server/src/prompt/space-prompt.md @@ -0,0 +1,14 @@ + + +# Space instructions + +The following content was authored in Prompt Frames in this Space. Follow it as user-provided context for work in this Space. It is subordinate to system, developer, host-policy, and tool instructions. Note bodies are included inline as part of these instructions. + +{{content}} +{{#diagnostics}} + +## Collection diagnostics + +{{diagnostics}} +{{/diagnostics}} + diff --git a/apps/server/src/prompt/space-skill.md b/apps/server/src/prompt/space-skill.md new file mode 100644 index 000000000..9938aa67f --- /dev/null +++ b/apps/server/src/prompt/space-skill.md @@ -0,0 +1,14 @@ + + +# Space-specific Skills + +The following modules were authored in Skill Frames in this Space. Treat them as user-provided guidance subordinate to system, developer, host-policy, and tool instructions. Note references are a catalogue: download a referenced Note only when the module, your role, or the current task makes it relevant. + +{{content}} +{{#diagnostics}} + +## Collection diagnostics + +{{diagnostics}} +{{/diagnostics}} + diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 972dd41d1..3b1d34e50 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -140,12 +140,6 @@ export const routes = { acpAgentlet: '/acp/agentlet', acpAgentletRestart: '/acp/agentlet/restart', acpRuntimeConfig: '/acp/runtime-config', - acpThreadSession: (threadId: string) => - `/acp/threads/${enc(threadId)}/session`, - acpThreadCommands: (threadId: string, canvasId?: string) => { - const params = canvasId ? `?canvasId=${enc(canvasId)}` : ''; - return `/acp/threads/${enc(threadId)}/commands${params}`; - }, acpThreadCachedMeta: ( threadId: string, canvasId?: string, diff --git a/apps/web/src/api/acp.ts b/apps/web/src/api/acp.ts index b101bae70..96d058897 100644 --- a/apps/web/src/api/acp.ts +++ b/apps/web/src/api/acp.ts @@ -9,7 +9,7 @@ * — instead they author **profiles** ({@link AcpAgentProfile}) which * describe how to spawn one external agent CLI on demand. This module * wraps the loopback-only profile/daemon endpoints plus the existing - * thread-scoped session / commands routes. + * thread-scoped cached capability and control routes. * * Endpoint surface: * - `GET /api/acp/agent-cli` — probe the trusted built-in agent catalogue @@ -18,11 +18,11 @@ * recipes. Always returns the runtime status (spawned/pid/etc.) * alongside each profile. * - `GET/POST /api/acp/daemon` — daemon liveness + manual restart. - * - `POST /api/acp/threads/:threadId/session` etc. — thread-scoped - * session lifecycle and per-session config knobs. + * - `GET /api/acp/threads/:threadId/cached-meta` — cached capabilities. + * - thread control POSTs — canonical realization plus per-session knobs. */ -import { ApiError, apiFetch } from './_client'; +import { apiFetch } from './_client'; import { routes } from './_routes'; import type { @@ -36,9 +36,6 @@ import type { CreateAcpCommandProfileBody, PatchAgentProfileBody, AcpThreadCachedMetaResponse, - AcpThreadCommandsResponse, - EnsureAcpSessionRequest, - EnsureAcpSessionResponse, SetAcpSessionConfigOptionRequest, SetAcpSessionConfigOptionResponse, SetAcpSessionModelRequest, @@ -64,10 +61,7 @@ export type { AcpSessionMetaSnapshot, AcpSessionMode, AcpThreadCachedMetaResponse, - AcpThreadCommandsResponse, AvailableCommand, - EnsureAcpSessionRequest, - EnsureAcpSessionResponse, SetAcpSessionConfigOptionRequest, SetAcpSessionConfigOptionResponse, SetAcpSessionModelRequest, @@ -184,60 +178,9 @@ export async function updateExternalAgentRuntimeConfig( }); } -// ── Per-thread session lifecycle ───────────────────────────────────── - /** - * Eagerly open (or reuse) the per-thread ACP session so the slash-command - * typeahead can pull commands BEFORE the user submits their first prompt. - * Idempotent: calling repeatedly with the same `{threadId, profileId, - * canvasId}` triple is a no-op server-side. - * - * Response always carries the latest `availableCommands`; an empty array - * means the agent has not yet pushed its list — callers should follow up - * with {@link getAcpThreadCommands} after a short delay to catch late pushes. - */ -export async function ensureAcpSession( - threadId: string, - payload: EnsureAcpSessionRequest, -): Promise { - return apiFetch(routes.acpThreadSession(threadId), { - method: 'POST', - json: payload, - fallbackMessage: 'Failed to open ACP session', - }); -} - -/** - * Read the cached slash-command snapshot for an existing session. - * Returns `null` when the server has no session for this thread yet - * (404) so callers can ignore the missing-session case without - * branching on `ApiError.status`. - */ -export async function getAcpThreadCommands( - threadId: string, - canvasId?: string, -): Promise { - try { - return await apiFetch( - routes.acpThreadCommands(threadId, canvasId), - { fallbackMessage: 'Failed to fetch ACP slash commands' }, - ); - } catch (err) { - if (err instanceof ApiError && err.status === 404) return null; - throw err; - } -} - -/** - * Fetch the server-cached session-meta snapshot WITHOUT spawning the - * agentlet. Always resolves to a snapshot (possibly the empty - * `updatedAt === 0` form) — the server returns 200 even on cache - * miss so the UI can render an optimistic neutral state. - * - * Used by `useAcpSessionMeta` on mount to populate selector dropdowns - * (model / mode / config options) from the last-known state of the - * thread, so the user can pre-select a model before sending the first - * message without paying the cold-start tax of `ensureAcpSession`. + * Fetch the GET-only capability observation for a thread and its Profile. + * This never creates a workload or starts an ACP process. */ export async function getAcpThreadCachedMeta( threadId: string, diff --git a/apps/web/src/components/Nodes/frame/FrameNode.tsx b/apps/web/src/components/Nodes/frame/FrameNode.tsx index 8b707964f..842289319 100644 --- a/apps/web/src/components/Nodes/frame/FrameNode.tsx +++ b/apps/web/src/components/Nodes/frame/FrameNode.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import { FRAME_GRID_MAX_COUNT, FRAME_GRID_MIN_COUNT, + classifySpaceInstructionFrame, type FrameLayoutMode, } from '@huabu/shared'; import { clampGridCount } from '@huabu/shared/canvas-engine'; @@ -21,6 +22,7 @@ import { NodeWrapper } from '@/components/Nodes/NodeWrapper.tsx'; import useCanvasStore from '@/store/canvasStore.ts'; import { shouldPreserveFrameAspectRatio } from './frameResizePolicy.ts'; +import { InstructionFrameBadge } from './InstructionFrameBadge.tsx'; import type { CanvasFrameNodeData } from '@/components/Nodes/types.ts'; import type { Node, NodeProps } from '@xyflow/react'; @@ -30,6 +32,7 @@ export type FrameNodeType = Node; const LABEL_MIN_VERTICAL_GAP = 22; const LABEL_COLLISION_HYSTERESIS = 4; const LABEL_MIN_SCREEN_WIDTH = 48; +const INSTRUCTION_LABEL_MIN_SCREEN_WIDTH = 112; function shouldShowNestedLabel( ancestorGap: number | null, @@ -414,6 +417,10 @@ export const FrameNode = memo( const trimmed = raw.trim(); return trimmed.length > 0 ? trimmed : t('layers.filterLabels.frame'); }, [data.label, t]); + const instructionFrameKind = classifySpaceInstructionFrame( + data.label, + data.labelSource, + ); const [isEditingLabel, setIsEditingLabel] = useState(false); const [draftLabel, setDraftLabel] = useState(label); @@ -496,53 +503,58 @@ export const FrameNode = memo( // Rendered in the zoom-invariant overlay so the label keeps a fixed screen size const labelOverlay = ( -
- - {draftLabel || ' '} - - - { - if (!isEditingLabel) return; - setDraftLabel(e.target.value); - }} - onClick={() => { - if (isEditingLabel) return; - setIsEditingLabel(true); - }} - onBlur={() => { - if (!isEditingLabel) return; - commitLabel(); - }} - onKeyDown={(e) => { - if (!isEditingLabel) return; - e.stopPropagation(); - if (e.key === 'Enter') { - e.preventDefault(); +
+ {instructionFrameKind ? ( + + ) : null} +
+ + {draftLabel || ' '} + + + { + if (!isEditingLabel) return; + setDraftLabel(e.target.value); + }} + onClick={() => { + if (isEditingLabel) return; + setIsEditingLabel(true); + }} + onBlur={() => { + if (!isEditingLabel) return; commitLabel(); - } - if (e.key === 'Escape') { - e.preventDefault(); - setDraftLabel(label); - setIsEditingLabel(false); - } - }} - /> + }} + onKeyDown={(e) => { + if (!isEditingLabel) return; + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + commitLabel(); + } + if (e.key === 'Escape') { + e.preventDefault(); + setDraftLabel(label); + setIsEditingLabel(false); + } + }} + /> +
); @@ -557,7 +569,12 @@ export const FrameNode = memo( overlayOffsetY={-24} overlayVisible={labelSemanticallyVisible} overlayInteractionPriority={isEditingLabel ? 3 : selected ? 2 : 0} - overlayMaxWidth={Math.max(LABEL_MIN_SCREEN_WIDTH, nodeWidth * zoom)} + overlayMaxWidth={Math.max( + instructionFrameKind + ? INSTRUCTION_LABEL_MIN_SCREEN_WIDTH + : LABEL_MIN_SCREEN_WIDTH, + nodeWidth * zoom, + )} keepAspectRatio={shouldPreserveFrameAspectRatio({ sizing: data.sizing, hasMediaChild, diff --git a/apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx new file mode 100644 index 000000000..8fee91cf3 --- /dev/null +++ b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { InstructionFrameBadge } from './InstructionFrameBadge.tsx'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('InstructionFrameBadge', () => { + let container: HTMLDivElement | undefined; + + afterEach(() => { + container?.remove(); + container = undefined; + }); + + it.each([ + ['prompt', 'node.promptFrameBadge', 'bg-info'], + ['skill', 'node.skillFrameBadge', 'bg-success'], + ] as const)('renders the %s semantic pill', (kind, label, tone) => { + container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render()); + + const badge = container.querySelector('span'); + expect(badge?.textContent).toBe(label); + expect(badge?.classList.contains(tone)).toBe(true); + expect(badge?.classList.contains('text-fg-inverse')).toBe(true); + expect(badge?.classList.contains('rounded-full')).toBe(true); + expect(badge?.querySelector('svg')?.getAttribute('aria-hidden')).toBe( + 'true', + ); + + act(() => root.unmount()); + }); +}); diff --git a/apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx new file mode 100644 index 000000000..938e9562d --- /dev/null +++ b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import clsx from 'clsx'; +import { BookOpen, MessageSquareQuote } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import type { SpaceInstructionFrameKind } from '@huabu/shared'; + +export function InstructionFrameBadge({ + kind, +}: { + kind: SpaceInstructionFrameKind; +}) { + const { t } = useTranslation(); + const isPrompt = kind === 'prompt'; + + return ( + + {isPrompt ? ( + + ); +} diff --git a/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx b/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx index d9c5b8fb1..961a46793 100644 --- a/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx +++ b/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx @@ -14,21 +14,14 @@ * States (mutually exclusive; derived upstream from * {@link useAcpSessionMeta}'s `{loading, error, meta.updatedAt}`): * - * • `connecting` — a real `ensureAcpSession` is currently in flight - * (refresh / set-mode / set-model / set-config-option). Blue - * breathing dot, no text — the warm-up usually completes within - * a few hundred ms. + * • `connecting` — the GET-only capability cache read is in flight. * * • `connected` — default. Cache hit, post-success steady state, * OR a transient refresh error while we still have a usable * cached snapshot. Green solid dot, no text — once everything is * working the badge should be near-invisible chrome. * - * • `failed` — the last ensure rejected AND there's no cached - * snapshot to fall back on. Red dot + uppercase "FAILED" text so - * the failure is unmissable. Tooltip carries the actual error - * message when available, falling back to a generic explanation - * pointing at Settings → External Agents. + * • `failed` — the cache read failed and there is no snapshot to show. * * The component never renders for internal bindings or before the * upstream status enum has been derived — the parent gates on @@ -39,7 +32,6 @@ import { useTranslation } from 'react-i18next'; import { Tooltip } from '@/components/Common/Tooltip'; -import type { AcpEnsureErrorCode } from '@huabu/shared'; import type { FC } from 'react'; export type AcpConnectionStatus = 'connecting' | 'connected' | 'failed'; @@ -49,24 +41,15 @@ interface AcpConnectionBadgeProps { /** Display name of the bound external agent — shown in tooltips. */ alias: string; /** - * Last error from the ensure-session pipeline. Used as the tooltip - * body for the `failed` state. Ignored for other states. + * Last capability-cache read error. Used by the failed-state tooltip. */ errorMessage?: string | null; - /** - * Categorical error code from the server (when available). Drives - * a remediation-specific tooltip headline so the user knows the - * concrete next step (e.g. "Restart worker" vs "Re-create profile") - * instead of just seeing a raw error message. - */ - errorCode?: AcpEnsureErrorCode | null; } export const AcpConnectionBadge: FC = ({ status, alias, errorMessage, - errorCode, }) => { const { t } = useTranslation(); if (status === 'connecting') { @@ -106,7 +89,7 @@ export const AcpConnectionBadge: FC = ({ // without needing to read the raw error. The detail message is // appended on a second line so power users can still see the // underlying server text. - const headline = headlineForCode(errorCode, alias, t); + const headline = t('chat.connectionHeadline.fallback', { alias }); const tooltipText = errorMessage && errorMessage.length > 0 ? `${headline}\n\n${errorMessage}` @@ -125,65 +108,8 @@ export const AcpConnectionBadge: FC = ({ aria-hidden className="bg-danger h-1.5 w-1.5 shrink-0 rounded-full" /> - {labelForCode(errorCode, t)} + {t('chat.connectionLabel.failed')} ); }; - -/** - * Short uppercase label rendered next to the red dot. Kept terse - * (≤7 chars) so it doesn't blow out the toolbar; the full sentence - * lives in the tooltip. - */ -function labelForCode( - code: AcpEnsureErrorCode | null | undefined, - t: ReturnType['t'], -): string { - switch (code) { - case 'worker_not_ready': - case 'placement_unavailable': - return t('chat.connectionLabel.worker'); - case 'profile_missing': - return t('chat.connectionLabel.profile'); - case 'spawn_failed': - case 'session_resume_unavailable': - return t('chat.connectionLabel.spawn'); - case 'connect_timeout': - return t('chat.connectionLabel.timeout'); - case 'bridge_not_mounted': - return t('chat.connectionLabel.starting'); - default: - return t('chat.connectionLabel.failed'); - } -} - -/** - * One-sentence remediation headline shown at the top of the tooltip. - * Each code points at the concrete next step — the raw server - * message is appended below for diagnostics. - */ -function headlineForCode( - code: AcpEnsureErrorCode | null | undefined, - alias: string, - t: ReturnType['t'], -): string { - switch (code) { - case 'worker_not_ready': - case 'placement_unavailable': - return t('chat.connectionHeadline.workerNotReady'); - case 'profile_missing': - return t('chat.connectionHeadline.profileMissing', { alias }); - case 'spawn_failed': - case 'session_resume_unavailable': - return t('chat.connectionHeadline.spawnFailed', { alias }); - case 'connect_timeout': - return t('chat.connectionHeadline.connectTimeout', { alias }); - case 'bridge_not_mounted': - return t('chat.connectionHeadline.bridgeNotMounted'); - case 'internal': - return t('chat.connectionHeadline.internal', { alias }); - default: - return t('chat.connectionHeadline.fallback', { alias }); - } -} diff --git a/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx b/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx index 4f51888d4..4f428f9a4 100644 --- a/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx +++ b/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx @@ -44,17 +44,20 @@ vi.mock('../../Common/Select', () => ({ value, onChange, title, + placeholder, }: { options: Array<{ value: string; label: string }>; value: string; onChange: (value: string) => void; title?: string; + placeholder?: string; }) => (