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 a8b3aae5c..194dd8d08 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 @@ -16,6 +16,8 @@ vi.mock('@agenetes/agentlet-host', () => ({ getAgentlet: () => ({ status: 'connected' }), discoverHarnesses: mocks.discover, }), + resolveConnectedAgentletId: (target: string) => + target === 'legacy-host' ? 'remote-machine' : target, })); let app: FastifyInstance | undefined; @@ -119,7 +121,7 @@ describe('ACP agent CLI route', () => { }); it('queries the edited Profile machine and projects Custom for older catalogues', async () => { - mocks.getProfile.mockReturnValue({ agentletId: 'remote-machine' }); + mocks.getProfile.mockReturnValue({ agentletId: 'legacy-host' }); mocks.discover.mockResolvedValue({ harnesses: [] }); app = Fastify({ logger: false }); await app.register(createAcpAgentCliRoutes(), { prefix: '/api/acp' }); 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 24d053434..a173f3737 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.ts @@ -18,6 +18,7 @@ import { getAgentletGateway, getAgentProfileRegistry, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { CUSTOM_COMMAND_CAPABILITIES, @@ -46,8 +47,12 @@ async function detectAgentClis(target: { : undefined; 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 requestedAgentletId = profile?.agentletId ?? target.agentletId; + if (!requestedAgentletId) throw new Error('Agentlet target is required'); + const agentletId = profile + ? resolveConnectedAgentletId(requestedAgentletId) + : requestedAgentletId; + if (!agentletId) throw new Error('Agentlet is not connected'); const connection = gateway.getAgentlet(agentletId); if (connection?.status !== 'connected') throw new Error('Agentlet is not connected'); diff --git a/apps/server/src/modules/agent/acp/external-agent-realization.ts b/apps/server/src/modules/agent/acp/external-agent-realization.ts index 563117060..f10d864f8 100644 --- a/apps/server/src/modules/agent/acp/external-agent-realization.ts +++ b/apps/server/src/modules/agent/acp/external-agent-realization.ts @@ -6,6 +6,7 @@ import { ensureAcpSession, resolveAcpAgentletId, } from '@agenetes/acp-driver'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { canvasAcpNamespace } from '../../workspace/paths.js'; import { @@ -123,8 +124,10 @@ async function ensureSessionFromCanonicalSpec( ? { ...resolvedEnvironment, ...spec.spec.env } : undefined; const record = await agenetes.record(spec.namespace, spec.threadId); + const requestedAgentletId = resolveAcpAgentletId(spec); return ensureAcpSession({ - agentletId: resolveAcpAgentletId(spec), + agentletId: + resolveConnectedAgentletId(requestedAgentletId) ?? requestedAgentletId, threadId: spec.threadId, binding: spec.spec.binding, namespace: spec.namespace, diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts index e948a7029..3f3f935ef 100644 --- a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts +++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts @@ -63,7 +63,17 @@ function setup(initialMachines = ['machine-a']) { }), }; const gateway = { - getAgentlets: () => initialMachines.map((agentletId) => ({ agentletId })), + getAgentlets: () => + initialMachines.map((agentletId) => ({ + agentletId, + agentletProfile: { + machine: { + hostname: agentletId, + platform: 'linux', + arch: 'x64', + }, + }, + })), onAgentletsChanged: (handler: (event: Event) => void) => { listener = handler; return vi.fn(); @@ -116,6 +126,7 @@ describe('automatic ordinary Profile provisioning', () => { }, ); expect(context.profiles[0]).toMatchObject({ + alias: 'GitHub Copilot (machine-a: linux x64)', agentletId: 'machine-a', workingDirPath: '/home/user/.agentlet/workspace/copilot', launch: { kind: 'acp-command', command: 'copilot --acp' }, @@ -277,7 +288,7 @@ describe('automatic ordinary Profile provisioning', () => { const registry = createAgentProfileRegistry({ storageDir }); const legacy = registry.createProfile({ launchKind: 'acp-command', - alias: 'GitHub Copilot (machine-a)', + alias: 'GitHub Copilot (machine-a: linux x64)', agentletId: 'machine-a', workingDirPath: '/custom/work', command: 'copilot --acp --model old-model', @@ -328,7 +339,7 @@ describe('automatic ordinary Profile provisioning', () => { .listProfiles() .find((profile) => profile.launch.kind === 'acp-harness'); expect(typed).toMatchObject({ - alias: 'GitHub Copilot (machine-a) [copilot]', + alias: 'GitHub Copilot (machine-a: linux x64) [copilot]', workingDirPath: observation.harnesses[0].workingDirPath, launch: { kind: 'acp-harness', diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts index 3271b5c20..12c2133d8 100644 --- a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts +++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts @@ -3,6 +3,8 @@ import { CUSTOM_COMMAND_WRAPPER_ID } from '@agentlet/protocol'; +import { formatAgentletDeviceDisplayName } from '../agentlet-device-display.js'; + import type { AgentProfile, AgentProfileRegistry, @@ -76,7 +78,16 @@ export function mergeProfileCustomData( } interface DiscoveryGateway { - getAgentlets(filter: { status: 'connected' }): Array<{ agentletId: string }>; + getAgentlets(filter: { status: 'connected' }): Array<{ + agentletId: string; + agentletProfile?: { + machine?: { + hostname?: string; + platform?: string; + arch?: string; + }; + }; + }>; onAgentletsChanged( handler: (event: { agentletId: string; @@ -149,7 +160,12 @@ export function registerHarnessProfileDiscovery({ ); }); if (existing) continue; - const defaultAlias = `${harness.displayName} (${agentletId})`; + const connection = gateway + .getAgentlets({ status: 'connected' }) + .find((candidate) => candidate.agentletId === agentletId); + const defaultAlias = `${harness.displayName} (${formatAgentletDeviceDisplayName( + connection?.agentletProfile?.machine, + )})`; const common = { agentletId, alias: 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 dab86c012..ed5032b9a 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.test.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts @@ -51,6 +51,13 @@ vi.mock('@agenetes/agentlet-host', () => ({ }, })), }), + resolveConnectedAgentletId: (target: string) => { + if (mocks.connectedIds.has(target)) return target; + const matches = [...mocks.connectedIds].filter( + (agentletId) => `${agentletId}-host` === target, + ); + return matches.length === 1 ? matches[0] : undefined; + }, })); vi.mock('./profile-schema-cache.js', () => ({ @@ -190,6 +197,7 @@ describe('ordinary Profile catalog routes', () => { connectedDevices: [ expect.objectContaining({ agentletId: 'machine-a', + displayName: 'machine-a-host: linux x64', profileCount: 1, }), expect.objectContaining({ @@ -198,11 +206,39 @@ describe('ordinary Profile catalog routes', () => { }), ], }); + expect(mocks.registry.createProfile).not.toHaveBeenCalled(); expect(mocks.initializeDefaults).not.toHaveBeenCalled(); expect(mocks.discoverHarnesses).not.toHaveBeenCalled(); }); + it('maps one hostname-era Profile to the unique connected device', async () => { + const legacyProfile = { + ...commandProfile, + id: 'legacy-command', + agentletId: 'machine-a-host', + }; + mocks.registry.listProfiles.mockReturnValue([legacyProfile]); + const server = await setup(); + const response = await server.inject('/api/acp/profiles'); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + profiles: [legacyProfile], + selectableProfileIds: ['legacy-command'], + connectedDevices: [ + expect.objectContaining({ + agentletId: 'machine-a', + profileCount: 1, + }), + expect.objectContaining({ + agentletId: 'remote-machine', + profileCount: 0, + }), + ], + }); + }); + it('rejects caller-created automatic provenance', async () => { const server = await setup(); const response = await server.inject({ diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts index 9e4fe6d3d..0386a0a76 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.ts @@ -29,6 +29,7 @@ import { getAgentProfileRegistry, getAgentletGateway, getDaemonSupervisor, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { @@ -48,6 +49,7 @@ import { getAgentDefaults, initializeAgentDefaults, } from '../agent-defaults.js'; +import { formatAgentletDeviceDisplayName } from '../agentlet-device-display.js'; import type { AgentletConnection } from '@agenetes/agentlet-host'; import type { @@ -88,7 +90,10 @@ async function validateHarnessLaunch( try { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - const result = await gateway.discoverHarnesses(agentletId, { + const resolvedAgentletId = resolveConnectedAgentletId(agentletId); + if (!resolvedAgentletId) + throw new Error('The selected Agentlet is not connected'); + const result = await gateway.discoverHarnesses(resolvedAgentletId, { prepareWorkspaces: false, }); const harness = result.harnesses.find( @@ -117,7 +122,7 @@ async function validateHarnessLaunch( }); return false; } - await gateway.buildHarnessLaunch(agentletId, { launch }); + await gateway.buildHarnessLaunch(resolvedAgentletId, { launch }); return true; } catch (error) { request.log.warn( @@ -156,7 +161,9 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { try { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - const agentletId = profile?.agentletId ?? parsed.data.agentletId; + const agentletId = profile + ? resolveConnectedAgentletId(profile.agentletId) + : parsed.data.agentletId; if (!agentletId || !isAgentletConnected(agentletId)) { return reply.status(409).send({ code: 'agentlet_unavailable', @@ -190,19 +197,18 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { } 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 agentletId = resolveConnectedAgentletId(profile.agentletId); + if (!agentletId) continue; + profileCounts.set(agentletId, (profileCounts.get(agentletId) ?? 0) + 1); } const connectedDevices = connected .map((connection) => ({ agentletId: connection.agentletId, + displayName: formatAgentletDeviceDisplayName( + connection.agentletProfile?.machine, + ), ...(connection.agentletProfile?.machine?.hostname ? { hostname: connection.agentletProfile.machine.hostname } : {}), @@ -225,7 +231,7 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { return { profiles, selectableProfileIds: profiles - .filter((profile) => connectedIds.has(profile.agentletId)) + .filter((profile) => resolveConnectedAgentletId(profile.agentletId)) .map((profile) => profile.id), connectedDevices, agentlet: getDaemonSupervisor().getStatus(), 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 66dfbc803..9c2b3a8ea 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,6 +29,11 @@ vi.mock('@agenetes/acp-driver', () => ({ }, })); +vi.mock('@agenetes/agentlet-host', () => ({ + resolveConnectedAgentletId: (agentletId: string) => + agentletId === 'legacy-host' ? 'device-uuid' : agentletId, +})); + vi.mock('./external-agent-realization.js', () => ({ externalAgentRealization: { realize: vi.fn(), ensureSession: vi.fn() }, realizationHttpError: () => ({ @@ -115,6 +120,7 @@ describe('ACP cached-meta across awaited persistence', () => { usage: null, metaUpdatedAt: 32, }); + const server = await createApp(); const response = await server.inject({ @@ -135,6 +141,30 @@ describe('ACP cached-meta across awaited persistence', () => { }); }); + it('finds a live session through unique hostname-era placement resolution', async () => { + mocks.record = externalRecord('legacy-host'); + mocks.live.set('device-uuid\u0000thread-1', { + availableCommands: [{ name: 'review', description: 'Review changes' }], + commandsUpdatedAt: 5, + availableModes: [], + currentModeId: null, + availableModels: [], + currentModelId: null, + configOptions: [], + selections: {}, + sessionInfo: null, + usage: null, + metaUpdatedAt: 6, + }); + const response = await (await createApp()).inject(CACHED_META_URL); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + source: 'thread', + availableCommands: [{ name: 'review' }], + }); + }); + it('does not invent a placement for a thread with no record', async () => { mocks.live.set('agentlet-supervised\u0000thread-1', { availableCommands: [], diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts index df778ecb0..1950b9cbe 100644 --- a/apps/server/src/modules/agent/acp/threads.route.ts +++ b/apps/server/src/modules/agent/acp/threads.route.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { acpSessionRegistry } from '@agenetes/acp-driver'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { acpPermissionDecisionSchema, @@ -151,7 +152,8 @@ async function resolveThreadAgentletId( typeof driverSpec === 'object' && typeof (driverSpec as { agentletId?: unknown }).agentletId === 'string' ) { - return (driverSpec as { agentletId: string }).agentletId; + const agentletId = (driverSpec as { agentletId: string }).agentletId; + return resolveConnectedAgentletId(agentletId) ?? agentletId; } } return undefined; diff --git a/apps/server/src/modules/agent/agent-defaults.route.test.ts b/apps/server/src/modules/agent/agent-defaults.route.test.ts index 7a98899d1..7832a8512 100644 --- a/apps/server/src/modules/agent/agent-defaults.route.test.ts +++ b/apps/server/src/modules/agent/agent-defaults.route.test.ts @@ -26,7 +26,8 @@ vi.mock('./agent-defaults.js', () => ({ vi.mock('@agenetes/agentlet-host', () => ({ getAgentProfileRegistry: () => mocks.registryReady ? { getProfile: mocks.getProfile } : null, - getAgentletGateway: () => ({ getAgentlet: mocks.getAgentlet }), + resolveConnectedAgentletId: (target: string) => + mocks.getAgentlet(target)?.status === 'connected' ? target : undefined, })); vi.mock('./acp/profile-schema-cache.js', () => ({ getProfileSchemaCache: mocks.getCache, diff --git a/apps/server/src/modules/agent/agent-defaults.route.ts b/apps/server/src/modules/agent/agent-defaults.route.ts index 49d537e35..9412298bd 100644 --- a/apps/server/src/modules/agent/agent-defaults.route.ts +++ b/apps/server/src/modules/agent/agent-defaults.route.ts @@ -3,7 +3,7 @@ import { getAgentProfileRegistry, - getAgentletGateway, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { @@ -60,8 +60,7 @@ function projectDefaults(defaults: AgentDefaults): AgentDefaultsResponse { ? 'offline' : !profile ? 'deleted' - : getAgentletGateway()?.getAgentlet(profile.agentletId)?.status === - 'connected' + : resolveConnectedAgentletId(profile.agentletId) ? 'available' : 'offline', // Missing observations and editable CLI labels do not prove lack of support. diff --git a/apps/server/src/modules/agent/agent-defaults.test.ts b/apps/server/src/modules/agent/agent-defaults.test.ts index f900a4d99..0610c7cda 100644 --- a/apps/server/src/modules/agent/agent-defaults.test.ts +++ b/apps/server/src/modules/agent/agent-defaults.test.ts @@ -12,11 +12,8 @@ import { import type { AgentDefaults, AgentProfileView } from '@huabu/shared'; vi.mock('@agenetes/agentlet-host', () => ({ - getAgentletGateway: () => ({ - getAgentlet: (id: string) => ({ - status: id === 'connected' ? 'connected' : 'disconnected', - }), - }), + resolveConnectedAgentletId: (id: string) => + id === 'connected' || id === 'legacy-host' ? 'connected' : undefined, })); function profile( @@ -59,6 +56,19 @@ describe('installation Agent defaults', () => { } }); + it('offers a uniquely resolved hostname-era candidate to initialization', () => { + const initialize = vi + .spyOn(AgentDefaultsService.prototype, 'initializeAgentDefaults') + .mockReturnValue({ profileId: null, functionalModel: '' }); + try { + const legacy = profile('legacy', 'legacy-host'); + initializeAgentDefaults([legacy]); + expect(initialize).toHaveBeenCalledWith([legacy]); + } finally { + initialize.mockRestore(); + } + }); + it('requires an explicit external default instead of falling back to Huabu', () => { const { service, storage } = setup(); expect(() => service.requireDefaultAgentProfileId()).toThrow( diff --git a/apps/server/src/modules/agent/agent-defaults.ts b/apps/server/src/modules/agent/agent-defaults.ts index 3d5c3af62..0accc5686 100644 --- a/apps/server/src/modules/agent/agent-defaults.ts +++ b/apps/server/src/modules/agent/agent-defaults.ts @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { getAgentletGateway } from '@agenetes/agentlet-host'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { agentDefaultsSchema } from '@huabu/shared'; @@ -134,9 +134,7 @@ export const initializeAgentDefaults = ( profiles: readonly AgentProfileView[], ): AgentDefaults => service.initializeAgentDefaults( - profiles.filter( - (profile) => - getAgentletGateway()?.getAgentlet(profile.agentletId)?.status === - 'connected', + profiles.filter((profile) => + resolveConnectedAgentletId(profile.agentletId), ), ); diff --git a/apps/server/src/modules/agent/agentlet-device-display.test.ts b/apps/server/src/modules/agent/agentlet-device-display.test.ts new file mode 100644 index 000000000..a1ed05980 --- /dev/null +++ b/apps/server/src/modules/agent/agentlet-device-display.test.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { formatAgentletDeviceDisplayName } from './agentlet-device-display.js'; + +describe('formatAgentletDeviceDisplayName', () => { + it('combines hostname and environment without exposing device identity', () => { + expect( + formatAgentletDeviceDisplayName({ + hostname: 'huabu', + platform: 'linux', + arch: 'x64', + }), + ).toBe('huabu: linux x64'); + }); + + it('omits missing metadata and retains a non-identity fallback', () => { + expect(formatAgentletDeviceDisplayName({ hostname: 'huabu' })).toBe( + 'huabu', + ); + expect( + formatAgentletDeviceDisplayName({ platform: 'linux', arch: 'arm64' }), + ).toBe('Agentlet: linux arm64'); + expect(formatAgentletDeviceDisplayName()).toBe('Agentlet'); + }); +}); diff --git a/apps/server/src/modules/agent/agentlet-device-display.ts b/apps/server/src/modules/agent/agentlet-device-display.ts new file mode 100644 index 000000000..e67aa270a --- /dev/null +++ b/apps/server/src/modules/agent/agentlet-device-display.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +interface AgentletMachineDisplayMetadata { + hostname?: string; + platform?: string; + arch?: string; +} + +export function formatAgentletDeviceDisplayName( + machine?: AgentletMachineDisplayMetadata, +): string { + const hostname = machine?.hostname?.trim() || 'Agentlet'; + const environment = [machine?.platform, machine?.arch] + .map((value) => value?.trim()) + .filter(Boolean) + .join(' '); + return environment ? `${hostname}: ${environment}` : hostname; +} 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 0f649404f..0ce944a7c 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.test.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.test.ts @@ -14,9 +14,13 @@ vi.mock('@agenetes/agentlet-host', () => ({ host.profiles.find((profile) => profile.id === profileId), listProfiles: () => host.profiles, }), - getAgentletGateway: () => ({ - getAgentlets: () => host.connectedIds.map((agentletId) => ({ agentletId })), - }), + resolveConnectedAgentletId: (target: string) => { + if (host.connectedIds.includes(target)) return target; + const matches = host.connectedIds.filter( + (agentletId) => `${agentletId}-host` === target, + ); + return matches.length === 1 ? matches[0] : undefined; + }, })); import { @@ -75,6 +79,18 @@ describe('listAvailableAgentProfiles', () => { ]); }); + it('projects a hostname-era Profile when exactly one device reports that hostname', () => { + host.profiles = [ + { id: 'legacy', alias: 'Legacy', agentletId: 'device-a-host' }, + ]; + host.connectedIds = ['device-a']; + + expect(listAvailableAgentProfiles()).toEqual([ + { id: 'huabu', alias: 'Built-In Pi' }, + { id: 'legacy', alias: 'Legacy', 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 8b69df24e..a67e1d315 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.ts @@ -3,7 +3,7 @@ import { getAgentProfileRegistry, - getAgentletGateway, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared'; @@ -26,17 +26,11 @@ function getConnectedProfileRegistry(): AgentProfileRegistryPort | null { 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 + listSelectableProfileIds: () => + registry .listProfiles() - .filter((profile) => connectedIds.has(profile.agentletId)) - .map((profile) => profile.id); - }, + .filter((profile) => resolveConnectedAgentletId(profile.agentletId)) + .map((profile) => profile.id), }; } 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 fc388e428..9df45744c 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx @@ -13,6 +13,7 @@ import type { AcpAgentCliInfo, AcpProfileLaunchPreviewResponse, AgentProfileView, + ConnectedAgentletDevice, } from '@huabu/shared'; declare global { @@ -65,7 +66,12 @@ vi.mock('@/components/Common/Select', () => ({ ariaLabel, }: { value: string; - options: { value: string; label: string; disabled?: boolean }[]; + options: { + value: string; + label: string; + description?: string; + disabled?: boolean; + }[]; onChange: (value: string) => void; ariaLabel?: string; }) => ( @@ -82,6 +88,7 @@ vi.mock('@/components/Common/Select', () => ({ disabled={option.disabled} > {option.label} + {option.description ? ` (${option.description})` : ''} ))} @@ -148,6 +155,18 @@ function renderEditor( editing?: AgentProfileView, clis = agents, loaded = true, + connectedDevices: ConnectedAgentletDevice[] = [ + { + agentletId: editing?.agentletId ?? 'device-1', + displayName: 'Test device: linux x64', + hostname: 'Test device', + platform: 'linux', + arch: 'x64', + version: '1.0.0', + connectedAt: '2026-01-01T00:00:00.000Z', + profileCount: 0, + }, + ], ) { if (!container) { container = document.createElement('div'); @@ -162,17 +181,7 @@ function renderEditor( : ({ mode: 'create' } as const))} detectedClis={clis} detectionLoaded={loaded} - connectedDevices={[ - { - agentletId: editing?.agentletId ?? 'device-1', - hostname: 'Test device', - platform: 'linux', - arch: 'x64', - version: '1.0.0', - connectedAt: '2026-01-01T00:00:00.000Z', - profileCount: 0, - }, - ]} + connectedDevices={connectedDevices} agentletId={editing?.agentletId ?? 'device-1'} onAgentletChange={vi.fn()} onClose={onClose} @@ -256,9 +265,40 @@ describe('AgentProfileEditor', () => { alias: 'Renamed', customData: legacy.customData, }); + expect(api.preview).not.toHaveBeenCalled(); }); + it('renders human-readable device labels while retaining UUID identity details', () => { + renderEditor(); + const machine = container?.querySelector( + 'select[aria-label="settings.profileMachine"]', + ); + expect(machine?.selectedOptions[0]?.textContent).toContain( + 'Test device: linux x64', + ); + expect(machine?.selectedOptions[0]?.textContent).toContain('device-1'); + }); + + it('maps a hostname-era Profile to the unique connected device label', () => { + renderEditor({ ...legacy, agentletId: 'legacy-host' }, agents, true, [ + { + agentletId: 'device-uuid', + displayName: 'legacy-host: linux x64', + hostname: 'legacy-host', + platform: 'linux', + arch: 'x64', + version: '1.0.0', + connectedAt: '2026-01-01T00:00:00.000Z', + profileCount: 1, + }, + ]); + + expect(container?.textContent).toContain( + 'legacy-host: linux x64 (device-uuid)', + ); + }); + it('edits custom command and cwd without changing wrapper, machine, metadata, or custom data', async () => { renderEditor({ ...legacy, revision: 9 }); input('settings.launchCommand', 'new-agent --custom'); diff --git a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx index 752e2d8f2..107be07be 100644 --- a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx +++ b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx @@ -75,6 +75,18 @@ function displayNameFor(agent: string, cwd: string) { return folder ? `${agent} (${folder})` : agent; } +function resolveConnectedDevice( + target: string, + devices: ConnectedAgentletDevice[], +): ConnectedAgentletDevice | undefined { + const exact = devices.find((device) => device.agentletId === target); + if (exact) return exact; + const hostnameMatches = devices.filter( + (device) => device.hostname === target, + ); + return hostnameMatches.length === 1 ? hostnameMatches[0] : undefined; +} + /** One capability-driven form for both structured and raw-command Profiles. */ export function CommandProfileForm({ editing, @@ -200,6 +212,12 @@ export function CommandProfileForm({ })), { value: 'custom', label: t('settings.customCommand') }, ]; + const editingDevice = editing + ? resolveConnectedDevice(editing.agentletId, connectedDevices) + : undefined; + const editingDeviceLabel = editingDevice + ? `${editingDevice.displayName} (${editingDevice.agentletId})` + : `${t('settings.agentUnavailable')} (${editing?.agentletId ?? ''})`; async function save() { if (saveDisabled) return; @@ -248,14 +266,14 @@ export function CommandProfileForm({ {editing ? ( - + ) : (