diff --git a/backend/__tests__/service/pgMessages.test.js b/backend/__tests__/service/pgMessages.test.js index 00228d5c8..0b107eaae 100644 --- a/backend/__tests__/service/pgMessages.test.js +++ b/backend/__tests__/service/pgMessages.test.js @@ -136,6 +136,7 @@ describe('PostgreSQL Message Routes', () => { implicit: [], agentsInPod: expect.any(Number), woken: 0, + wokenAgents: [], }); }); @@ -188,6 +189,7 @@ describe('PostgreSQL Message Routes', () => { implicit: [], agentsInPod: expect.any(Number), woken: 0, + wokenAgents: [], }, }); expect(AgentMentionService.enqueueMentions).toHaveBeenCalledWith({ diff --git a/backend/__tests__/unit/controllers/messageController.test.js b/backend/__tests__/unit/controllers/messageController.test.js index 122b11c8c..962744dc9 100644 --- a/backend/__tests__/unit/controllers/messageController.test.js +++ b/backend/__tests__/unit/controllers/messageController.test.js @@ -254,7 +254,7 @@ describe('messageController', () => { expect(res.json).toHaveBeenCalledWith({ ...mockMessage, agentDelivery: { - enqueued: 0, implicit: [], agentsInPod: 2, woken: 0, + enqueued: 0, implicit: [], agentsInPod: 2, woken: 0, wokenAgents: [], }, }); expect(AgentMentionService.enqueueMentions).toHaveBeenCalled(); @@ -287,7 +287,7 @@ describe('messageController', () => { expect(res.json).toHaveBeenCalledWith({ ...mockMessage, agentDelivery: { - enqueued: 1, implicit: [], agentsInPod: 2, woken: 0, + enqueued: 1, implicit: [], agentsInPod: 2, woken: 0, wokenAgents: [], }, }); }); @@ -502,7 +502,7 @@ describe('messageController', () => { expect(res.json).toHaveBeenCalledWith({ ...mockMessage, agentDelivery: { - enqueued: 0, implicit: [], agentsInPod: 1, woken: 1, + enqueued: 0, implicit: [], agentsInPod: 1, woken: 1, wokenAgents: ['guide'], }, }); }); diff --git a/backend/__tests__/unit/controllers/pgMessageController.test.js b/backend/__tests__/unit/controllers/pgMessageController.test.js index 45b474306..df3d35023 100644 --- a/backend/__tests__/unit/controllers/pgMessageController.test.js +++ b/backend/__tests__/unit/controllers/pgMessageController.test.js @@ -80,7 +80,7 @@ describe('pgMessageController', () => { AgentMentionService.enqueueMentions.mockResolvedValue({ enqueued: [{ installationId: 'i1' }], implicit: ['recorder'], - woken: [{ installationId: 'i2' }], + woken: ['guide'], }); AgentInstallation.countDocuments.mockResolvedValue(2); const req = { @@ -103,6 +103,7 @@ describe('pgMessageController', () => { implicit: ['recorder'], agentsInPod: 2, woken: 1, + wokenAgents: ['guide'], }, }); }); diff --git a/backend/__tests__/unit/services/messageAgentDeliveryService.test.js b/backend/__tests__/unit/services/messageAgentDeliveryService.test.js index 639b4beee..298b4ced8 100644 --- a/backend/__tests__/unit/services/messageAgentDeliveryService.test.js +++ b/backend/__tests__/unit/services/messageAgentDeliveryService.test.js @@ -26,7 +26,7 @@ describe('messageAgentDeliveryService', () => { AgentMentionService.enqueueMentions.mockResolvedValue({ enqueued: [{ installationId: 'mention-1' }], implicit: ['recorder'], - woken: [{ installationId: 'wake-1' }], + woken: ['guide'], }); AgentInstallation.countDocuments.mockResolvedValue(2); }); @@ -66,6 +66,7 @@ describe('messageAgentDeliveryService', () => { implicit: ['recorder'], agentsInPod: 2, woken: 1, + wokenAgents: ['guide'], }, }); }); diff --git a/backend/models/OnboardingSilenceEpisode.ts b/backend/models/OnboardingSilenceEpisode.ts index f8ddb8a82..d541e9046 100644 --- a/backend/models/OnboardingSilenceEpisode.ts +++ b/backend/models/OnboardingSilenceEpisode.ts @@ -31,8 +31,9 @@ import mongoose, { Document, Model, Schema, Types } from 'mongoose'; * `noneEnqueued` in particular degrades from a fact into an inference the * moment anything else is enqueued there. * - * And the rows themselves are not permanent — they age out at 168h. "Outlives - * the deletion" is the claim, not "outlives retention". + * And the AgentEvent rows themselves are not permanent: they age out at 168h. + * "Outlives the deletion" is the claim, not "outlives retention". Episodes + * have no TTL; only syntheticAccountPurgeService deletes them. * * So: capture at fire time, because the queue can distinguish two failures that * look identical afterwards and need opposite fixes: diff --git a/backend/services/messageAgentDeliveryService.ts b/backend/services/messageAgentDeliveryService.ts index dcab8ccde..4c225a456 100644 --- a/backend/services/messageAgentDeliveryService.ts +++ b/backend/services/messageAgentDeliveryService.ts @@ -35,6 +35,8 @@ interface AgentDelivery { implicit: string[]; agentsInPod: number; woken: number; + // Who was woken, so the client can name an agent that was not. + wokenAgents: string[]; } type DeliveredMessage = T | (T & { mentionNotices?: unknown[]; agentDelivery?: AgentDelivery }); @@ -116,6 +118,7 @@ export const deliverMessageToAgents = async ( implicit: mentionResult.implicit || [], agentsInPod, woken: mentionResult.woken?.length ?? 0, + wokenAgents: mentionResult.woken || [], }, }; }; diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts index f4b568d7e..a4e9ef715 100644 --- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts +++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts @@ -50,6 +50,8 @@ const OPAQUE_PLACEHOLDERS = [ 'requested', 'served', // runTrace.call inserts the opaque PG call ID, not a number. 'id', + // podChat.deliveryHintOthers names the agent that was woken. + 'woken', ]; // The exception runs the safe way: a name that is usually opaque and is a number diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3dbd8b52a..5efb985eb 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -566,6 +566,7 @@ "cancelReply": "Cancel reply", "replyingTo": "Replying to {{author}}:", "deliveryHint": "No agent was notified — @mention one to get a reply. Try @{{handle}}.", + "deliveryHintOthers": "{{woken}} will answer this. Other agents here answer only when you @mention them. Try @{{handle}}.", "mentionNotInPod": "{{handle}} isn't in this pod. Invite them, or ask in a pod they're in.", "mentionNotInPodWithPods": "{{handle}} isn't in this pod. Ask in:", "noAgentsInPod": { @@ -580,6 +581,10 @@ "waiting": "Waiting for {{agentName}} to reply…", "timedOut": "{{agentName}} hasn't replied yet. Your message is still in this conversation." }, + "podAgentLiveness": { + "notConnected": "{{agentName}} isn't connected yet, so it won't answer here until it's running.", + "offlineSince": "{{agentName}} last checked in {{age}}. It answers when it's back." + }, "typing": { "one": "{{name}} is thinking…", "two": "{{first}} and {{second}} are thinking…", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 4755445bb..847591f93 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -566,6 +566,7 @@ "cancelReply": "取消回复", "replyingTo": "回复 {{author}}:", "deliveryHint": "尚未通知任何智能体。@提及一个智能体即可让它回复,例如 @{{handle}}。", + "deliveryHintOthers": "{{woken}} 会回复这条消息。这里的其他智能体只在你 @提及 时回复,试试 @{{handle}}。", "mentionNotInPod": "{{handle}} 不在这个 Pod 中。请邀请对方加入,或到对方已加入的 Pod 中提问。", "mentionNotInPodWithPods": "{{handle}} 不在这个 Pod 中。可前往这些 Pod 提问:", "noAgentsInPod": { @@ -580,6 +581,10 @@ "waiting": "正在等待 {{agentName}} 回复…", "timedOut": "{{agentName}} 还没有回复。你的消息仍在此对话中。" }, + "podAgentLiveness": { + "notConnected": "{{agentName}} 还没有连接,运行起来之前不会在这里回复。", + "offlineSince": "{{agentName}} 上次连接是 {{age}},恢复连接后会回复。" + }, "typing": { "one": "{{name}} 正在思考…", "two": "{{first}} 和 {{second}} 正在思考…", diff --git a/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx b/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx new file mode 100644 index 000000000..7bfddeb8d --- /dev/null +++ b/frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx @@ -0,0 +1,124 @@ +// @ts-nocheck +// A group pod names each installed agent that isn't running, so a newcomer +// doesn't wait on a reply that can't come. Silence census 10-30: 5 of 7 people +// who heard nothing in their first pod had an agent that never connected. +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import V2Thread from '../components/V2Thread'; +import { AuthContext } from '../../context/AuthContext'; + +jest.mock('../../context/SocketContext', () => ({ + useSocket: () => ({ socket: null, connected: false }), +})); + +beforeAll(() => { + Element.prototype.scrollIntoView = jest.fn(); +}); + +jest.mock('axios', () => { + const mock = { + get: jest.fn(() => Promise.resolve({ data: {} })), + post: jest.fn(() => Promise.resolve({ data: {} })), + patch: jest.fn(), + delete: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { __esModule: true, default: mock, ...mock }; +}); + +const axios = jest.requireMock('axios').default; + +const authValue = { + currentUser: { _id: 'u1', username: 'solo-user' }, + user: { _id: 'u1', username: 'solo-user' }, + token: 't', + loading: false, + error: null, + isAuthenticated: true, + register: jest.fn(), + login: jest.fn(), + logout: jest.fn(), + updateProfile: jest.fn(), +}; + +const makeDetail = (overrides = {}) => ({ + pod: { _id: 'p1', name: 'My Workspace', type: 'chat' }, + members: [{ _id: 'u1', username: 'solo-user', isBot: false }], + messages: [], + agents: [{ agentName: 'sam-agent', instanceId: 'default', displayName: 'Sam Agent' }], + sendMessage: jest.fn(() => Promise.resolve({ _id: 'm1' })), + loading: false, + error: null, + refresh: jest.fn(), + ...overrides, +}); + +const mockStates = (agents) => { + axios.get.mockImplementation((url) => { + if (typeof url === 'string' && url.endsWith('/agent-states')) { + return Promise.resolve({ data: { agents } }); + } + return Promise.resolve({ data: {} }); + }); +}; + +const renderChat = (detail) => render( + + + + + , +); + +afterEach(() => jest.clearAllMocks()); + +const HOURS_AGO_3 = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + +describe('group pod agent liveness', () => { + test('names an agent that never connected', async () => { + mockStates([{ agentName: 'sam-agent', instanceId: 'default', displayName: 'Sam Agent', state: 'never-connected', lastUsedAt: null, isOwner: true }]); + renderChat(makeDetail()); + + const row = await screen.findByTestId('pod-agent-liveness'); + expect(row).toHaveTextContent("Sam Agent isn't connected yet"); + }); + + test('says when an agent that went quiet last checked in', async () => { + mockStates([{ agentName: 'sam-agent', instanceId: 'default', displayName: 'Sam Agent', state: 'gone-dark', lastUsedAt: HOURS_AGO_3, isOwner: false }]); + renderChat(makeDetail()); + + const row = await screen.findByTestId('pod-agent-liveness'); + expect(row).toHaveTextContent('Sam Agent last checked in 3h ago'); + }); + + test.each([ + ['listening', { state: 'listening', lastUsedAt: HOURS_AGO_3 }], + ['hosted (reachable)', { state: 'reachable', lastUsedAt: null, hosted: true }], + ['unknown', { state: 'unknown', lastUsedAt: null }], + ])('shows nothing for an agent that is %s', async (_label, fields) => { + mockStates([{ agentName: 'sam-agent', instanceId: 'default', displayName: 'Sam Agent', isOwner: true, ...fields }]); + renderChat(makeDetail()); + + await waitFor(() => expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/agent-states'), expect.anything())); + expect(screen.queryByTestId('pod-agent-liveness')).not.toBeInTheDocument(); + }); + + test('a 1:1 agent room keeps its own row and never doubles it', async () => { + mockStates([{ agentName: 'sam-agent', instanceId: 'default', displayName: 'Sam Agent', state: 'never-connected', lastUsedAt: null, isOwner: true }]); + renderChat(makeDetail({ + pod: { _id: 'p1', name: 'Sam Agent', type: 'agent-room' }, + members: [ + { _id: 'u1', username: 'solo-user', isBot: false }, + { _id: 'b1', username: 'sam-agent', isBot: true }, + ], + })); + + await screen.findByTestId('agent-room-liveness'); + expect(screen.queryByTestId('pod-agent-liveness')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx b/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx index 8027b5106..fd27a12a2 100644 --- a/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx @@ -418,4 +418,46 @@ describe('V2Thread agent delivery hint', () => { expect(screen.queryByText('no agent in this pod')).not.toBeInTheDocument(); }); }); + + // Silence census, 10-20: Scout woke on an unmentioned message, the person's + // own hosted agent did not, and the hint stayed hidden because "an agent + // was woken". Name the agent that wasn't. + const SCOUT_AND_OWN = [ + { agentName: 'guide', instanceId: 'u3f9c2a1b7d', displayName: 'Scout', status: 'active' }, + { agentName: 'sly-agent', instanceId: 'default', displayName: 'Sly', status: 'active' }, + ]; + + test('when Scout woke and another agent did not, names that agent', async () => { + renderHarness( + makeMessage('can you research this', { + enqueued: 0, implicit: [], agentsInPod: 2, woken: 1, wokenAgents: ['guide'], + }), + jest.fn(), + { agents: SCOUT_AND_OWN }, + ); + + sendDraft('can you research this'); + + const hint = await screen.findByText(/will answer this/); + expect(hint.closest('.v2-chat__delivery-hint')).toHaveTextContent( + 'Scout will answer this. Other agents here answer only when you @mention them. Try @sly.', + ); + }); + + test('stays hidden when every agent in the pod was woken', async () => { + const sendSpy = jest.fn(); + renderHarness( + makeMessage('hello all', { + enqueued: 0, implicit: [], agentsInPod: 2, woken: 2, wokenAgents: ['guide', 'sly-agent'], + }), + sendSpy, + { agents: SCOUT_AND_OWN }, + ); + + sendDraft('hello all'); + await screen.findByText('hello all'); + + expect(screen.queryByText(/will answer this/)).not.toBeInTheDocument(); + expect(screen.queryByText(/No agent was notified/i)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index ac2ac98b3..3939591b9 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -34,6 +34,7 @@ import { useWelcomeHostedStart, WELCOME_HOSTED_ACTION } from '../hooks/useWelcom import { buildThreadView, freezeOrphanReplyIds } from '../utils/threadView'; import { agentKeyFor } from '../utils/agentKey'; import { runtimeChipLabel } from '../utils/runtimeChip'; +import { localizeRelativeTime } from '../utils/localizeRelativeTime'; import { buildAgentUsername, isOpaqueInstanceToken, @@ -79,11 +80,14 @@ interface AgentStateRow { displayName?: string; state: AgentReachState; isOwner: boolean; + /** Last time the agent's own runtime called in; null when it never has. */ + lastUsedAt?: string | null; /** A Commonly-hosted agent; absent from an older backend. */ hosted?: boolean; fixCommand?: string; } const REACH_IS_ACTIVE = new Set(['listening', 'reachable']); +const POD_LIVENESS_MAX_ROWS = 3; interface DirectAgentIdentity { username: string; @@ -266,6 +270,8 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in const [agentDeliveryHint, setAgentDeliveryHint] = useState<{ messageId: string; mentionHandle: string; + // The wake-on-message agent that did answer, when another agent did not. + wokenAgent?: string; } | null>(null); // A send into a pod with no installed agent: the delivery hint below is // deliberately gated on `agentsInPod > 0`, which is exactly the case this is @@ -1527,6 +1533,14 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in : directAgentReach === 'gone-dark' ? 'podChat.agentRoomLiveness.goneDark' : 'podChat.agentRoomLiveness.unknown'; + // Group pods name each agent that isn't running, so nobody waits on a reply + // that can't come: 5 of 7 people who heard nothing in their first pod had an + // agent that never connected (silence census, 10-30). Only reach states the + // server measured; an unknown state shows nothing. + const podAgentLiveness = isAgentRoom || isAgentDm ? [] : agentStates.filter((s) => ( + typeof s?.agentName === 'string' && s.agentName + && (s.state === 'never-connected' || s.state === 'gone-dark') + )).slice(0, POD_LIVENESS_MAX_ROWS); const showAwaitingAgentReply = Boolean( isAgentRoom && awaitingAgentReply && awaitingAgentReply.podId === pod._id, ); @@ -1584,7 +1598,20 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in }); } const delivery = created.agentDelivery; - const exampleAgent = agents.find((agent) => agent.status === 'active') || agents[0]; + // A wake-on-message agent (Scout) answering does not mean the person + // reached the agent they came for: in a pod with Scout and their own + // hosted agent, an unmentioned message woke Scout alone and the hint + // stayed hidden (silence census, 10-20). Name an agent that wasn't woken. + const wokenNames = new Set((delivery?.wokenAgents || []).map((name) => String(name).toLowerCase())); + const isWoken = (agent: (typeof agents)[number]) => wokenNames.has(String(agent?.agentName || '').toLowerCase()); + const anyWoken = (delivery?.woken ?? 0) > 0; + const unwokenAgent = anyWoken && wokenNames.size > 0 + ? agents.find((agent) => agent.status === 'active' && !isWoken(agent)) + : undefined; + const wokenAgent = unwokenAgent ? agents.find(isWoken) : undefined; + const exampleAgent = anyWoken + ? unwokenAgent + : (agents.find((agent) => agent.status === 'active') || agents[0]); // Same handle rule as the typeahead: human-chosen instanceId is the // identity and stays; an opaque per-user token must never be the // suggested handle ("Try @u3f9c2a1b7d") — persona displayName slug @@ -1602,7 +1629,8 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in // A wake-on-message agent (the Guide in every fresh workspace) WAS // notified — "No agent was notified" would be false on screen while // it's already typing (#914). - && (delivery.woken ?? 0) === 0 + // Unless another agent wasn't woken: then name that one. + && (!anyWoken || unwokenAgent) // A plain reply to an agent's message reaches it through the // reply-edge (implicit) path, not a mention. That agent WILL answer, // so "No agent was notified" would be false on screen. @@ -1624,7 +1652,13 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in } catch { // sessionStorage unavailable; the in-memory guard still works. } - setAgentDeliveryHint({ messageId: created.id, mentionHandle }); + setAgentDeliveryHint({ + messageId: created.id, + mentionHandle, + ...(wokenAgent ? { + wokenAgent: wokenAgent.displayName || wokenAgent.profile?.displayName || wokenAgent.agentName, + } : {}), + }); } } else if ( delivery @@ -2007,6 +2041,21 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in {t(directAgentLivenessKey, { agentName: directAgent.displayName })} )} + {podAgentLiveness.map((row) => ( +
+ {row.state === 'never-connected' || !row.lastUsedAt + ? t('podChat.podAgentLiveness.notConnected', { agentName: row.displayName || row.agentName }) + : t('podChat.podAgentLiveness.offlineSince', { + agentName: row.displayName || row.agentName, + age: localizeRelativeTime(row.lastUsedAt, t), + })} +
+ ))} {showAwaitingAgentReply && awaitingAgentReply && (
= ({ {agentDeliveryHint?.messageId === message.id && (
}} />
diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 88182ea11..bb2893fce 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -60,6 +60,8 @@ export interface V2Message { agentsInPod: number; // Wake-on-message targets (#914). Optional: older backends omit it. woken?: number; + // Who was woken; older backends omit it. + wokenAgents?: string[]; }; // Sender-only feedback from the POST response. Socket broadcasts and // history omit it, so other pod members never see the notice.