Skip to content
Open
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
2 changes: 2 additions & 0 deletions backend/__tests__/service/pgMessages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ describe('PostgreSQL Message Routes', () => {
implicit: [],
agentsInPod: expect.any(Number),
woken: 0,
wokenAgents: [],
});
});

Expand Down Expand Up @@ -188,6 +189,7 @@ describe('PostgreSQL Message Routes', () => {
implicit: [],
agentsInPod: expect.any(Number),
woken: 0,
wokenAgents: [],
},
});
expect(AgentMentionService.enqueueMentions).toHaveBeenCalledWith({
Expand Down
6 changes: 3 additions & 3 deletions backend/__tests__/unit/controllers/messageController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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: [],
},
});
});
Expand Down Expand Up @@ -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'],
},
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -103,6 +103,7 @@ describe('pgMessageController', () => {
implicit: ['recorder'],
agentsInPod: 2,
woken: 1,
wokenAgents: ['guide'],
},
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -66,6 +66,7 @@ describe('messageAgentDeliveryService', () => {
implicit: ['recorder'],
agentsInPod: 2,
woken: 1,
wokenAgents: ['guide'],
},
});
});
Expand Down
5 changes: 3 additions & 2 deletions backend/models/OnboardingSilenceEpisode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions backend/services/messageAgentDeliveryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | (T & { mentionNotices?: unknown[]; agentDelivery?: AgentDelivery });
Expand Down Expand Up @@ -116,6 +118,7 @@ export const deliverMessageToAgents = async <T extends MessageForAgentDelivery>(
implicit: mentionResult.implicit || [],
agentsInPod,
woken: mentionResult.woken?.length ?? 0,
wokenAgents: mentionResult.woken || [],
},
};
};
2 changes: 2 additions & 0 deletions frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@
"cancelReply": "Cancel reply",
"replyingTo": "Replying to <author>{{author}}</author>:",
"deliveryHint": "No agent was notified — @mention one to get a reply. Try <handle>@{{handle}}</handle>.",
"deliveryHintOthers": "{{woken}} will answer this. Other agents here answer only when you @mention them. Try <handle>@{{handle}}</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": {
Expand All @@ -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…",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@
"cancelReply": "取消回复",
"replyingTo": "回复 <author>{{author}}</author>:",
"deliveryHint": "尚未通知任何智能体。@提及一个智能体即可让它回复,例如 <handle>@{{handle}}</handle>。",
"deliveryHintOthers": "{{woken}} 会回复这条消息。这里的其他智能体只在你 @提及 时回复,试试 <handle>@{{handle}}</handle>。",
"mentionNotInPod": "{{handle}} 不在这个 Pod 中。请邀请对方加入,或到对方已加入的 Pod 中提问。",
"mentionNotInPodWithPods": "{{handle}} 不在这个 Pod 中。可前往这些 Pod 提问:",
"noAgentsInPod": {
Expand All @@ -580,6 +581,10 @@
"waiting": "正在等待 {{agentName}} 回复…",
"timedOut": "{{agentName}} 还没有回复。你的消息仍在此对话中。"
},
"podAgentLiveness": {
"notConnected": "{{agentName}} 还没有连接,运行起来之前不会在这里回复。",
"offlineSince": "{{agentName}} 上次连接是 {{age}},恢复连接后会回复。"
},
"typing": {
"one": "{{name}} 正在思考…",
"two": "{{first}} 和 {{second}} 正在思考…",
Expand Down
124 changes: 124 additions & 0 deletions frontend/src/v2/__tests__/V2PodAgentLiveness.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<V2Thread detail={detail} />
</MemoryRouter>
</AuthContext.Provider>,
);

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();
});
});
42 changes: 42 additions & 0 deletions frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading
Loading