Merge remote-tracking branch 'origin/main' into nc-cli

This commit is contained in:
gavrielc
2026-05-08 15:35:03 +03:00
62 changed files with 2919 additions and 254 deletions

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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.
}
}

View File

@@ -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({

View File

@@ -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'));
});
});

View File

@@ -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);

View File

@@ -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');
});
});

View File

@@ -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,

View File

@@ -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();