Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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');
});
});
13 changes: 11 additions & 2 deletions backend/routes/registry/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions backend/services/agentStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,7 @@
"card": {
"inProject": "属于",
"lastSaid": "“{{snippet}}”",
"outputUnverifiable": "无法验证——过去 30 分钟内没有消息",
"roleTitle": "角色:{{role}}",
"profile": "资料页",
"viewProfileAria": "查看 {{name}} 的资料页",
Expand Down
35 changes: 33 additions & 2 deletions frontend/src/v2/__tests__/V2YourTeamTiers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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());
Expand All @@ -99,6 +113,23 @@ describe('Your Team tiers', () => {
expect(sageCard.querySelector('.v2-team-card__talk')).toBeNull();
});

test('a compact card puts UNVERIFIABLE on its own label line above its pod', async () => {
renderPage([
...agents,
{ name: 'newest-1', instanceId: 'default', displayName: 'Newest 1', lastActiveAt: minutesAgo(1) },
{ name: 'newest-2', instanceId: 'default', displayName: 'Newest 2', lastActiveAt: minutesAgo(2) },
{ name: 'newest-3', instanceId: 'default', displayName: 'Newest 3', lastActiveAt: minutesAgo(3) },
{ name: 'silent-but-live', instanceId: 'default', displayName: 'Silent but live', lastActiveAt: minutesAgo(5), outputState: 'unverifiable' },
]);

const card = (await screen.findByText('Silent but live')).closest('.v2-team-card');
const output = card.querySelector('.v2-team-card__output-state');
const pod = card.querySelector('.v2-team-card__pod');
expect(output).toHaveTextContent('UNVERIFIABLE — no message in the last 30 minutes');
expect(pod).toHaveTextContent('in Workspace · 5m ago');
expect(pod).not.toHaveTextContent('UNVERIFIABLE');
});

test('quiet agents collapse behind a count when more than 3, expand on toggle', async () => {
renderPage();
await waitFor(() => expect(screen.getByText('Fable')).toBeInTheDocument());
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(rule).not.toContain('white-space: nowrap');
});

test('UNVERIFIABLE gets its own wrapping label line on compact team cards', () => {
// The pod line deliberately stays one-line-ellipsized. A live seat with
// no recent output must therefore render its state before that line, or
// either the state or the relative time disappears at narrow widths.
const state = ruleBody(v2, '.v2-team-card__output-state');
expect(state).toContain('font-size: 11px');
expect(state).toContain('line-height: 14px');
expect(state).toContain('font-weight: 600');
expect(state).toContain('overflow-wrap: anywhere');
expect(yourTeam.indexOf('v2-team-card__output-state'))
.toBeLessThan(yourTeam.indexOf('v2-team-card__pod'));
});

test('sidebar pod name WRAPS — it never loses to its own timestamp (craft audit finding 8)', () => {
// "Sharpen — pod m…", "Team Orchestra…": the name shared its line with the
// relative time and ellipsized at desktop width. Same primary-identifier
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/v2/components/V2YourTeamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -341,7 +344,11 @@ const V2YourTeamPage: React.FC = () => {
<span className="v2-team-feature__name">{display}</span>
<span className="v2-team-feature__dot" data-testid="team-dot" />
</div>
{a.lastMessage?.snippet ? (
{a.outputState === 'unverifiable' ? (
<div className="v2-team-feature__doing" data-testid="team-output-unverifiable">
{t('yourTeam.card.outputUnverifiable')}
</div>
) : a.lastMessage?.snippet ? (
// Line 2 is what the agent last said (Wren spec §1.1) — quoted,
// one line, ellipsized. The project line is the fallback for an
// agent that has not spoken in this pod yet.
Expand Down Expand Up @@ -403,6 +410,11 @@ const V2YourTeamPage: React.FC = () => {
<div className="v2-team-card__name-row">
<span className="v2-team-card__name">{display}</span>
</div>
{a.outputState === 'unverifiable' ? (
<div className="v2-team-card__output-state" data-testid="team-output-unverifiable">
{t('yourTeam.card.outputUnverifiable')}
</div>
) : null}
<div className="v2-team-card__pod">
{t('yourTeam.card.inProject')} <em>{a.podName || t('yourTeam.untitledProject')}</em>
{' · '}
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -6225,6 +6225,15 @@ body.modern-ui.v2-canvas {
/* .v2-team-card__runtime removed 2026-08-22: runtime vocabulary on cards
violated ratified ADR-022 D1 (craft audit finding 2). */

.v2-team-card__output-state {
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: #5f6470;
margin-bottom: 2px;
overflow-wrap: anywhere;
}

.v2-team-card__pod {
font-size: 13px;
color: #5f6470;
Expand Down
Loading