test(agent-to-agent): add missing routing coverage
- Stale origin fallback (archived session falls through to newest) - Cross-agent-group guard (origin from wrong group rejected) - Non-a2a in_reply_to (channel message ref falls through) - Self-message bypass (no destination row needed) - File forwarding (bytes copied from outbox to inbox) - Unbounded ping-pong documenting #2063 loop gap Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { isSafeAttachmentName, routeAgentMessage } from './agent-route.js';
|
||||
import { createDestination } from './db/agent-destinations.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
|
||||
import { createSession, updateSession } from '../../db/sessions.js';
|
||||
import { initSessionFolder, inboundDbPath, sessionDir, writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
@@ -273,4 +274,163 @@ describe('routeAgentMessage return-path', () => {
|
||||
expect(JSON.parse(s1Rows[0].content).text).toBe('standing by');
|
||||
expect(s2Rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stale origin fallback: archived origin session falls through to newest active', async () => {
|
||||
// A.S1 sends to B, establishing source_session_id = S1.id on B's inbound.
|
||||
await routeAgentMessage(
|
||||
{ id: 'msg-fwd', platform_id: B, content: JSON.stringify({ text: 'hello' }), in_reply_to: null },
|
||||
S1,
|
||||
);
|
||||
const bRows = readInbound(B, SB.id);
|
||||
const inboundId = bRows[0].id;
|
||||
|
||||
// Archive S1 — simulates session cleanup or channel disconnect.
|
||||
updateSession(S1.id, { status: 'archived' });
|
||||
|
||||
// B replies. origin points to S1 (archived), should fall through to S2.
|
||||
await routeAgentMessage(
|
||||
{ id: 'msg-reply-stale', platform_id: A, content: JSON.stringify({ text: 'reply' }), in_reply_to: inboundId },
|
||||
SB,
|
||||
);
|
||||
|
||||
const s1Rows = readInbound(A, S1.id);
|
||||
const s2Rows = readInbound(A, S2.id);
|
||||
expect(s1Rows).toHaveLength(0);
|
||||
expect(s2Rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('cross-agent-group guard: origin session belonging to wrong agent group is rejected', async () => {
|
||||
// Third agent group C sends to B, stamping source_session_id = SC on B's inbound.
|
||||
const C = 'ag-C';
|
||||
createAgentGroup({ id: C, name: 'C', folder: 'c', agent_provider: null, created_at: now() });
|
||||
const SC: Session = {
|
||||
id: 'sess-C',
|
||||
agent_group_id: C,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: '2026-03-01T00:00:00.000Z',
|
||||
};
|
||||
createSession(SC);
|
||||
initSessionFolder(C, SC.id);
|
||||
createDestination({ agent_group_id: C, local_name: 'b', target_type: 'agent', target_id: B, created_at: now() });
|
||||
|
||||
await routeAgentMessage(
|
||||
{ id: 'msg-from-C', platform_id: B, content: JSON.stringify({ text: 'from C' }), in_reply_to: null },
|
||||
SC,
|
||||
);
|
||||
const bRows = readInbound(B, SB.id);
|
||||
const cInboundId = bRows.find((r) => r.platform_id === C)!.id;
|
||||
|
||||
// B replies to A, but in_reply_to references the C-originated row.
|
||||
// Guard rejects (SC belongs to C, not A) → falls through to newest of A.
|
||||
await routeAgentMessage(
|
||||
{ id: 'msg-reply-tamper', platform_id: A, content: JSON.stringify({ text: 'misdirected' }), in_reply_to: cInboundId },
|
||||
SB,
|
||||
);
|
||||
|
||||
const s1Rows = readInbound(A, S1.id);
|
||||
const s2Rows = readInbound(A, S2.id);
|
||||
expect(s1Rows).toHaveLength(0);
|
||||
expect(s2Rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('in_reply_to referencing a non-a2a row falls through to newest session', async () => {
|
||||
// Write a channel message into B's inbound (no source_session_id).
|
||||
writeSessionMessage(B, SB.id, {
|
||||
id: 'channel-msg-1',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
platformId: 'user-123',
|
||||
channelType: 'slack',
|
||||
threadId: null,
|
||||
content: 'hello from slack',
|
||||
});
|
||||
|
||||
// B replies to A with in_reply_to pointing to the channel message.
|
||||
// source_session_id is null → peer-affinity finds nothing → newest of A.
|
||||
await routeAgentMessage(
|
||||
{ id: 'msg-reply-channel', platform_id: A, content: JSON.stringify({ text: 'response' }), in_reply_to: 'channel-msg-1' },
|
||||
SB,
|
||||
);
|
||||
|
||||
const s1Rows = readInbound(A, S1.id);
|
||||
const s2Rows = readInbound(A, S2.id);
|
||||
expect(s1Rows).toHaveLength(0);
|
||||
expect(s2Rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('self-message is allowed without a destination row', async () => {
|
||||
// A targets itself — no agent_destinations row exists for A→A.
|
||||
await routeAgentMessage(
|
||||
{ id: 'self-msg', platform_id: A, content: JSON.stringify({ text: 'self-note' }), in_reply_to: null },
|
||||
S1,
|
||||
);
|
||||
|
||||
// Lands in S2 (newest active session of A via resolveSession fallback).
|
||||
const s2Rows = readInbound(A, S2.id);
|
||||
expect(s2Rows).toHaveLength(1);
|
||||
expect(JSON.parse(s2Rows[0].content).text).toBe('self-note');
|
||||
});
|
||||
|
||||
it('BUG: no volume cap on a2a routing — unbounded ping-pong is allowed (#2063)', async () => {
|
||||
// Two agents can exchange unlimited messages with no rate limit or loop
|
||||
// detection. This test documents the gap — it should FAIL once #2063 lands.
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try {
|
||||
await routeAgentMessage(
|
||||
{ id: `ping-${i}`, platform_id: B, content: JSON.stringify({ text: `ping ${i}` }), in_reply_to: null },
|
||||
S1,
|
||||
);
|
||||
await routeAgentMessage(
|
||||
{ id: `pong-${i}`, platform_id: A, content: JSON.stringify({ text: `pong ${i}` }), in_reply_to: null },
|
||||
SB,
|
||||
);
|
||||
} catch (e) {
|
||||
errors.push((e as Error).message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// BUG: all 40 messages go through — no cap, no throttle.
|
||||
// Once loop prevention lands, this should throw or reject after a threshold.
|
||||
const bRows = readInbound(B, SB.id);
|
||||
const s1Rows = readInbound(A, S1.id);
|
||||
const s2Rows = readInbound(A, S2.id);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(bRows).toHaveLength(20);
|
||||
expect(s1Rows.length + s2Rows.length).toBe(20);
|
||||
});
|
||||
|
||||
it('file forwarding: copies bytes from source outbox to target inbox', async () => {
|
||||
// Place a file in S1's outbox for the message.
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-with-file');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'report.pdf'), 'fake-pdf-bytes');
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-with-file',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['report.pdf'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(1);
|
||||
expect(parsed.attachments[0].name).toBe('report.pdf');
|
||||
expect(parsed.attachments[0].type).toBe('file');
|
||||
|
||||
// Verify actual file bytes were copied to the target inbox.
|
||||
const targetPath = path.join(sessionDir(B, SB.id), parsed.attachments[0].localPath);
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.readFileSync(targetPath, 'utf-8')).toBe('fake-pdf-bytes');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user