diff --git a/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js b/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js index 8b424b77b8..330de9ddd8 100644 --- a/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js +++ b/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js @@ -225,6 +225,43 @@ describe('pod agents lastActiveAt derivation (#915)', () => { expect(res.body.agents[0].connection.lastSeenAt).toEqual(expect.any(String)); }); + it('projects a daemon-bound seat as a wrapper even when its install label is not wrapper', async () => { + AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation({ + agentName: 'local', config: new Map([['runtime', { runtimeType: 'claude-code' }]]), + })]); + User.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{ + _id: 'bot-1', username: 'local-default', botMetadata: { machineId: 'machine-1' }, agentRuntimeTokens: [], + }]) }) }); + // The runtime key is aged past the 5-minute window, so only the live machine can make this seat connected. + AgentCredential.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([ + { kind: 'runtime', agentUserId: 'bot-1', status: 'active', lastUsedAt: new Date(Date.now() - 10 * 60 * 1000) }, + { kind: 'daemon', machineId: 'machine-1', status: 'active' }, + ]) }) }); + Machine.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{ + machineId: 'machine-1', lastSeenAt: new Date(), + }]) }) }); + const res = await request(app).get(`/api/registry/pods/${POD_ID}/agents`); + expect(res.status).toBe(200); + expect(res.body.agents[0].connection.state).toBe('connected'); + expect(res.body.agents[0].connection.lastSeenAt).toEqual(expect.any(String)); + expect(res.body.agents[0].runtime.runtimeType).toBe('wrapper'); + expect(res.body.agents[0].runtime.adapter).toBe('claude-code'); + }); + + it('reports a daemon-bound seat with no live signal as never connected, not missing', async () => { + AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation({ + agentName: 'local', config: new Map([['runtime', { runtimeType: 'claude-code' }]]), + })]); + User.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{ + _id: 'bot-1', username: 'local-default', botMetadata: { machineId: 'machine-1' }, agentRuntimeTokens: [], + }]) }) }); + AgentCredential.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }) }); + Machine.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }) }); + const res = await request(app).get(`/api/registry/pods/${POD_ID}/agents`); + expect(res.status).toBe(200); + expect(res.body.agents[0].connection).toEqual({ state: 'never', lastSeenAt: null }); + }); + it('surfaces AgentRun activity for native agents with no heartbeats and no tokens', async () => { AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation()]); AgentRun.aggregate.mockResolvedValue([ diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts index 8f809dc5a3..15802ca223 100644 --- a/backend/routes/registry/pod-agents.ts +++ b/backend/routes/registry/pod-agents.ts @@ -381,16 +381,32 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => { // Legacy local-cli installs are normalized to their harness name // (codex/claude-code) in the public payload. Host, not that label, // identifies a BYO listener whose credential use proves liveness. + // A seat bound to a daemon machine is a wrapper whatever its install + // label says: manage-settings projects it as `wrapper` for the same + // reason. Without this, a running daemon-managed seat carried no + // connection and Settings rendered "Connection status unavailable". + // The runtime type is projected as `wrapper` too: Settings keys its + // machine-key action and Reconnect link on that field, not on connection. + const machineId = user?.botMetadata?.machineId; + const daemonBound = Boolean(machineId); + // The harness label moves to `adapter`, so the chat tag keeps naming it. + const seatPayload = daemonBound + ? { ...payload, runtime: { + ...payload?.runtime, + runtimeType: 'wrapper', + adapter: payload?.runtime?.adapter || payload?.runtime?.runtimeType, + } } + : payload; const hasRuntimeConnection = payload?.runtime?.host === 'byo' || runtimeType === 'wrapper' + || daemonBound || (runtimeType === 'webhook' && !payload?.runtime?.host); if (!connectionAvailable || !user || !hasRuntimeConnection - || normalizeConfigMap(i.config)?.runtime?.webhookUrl) return payload; - const machineId = user.botMetadata?.machineId; + || normalizeConfigMap(i.config)?.runtime?.webhookUrl) return seatPayload; return { - ...payload, + ...seatPayload, connection: deriveAgentConnection({ - runtimeType, + runtimeType: daemonBound ? 'wrapper' : runtimeType, credentials: credentialByAgent.get(String(user._id)) || [], machineCredentials: credentialByMachine.get(String(machineId)) || [], embeddedTokens: [...(user.agentRuntimeTokens || []), ...(i.runtimeTokens || [])],