diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 96758aa79..231aaeffe 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { existsSync, unlinkSync } from 'node:fs'; +import { unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -25,13 +25,10 @@ import { externalAgentRuntimeConfigRoutes, getExternalAgentRuntimeConfig, getAgentProfileRegistry, - getSupervisedAgentletId, installAcpProfileCachePort, mountAgenetes, resolveDaemonEntry, } from './modules/agent/acp/index.js'; -import { buildLegacyCommandProfiles } from './modules/agent/acp/legacy-profile-migration.js'; -import { listProfiles as listLegacyAcpProfiles } from './modules/agent/acp/profile-store.js'; import { initializeAgentDefaults } from './modules/agent/agent-defaults.js'; import agentDefaultsRoutes from './modules/agent/agent-defaults.route.js'; import agentRoutes from './modules/agent/agent.route.js'; @@ -317,15 +314,7 @@ const agentletGateway = mountAgenetes(app, { profiles: { storageDir: join(getDataDir(), 'agent-profiles'), legacyStorageDir: join(getDataDir(), 'agent-team'), - legacyCommandProfiles: existsSync( - join(getDataDir(), 'agent-profiles', 'registry.json'), - ) - ? [] - : buildLegacyCommandProfiles( - listLegacyAcpProfiles(), - getSupervisedAgentletId(), - process.cwd(), - ), + legacyCommandProfiles: [], }, }); let unregisterHarnessDiscovery: (() => void) | undefined; diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts index a02f85b31..a8b3aae5c 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts @@ -12,8 +12,10 @@ import type { FastifyInstance } from 'fastify'; const mocks = vi.hoisted(() => ({ getProfile: vi.fn(), discover: vi.fn() })); vi.mock('@agenetes/agentlet-host', () => ({ getAgentProfileRegistry: () => ({ getProfile: mocks.getProfile }), - getSupervisedAgentletId: () => 'supervised', - getAgentletGateway: () => ({ discoverHarnesses: mocks.discover }), + getAgentletGateway: () => ({ + getAgentlet: () => ({ status: 'connected' }), + discoverHarnesses: mocks.discover, + }), })); let app: FastifyInstance | undefined; @@ -52,7 +54,7 @@ describe('ACP agent CLI route', () => { const response = await app.inject({ method: 'GET', - url: '/api/acp/agent-cli', + url: '/api/acp/agent-cli?agentletId=machine-a', }); expect(response.statusCode).toBe(200); @@ -72,7 +74,7 @@ describe('ACP agent CLI route', () => { const response = await app.inject({ method: 'GET', - url: '/api/acp/agent-cli', + url: '/api/acp/agent-cli?agentletId=machine-a', remoteAddress: '192.0.2.10', }); @@ -92,7 +94,7 @@ describe('ACP agent CLI route', () => { const response = await app.inject({ method: 'GET', - url: '/api/acp/agent-cli', + url: '/api/acp/agent-cli?agentletId=machine-a', remoteAddress: '192.0.2.10', }); @@ -108,7 +110,9 @@ describe('ACP agent CLI route', () => { ), { prefix: '/api/acp' }, ); - const response = await app.inject('/api/acp/agent-cli'); + const response = await app.inject( + '/api/acp/agent-cli?agentletId=machine-a', + ); expect(response.statusCode).toBe(503); expect(response.json().code).toBe('harness_discovery_unavailable'); expect(response.json()).not.toHaveProperty('agents'); diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.ts b/apps/server/src/modules/agent/acp/agent-cli.route.ts index 116e1915a..24d053434 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.ts @@ -17,7 +17,6 @@ import { getAgentletGateway, - getSupervisedAgentletId, getAgentProfileRegistry, } from '@agenetes/agentlet-host'; import { @@ -36,19 +35,25 @@ import type { } from '@huabu/shared'; import type { FastifyPluginAsync } from 'fastify'; -async function detectAgentClis(profileId?: string): Promise { +async function detectAgentClis(target: { + profileId?: string; + agentletId?: string; +}): Promise { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - const profile = profileId - ? getAgentProfileRegistry()?.getProfile(profileId) + const profile = target.profileId + ? getAgentProfileRegistry()?.getProfile(target.profileId) : undefined; - if (profileId && !profile) throw new Error('Agent Profile is unavailable'); - const result = await gateway.discoverHarnesses( - profile?.agentletId ?? getSupervisedAgentletId(), - { - prepareWorkspaces: false, - }, - ); + if (target.profileId && !profile) + throw new Error('Agent Profile is unavailable'); + const agentletId = profile?.agentletId ?? target.agentletId; + if (!agentletId) throw new Error('Agentlet target is required'); + const connection = gateway.getAgentlet(agentletId); + if (connection?.status !== 'connected') + throw new Error('Agentlet is not connected'); + const result = await gateway.discoverHarnesses(agentletId, { + prepareWorkspaces: false, + }); if (result.harnesses.some((entry) => entry.id === CUSTOM_COMMAND_WRAPPER_ID)) return result.harnesses; return [ @@ -96,7 +101,7 @@ export function createAcpAgentCliRoutes( message: 'Agent Profile is unavailable', }); } - return { agents: await detect(parsed.data.profileId) }; + return { agents: await detect(parsed.data) }; } catch (error) { request.log.warn( { err: error }, diff --git a/apps/server/src/modules/agent/acp/daemon-auth.test.ts b/apps/server/src/modules/agent/acp/daemon-auth.test.ts index 7fad62a5c..975015cea 100644 --- a/apps/server/src/modules/agent/acp/daemon-auth.test.ts +++ b/apps/server/src/modules/agent/acp/daemon-auth.test.ts @@ -32,7 +32,7 @@ function makeAgentletHello(): AgentletHelloParams { agentletId: 'test:agentlet', agentletProfile: { bridge: { name: 'agentlet', version: '1.0.0' }, - machine: { hostname: 'test', platform: 'linux' }, + machine: { hostname: 'test', platform: 'linux', arch: 'x64' }, capabilities: { autoRestart: true, bufferLimit: 1000 }, }, }; diff --git a/apps/server/src/modules/agent/acp/index.ts b/apps/server/src/modules/agent/acp/index.ts index 45b29ac2e..e2d0430e9 100644 --- a/apps/server/src/modules/agent/acp/index.ts +++ b/apps/server/src/modules/agent/acp/index.ts @@ -4,7 +4,6 @@ export { mountAgenetes, getAgentProfileRegistry, - getSupervisedAgentletId, ACP_UPGRADE_PATH, } from '@agenetes/agentlet-host'; export type { diff --git a/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts b/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts deleted file mode 100644 index 2b8afabb5..000000000 --- a/apps/server/src/modules/agent/acp/legacy-profile-migration.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { describe, expect, it } from 'vitest'; - -import { buildLegacyCommandProfiles } from './legacy-profile-migration.js'; - -import type { AcpAgentProfile } from '@huabu/shared'; - -function makeProfile( - overrides: Partial = {}, -): AcpAgentProfile { - return { - id: 'legacy-profile', - displayName: 'Legacy Profile', - cliId: 'copilot', - command: 'copilot --acp', - cwd: '/workspace', - autoRestart: true, - createdAt: 1, - updatedAt: 1, - ...overrides, - }; -} - -describe('buildLegacyCommandProfiles', () => { - it('preserves ordinary profile identity and launch fields', () => { - expect( - buildLegacyCommandProfiles( - [makeProfile()], - 'local-agentlet', - '/server-cwd', - ), - ).toEqual([ - { - id: 'legacy-profile', - alias: 'Legacy Profile', - agentletId: 'local-agentlet', - command: 'copilot --acp', - workingDirPath: '/workspace', - metadata: { cliId: 'copilot' }, - }, - ]); - }); - - it('uses the inherited host directory when an old profile omitted cwd', () => { - const [profile] = buildLegacyCommandProfiles( - [makeProfile({ cwd: undefined })], - 'local-agentlet', - '/server-cwd', - ); - - expect(profile?.workingDirPath).toBe('/server-cwd'); - }); - - it('leaves legacy Agent Team records unmigrated', () => { - expect( - buildLegacyCommandProfiles( - [makeProfile({ id: 'team', cliId: 'agent-team' })], - 'local-agentlet', - '/server-cwd', - ), - ).toEqual([]); - }); - - it('fails explicitly for an ordinary record without a command', () => { - expect(() => - buildLegacyCommandProfiles( - [makeProfile({ command: undefined })], - 'local-agentlet', - '/server-cwd', - ), - ).toThrow("Legacy command Profile 'legacy-profile' has no command"); - }); -}); diff --git a/apps/server/src/modules/agent/acp/legacy-profile-migration.ts b/apps/server/src/modules/agent/acp/legacy-profile-migration.ts deleted file mode 100644 index 474278013..000000000 --- a/apps/server/src/modules/agent/acp/legacy-profile-migration.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import type { CreateAcpCommandProfileInput } from '@agenetes/agent-profile'; -import type { AcpAgentProfile } from '@huabu/shared'; - -/** - * Convert spawnable legacy ACP profiles into unified command Profiles. - * - * Older records may omit `cwd`; the old launcher then inherited the host - * process directory, so migration makes that implicit behavior explicit. - */ -export function buildLegacyCommandProfiles( - profiles: AcpAgentProfile[], - agentletId: string, - defaultWorkingDir: string, -): CreateAcpCommandProfileInput[] { - return profiles.flatMap((profile) => { - if (profile.cliId === 'agent-team') return []; - if (!profile.command?.trim()) { - throw new Error(`Legacy command Profile '${profile.id}' has no command`); - } - return [ - { - id: profile.id, - alias: profile.displayName, - agentletId, - command: profile.command, - workingDirPath: profile.cwd ?? defaultWorkingDir, - metadata: { cliId: profile.cliId }, - }, - ]; - }); -} diff --git a/apps/server/src/modules/agent/acp/profile-store.test.ts b/apps/server/src/modules/agent/acp/profile-store.test.ts deleted file mode 100644 index cc70c2b81..000000000 --- a/apps/server/src/modules/agent/acp/profile-store.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { listProfiles } from './profile-store.js'; - -let directory: string; -let previousDataDir: string | undefined; - -const commandProfile = { - id: 'command', - displayName: 'Copilot', - cliId: 'copilot', - command: 'copilot --acp', - cwd: '/workspace', - autoRestart: true, - createdAt: 1, - updatedAt: 1, -}; - -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'huabu-legacy-profiles-')); - previousDataDir = process.env.HUABU_DATA_DIR; - process.env.HUABU_DATA_DIR = directory; -}); - -afterEach(() => { - if (previousDataDir === undefined) delete process.env.HUABU_DATA_DIR; - else process.env.HUABU_DATA_DIR = previousDataDir; - rmSync(directory, { recursive: true, force: true }); -}); - -describe('read-only legacy Profile import source', () => { - it('returns no records for an absent file', () => { - expect(listProfiles()).toEqual([]); - }); - - it('reads ordinary Profiles without rewriting retired Team data', () => { - const file = join(directory, 'agent-profiles.json'); - const original = JSON.stringify({ - schemaVersion: 1, - profiles: [ - commandProfile, - { - id: 'retired-team', - cliId: 'agent-team', - agentTeam: { agentDir: '/team' }, - }, - { ...commandProfile, id: 'team-disguised-as-command', agentTeam: {} }, - ], - }); - writeFileSync(file, original); - expect(listProfiles()).toEqual([commandProfile]); - expect(readFileSync(file, 'utf8')).toBe(original); - }); - - it.each([ - '{"profiles": []}', - '{"schemaVersion": 999, "profiles": []}', - '{"schemaVersion": 1, "profiles": [{}]}', - '{invalid', - ])( - 'rejects corrupt or unsupported data rather than silently losing it', - (text) => { - writeFileSync(join(directory, 'agent-profiles.json'), text); - expect(() => listProfiles()).toThrow(); - }, - ); -}); diff --git a/apps/server/src/modules/agent/acp/profile-store.ts b/apps/server/src/modules/agent/acp/profile-store.ts deleted file mode 100644 index 21437bc56..000000000 --- a/apps/server/src/modules/agent/acp/profile-store.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { acpAgentProfileSchema } from '@huabu/shared'; - -import { getDataDir } from '../../../data-dir.js'; -import { logger } from '../../../utils/logger.js'; - -import type { AcpAgentProfile } from '@huabu/shared'; - -/** Read the pre-registry command data once during migration; never rewrite it. */ -export function listProfiles(): AcpAgentProfile[] { - let text: string; - try { - text = readFileSync(join(getDataDir(), 'agent-profiles.json'), 'utf8'); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return []; - } - throw error; - } - const file: unknown = JSON.parse(text); - if ( - !file || - typeof file !== 'object' || - !('schemaVersion' in file) || - file.schemaVersion !== 1 || - !('profiles' in file) || - !Array.isArray(file.profiles) - ) { - throw new Error('Unsupported legacy command Profile file'); - } - - const profiles: AcpAgentProfile[] = []; - let retired = 0; - for (const raw of file.profiles) { - if ( - raw && - typeof raw === 'object' && - (raw.cliId === 'agent-team' || - Object.prototype.hasOwnProperty.call(raw, 'agentTeam')) - ) { - retired += 1; - continue; - } - const parsed = acpAgentProfileSchema.safeParse(raw); - if (!parsed.success) { - throw new Error( - `Invalid legacy command Profile: ${parsed.error.message}`, - ); - } - profiles.push(parsed.data); - } - if (retired) { - logger.warn( - { retired }, - '[acp] legacy Agent Team records retained as data only', - ); - } - return profiles.sort((left, right) => left.createdAt - right.createdAt); -} diff --git a/apps/server/src/modules/agent/acp/profiles.route.test.ts b/apps/server/src/modules/agent/acp/profiles.route.test.ts index bd206adb8..dab86c012 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.test.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ initializeDefaults: vi.fn(), discoverHarnesses: vi.fn(), buildHarnessLaunch: vi.fn(), + connectedIds: new Set(['machine-a', 'remote-machine']), })); vi.mock('../agent-defaults.js', () => ({ @@ -32,10 +33,23 @@ vi.mock('@agenetes/agentlet-host', () => ({ getDaemonSupervisor: () => ({ getStatus: () => ({ online: true, restartAttempt: 0 }), }), - getSupervisedAgentletId: () => 'machine-a', getAgentletGateway: () => ({ discoverHarnesses: mocks.discoverHarnesses, buildHarnessLaunch: mocks.buildHarnessLaunch, + getAgentlets: () => + [...mocks.connectedIds].map((agentletId) => ({ + agentletId, + status: 'connected', + connectedAt: new Date('2026-01-01T00:00:00.000Z'), + agentletProfile: { + bridge: { name: 'agentlet', version: '1.0.0' }, + machine: { + hostname: `${agentletId}-host`, + platform: 'linux', + arch: 'x64', + }, + }, + })), }), })); @@ -92,7 +106,12 @@ describe('ordinary Profile catalog routes', () => { const response = await server.inject({ method: 'POST', url: '/api/acp/profiles', - payload: { alias: 'Typed', workingDirPath: '/work', launch }, + payload: { + alias: 'Typed', + agentletId: 'machine-a', + workingDirPath: '/work', + launch, + }, }); expect(response.statusCode).toBe(200); expect(mocks.discoverHarnesses).toHaveBeenCalledWith('machine-a', { @@ -122,6 +141,7 @@ describe('ordinary Profile catalog routes', () => { url: '/api/acp/profiles', payload: { alias: 'Typed', + agentletId: 'machine-a', workingDirPath: '/work', launch: { kind: 'acp-harness', harnessId: 'copilot' }, }, @@ -139,6 +159,7 @@ describe('ordinary Profile catalog routes', () => { url: '/api/acp/profiles', payload: { alias: 'Copilot', + agentletId: 'machine-a', workingDirPath: '/work/project', launch: commandProfile.launch, metadata: { cliId: 'copilot' }, @@ -166,6 +187,16 @@ describe('ordinary Profile catalog routes', () => { expect(response.json()).toMatchObject({ profiles: [commandProfile], selectableProfileIds: ['command-1'], + connectedDevices: [ + expect.objectContaining({ + agentletId: 'machine-a', + profileCount: 1, + }), + expect.objectContaining({ + agentletId: 'remote-machine', + profileCount: 0, + }), + ], }); expect(mocks.registry.createProfile).not.toHaveBeenCalled(); expect(mocks.initializeDefaults).not.toHaveBeenCalled(); @@ -179,6 +210,7 @@ describe('ordinary Profile catalog routes', () => { url: '/api/acp/profiles', payload: { alias: 'Forged', + agentletId: 'machine-a', workingDirPath: '/work', launch: commandProfile.launch, customData: { discoveredAgent: source }, @@ -419,7 +451,10 @@ describe('ordinary Profile catalog routes', () => { const response = await server.inject({ method: 'POST', url: '/api/acp/profile-launch-preview', - payload: { launch: commandProfile.launch }, + payload: { + agentletId: 'machine-a', + launch: commandProfile.launch, + }, }); expect(response.statusCode).toBe(503); expect(response.json().code).toBe('harness_preview_unavailable'); diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts index cf06c373d..9e4fe6d3d 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.ts @@ -29,7 +29,6 @@ import { getAgentProfileRegistry, getAgentletGateway, getDaemonSupervisor, - getSupervisedAgentletId, } from '@agenetes/agentlet-host'; import { @@ -50,6 +49,7 @@ import { initializeAgentDefaults, } from '../agent-defaults.js'; +import type { AgentletConnection } from '@agenetes/agentlet-host'; import type { AcpProfileMutationResponse, AcpProfilesListResponse, @@ -68,6 +68,16 @@ function denyRemote(request: FastifyRequest, reply: FastifyReply): boolean { return true; } +function getConnectedAgentlets(): AgentletConnection[] { + return getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? []; +} + +function isAgentletConnected(agentletId: string): boolean { + return getConnectedAgentlets().some( + (connection) => connection.agentletId === agentletId, + ); +} + async function validateHarnessLaunch( launch: AgentProfileView['launch'], agentletId: string, @@ -146,10 +156,16 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { try { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - return await gateway.buildHarnessLaunch( - profile?.agentletId ?? getSupervisedAgentletId(), - { launch: parsed.data.launch }, - ); + const agentletId = profile?.agentletId ?? parsed.data.agentletId; + if (!agentletId || !isAgentletConnected(agentletId)) { + return reply.status(409).send({ + code: 'agentlet_unavailable', + message: 'The selected Agentlet is not connected.', + }); + } + return await gateway.buildHarnessLaunch(agentletId, { + launch: parsed.data.launch, + }); } catch (error) { request.log.warn({ err: error }, 'Profile launch preview failed'); return reply.status(503).send({ @@ -172,9 +188,46 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { code: 'profile_registry_unavailable', }); } + const profiles = registry.listProfiles(); + const connected = getConnectedAgentlets(); + const connectedIds = new Set( + connected.map((connection) => connection.agentletId), + ); + const profileCounts = new Map(); + for (const profile of profiles) { + profileCounts.set( + profile.agentletId, + (profileCounts.get(profile.agentletId) ?? 0) + 1, + ); + } + const connectedDevices = connected + .map((connection) => ({ + agentletId: connection.agentletId, + ...(connection.agentletProfile?.machine?.hostname + ? { hostname: connection.agentletProfile.machine.hostname } + : {}), + ...(connection.agentletProfile?.machine?.platform + ? { platform: connection.agentletProfile.machine.platform } + : {}), + ...(connection.agentletProfile?.machine?.arch + ? { arch: connection.agentletProfile.machine.arch } + : {}), + version: connection.agentletProfile?.bridge.version ?? 'unknown', + connectedAt: connection.connectedAt.toISOString(), + profileCount: profileCounts.get(connection.agentletId) ?? 0, + })) + .sort( + (left, right) => + (left.hostname ?? left.agentletId).localeCompare( + right.hostname ?? right.agentletId, + ) || left.agentletId.localeCompare(right.agentletId), + ); return { - profiles: registry.listProfiles(), - selectableProfileIds: registry.listSelectableProfileIds(), + profiles, + selectableProfileIds: profiles + .filter((profile) => connectedIds.has(profile.agentletId)) + .map((profile) => profile.id), + connectedDevices, agentlet: getDaemonSupervisor().getStatus(), agentDefaults: getAgentDefaults(), }; @@ -206,7 +259,13 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { code: 'profile_registry_unavailable', }); } - const agentletId = getSupervisedAgentletId(); + const agentletId = parsed.data.agentletId; + if (!isAgentletConnected(agentletId)) { + return reply.status(409).send({ + code: 'agentlet_unavailable', + message: 'The selected Agentlet is not connected.', + }); + } const launch = parsed.data.launch; if (!(await validateHarnessLaunch(launch, agentletId, request, reply))) return; diff --git a/apps/server/src/modules/agent/acp/service.ts b/apps/server/src/modules/agent/acp/service.ts index 24d5fae3f..62a6d0194 100644 --- a/apps/server/src/modules/agent/acp/service.ts +++ b/apps/server/src/modules/agent/acp/service.ts @@ -22,10 +22,8 @@ import { randomUUID } from 'node:crypto'; -import { - getAgentProfileRegistry, - getSupervisedAgentletId, -} from '@agenetes/agentlet-host'; +import { AcpServiceError } from '@agenetes/acp-driver'; +import { getAgentProfileRegistry } from '@agenetes/agentlet-host'; import { renderExternalAgentInputs } from './preprocessor.js'; import { getProfileSessionPreferences } from './profile-session-preferences.js'; @@ -183,18 +181,18 @@ export function buildAcpWorkloadSpec( const { binding, threadId } = opts; const canvasId = opts.canvasId ?? ''; const profile = resolveProfileSnapshot(binding.profileId); - let agentletId: string; - let cwd: string | undefined; - let recipe: AcpBindingRecipe | null; - if (profile) { - agentletId = profile.agentletId; - cwd = profile.workingDirPath; - recipe = recipeFromProfileSnapshot(profile, binding.alias); - } else { - agentletId = getSupervisedAgentletId(); - cwd = opts.cwd; - recipe = resolveBindingRecipe(binding.profileId); + if (!profile) { + throw new AcpServiceError( + 'profile_missing', + `Agent Profile '${binding.profileId}' is unavailable.`, + ); } + const agentletId = profile.agentletId; + let cwd: string | undefined = profile.workingDirPath; + let recipe: AcpBindingRecipe | null = recipeFromProfileSnapshot( + profile, + binding.alias, + ); const workingDirPath = opts.launchOverrides?.workingDirPath; cwd = workingDirPath ?? cwd; 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 732e764eb..0434f5611 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 @@ -20,7 +20,6 @@ vi.mock('@agenetes/agentlet-host', () => ({ getAgentProfileRegistry: () => ({ getProfile: () => mocks.profile, }), - getSupervisedAgentletId: () => 'supervised-agentlet', })); vi.mock('../agenetes/drivers.js', () => ({ @@ -137,4 +136,14 @@ describe('buildAcpWorkloadSpec', () => { 'Node constraints', ]); }); + + it('rejects a missing Profile instead of inventing a placement', () => { + expect(() => + buildAcpWorkloadSpec({ + binding: { profileId: 'missing', alias: 'Missing' }, + threadId: 'thread-a', + canvasId: 'canvas-a', + }), + ).toThrow("Agent Profile 'missing' is unavailable."); + }); }); diff --git a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts index c8fdf0d8a..66dfbc803 100644 --- a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts +++ b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts @@ -29,10 +29,6 @@ vi.mock('@agenetes/acp-driver', () => ({ }, })); -vi.mock('@agenetes/agentlet-host', () => ({ - getSupervisedAgentletId: () => 'agentlet-supervised', -})); - vi.mock('./external-agent-realization.js', () => ({ externalAgentRealization: { realize: vi.fn(), ensureSession: vi.fn() }, realizationHttpError: () => ({ @@ -139,7 +135,7 @@ describe('ACP cached-meta across awaited persistence', () => { }); }); - it('falls back to the supervised agentlet for a thread with no record', async () => { + it('does not invent a placement for a thread with no record', async () => { mocks.live.set('agentlet-supervised\u0000thread-1', { availableCommands: [], commandsUpdatedAt: 3, @@ -160,11 +156,7 @@ describe('ACP cached-meta across awaited persistence', () => { url: CACHED_META_URL, }); - expect(response.json()).toMatchObject({ - source: 'thread', - commandsUpdatedAt: 3, - sessionMeta: { updatedAt: 4 }, - }); + expect(response.json()).toMatchObject({ source: 'none' }); }); it('answers a dormant thread from the metadata its record kept', async () => { diff --git a/apps/server/src/modules/agent/acp/threads.route.test.ts b/apps/server/src/modules/agent/acp/threads.route.test.ts index 0a1f91c1b..4f9549346 100644 --- a/apps/server/src/modules/agent/acp/threads.route.test.ts +++ b/apps/server/src/modules/agent/acp/threads.route.test.ts @@ -19,10 +19,6 @@ 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, diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts index 58947fc60..df778ecb0 100644 --- a/apps/server/src/modules/agent/acp/threads.route.ts +++ b/apps/server/src/modules/agent/acp/threads.route.ts @@ -2,7 +2,6 @@ // Licensed under the MIT license. import { acpSessionRegistry } from '@agenetes/acp-driver'; -import { getSupervisedAgentletId } from '@agenetes/agentlet-host'; import { acpPermissionDecisionSchema, @@ -140,7 +139,7 @@ export async function awaitSchemaQuiescence( async function resolveThreadAgentletId( threadId: string, canvasId?: string, -): Promise { +): Promise { if (canvasId) { const record = await agenetes.record( canvasAcpNamespace(canvasId), @@ -155,7 +154,7 @@ async function resolveThreadAgentletId( return (driverSpec as { agentletId: string }).agentletId; } } - return getSupervisedAgentletId(); + return undefined; } /** @@ -285,7 +284,9 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { } const { canvasId, profileId } = parsed.data; const agentletId = await resolveThreadAgentletId(threadId, canvasId); - const live = acpSessionRegistry.get(agentletId, threadId); + const live = agentletId + ? acpSessionRegistry.get(agentletId, threadId) + : undefined; if (live) { return { source: 'thread', diff --git a/apps/server/src/modules/agent/selectable-agent-profile.test.ts b/apps/server/src/modules/agent/selectable-agent-profile.test.ts index d2976c00e..0f649404f 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.test.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.test.ts @@ -1,13 +1,34 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const host = vi.hoisted(() => ({ + profiles: [] as Array<{ id: string; alias: string; agentletId: string }>, + connectedIds: [] as string[], +})); + +vi.mock('@agenetes/agentlet-host', () => ({ + getAgentProfileRegistry: () => ({ + getProfile: (profileId: string) => + host.profiles.find((profile) => profile.id === profileId), + listProfiles: () => host.profiles, + }), + getAgentletGateway: () => ({ + getAgentlets: () => host.connectedIds.map((agentletId) => ({ agentletId })), + }), +})); import { listAvailableAgentProfiles, requireAvailableAgentProfile, } from './selectable-agent-profile.js'; +afterEach(() => { + host.profiles = []; + host.connectedIds = []; +}); + describe('listAvailableAgentProfiles', () => { it('prepends Huabu and projects available Profile identities', () => { const profiles = new Map([ @@ -41,6 +62,19 @@ describe('listAvailableAgentProfiles', () => { ]); }); + it('projects only Profiles on currently connected Agentlets by default', () => { + host.profiles = [ + { id: 'online', alias: 'Online', agentletId: 'device-a' }, + { id: 'offline', alias: 'Offline', agentletId: 'device-b' }, + ]; + host.connectedIds = ['device-a']; + + expect(listAvailableAgentProfiles()).toEqual([ + { id: 'huabu', alias: 'Built-In Pi' }, + { id: 'online', alias: 'Online', default: true }, + ]); + }); + it('accepts the Huabu Profile without an external registry', () => { expect(() => requireAvailableAgentProfile('huabu', null)).not.toThrow(); }); diff --git a/apps/server/src/modules/agent/selectable-agent-profile.ts b/apps/server/src/modules/agent/selectable-agent-profile.ts index aabc27e89..8b69df24e 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.ts @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { getAgentProfileRegistry } from '@agenetes/agentlet-host'; +import { + getAgentProfileRegistry, + getAgentletGateway, +} from '@agenetes/agentlet-host'; import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared'; @@ -18,6 +21,25 @@ interface AgentProfileRegistryPort { listSelectableProfileIds(): string[]; } +function getConnectedProfileRegistry(): AgentProfileRegistryPort | null { + const registry = getAgentProfileRegistry(); + if (!registry) return null; + return { + getProfile: (profileId) => registry.getProfile(profileId), + listSelectableProfileIds: () => { + const connectedIds = new Set( + (getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? []).map( + (connection) => connection.agentletId, + ), + ); + return registry + .listProfiles() + .filter((profile) => connectedIds.has(profile.agentletId)) + .map((profile) => profile.id); + }, + }; +} + export interface AvailableAgentProfileSummary { id: string; alias: string; @@ -36,7 +58,7 @@ export class SelectableAgentProfileError extends Error { export function requireSelectableAgentProfile( profileId: string, - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): SelectableAgentProfile { if (!registry) { throw new SelectableAgentProfileError( @@ -60,20 +82,20 @@ export function requireSelectableAgentProfile( export function requireAvailableAgentProfile( profileId: string, - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): void { if (profileId === HUABU_AGENT_PROFILE_ID) return; requireSelectableAgentProfile(profileId, registry); } export function getFirstSelectableAgentProfileId( - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): string | null { return registry?.listSelectableProfileIds()[0] ?? null; } export function listAvailableAgentProfiles( - registry: AgentProfileRegistryPort | null = getAgentProfileRegistry(), + registry: AgentProfileRegistryPort | null = getConnectedProfileRegistry(), ): AvailableAgentProfileSummary[] { const defaultProfileId = getFirstSelectableAgentProfileId(registry); const huabu = { diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index b14e52c9b..2cabc6501 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -149,8 +149,12 @@ export const routes = { agentChangeReviewConfig: '/agent-change-review/config', // ── ACP (external agent bridge) ─────────────────────────────────── - acpAgentCli: (profileId?: string) => - `/acp/agent-cli${profileId ? `?profileId=${enc(profileId)}` : ''}`, + acpAgentCli: (target: { profileId?: string; agentletId?: string }) => { + const params = target.profileId + ? `profileId=${enc(target.profileId)}` + : `agentletId=${enc(target.agentletId ?? '')}`; + return `/acp/agent-cli?${params}`; + }, // Profiles (loopback-only) — user-managed spawn recipes. acpProfiles: '/acp/profiles', acpProfileLaunchPreview: '/acp/profile-launch-preview', diff --git a/apps/web/src/api/acp.test.ts b/apps/web/src/api/acp.test.ts index 26697c41d..a4841ea6d 100644 --- a/apps/web/src/api/acp.test.ts +++ b/apps/web/src/api/acp.test.ts @@ -18,19 +18,19 @@ describe('ACP Profile editing API', () => { it('routes discovery to the saved Profile and encodes its identity', async () => { const fetch = vi.fn().mockResolvedValue(new Response('{"agents":[]}')); vi.stubGlobal('fetch', fetch); - await listAcpAgentClis('remote/#1'); + await listAcpAgentClis({ profileId: 'remote/#1' }); expect(fetch).toHaveBeenCalledWith( expect.stringMatching(/\/api\/acp\/agent-cli\?profileId=remote%2F%231$/), expect.any(Object), ); }); - it('uses supervised-daemon discovery for creation', async () => { + it('routes creation discovery to the selected Agentlet', async () => { const fetch = vi.fn().mockResolvedValue(new Response('{"agents":[]}')); vi.stubGlobal('fetch', fetch); - await listAcpAgentClis(); + await listAcpAgentClis({ agentletId: 'device/#1' }); expect(fetch).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/acp\/agent-cli$/), + expect.stringMatching(/\/api\/acp\/agent-cli\?agentletId=device%2F%231$/), expect.any(Object), ); }); diff --git a/apps/web/src/api/acp.ts b/apps/web/src/api/acp.ts index 4b56cb1c9..f8965f44f 100644 --- a/apps/web/src/api/acp.ts +++ b/apps/web/src/api/acp.ts @@ -29,6 +29,7 @@ import { routes } from './_routes'; import type { AcpAgentCliListResponse, + AcpAgentCliQuery, AcpAgentletStatus, AcpAgentletStatusResponse, AcpPermissionDecisionRequest, @@ -55,6 +56,7 @@ import type { } from '@huabu/shared'; export type { + AcpAgentCliQuery, AcpAgentCliInfo, AcpAgentCliListResponse, AcpAgentProfile, @@ -84,6 +86,7 @@ export type { ConnectionTokenConfig, ConnectionTokenUpdate, AgentletConnectionCommandResponse, + ConnectedAgentletDevice, WarmAcpSessionRequest, WarmAcpSessionResponse, } from '@huabu/shared'; @@ -91,12 +94,12 @@ export type { // ── Agent CLI detection ────────────────────────────────────────────── /** - * Read the supervised daemon's catalogue, or the saved Profile's target daemon. + * Read the explicitly selected Agentlet's catalogue. */ export async function listAcpAgentClis( - profileId?: string, + target: AcpAgentCliQuery, ): Promise { - return apiFetch(routes.acpAgentCli(profileId), { + return apiFetch(routes.acpAgentCli(target), { fallbackMessage: 'Failed to detect installed agent CLIs', }); } @@ -111,7 +114,7 @@ export async function listAcpProfiles(): Promise { } /** - * Create a command Profile on the local agentlet. The server allocates its id. + * Create a command Profile on its explicitly selected Agentlet. */ export async function createAcpProfile( payload: CreateAcpProfileBody, diff --git a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx index 0140a3aaf..fc388e428 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx @@ -62,12 +62,18 @@ vi.mock('@/components/Common/Select', () => ({ value, options, onChange, + ariaLabel, }: { value: string; options: { value: string; label: string; disabled?: boolean }[]; onChange: (value: string) => void; + ariaLabel?: string; }) => ( - onChange(event.target.value)} + > {options.map((option) => (