Merge remote-tracking branch 'origin/main' into nc-cli
This commit is contained in:
@@ -307,8 +307,14 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
// Start local HTTP server to receive forwarded Gateway events (including interactions)
|
||||
const webhookUrl = await startLocalWebhookServer(gatewayAdapter, setupConfig, config.botToken);
|
||||
|
||||
// Exponential backoff capped at 1h. Without this, an unrecoverable
|
||||
// failure (e.g., TokenInvalid) restarts ~10×/sec and Discord's
|
||||
// Cloudflare layer issues a multi-hour IP block. A run that lasts
|
||||
// longer than 5 minutes counts as healthy and resets the counter.
|
||||
let consecutiveFailures = 0;
|
||||
const startGateway = () => {
|
||||
if (gatewayAbort?.signal.aborted) return;
|
||||
const startedAt = Date.now();
|
||||
// Capture the long-running listener promise via waitUntil
|
||||
let listenerPromise: Promise<unknown> | undefined;
|
||||
gatewayAdapter.startGatewayListener!(
|
||||
@@ -323,21 +329,30 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
).then(() => {
|
||||
// startGatewayListener resolves immediately with a Response;
|
||||
// the actual work is in the listenerPromise passed to waitUntil
|
||||
if (listenerPromise) {
|
||||
listenerPromise
|
||||
.then(() => {
|
||||
if (!gatewayAbort?.signal.aborted) {
|
||||
log.info('Gateway listener expired, restarting', { adapter: adapter.name });
|
||||
startGateway();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!gatewayAbort?.signal.aborted) {
|
||||
log.error('Gateway listener error, restarting in 5s', { adapter: adapter.name, err });
|
||||
setTimeout(startGateway, 5000);
|
||||
}
|
||||
if (!listenerPromise) return;
|
||||
const reschedule = (err?: unknown) => {
|
||||
if (gatewayAbort?.signal.aborted) return;
|
||||
const ranForMs = Date.now() - startedAt;
|
||||
if (ranForMs > 5 * 60 * 1000) consecutiveFailures = 0;
|
||||
else consecutiveFailures++;
|
||||
const delayMs = Math.min(60 * 60 * 1000, 2 ** consecutiveFailures * 1000);
|
||||
if (err) {
|
||||
log.error('Gateway listener error, retrying', {
|
||||
adapter: adapter.name,
|
||||
err,
|
||||
consecutiveFailures,
|
||||
delayMs,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info('Gateway listener expired, restarting', {
|
||||
adapter: adapter.name,
|
||||
consecutiveFailures,
|
||||
delayMs,
|
||||
});
|
||||
}
|
||||
setTimeout(startGateway, delayMs);
|
||||
};
|
||||
listenerPromise.then(() => reschedule()).catch(reschedule);
|
||||
});
|
||||
};
|
||||
startGateway();
|
||||
|
||||
@@ -171,7 +171,13 @@ CREATE TABLE IF NOT EXISTS messages_in (
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL
|
||||
content TEXT NOT NULL,
|
||||
-- For agent-to-agent inbound rows: the source session that emitted the
|
||||
-- triggering outbound. Used as a return path when the target replies —
|
||||
-- the reply routes back to this exact session, not to the source agent
|
||||
-- group's "newest" session. NULL on channel-side inbound and on a2a rows
|
||||
-- written before this column existed.
|
||||
source_session_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { migrateMessagesInTable } from './session-db.js';
|
||||
import { getInboundSourceSessionId, migrateMessagesInTable } from './session-db.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-session-db-test';
|
||||
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
|
||||
@@ -55,4 +55,40 @@ describe('migrateMessagesInTable', () => {
|
||||
expect(row.series_id).toBe('legacy-1');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('adds source_session_id on a legacy DB, leaves existing rows NULL, is idempotent', () => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.exec(`
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
process_after TEXT,
|
||||
recurrence TEXT,
|
||||
tries INTEGER DEFAULT 0,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.prepare(
|
||||
"INSERT INTO messages_in (id, seq, kind, timestamp, status, content) VALUES (?, ?, 'chat', datetime('now'), 'pending', '{}')",
|
||||
).run('legacy-2', 2);
|
||||
|
||||
migrateMessagesInTable(db);
|
||||
migrateMessagesInTable(db); // idempotent
|
||||
|
||||
const cols = (db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name);
|
||||
expect(cols).toContain('source_session_id');
|
||||
|
||||
expect(getInboundSourceSessionId(db, 'legacy-2')).toBeNull();
|
||||
expect(getInboundSourceSessionId(db, 'does-not-exist')).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,14 +108,21 @@ export function insertMessage(
|
||||
* Host countDueMessages gates on this; container reads everything.
|
||||
*/
|
||||
trigger?: 0 | 1;
|
||||
/**
|
||||
* For agent-to-agent inbound: the source session id that emitted the
|
||||
* outbound message which became this inbound row. Used as the return
|
||||
* path for the target's reply. NULL on channel-side inbound.
|
||||
*/
|
||||
sourceSessionId?: string | null;
|
||||
},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger)
|
||||
VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger)`,
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger, source_session_id)
|
||||
VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId)`,
|
||||
).run({
|
||||
...message,
|
||||
trigger: message.trigger ?? 1,
|
||||
sourceSessionId: message.sourceSessionId ?? null,
|
||||
seq: nextEvenSeq(db),
|
||||
});
|
||||
}
|
||||
@@ -239,6 +246,7 @@ export interface OutboundMessage {
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
content: string;
|
||||
in_reply_to: string | null;
|
||||
}
|
||||
|
||||
export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] {
|
||||
@@ -305,4 +313,47 @@ export function migrateMessagesInTable(db: Database.Database): void {
|
||||
// the agent" semantics, so backfill 1 and default 1 for new inserts.
|
||||
db.prepare('ALTER TABLE messages_in ADD COLUMN trigger INTEGER NOT NULL DEFAULT 1').run();
|
||||
}
|
||||
if (!cols.has('source_session_id')) {
|
||||
// For agent-to-agent return-path routing. NULL on existing rows is fine —
|
||||
// their replies fall back to the legacy "newest active session" lookup.
|
||||
db.prepare('ALTER TABLE messages_in ADD COLUMN source_session_id TEXT').run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an inbound row's source_session_id by its message id. Returns null
|
||||
* if the row doesn't exist or the column is NULL (channel inbound or
|
||||
* pre-migration a2a inbound). Used by a2a routing to route replies back to
|
||||
* the originating session.
|
||||
*/
|
||||
export function getInboundSourceSessionId(db: Database.Database, messageId: string): string | null {
|
||||
const row = db.prepare('SELECT source_session_id FROM messages_in WHERE id = ?').get(messageId) as
|
||||
| { source_session_id: string | null }
|
||||
| undefined;
|
||||
return row?.source_session_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the source_session_id of the most recent a2a inbound row from a
|
||||
* specific peer (by agent group id). Used as a peer-affinity fallback in
|
||||
* a2a routing when an outbound reply has no `in_reply_to` (e.g. the
|
||||
* container's send_message MCP tool path didn't thread the batch's
|
||||
* in_reply_to through).
|
||||
*
|
||||
* Heuristic: "the last time this peer talked to me, which session was it?"
|
||||
* Returns null when no prior a2a inbound from that peer carries a
|
||||
* non-null source_session_id (typical for pre-migration installs).
|
||||
*/
|
||||
export function getMostRecentPeerSourceSessionId(db: Database.Database, peerAgentGroupId: string): string | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT source_session_id FROM messages_in
|
||||
WHERE channel_type = 'agent'
|
||||
AND platform_id = ?
|
||||
AND source_session_id IS NOT NULL
|
||||
ORDER BY seq DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(peerAgentGroupId) as { source_session_id: string | null } | undefined;
|
||||
return row?.source_session_id ?? null;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,9 @@ vi.mock('./config.js', async () => {
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-delivery';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from './db/index.js';
|
||||
import { resolveSession, outboundDbPath } from './session-manager.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } from './db/index.js';
|
||||
import { getDeliveredIds } from './db/session-db.js';
|
||||
import { resolveSession, outboundDbPath, openInboundDb } from './session-manager.js';
|
||||
import { deliverSessionMessages, setDeliveryAdapter } from './delivery.js';
|
||||
|
||||
function now(): string {
|
||||
@@ -146,3 +147,118 @@ describe('deliverSessionMessages — concurrent invocations', () => {
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverSessionMessages — retry and permanent failure', () => {
|
||||
it('retries on adapter failure and marks failed after MAX_DELIVERY_ATTEMPTS (3)', async () => {
|
||||
seedAgentAndChannel();
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
insertOutbound('ag-1', session.id, 'out-flaky');
|
||||
|
||||
let callCount = 0;
|
||||
setDeliveryAdapter({
|
||||
async deliver() {
|
||||
callCount++;
|
||||
throw new Error('network timeout');
|
||||
},
|
||||
});
|
||||
|
||||
// Attempt 1
|
||||
await deliverSessionMessages(session);
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// Attempt 2
|
||||
await deliverSessionMessages(session);
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
// Attempt 3 — should mark as permanently failed
|
||||
await deliverSessionMessages(session);
|
||||
expect(callCount).toBe(3);
|
||||
|
||||
// Attempt 4 — message is now in delivered (as failed), adapter not called
|
||||
await deliverSessionMessages(session);
|
||||
expect(callCount).toBe(3);
|
||||
|
||||
// Verify the message is in the delivered table with 'failed' status
|
||||
const inDb = openInboundDb('ag-1', session.id);
|
||||
const delivered = getDeliveredIds(inDb);
|
||||
inDb.close();
|
||||
expect(delivered.has('out-flaky')).toBe(true);
|
||||
});
|
||||
|
||||
it('clears attempt counter on successful delivery', async () => {
|
||||
seedAgentAndChannel();
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
insertOutbound('ag-1', session.id, 'out-retry-ok');
|
||||
|
||||
let callCount = 0;
|
||||
setDeliveryAdapter({
|
||||
async deliver() {
|
||||
callCount++;
|
||||
if (callCount === 1) throw new Error('transient');
|
||||
return 'plat-ok';
|
||||
},
|
||||
});
|
||||
|
||||
// Attempt 1 — fails
|
||||
await deliverSessionMessages(session);
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// Attempt 2 — succeeds
|
||||
await deliverSessionMessages(session);
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
// Attempt 3 — not called, message already delivered
|
||||
await deliverSessionMessages(session);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverSessionMessages — permission check', () => {
|
||||
it('rejects delivery to an unauthorized channel destination', async () => {
|
||||
seedAgentAndChannel();
|
||||
|
||||
// Create a second messaging group that the agent is NOT wired to
|
||||
createMessagingGroup({
|
||||
id: 'mg-2',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'discord:456',
|
||||
name: 'Unauthorized Chat',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
// Session is on mg-1 (telegram)
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
// Insert an outbound message targeting mg-2 (discord) — not the origin chat
|
||||
const outDb = new Database(outboundDbPath('ag-1', session.id));
|
||||
outDb.prepare(
|
||||
`INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content)
|
||||
VALUES (?, datetime('now'), 'chat', 'discord:456', 'discord', ?)`,
|
||||
).run('out-unauth', JSON.stringify({ text: 'sneaky' }));
|
||||
outDb.close();
|
||||
|
||||
const calls: string[] = [];
|
||||
setDeliveryAdapter({
|
||||
async deliver(_ct, _pid, _tid, _kind, content) {
|
||||
calls.push(content);
|
||||
return 'plat-msg';
|
||||
},
|
||||
});
|
||||
|
||||
// Deliver 3 times to exhaust retries
|
||||
await deliverSessionMessages(session);
|
||||
await deliverSessionMessages(session);
|
||||
await deliverSessionMessages(session);
|
||||
|
||||
// Adapter never called — permission check throws before reaching it
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
// Message is marked as permanently failed
|
||||
const inDb = openInboundDb('ag-1', session.id);
|
||||
const delivered = getDeliveredIds(inDb);
|
||||
inDb.close();
|
||||
expect(delivered.has('out-unauth')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,6 +239,7 @@ async function deliverMessage(
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
content: string;
|
||||
in_reply_to: string | null;
|
||||
},
|
||||
session: Session,
|
||||
inDb: Database.Database,
|
||||
|
||||
@@ -14,6 +14,18 @@ const DEFAULT_SETTINGS_JSON =
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'bun /app/src/compact-instructions.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -71,6 +83,8 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
|
||||
initialized.push('settings.json');
|
||||
} else {
|
||||
ensurePreCompactHook(settingsFile, initialized);
|
||||
}
|
||||
|
||||
// Skills directory — created empty here; symlinks are synced at spawn
|
||||
@@ -90,3 +104,32 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts';
|
||||
|
||||
/**
|
||||
* Patch an existing settings.json to add the PreCompact hook if missing.
|
||||
* Runs on every group init so pre-existing groups pick up the hook.
|
||||
*/
|
||||
function ensurePreCompactHook(settingsFile: string, initialized: string[]): void {
|
||||
try {
|
||||
const raw = fs.readFileSync(settingsFile, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
|
||||
// Check if there's already a PreCompact hook with our command.
|
||||
const existing = settings.hooks?.PreCompact as unknown[] | undefined;
|
||||
if (existing && JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return;
|
||||
|
||||
// Add the hook, preserving existing hooks.
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
if (!settings.hooks.PreCompact) settings.hooks.PreCompact = [];
|
||||
settings.hooks.PreCompact.push({
|
||||
hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }],
|
||||
});
|
||||
|
||||
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
||||
initialized.push('settings.json (added PreCompact hook)');
|
||||
} catch {
|
||||
// Don't break init if settings.json is malformed — it'll use whatever's there.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
initTestDb,
|
||||
closeDb,
|
||||
getDb,
|
||||
runMigrations,
|
||||
createAgentGroup,
|
||||
createMessagingGroup,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import {
|
||||
resolveSession,
|
||||
writeSessionMessage,
|
||||
writeSessionRouting,
|
||||
initSessionFolder,
|
||||
sessionDir,
|
||||
inboundDbPath,
|
||||
@@ -595,6 +597,396 @@ describe('router', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing metadata preservation', () => {
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Test Agent',
|
||||
folder: 'test-agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-1',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'chan-123',
|
||||
name: 'General',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroupAgent({
|
||||
id: 'mga-1',
|
||||
messaging_group_id: 'mg-1',
|
||||
agent_group_id: 'ag-1',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
|
||||
it('routed message carries platformId, channelType, threadId on the messages_in row', async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
|
||||
await routeInbound({
|
||||
channelType: 'discord',
|
||||
platformId: 'chan-123',
|
||||
threadId: 'thread-42',
|
||||
message: { id: 'msg-r1', kind: 'chat', content: JSON.stringify({ sender: 'A', text: 'hi' }), timestamp: now() },
|
||||
});
|
||||
|
||||
const session = findSession('mg-1', null);
|
||||
const db = new Database(inboundDbPath('ag-1', session!.id));
|
||||
const row = db
|
||||
.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in WHERE id LIKE ?')
|
||||
.get('msg-r1%') as {
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
};
|
||||
db.close();
|
||||
|
||||
expect(row.platform_id).toBe('chan-123');
|
||||
expect(row.channel_type).toBe('discord');
|
||||
expect(row.thread_id).toBe('thread-42');
|
||||
});
|
||||
|
||||
it('fan-out gives each agent its own routing, not leaked from sibling', async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
|
||||
createAgentGroup({
|
||||
id: 'ag-2',
|
||||
name: 'Agent Two',
|
||||
folder: 'agent-two',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroupAgent({
|
||||
id: 'mga-2',
|
||||
messaging_group_id: 'mg-1',
|
||||
agent_group_id: 'ag-2',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
await routeInbound({
|
||||
channelType: 'discord',
|
||||
platformId: 'chan-123',
|
||||
threadId: 'thread-fanout',
|
||||
message: { id: 'msg-fo', kind: 'chat', content: JSON.stringify({ text: 'fan' }), timestamp: now() },
|
||||
});
|
||||
|
||||
// Both agents should have the message with correct routing
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
for (const agId of ['ag-1', 'ag-2']) {
|
||||
const sessions = getSessionsByAgentGroup(agId);
|
||||
expect(sessions).toHaveLength(1);
|
||||
const db = new Database(inboundDbPath(agId, sessions[0].id));
|
||||
const row = db.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in LIMIT 1').get() as {
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
};
|
||||
db.close();
|
||||
expect(row.platform_id).toBe('chan-123');
|
||||
expect(row.channel_type).toBe('discord');
|
||||
expect(row.thread_id).toBe('thread-fanout');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeSessionRouting', () => {
|
||||
it('populates session_routing from the messaging group', () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-1',
|
||||
channel_type: 'telegram',
|
||||
platform_id: 'tg:12345',
|
||||
name: 'Chat',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
writeSessionRouting('ag-1', session.id);
|
||||
|
||||
const db = new Database(inboundDbPath('ag-1', session.id));
|
||||
const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as
|
||||
| {
|
||||
channel_type: string | null;
|
||||
platform_id: string | null;
|
||||
thread_id: string | null;
|
||||
}
|
||||
| undefined;
|
||||
db.close();
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.channel_type).toBe('telegram');
|
||||
expect(row!.platform_id).toBe('tg:12345');
|
||||
expect(row!.thread_id).toBeNull();
|
||||
});
|
||||
|
||||
it('writes null routing for agent-shared session (no messaging group)', () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const { session } = resolveSession('ag-1', null, null, 'agent-shared');
|
||||
writeSessionRouting('ag-1', session.id);
|
||||
|
||||
const db = new Database(inboundDbPath('ag-1', session.id));
|
||||
const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as
|
||||
| {
|
||||
channel_type: string | null;
|
||||
platform_id: string | null;
|
||||
thread_id: string | null;
|
||||
}
|
||||
| undefined;
|
||||
db.close();
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.channel_type).toBeNull();
|
||||
expect(row!.platform_id).toBeNull();
|
||||
expect(row!.thread_id).toBeNull();
|
||||
});
|
||||
|
||||
it('includes thread_id from per-thread session', () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-1',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'chan-123',
|
||||
name: 'General',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const { session } = resolveSession('ag-1', 'mg-1', 'thread-77', 'per-thread');
|
||||
writeSessionRouting('ag-1', session.id);
|
||||
|
||||
const db = new Database(inboundDbPath('ag-1', session.id));
|
||||
const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as
|
||||
| {
|
||||
channel_type: string | null;
|
||||
platform_id: string | null;
|
||||
thread_id: string | null;
|
||||
}
|
||||
| undefined;
|
||||
db.close();
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.channel_type).toBe('discord');
|
||||
expect(row!.platform_id).toBe('chan-123');
|
||||
expect(row!.thread_id).toBe('thread-77');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-shared session resolution', () => {
|
||||
it('resolves to the same session on repeated calls', () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const { session: s1, created: c1 } = resolveSession('ag-1', null, null, 'agent-shared');
|
||||
const { session: s2, created: c2 } = resolveSession('ag-1', null, null, 'agent-shared');
|
||||
|
||||
expect(c1).toBe(true);
|
||||
expect(c2).toBe(false);
|
||||
expect(s1.id).toBe(s2.id);
|
||||
});
|
||||
|
||||
it('agent-shared session has null messaging_group_id', () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const { session } = resolveSession('ag-1', null, null, 'agent-shared');
|
||||
expect(session.messaging_group_id).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('agent-to-agent routing', () => {
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
id: 'ag-pa',
|
||||
name: 'PA',
|
||||
folder: 'pa-agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-slack',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'C-GENERAL',
|
||||
name: 'Slack General',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
createAgentGroup({
|
||||
id: 'ag-researcher',
|
||||
name: 'Researcher',
|
||||
folder: 'researcher-agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
// Wire bidirectional A2A destinations (table created by runMigrations)
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES ('ag-pa', 'researcher', 'agent', 'ag-researcher', ?)`,
|
||||
).run(now());
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES ('ag-researcher', 'pa', 'agent', 'ag-pa', ?)`,
|
||||
).run(now());
|
||||
});
|
||||
|
||||
it('A2A outbound lands in a session for the target agent', async () => {
|
||||
const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js');
|
||||
|
||||
const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared');
|
||||
|
||||
await routeAgentMessage(
|
||||
{ id: 'out-a2a-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research this' }), in_reply_to: null },
|
||||
paSlackSession,
|
||||
);
|
||||
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
const researcherSessions = getSessionsByAgentGroup('ag-researcher');
|
||||
expect(researcherSessions.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id));
|
||||
const rows = rDb.prepare('SELECT platform_id, channel_type, content FROM messages_in').all() as Array<{
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
content: string;
|
||||
}>;
|
||||
rDb.close();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].channel_type).toBe('agent');
|
||||
expect(rows[0].platform_id).toBe('ag-pa');
|
||||
expect(JSON.parse(rows[0].content).text).toBe('research this');
|
||||
});
|
||||
|
||||
it('A2A return path routes to originating session, not newest (#2332)', async () => {
|
||||
// PA has Slack session, then gets wired to Discord (newer session).
|
||||
// Researcher responds to PA. With the return-path fix, the reply
|
||||
// routes back to the Slack session (originator) not Discord (newest).
|
||||
const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js');
|
||||
|
||||
const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared');
|
||||
|
||||
createMessagingGroup({
|
||||
id: 'mg-discord',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'chan-discord',
|
||||
name: 'Discord',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
const { session: paDiscordSession } = resolveSession('ag-pa', 'mg-discord', null, 'shared');
|
||||
|
||||
// PA sends from Slack
|
||||
await routeAgentMessage(
|
||||
{ id: 'out-fwd', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research' }), in_reply_to: null },
|
||||
paSlackSession,
|
||||
);
|
||||
|
||||
// Researcher responds back to PA
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
const researcherSession = getSessionsByAgentGroup('ag-researcher')[0];
|
||||
|
||||
await routeAgentMessage(
|
||||
{ id: 'out-reply', platform_id: 'ag-pa', content: JSON.stringify({ text: 'found it' }), in_reply_to: null },
|
||||
researcherSession,
|
||||
);
|
||||
|
||||
const slackDb = new Database(inboundDbPath('ag-pa', paSlackSession.id));
|
||||
const slackA2a = slackDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all();
|
||||
slackDb.close();
|
||||
|
||||
const discordDb = new Database(inboundDbPath('ag-pa', paDiscordSession.id));
|
||||
const discordA2a = discordDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all();
|
||||
discordDb.close();
|
||||
|
||||
// Fixed: response lands in Slack (origin) not Discord (newest)
|
||||
expect(slackA2a).toHaveLength(1);
|
||||
expect(discordA2a).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('BUG: A2A-only session gets null session_routing (#2332)', async () => {
|
||||
// Researcher only has an agent-shared session (no channel wiring).
|
||||
// writeSessionRouting writes nulls because messaging_group_id is null.
|
||||
const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js');
|
||||
|
||||
const { session: paSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared');
|
||||
await routeAgentMessage(
|
||||
{ id: 'out-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'go' }), in_reply_to: null },
|
||||
paSession,
|
||||
);
|
||||
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
const researcherSessions = getSessionsByAgentGroup('ag-researcher');
|
||||
expect(researcherSessions).toHaveLength(1);
|
||||
|
||||
writeSessionRouting('ag-researcher', researcherSessions[0].id);
|
||||
|
||||
const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id));
|
||||
const routing = rDb.prepare('SELECT channel_type, platform_id FROM session_routing WHERE id = 1').get() as
|
||||
| {
|
||||
channel_type: string | null;
|
||||
platform_id: string | null;
|
||||
}
|
||||
| undefined;
|
||||
rDb.close();
|
||||
|
||||
// BUG: session_routing is all null — researcher has no default routing
|
||||
expect(routing).toBeDefined();
|
||||
expect(routing!.channel_type).toBeNull();
|
||||
expect(routing!.platform_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivery', () => {
|
||||
it('should detect undelivered messages in outbound DB', () => {
|
||||
createAgentGroup({
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CLAIM_STUCK_MS,
|
||||
_resetStuckProcessingRowsForTesting,
|
||||
decideStuckAction,
|
||||
parseSqliteUtc,
|
||||
} from './host-sweep.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
@@ -292,3 +293,44 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => {
|
||||
expect(row.tries).toBe(1); // not bumped, the skip path held
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSqliteUtc', () => {
|
||||
// Regression: SQLite TIMESTAMP strings have no zone marker, but Date.parse
|
||||
// treats those as local time. On non-UTC hosts this made every claim look
|
||||
// (TZ offset) hours stale and tripped kill-claim on freshly-claimed messages.
|
||||
// The helper appends "Z" only when no marker is present, so parsing is
|
||||
// always anchored to UTC regardless of host timezone.
|
||||
|
||||
const utcMs = Date.parse('2026-04-20T12:00:00.000Z');
|
||||
|
||||
it('treats a SQLite-style timestamp (no zone) as UTC', () => {
|
||||
expect(parseSqliteUtc('2026-04-20 12:00:00')).toBe(utcMs);
|
||||
expect(parseSqliteUtc('2026-04-20T12:00:00')).toBe(utcMs);
|
||||
expect(parseSqliteUtc('2026-04-20T12:00:00.000')).toBe(utcMs);
|
||||
});
|
||||
|
||||
it('preserves an explicit Z marker', () => {
|
||||
expect(parseSqliteUtc('2026-04-20T12:00:00.000Z')).toBe(utcMs);
|
||||
expect(parseSqliteUtc('2026-04-20T12:00:00z')).toBe(utcMs);
|
||||
});
|
||||
|
||||
it('preserves an explicit numeric offset', () => {
|
||||
// 14:00+02:00 == 12:00 UTC
|
||||
expect(parseSqliteUtc('2026-04-20T14:00:00+02:00')).toBe(utcMs);
|
||||
expect(parseSqliteUtc('2026-04-20T14:00:00+0200')).toBe(utcMs);
|
||||
// 07:00-05:00 == 12:00 UTC
|
||||
expect(parseSqliteUtc('2026-04-20T07:00:00-05:00')).toBe(utcMs);
|
||||
});
|
||||
|
||||
it('returns NaN for unparseable input', () => {
|
||||
expect(Number.isNaN(parseSqliteUtc('not a date'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not drift across host timezones for SQLite-style input', () => {
|
||||
// The helper itself is timezone-independent because it forces UTC parsing.
|
||||
// (Verifying the regex branch — without the helper, `Date.parse` of the
|
||||
// bare string returns different values depending on the host TZ.)
|
||||
const bare = '2026-04-20T12:00:00';
|
||||
expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,17 @@ import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbe
|
||||
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
/**
|
||||
* SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse
|
||||
* treats timezoneless ISO strings as local time, so on non-UTC hosts every
|
||||
* timestamp looks (TZ offset) hours stale — leading to spurious kill-claim
|
||||
* decisions on freshly-claimed messages. Append "Z" when no zone marker is
|
||||
* present so Date.parse interprets the string as UTC.
|
||||
*/
|
||||
export function parseSqliteUtc(s: string): number {
|
||||
return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z');
|
||||
}
|
||||
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
// Absolute idle ceiling for a running container. If the heartbeat file hasn't
|
||||
// been touched in this long, the container is either stuck or doing genuinely
|
||||
@@ -95,7 +106,7 @@ export function decideStuckAction(args: {
|
||||
|
||||
const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0);
|
||||
for (const claim of claims) {
|
||||
const claimedAt = Date.parse(claim.status_changed);
|
||||
const claimedAt = parseSqliteUtc(claim.status_changed);
|
||||
if (Number.isNaN(claimedAt)) continue;
|
||||
const claimAge = now - claimedAt;
|
||||
if (claimAge <= tolerance) continue;
|
||||
@@ -275,7 +286,7 @@ function resetStuckProcessingRows(
|
||||
// Already rescheduled for a future retry — don't bump tries again. The
|
||||
// wake path (sweep step 2) will fire when process_after elapses and a
|
||||
// fresh container will clean the orphan claim on startup.
|
||||
if (msg.processAfter && Date.parse(msg.processAfter) > now) continue;
|
||||
if (msg.processAfter && parseSqliteUtc(msg.processAfter) > now) continue;
|
||||
|
||||
if (msg.tries >= MAX_TRIES) {
|
||||
markMessageFailed(inDb, msg.id);
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { isSafeAttachmentName } from './agent-route.js';
|
||||
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, 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', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-a2a-route' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-a2a-route';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readInbound(agentGroupId: string, sessionId: string) {
|
||||
const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true });
|
||||
const rows = db
|
||||
.prepare('SELECT id, platform_id, channel_type, content, source_session_id FROM messages_in ORDER BY seq')
|
||||
.all() as Array<{
|
||||
id: string;
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
content: string;
|
||||
source_session_id: string | null;
|
||||
}>;
|
||||
db.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* `forwardAttachedFiles` has a filesystem side that's awkward to unit-test
|
||||
* without mocking DATA_DIR. The guarantee worth pinning is that the
|
||||
* filename validator rejects everything that could escape the inbox dir —
|
||||
* `forwardAttachedFiles` runs this guard before any I/O, so traversal is
|
||||
* impossible as long as this matrix holds.
|
||||
*/
|
||||
describe('isSafeAttachmentName', () => {
|
||||
it('accepts plain filenames', () => {
|
||||
expect(isSafeAttachmentName('baby-duck.png')).toBe(true);
|
||||
expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true);
|
||||
expect(isSafeAttachmentName('report.v2.docx')).toBe(true);
|
||||
expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..`
|
||||
expect(isSafeAttachmentName('.hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty / sentinel values', () => {
|
||||
@@ -44,3 +78,359 @@ describe('isSafeAttachmentName', () => {
|
||||
expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Return-path routing: when an a2a reply targets an agent group with multiple
|
||||
* sessions, it must land in the *originating* session — not the newest one.
|
||||
*
|
||||
* Setup: agent A has two active sessions S1 (older) + S2 (newer).
|
||||
* Agent B is the peer A talks to. Bidirectional destinations wired.
|
||||
*/
|
||||
describe('routeAgentMessage return-path', () => {
|
||||
const A = 'ag-A';
|
||||
const B = 'ag-B';
|
||||
let S1: Session;
|
||||
let S2: Session;
|
||||
let SB: Session;
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: A, name: 'A', folder: 'a', agent_provider: null, created_at: now() });
|
||||
createAgentGroup({ id: B, name: 'B', folder: 'b', agent_provider: null, created_at: now() });
|
||||
|
||||
// S1 (older), S2 (newer) — both active sessions on A.
|
||||
S1 = {
|
||||
id: 'sess-A-old',
|
||||
agent_group_id: A,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
S2 = {
|
||||
id: 'sess-A-new',
|
||||
agent_group_id: A,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: '2026-02-01T00:00:00.000Z',
|
||||
};
|
||||
SB = {
|
||||
id: 'sess-B',
|
||||
agent_group_id: B,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: '2026-01-15T00:00:00.000Z',
|
||||
};
|
||||
createSession(S1);
|
||||
createSession(S2);
|
||||
createSession(SB);
|
||||
initSessionFolder(A, S1.id);
|
||||
initSessionFolder(A, S2.id);
|
||||
initSessionFolder(B, SB.id);
|
||||
|
||||
createDestination({
|
||||
agent_group_id: A,
|
||||
local_name: 'b',
|
||||
target_type: 'agent',
|
||||
target_id: B,
|
||||
created_at: now(),
|
||||
});
|
||||
createDestination({
|
||||
agent_group_id: B,
|
||||
local_name: 'a',
|
||||
target_type: 'agent',
|
||||
target_id: A,
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('forward direction: stamps source_session_id on the target inbound row', async () => {
|
||||
// A.S1 emits an outbound a2a to B.
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-from-A-S1',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'hello B' }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
expect(bRows[0].platform_id).toBe(A);
|
||||
expect(bRows[0].source_session_id).toBe(S1.id); // <- the return address
|
||||
});
|
||||
|
||||
it('reply direction: routes back to the originating session, not the newest', async () => {
|
||||
// A.S1 sends to B.
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-from-A-S1',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'ping' }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
// Capture the synthetic id the host stamped on B's inbound — that's what
|
||||
// B's container would reference as `in_reply_to` when replying.
|
||||
const bRows = readInbound(B, SB.id);
|
||||
const yId = bRows[0].id;
|
||||
|
||||
// B replies to that message.
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-from-B',
|
||||
platform_id: A,
|
||||
content: JSON.stringify({ text: 'pong' }),
|
||||
in_reply_to: yId,
|
||||
},
|
||||
SB,
|
||||
);
|
||||
|
||||
const s1Rows = readInbound(A, S1.id);
|
||||
const s2Rows = readInbound(A, S2.id);
|
||||
|
||||
// The reply lands in S1 (originator) even though S2 is newer.
|
||||
expect(s1Rows).toHaveLength(1);
|
||||
expect(s1Rows[0].platform_id).toBe(B);
|
||||
expect(JSON.parse(s1Rows[0].content).text).toBe('pong');
|
||||
expect(s2Rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fallback: a2a with no in_reply_to falls through to newest-session lookup', async () => {
|
||||
// No prior conversation. B initiates an a2a to A out of the blue.
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-from-B-fresh',
|
||||
platform_id: A,
|
||||
content: JSON.stringify({ text: 'unsolicited' }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
SB,
|
||||
);
|
||||
|
||||
// Newest session wins (current heuristic, preserved).
|
||||
const s1Rows = readInbound(A, S1.id);
|
||||
const s2Rows = readInbound(A, S2.id);
|
||||
expect(s1Rows).toHaveLength(0);
|
||||
expect(s2Rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('peer-affinity fallback: with no in_reply_to, routes to most recent peer-source session', async () => {
|
||||
// A.S1 sends to B (establishing affinity: B's last contact from A was via S1).
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-from-A-S1-pre',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'context-establishing' }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
// B sends a follow-up but its container forgot to set in_reply_to (e.g.
|
||||
// emitted via an MCP tool path that doesn't thread the batch's in_reply_to
|
||||
// through). The host should still route this to S1 because S1 is the
|
||||
// session most recently in conversation with B — not the chronologically
|
||||
// newest session of A.
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-from-B-followup',
|
||||
platform_id: A,
|
||||
content: JSON.stringify({ text: 'standing by' }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
SB,
|
||||
);
|
||||
|
||||
const s1Rows = readInbound(A, S1.id);
|
||||
const s2Rows = readInbound(A, S2.id);
|
||||
// Affinity wins: reply to S1, not the newer S2.
|
||||
expect(s1Rows).toHaveLength(1);
|
||||
expect(JSON.parse(s1Rows[0].content).text).toBe('standing by');
|
||||
expect(s2Rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stale origin fallback: closed 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;
|
||||
|
||||
// Close S1 — simulates session cleanup or channel disconnect.
|
||||
updateSession(S1.id, { status: 'closed' });
|
||||
|
||||
// B replies. origin points to S1 (closed), 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,10 +23,11 @@ import path from 'path';
|
||||
|
||||
import { isSafeAttachmentName } from '../../attachment-safety.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { log } from '../../log.js';
|
||||
import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
|
||||
import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { hasDestination } from './db/agent-destinations.js';
|
||||
|
||||
@@ -101,6 +102,61 @@ export interface RoutableAgentMessage {
|
||||
id: string;
|
||||
platform_id: string | null;
|
||||
content: string;
|
||||
/**
|
||||
* For replies, the id of the inbound message being replied to. The
|
||||
* container's formatter sets this from the first inbound in the batch
|
||||
* (`container/agent-runner/src/formatter.ts`). Used here to route the
|
||||
* reply back to the originating session — see `resolveTargetSession`.
|
||||
*/
|
||||
in_reply_to: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick which session of `targetAgentGroupId` should receive this a2a message.
|
||||
*
|
||||
* Three layers, highest-fidelity first:
|
||||
*
|
||||
* 1. **Direct return-path** (in_reply_to lookup): if the message is a reply
|
||||
* (`in_reply_to` set), open the source agent's inbound DB and read the
|
||||
* triggering row's `source_session_id`. That column was stamped when the
|
||||
* original outbound was routed — it's the session that started the
|
||||
* conversation, and replies should land there even when the target has
|
||||
* multiple active sessions.
|
||||
*
|
||||
* 2. **Peer-affinity fallback**: if (1) misses (in_reply_to is null or the
|
||||
* referenced row isn't an a2a inbound), look up the most recent a2a
|
||||
* inbound *from the target agent group* in source's inbound and use its
|
||||
* `source_session_id`. The intuition: the last time this peer talked to
|
||||
* me, which target session was driving? Route the reply there, since
|
||||
* that's the session most plausibly in active conversation.
|
||||
*
|
||||
* 3. **Newest active session**: legacy heuristic. Used when no prior a2a
|
||||
* has been recorded with `source_session_id` (e.g. fresh installs,
|
||||
* pre-migration data).
|
||||
*/
|
||||
function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session, targetAgentGroupId: string): Session {
|
||||
const srcDb = openInboundDb(sourceSession.agent_group_id, sourceSession.id);
|
||||
let originSessionId: string | null = null;
|
||||
try {
|
||||
if (msg.in_reply_to) {
|
||||
originSessionId = getInboundSourceSessionId(srcDb, msg.in_reply_to);
|
||||
}
|
||||
if (!originSessionId) {
|
||||
// Peer-affinity fallback — covers the case where the container's
|
||||
// outbound write didn't carry in_reply_to (e.g. legacy MCP send_message
|
||||
// path, container running pre-fix code).
|
||||
originSessionId = getMostRecentPeerSourceSessionId(srcDb, targetAgentGroupId);
|
||||
}
|
||||
} finally {
|
||||
srcDb.close();
|
||||
}
|
||||
if (originSessionId) {
|
||||
const candidate = getSession(originSessionId);
|
||||
if (candidate && candidate.agent_group_id === targetAgentGroupId && candidate.status === 'active') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return resolveSession(targetAgentGroupId, null, null, 'agent-shared').session;
|
||||
}
|
||||
|
||||
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
|
||||
@@ -119,7 +175,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess
|
||||
if (!getAgentGroup(targetAgentGroupId)) {
|
||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||
}
|
||||
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
||||
const targetSession = resolveTargetSession(msg, session, targetAgentGroupId);
|
||||
const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// If the source message references files (via `send_file`), forward the
|
||||
@@ -137,6 +193,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: forwardedContent,
|
||||
sourceSessionId: session.id,
|
||||
});
|
||||
log.info('Agent message routed', {
|
||||
from: session.agent_group_id,
|
||||
|
||||
@@ -210,6 +210,12 @@ export function writeSessionMessage(
|
||||
* a trigger-1 message does arrive.
|
||||
*/
|
||||
trigger?: 0 | 1;
|
||||
/**
|
||||
* For agent-to-agent inbound: the source session id that emitted the
|
||||
* outbound message which became this inbound row. Used as the return
|
||||
* path so the target's reply routes back to that exact session.
|
||||
*/
|
||||
sourceSessionId?: string | null;
|
||||
},
|
||||
): void {
|
||||
// Extract base64 attachment data, save to inbox, replace with file paths
|
||||
@@ -228,6 +234,7 @@ export function writeSessionMessage(
|
||||
processAfter: message.processAfter ?? null,
|
||||
recurrence: message.recurrence ?? null,
|
||||
trigger: message.trigger ?? 1,
|
||||
sourceSessionId: message.sourceSessionId ?? null,
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
Reference in New Issue
Block a user