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
30 changes: 30 additions & 0 deletions packages/api/src/mcp/tools/inbox-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,36 @@ describe('handleSendToInbox - threadKey', () => {
);
});

it('should dispatch trigger for new self-thread session_resume (strategy first kickoff)', async () => {
const { getAgentGateway } = await import('../../channels/agent-gateway.js');
const mockGateway = (getAgentGateway as ReturnType<typeof vi.fn>)();
// findThread returns null → new thread path
const { findThread } = await import('./thread-handlers.js');
vi.mocked(findThread).mockResolvedValue(null);

const mockSb = createThreadMockSupabase();
const mockDc = createThreadMockDataComposer(mockSb);

await handleSendToInbox(
{
email: 'test@test.com',
recipientAgentId: 'wren',
senderAgentId: 'wren',
messageType: 'session_resume',
threadKey: 'strategy:new-group-123',
content: 'Strategy kickoff — first trigger',
},
mockDc as never
);

expect(mockGateway.dispatchTrigger).toHaveBeenCalledWith(
expect.objectContaining({
toAgentId: 'wren',
threadKey: 'strategy:new-group-123',
})
);
});

// ===================================================================
// 2FA SECURITY — permission_grant from agent senders must be rejected
// ===================================================================
Expand Down
7 changes: 5 additions & 2 deletions packages/api/src/mcp/tools/inbox-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,8 +547,11 @@ export async function handleSendToInbox(args: unknown, dataComposer: DataCompose
selfStudioTarget,
});
} else {
// New thread: trigger all recipients (exclude sender unless cross-studio self-message)
agentsToTrigger = allRecipients.filter((a) => selfStudioTarget || a !== senderAgentId);
// New thread: trigger all recipients (exclude sender unless cross-studio
// self-message or actionable self-target like strategy kickoff)
const actionableSelf = new Set(['task_request', 'session_resume']);
const allowSelf = selfStudioTarget || (!!messageType && actionableSelf.has(messageType));
agentsToTrigger = allRecipients.filter((a) => allowSelf || a !== senderAgentId);
}
}

Expand Down
32 changes: 31 additions & 1 deletion packages/api/src/mcp/tools/thread-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,44 @@ describe('resolveTriggeredAgents', () => {
});

describe('self-thread (1 participant)', () => {
it('should trigger no one', () => {
it('should trigger no one for plain messages', () => {
const result = resolveTriggeredAgents({
senderAgentId: 'wren',
participants: ['wren'],
creatorAgentId: 'wren',
});
expect(result).toEqual([]);
});

it('should trigger self for session_resume (strategy self-trigger)', () => {
const result = resolveTriggeredAgents({
senderAgentId: 'wren',
participants: ['wren'],
creatorAgentId: 'wren',
messageType: 'session_resume',
});
expect(result).toEqual(['wren']);
});

it('should trigger self for task_request', () => {
const result = resolveTriggeredAgents({
senderAgentId: 'wren',
participants: ['wren'],
creatorAgentId: 'wren',
messageType: 'task_request',
});
expect(result).toEqual(['wren']);
});

it('should not trigger self for notification', () => {
const result = resolveTriggeredAgents({
senderAgentId: 'wren',
participants: ['wren'],
creatorAgentId: 'wren',
messageType: 'notification',
});
expect(result).toEqual([]);
});
});

describe('triggerAll override', () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/api/src/mcp/tools/thread-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,14 @@ export function resolveTriggeredAgents(opts: {
// Precedence 3: default rules by thread size
const otherParticipants = participants.filter((a) => a !== senderAgentId);

// Self-thread (1 participant): trigger only if cross-studio self-message
// Self-thread (1 participant): trigger if cross-studio OR actionable message type.
// session_resume / task_request to self are inherently "wake me up" signals
// (e.g., strategy triggers) and must not be silently dropped.
if (otherParticipants.length === 0) {
return selfStudioTarget ? [senderAgentId] : [];
if (selfStudioTarget) return [senderAgentId];
const selfActionable = new Set(['task_request', 'session_resume']);
if (messageType && selfActionable.has(messageType)) return [senderAgentId];
return [];
}

// 1:1 thread (2 participants): trigger the other one
Expand Down
Loading