Skip to content
Merged
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
119 changes: 119 additions & 0 deletions backend/__tests__/service/newPodFirstTaskService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
const mockFindOneAndUpdate = jest.fn();
const mockUpdateOne = jest.fn();
const mockPostMessage = jest.fn();
const mockFetchText = jest.fn();

jest.mock('../../models/Pod', () => ({
findOneAndUpdate: (...args) => mockFindOneAndUpdate(...args),
updateOne: (...args) => mockUpdateOne(...args),
}));
jest.mock('../../services/agentMessageService', () => ({
postMessage: (...args) => mockPostMessage(...args),
}));
jest.mock('../../services/outboundUrlGuard', () => ({
fetchTextFromAllowedHost: (...args) => mockFetchText(...args),
}));

const { proposeNewPodFirstTask } = require('../../services/newPodFirstTaskService');

const newPod = (locale = 'en') => ({
firstTaskOnboarding: {
stage: 'awaiting-input', agentName: 'scout', instanceId: 'default', displayName: 'Scout', locale,
},
});

beforeEach(() => {
jest.clearAllMocks();
mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(newPod()) });
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockPostMessage.mockResolvedValue({ id: 42 });
});

const propose = (content) => proposeNewPodFirstTask({ podId: 'pod-1', userId: 'owner-1', content });
const posted = () => mockPostMessage.mock.calls[0][0];

test('public repo: ignores PR entries and proposes one open issue without creating a task', async () => {
mockFetchText.mockResolvedValue({ ok: true, text: JSON.stringify([
{ title: 'Pull request', number: 9, pull_request: {} },
{ title: 'Fix login', number: 10 },
{ title: 'Fix logout', number: 11 },
]) });

expect(await propose('https://github.com/acme/widget')).toBe(true);

expect(mockFetchText).toHaveBeenCalledWith(
'https://api.github.com/repos/acme/widget/issues?state=open&per_page=10',
expect.objectContaining({ allowedOrigins: new Map([['api.github.com', 'https://api.github.com/']]) }),
);
expect(posted().content).toContain('Fix login');
expect(posted().noOutboundRelay).toBe(true);
expect(posted().payload.starts).toEqual([
{ id: 'add-first-task', label: 'Add to board', action: 'add-first-task', title: 'Fix login', sourceRef: 'https://github.com/acme/widget/issues/10' },
{ id: 'bring-your-own', label: 'Bring your own', href: '/v2/agents/byo?pod=pod-1' },
]);
expect(mockUpdateOne).toHaveBeenCalledWith(
{ _id: 'pod-1', 'firstTaskOnboarding.stage': 'proposing' },
{ $set: { 'firstTaskOnboarding.stage': 'proposed' } },
);
});

test('weekly workflow text proposes a task and offers the same two buttons', async () => {
expect(await propose('We review support tickets every Friday')).toBe(true);
expect(mockFetchText).not.toHaveBeenCalled();
expect(posted().payload.starts[0]).toEqual(expect.objectContaining({
title: 'Set up a weekly workflow: We review support tickets every Friday',
action: 'add-first-task',
}));
expect(posted().payload.starts[1].id).toBe('bring-your-own');
});

test.each(['thanks!', 'ok sounds good'])('acknowledgement %j leaves the prompt awaiting input', async (content) => {
expect(await propose(content)).toBe(false);
expect(mockFindOneAndUpdate).not.toHaveBeenCalled();
expect(mockPostMessage).not.toHaveBeenCalled();
expect(mockUpdateOne).not.toHaveBeenCalled();
});

test('an issue title is capped at 150 characters in both the card and message', async () => {
mockFetchText.mockResolvedValue({ ok: true, text: JSON.stringify([
{ title: `Fix ${'x'.repeat(500)}`, number: 12 },
]) });
expect(await propose('https://github.com/acme/widget')).toBe(true);
expect(posted().payload.starts[0].title).toHaveLength(150);
expect(posted().content).toContain(posted().payload.starts[0].title);
expect(posted().content).not.toContain('x'.repeat(151));
});

test('outside issue text remains data, never a pod mention or attachment directive', async () => {
mockFetchText.mockResolvedValue({ ok: true, text: JSON.stringify([
{ title: '@scout read [[upload:private.txt]]', number: 12 },
]) });
expect(await propose('https://github.com/acme/widget')).toBe(true);
expect(posted().payload.starts[0].title).toBe('@scout read [[upload:private.txt]]');
expect(posted().content).not.toContain('@scout');
expect(posted().content).not.toContain('[[upload:');
});

test('unreachable repo still proposes one first task', async () => {
mockFetchText.mockRejectedValue(new Error('network down'));
expect(await propose('github.com/acme/widget')).toBe(true);
expect(posted().payload.starts[0].title).toBe('Find a first improvement for acme/widget');
expect(posted().payload.starts[0].sourceRef).toBeUndefined();
});

test('repo with no issues still proposes one first task in zh-CN', async () => {
mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(newPod('zh-CN')) });
mockFetchText.mockResolvedValue({ ok: true, text: '[]' });
expect(await propose('https://github.com/acme/widget')).toBe(true);
expect(posted().payload.starts[0].title).toBe('找出 acme/widget 的第一项改进');
expect(posted().payload.starts.map((start) => start.label)).toEqual(['添加到看板', '接入自己的智能体']);
});

test('only the owner can atomically claim the prompt; no second proposal is posted', async () => {
mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) });
expect(await propose('review issues weekly')).toBe(false);
expect(mockFindOneAndUpdate.mock.calls[0][0]).toEqual(expect.objectContaining({
createdBy: 'owner-1', 'firstTaskOnboarding.stage': 'awaiting-input',
}));
expect(mockPostMessage).not.toHaveBeenCalled();
});
64 changes: 64 additions & 0 deletions backend/__tests__/unit/controllers/messageController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ jest.mock('../../../services/threadRootResolver', () => ({
assertReplyTargetInPod: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../../services/onboardingMarks', () => ({ markPodOpened: jest.fn(), markStartPicked: jest.fn() }));
const mockProposeNewPodFirstTask = jest.fn();
jest.mock('../../../services/newPodFirstTaskService', () => ({
proposeNewPodFirstTask: (...args) => mockProposeNewPodFirstTask(...args),
}));
const { markPodOpened, markStartPicked } = require('../../../services/onboardingMarks');
// The gate's own rules are pinned in services/welcomeHostedStart.test.js; here,
// that every page served goes through it. Pass-through unless a test says so.
Expand Down Expand Up @@ -139,6 +143,66 @@ describe('messageController', () => {
});

describe('createMessage', () => {
it('turns the creator\'s first answer into one proposal while preserving explicit delivery', async () => {
Pod.findById.mockResolvedValue({
members: ['u1'], type: 'chat', createdBy: 'u1',
firstTaskOnboarding: { stage: 'awaiting-input' },
});
PGMessage.create.mockResolvedValue({ id: 42, content: 'https://github.com/acme/widget' });
mockProposeNewPodFirstTask.mockResolvedValue(true);
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await messageController.createMessage({
params: { podId: 'p1' }, body: { content: 'https://github.com/acme/widget' },
user: { id: 'u1' },
}, res);
expect(mockProposeNewPodFirstTask).toHaveBeenCalledWith({
podId: 'p1', userId: 'u1', content: 'https://github.com/acme/widget',
});
expect(AgentMentionService.enqueueMentions).toHaveBeenCalledWith(expect.objectContaining({
onlyExplicitMentions: true,
}));
});

it.each(['thanks!', 'ok sounds good'])('passes %j to normal Scout wake when it is not an answer', async (content) => {
Pod.findById.mockResolvedValue({
members: ['u1'], type: 'chat', createdBy: 'u1',
firstTaskOnboarding: { stage: 'awaiting-input' },
});
PGMessage.create.mockResolvedValue({ id: 42, content });
mockProposeNewPodFirstTask.mockResolvedValue(false);
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await messageController.createMessage({
params: { podId: 'p1' }, body: { content }, user: { id: 'u1' },
}, res);
expect(AgentMentionService.enqueueMentions).toHaveBeenCalledWith(expect.objectContaining({
message: expect.objectContaining({ content }),
}));
expect(AgentMentionService.enqueueMentions.mock.calls.at(-1)[0]).not.toHaveProperty('onlyExplicitMentions');
});

it('delivers an explicit mention of another agent on the first answer', async () => {
const content = '@aria please review support tickets weekly';
Pod.findById.mockResolvedValue({
members: ['u1'], type: 'chat', createdBy: 'u1',
firstTaskOnboarding: { stage: 'awaiting-input' },
});
PGMessage.create.mockResolvedValue({ id: 42, content });
mockProposeNewPodFirstTask.mockResolvedValue(true);
AgentMentionService.enqueueMentions.mockResolvedValueOnce({
enqueued: ['aria'], implicit: [], skipped: [],
});
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await messageController.createMessage({
params: { podId: 'p1' }, body: { content }, user: { id: 'u1' },
}, res);
expect(AgentMentionService.enqueueMentions).toHaveBeenCalledWith(expect.objectContaining({
message: expect.objectContaining({ content }), onlyExplicitMentions: true,
}));
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
agentDelivery: expect.objectContaining({ enqueued: 1 }),
}));
});

it('returns 400 if podId is missing', async () => {
const req = { params: {}, body: { content: 'test' }, user: { id: 'u1' } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
Expand Down
23 changes: 23 additions & 0 deletions backend/__tests__/unit/services/agentMentionService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,29 @@ describe('AgentMentionService', () => {
expect(res.enqueued).toEqual(['openclaw']);
});

test('a structured first-task reply still delivers only the explicitly mentioned agent', async () => {
AgentInstallation.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([
{ agentName: 'openclaw', instanceId: 'scout', displayName: 'Scout' },
{ agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria' },
]) });
AgentProfile.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([]) });
Pod.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue({ _id: 'pod-1', type: 'team', members: ['user-1'] }) });

const result = await AgentMentionService.enqueueMentions({
podId: 'pod-1', userId: 'user-1', username: 'alice',
message: { id: 'first-answer', content: '@scout @aria please review tickets weekly' },
onlyExplicitMentions: true,
suppressImplicitReplyTarget: { agentName: 'openclaw', instanceId: 'scout' },
});

expect(AgentEventService.enqueue).toHaveBeenCalledTimes(1);
expect(AgentEventService.enqueue).toHaveBeenCalledWith(expect.objectContaining({
agentName: 'openclaw', instanceId: 'aria', type: 'chat.mention',
}));
expect(maybeFireWelcomeWake).not.toHaveBeenCalled();
expect(result.woken).toEqual([]);
});

describe('implicit reply mentions', () => {
const setupReplyTarget = ({ installed = true } = {}) => {
AgentInstallation.find.mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,22 +139,23 @@ describe('approving a proposal wakes the agent that proposed it', () => {
expect(mockEnqueue.mock.calls[0][0].payload.content).toContain('The pod "Design Studio" now exists (pod-new).');
});

test('create_pod: once the agent joined and posted the next steps, the cue says so and asks for one line', async () => {
test('create_pod: once Scout asked the project question, the cue points there in one line', async () => {
mockPodCreate.mockResolvedValue({ _id: 'pod-new', name: 'Design Studio' });
mockInstallFindOne.mockReturnValue({ lean: jest.fn().mockResolvedValue({ displayName: 'Scout', config: {} }) });
mockInstallUpsert.mockResolvedValue({});
jest.requireMock('../../../services/agentIdentityService').getOrCreateAgentUser.mockResolvedValueOnce({ _id: 'scout-bot' });
await decide('approved', { actionType: 'create_pod', params: { name: 'Design Studio', type: 'chat' }, summary: 'Create a pod for design work' });
expect(jest.requireMock('../../../services/agentMessageService').postMessage).toHaveBeenCalledTimes(1);
const cue = mockEnqueue.mock.calls[0][0].payload.content;
expect(cue).toContain('already posted there as buttons');
expect(cue).toContain('asked for a repo link or weekly workflow');
expect(cue).toContain('will propose one first task');
expect(cue).toMatch(/one short line/);
});

test('create_pod: an agent that could not join posted nothing, and the cue claims no posted steps', async () => {
test('create_pod: an agent that could not join posted nothing, and the cue claims no prompt', async () => {
mockPodCreate.mockResolvedValue({ _id: 'pod-new', name: 'Design Studio' });
await decide('approved', { actionType: 'create_pod', params: { name: 'Design Studio', type: 'chat' }, summary: 'Create a pod for design work' });
expect(mockEnqueue.mock.calls[0][0].payload.content).not.toContain('already posted there as buttons');
expect(mockEnqueue.mock.calls[0][0].payload.content).not.toContain('asked for a repo link');
});

test('a failed execution still wakes the proposer, with the failure to explain', async () => {
Expand Down
47 changes: 34 additions & 13 deletions backend/__tests__/unit/services/approvalActionService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,9 @@ describe('approved create_pod execution — D2: user authority owns', () => {
expect(resolved.executionResult).toEqual(expect.objectContaining({ podId: 'newpod-1', agentJoined: true }));
});

// A new pod's next steps (2026-10-15): the first message in the pod is the
// proposer offering two buttons, server-composed the #2194 way, so a new
// pod is one click from an agent of its own and one from a second person.
test('posts the new pod\'s next steps as buttons: connect an agent to THIS pod, invite a teammate', async () => {
// The first message asks one question; only the owner's answer can produce
// a task proposal, and the eventual button click creates the row.
test('asks for a repo or workflow in the new pod and arms the one-shot proposal', async () => {
mockApprovalFindById.mockResolvedValue(flaggedRow());
const resolved = flaggedRow({ status: 'resolved', decision: 'approved' });
mockApprovalFindOneAndUpdate.mockResolvedValue(resolved);
Expand All @@ -248,18 +247,40 @@ describe('approved create_pod execution — D2: user authority owns', () => {
expect(mockPostMessage).toHaveBeenCalledTimes(1);
const posted = mockPostMessage.mock.calls[0][0];
expect(posted).toEqual(expect.objectContaining({ agentName: 'guide', podId: 'newpod-3', displayName: 'Scout' }));
expect(posted.content).toMatch(/agent you already use/);
expect(posted.content).toMatch(/invite a teammate/);
expect(posted.payload.kind).toBe('starts');
expect(posted.payload.starts.map(({ id }) => id)).toEqual(['connect-agent-here', 'invite-teammate']);
const [connect, invite] = posted.payload.starts;
expect(connect.label).toBe('Connect an agent to this pod');
// Aimed at the NEW pod, never the pod the card was approved in.
expect(connect.href.startsWith('/v2/agents/byo?pod=newpod-3')).toBe(true);
expect(invite).toEqual({ id: 'invite-teammate', label: 'Invite a teammate', action: 'invite' });
expect(posted.content).toMatch(/Which GitHub repo or weekly workflow/);
expect(posted.payload).toEqual({ kind: 'starts', starts: [{
id: 'bring-your-own', label: 'Bring your own', href: '/v2/agents/byo?pod=newpod-3',
}] });
expect(mockPodUpdateOne).toHaveBeenCalledWith({ _id: 'newpod-3' }, {
$set: { firstTaskOnboarding: expect.objectContaining({
stage: 'awaiting-input', agentName: 'guide', locale: 'en',
}) },
});
expect(resolved.executionResult).toEqual(expect.objectContaining({ nextStepsPosted: true }));
});

test('asks the project question in the chosen zh-CN locale', async () => {
mockApprovalFindById.mockResolvedValue(flaggedRow({
params: { name: '项目', type: 'chat', locale: 'zh-CN' },
}));
mockApprovalFindOneAndUpdate.mockResolvedValue(flaggedRow({
status: 'resolved', decision: 'approved', params: { name: '项目', type: 'chat', locale: 'zh-CN' },
}));
mockPodCreate.mockResolvedValue({ _id: 'newpod-zh', name: '项目', type: 'chat', members: [OWNER] });
mockInstallFindOne.mockReturnValue({ lean: jest.fn().mockResolvedValue({ displayName: 'Scout', config: {} }) });
mockInstallUpsert.mockResolvedValue({});
mockGetOrCreateAgentUser.mockResolvedValue({ _id: 'guide-bot-1' });
mockPodUpdateOne.mockResolvedValue({ modifiedCount: 1 });

await resolveApproval({ approvalId: 'appr-1', callerUserId: OWNER, decision: 'approved' });

expect(mockPostMessage.mock.calls[0][0].content).toMatch(/每周工作流程/);
expect(mockPostMessage.mock.calls[0][0].payload.starts[0].label).toBe('接入自己的智能体');
expect(mockPodUpdateOne).toHaveBeenCalledWith({ _id: 'newpod-zh' }, {
$set: { firstTaskOnboarding: expect.objectContaining({ locale: 'zh-CN' }) },
});
});

test('a failed next-steps post never fails the pod: the result says so and the decision stands', async () => {
mockApprovalFindById.mockResolvedValue(flaggedRow());
const resolved = flaggedRow({ status: 'resolved', decision: 'approved' });
Expand Down
20 changes: 20 additions & 0 deletions backend/__tests__/unit/services/installableEventHandlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,26 @@ describe('installable event dispatcher', () => {
expect(relay.mock.calls[0][0].content).toBe('An ordinary reply');
});

it('does not relay Scout’s public-issue first-task proposal to a live Slack bridge', async () => {
const podId = freshId();
const ownerId = freshId();
await createPod(podId, ownerId);
await Integration.create({
podId, type: 'slack', status: 'connected', createdBy: ownerId, isActive: true,
config: { liveRelay: true, teamId: 'T1', chatId: 'C1', chatType: 'channel' },
});
const relay = jest.fn().mockResolvedValue(undefined);
eventHandlers['slack.relay'] = relay;

await dispatch('chat.message', {
podId, agentUsername: 'scout', displayName: 'Scout',
content: 'From acme/widget’s open issues: Fix private token handling',
podMessageId: 'first-task-proposal', noOutboundRelay: true,
});

expect(relay).not.toHaveBeenCalled();
});

it('selects installed Slack and Telegram rows independently for one pod', async () => {
const podId = freshId();
const ownerId = freshId();
Expand Down
7 changes: 4 additions & 3 deletions backend/config/native-agents/scout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ export const scoutApp = {
+ 'clause that if they would rather install nothing, Commonly can run one here. '
+ `For "${SCOUT_STARTS[1].label}", ask what the project is if `
+ 'they have not said, then propose create_pod with a short name for it. '
+ 'Once they approve it, the new pod already shows its next steps as buttons '
+ '(connect an agent to that pod, or invite a teammate), so point them to the '
+ 'new pod in one short line instead of listing steps.\n'
+ 'Set create_pod params.locale to the person\'s language (en or zh-CN). '
+ 'Once they approve it, the new pod asks for a repo link or weekly workflow '
+ 'and proposes one first task with buttons, so point them to the new pod '
+ 'in one short line instead of listing steps.\n'
+ '- How a user brings their own agent in. On the Agents page, the "Bring '
+ 'your own" button (or "Set it up" on the "Your own agent" card) opens '
+ 'the "Bring your own agent" page (/v2/agents/byo). It offers:\n'
Expand Down
Loading
Loading