From b4bc53d7642511d4bb58979f411566fbd63d51ae Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:46:50 -0700 Subject: [PATCH 1/2] feat(team): surface unverifiable live seats --- .../registry.last-message-snippet.test.js | 18 +++++++++ .../agentStateService.activity.test.js | 36 ++++++++++++++++- backend/routes/registry/helpers.ts | 13 ++++++- backend/services/agentStateService.ts | 39 +++++++++++++++++++ frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + .../src/v2/__tests__/V2YourTeamTiers.test.tsx | 18 ++++++++- frontend/src/v2/components/V2YourTeamPage.tsx | 15 ++++++- 8 files changed, 135 insertions(+), 6 deletions(-) diff --git a/backend/__tests__/unit/routes/registry.last-message-snippet.test.js b/backend/__tests__/unit/routes/registry.last-message-snippet.test.js index 28ff528e3..53b396526 100644 --- a/backend/__tests__/unit/routes/registry.last-message-snippet.test.js +++ b/backend/__tests__/unit/routes/registry.last-message-snippet.test.js @@ -29,4 +29,22 @@ describe('agent listing last-message snippet', () => { expect(buildAgentInstallationPayload(install).lastMessage).toBeNull(); expect(buildAgentInstallationPayload(install, { lastMessage: { content: ' \n ' } }).lastMessage).toBeNull(); }); + + test('payload carries the output state instead of inferring it in a roster client', () => { + const now = Date.now(); + const p = buildAgentInstallationPayload(install, { + lastActiveAt: new Date(now - 1000), + lastMessage: { content: 'A reply from earlier today.', createdAt: new Date(now - 31 * 60 * 1000) }, + }); + expect(p.outputState).toBe('unverifiable'); + }); + + test('blank message content does not count as observed output', () => { + const p = buildAgentInstallationPayload(install, { + lastActiveAt: new Date(), + lastMessage: { content: ' \n ', createdAt: new Date() }, + }); + expect(p.lastMessage).toBeNull(); + expect(p.outputState).toBe('unverifiable'); + }); }); diff --git a/backend/__tests__/unit/services/agentStateService.activity.test.js b/backend/__tests__/unit/services/agentStateService.activity.test.js index 0aca1dc1e..afee459f2 100644 --- a/backend/__tests__/unit/services/agentStateService.activity.test.js +++ b/backend/__tests__/unit/services/agentStateService.activity.test.js @@ -5,7 +5,11 @@ * replaced the #891 honesty-rules suite — which is exactly why the clobber * it should have caught shipped (2026-08-13 live incident). */ -const { deriveActivityBucket } = require('../../../services/agentStateService'); +const { + deriveActivityBucket, + deriveAgentOutputState, + OUTPUT_VERIFICATION_WINDOW_MS, +} = require('../../../services/agentStateService'); const minutesAgo = (m) => new Date(Date.now() - m * 60 * 1000); @@ -31,3 +35,33 @@ describe('deriveActivityBucket', () => { expect(deriveActivityBucket(minutesAgo(60 * 25), 'webhook')).toBe('stale'); }); }); + +describe('deriveAgentOutputState', () => { + const NOW = 1700000000000; + + test('calls a recently alive seat with no recent message UNVERIFIABLE, not quiet', () => { + expect(deriveAgentOutputState( + new Date(NOW - 2 * 60 * 1000), + new Date(NOW - OUTPUT_VERIFICATION_WINDOW_MS - 1), + NOW, + )).toBe('unverifiable'); + expect(deriveAgentOutputState(new Date(NOW - 2 * 60 * 1000), null, NOW)).toBe('unverifiable'); + }); + + test('a recent persisted message verifies output even when another liveness source is delayed', () => { + expect(deriveAgentOutputState( + new Date(NOW - 2 * OUTPUT_VERIFICATION_WINDOW_MS), + new Date(NOW - 1), + NOW, + )).toBe('observed'); + }); + + test('distinguishes a quiet seat and a seat with no liveness evidence', () => { + expect(deriveAgentOutputState( + new Date(NOW - OUTPUT_VERIFICATION_WINDOW_MS - 1), + new Date(NOW - OUTPUT_VERIFICATION_WINDOW_MS - 1), + NOW, + )).toBe('quiet'); + expect(deriveAgentOutputState(null, null, NOW)).toBe('unknown'); + }); +}); diff --git a/backend/routes/registry/helpers.ts b/backend/routes/registry/helpers.ts index 4e2f34f16..91c5d8838 100644 --- a/backend/routes/registry/helpers.ts +++ b/backend/routes/registry/helpers.ts @@ -3,6 +3,7 @@ const User = require('../../models/User'); const Gateway = require('../../models/Gateway'); const { AgentInstallation } = require('../../models/AgentRegistry'); const { isK8sMode } = require('../../services/agentProvisionerService'); +const { deriveAgentOutputState } = require('../../services/agentStateService'); const AgentIdentityService = require('../../services/agentIdentityService').default; const { PRESET_DEFINITIONS } = require('./presets'); @@ -409,6 +410,7 @@ const buildAgentInstallationPayload = (installation: any, { const displayName = profileDisplayName || installationDisplayName || (user ? resolveDisplayLabelFromUser(user, identityFallback) : identityFallback); + const lastMessageSnippet = toSnippet(lastMessage?.content); return { name: installation.agentName, instanceId: installation.instanceId || 'default', @@ -429,9 +431,16 @@ const buildAgentInstallationPayload = (installation: any, { // Wren spec §1.1 line 2: what the agent last said, pre-trimmed. Null when // it has never spoken in this pod (or the PG lookup was skipped/failed — // the roster never fails over a snippet). - lastMessage: lastMessage && toSnippet(lastMessage.content) - ? { snippet: toSnippet(lastMessage.content), at: lastMessage.createdAt || null } + lastMessage: lastMessageSnippet + ? { snippet: lastMessageSnippet, at: lastMessage?.createdAt || null } : null, + // Heartbeats, token use, and runs prove the seat is alive; only a message + // proves it produced visible output. Do not collapse a live-but-silent + // seat into the roster's quiet state (#TASK-113). + outputState: deriveAgentOutputState( + lastActiveAt || lastHeartbeatAt, + lastMessageSnippet ? lastMessage?.createdAt || null : null, + ), installedBy: installation.installedBy?.toString?.() || installation.installedBy, runtime: runtimeConfig, // Resolved at the boundary so the frontend doesn't need to know diff --git a/backend/services/agentStateService.ts b/backend/services/agentStateService.ts index aff6e5371..a7091e6b3 100644 --- a/backend/services/agentStateService.ts +++ b/backend/services/agentStateService.ts @@ -119,6 +119,44 @@ export function deriveAgentState( const ACTIVE_WINDOW_MS = 10 * 60 * 1000; // active within 10 minutes const STALE_AFTER_MS = 24 * 60 * 60 * 1000; // stale after a silent day +// A recent liveness signal only proves that the runtime is alive. It does not +// prove that the seat produced a visible result for its human. Keep that +// distinction explicit on the roster: a live seat with no message in this +// window is *unverifiable*, not quietly productive. +export const OUTPUT_VERIFICATION_WINDOW_MS = 30 * 60 * 1000; +export type AgentOutputState = 'observed' | 'unverifiable' | 'quiet' | 'unknown'; + +const timestampOf = (value: Date | string | null | undefined): number | null => { + if (!value) return null; + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +}; + +/** + * Distinguishes a recent runtime proof-of-life from a recent visible reply. + * + * `lastActiveAt` is deliberately not treated as output: it may come from a + * heartbeat, runtime-token use, or AgentRun. Conversely, a persisted message + * is direct proof that the agent did produce output, even if one of those + * liveness collectors is behind. The caller owns the records; this pure seam + * owns the 30-minute interpretation so roster consumers cannot drift. + */ +export const deriveAgentOutputState = ( + lastActiveAt: Date | string | null | undefined, + lastMessageAt: Date | string | null | undefined, + now: number = Date.now(), +): AgentOutputState => { + const lastMessage = timestampOf(lastMessageAt); + if (lastMessage !== null && now - lastMessage <= OUTPUT_VERIFICATION_WINDOW_MS) { + return 'observed'; + } + + const lastActive = timestampOf(lastActiveAt); + if (lastActive === null) return 'unknown'; + if (now - lastActive <= OUTPUT_VERIFICATION_WINDOW_MS) return 'unverifiable'; + return 'quiet'; +}; + export type AgentActivityBucket = 'active' | 'idle' | 'stale' | 'ready' | 'never-connected'; interface ActivityInstallationLike { @@ -255,6 +293,7 @@ export const deriveActivityBucket = ( export default { deriveAgentState, AGENT_LISTENING_STALE_MS, collectPodAgentActivity, deriveActivityBucket, + deriveAgentOutputState, OUTPUT_VERIFICATION_WINDOW_MS, }; // CJS compat: let require() return the default export directly // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 69cf9df64..9390a0e0f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1712,6 +1712,7 @@ "card": { "inProject": "in", "lastSaid": "“{{snippet}}”", + "outputUnverifiable": "UNVERIFIABLE — no message in the last 30 minutes", "roleTitle": "Role: {{role}}", "profile": "Profile", "viewProfileAria": "View {{name}}'s profile", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 87fd2b001..26c6ac3d4 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1706,6 +1706,7 @@ "card": { "inProject": "属于", "lastSaid": "“{{snippet}}”", + "outputUnverifiable": "无法验证——过去 30 分钟内没有消息", "roleTitle": "角色:{{role}}", "profile": "资料页", "viewProfileAria": "查看 {{name}} 的资料页", diff --git a/frontend/src/v2/__tests__/V2YourTeamTiers.test.tsx b/frontend/src/v2/__tests__/V2YourTeamTiers.test.tsx index 308c33c08..d10a25f83 100644 --- a/frontend/src/v2/__tests__/V2YourTeamTiers.test.tsx +++ b/frontend/src/v2/__tests__/V2YourTeamTiers.test.tsx @@ -55,10 +55,10 @@ const agents = [ { name: 'smoke-widget', instanceId: 'default', displayName: 'Smoke Widget', lastActiveAt: minutesAgo(3 * 24 * 60) }, ]; -const renderPage = () => { +const renderPage = (agentRows = agents) => { axios.get.mockImplementation((url) => { if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Workspace' }] }); - if (url.startsWith('/api/registry/pods/p1/agents')) return Promise.resolve({ data: { agents } }); + if (url.startsWith('/api/registry/pods/p1/agents')) return Promise.resolve({ data: { agents: agentRows } }); return Promise.resolve({ data: {} }); }); return render( @@ -88,6 +88,20 @@ describe('Your Team tiers', () => { expect(featured[0]).toHaveTextContent('“Shipped the fix to main.”'); }); + test('a live seat without recent output says UNVERIFIABLE instead of reading as quiet', async () => { + renderPage([ + ...agents, + { + name: 'silent-but-live', instanceId: 'default', displayName: 'Silent but live', + lastActiveAt: minutesAgo(2), outputState: 'unverifiable', + }, + ]); + + const card = (await screen.findByText('Silent but live')).closest('.v2-team-feature'); + expect(card).toHaveTextContent('UNVERIFIABLE — no message in the last 30 minutes'); + expect(card).not.toHaveTextContent('Quiet'); + }); + test('standard cards carry the always-visible talk icon, no dot, no button pair', async () => { renderPage(); await waitFor(() => expect(screen.getByText('Sage')).toBeInTheDocument()); diff --git a/frontend/src/v2/components/V2YourTeamPage.tsx b/frontend/src/v2/components/V2YourTeamPage.tsx index d96ab597e..1bf84e854 100644 --- a/frontend/src/v2/components/V2YourTeamPage.tsx +++ b/frontend/src/v2/components/V2YourTeamPage.tsx @@ -33,6 +33,9 @@ interface AgentInstallationSummary { internal?: boolean; // What the agent last said in this pod, pre-trimmed by the server. lastMessage?: { snippet?: string; at?: string | null } | null; + // Runtime liveness and visible output are distinct facts. A recent live + // seat with no message in 30 minutes is intentionally not called quiet. + outputState?: 'observed' | 'unverifiable' | 'quiet' | 'unknown'; } // Runtime labels removed from cards 2026-08-22: ADR-022 D1 (ratified) bans @@ -341,7 +344,11 @@ const V2YourTeamPage: React.FC = () => { {display} - {a.lastMessage?.snippet ? ( + {a.outputState === 'unverifiable' ? ( +