diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts
index cc537b5..4a2b806 100644
--- a/container/agent-runner/src/integration.test.ts
+++ b/container/agent-runner/src/integration.test.ts
@@ -249,6 +249,51 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
+ it('internal tags between message blocks are stripped from scratchpad', async () => {
+ insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
+
+ const provider = new MockProvider(
+ {},
+ () => 'thinking about this...answerdone thinking',
+ );
+ const controller = new AbortController();
+ const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
+
+ await waitFor(() => getUndeliveredMessages().length > 0, 2000);
+ controller.abort();
+
+ const out = getUndeliveredMessages();
+ expect(out).toHaveLength(1);
+ expect(JSON.parse(out[0].content).text).toBe('answer');
+
+ await loopPromise.catch(() => {});
+ });
+
+ it('handles mixed task + chat batch with correct origin metadata', async () => {
+ // Seed destination for routing lookup
+ insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' });
+ // Task with same routing — simulates a scheduled task in a channel session
+ getInboundDb()
+ .prepare(
+ `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
+ VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
+ )
+ .run(JSON.stringify({ prompt: 'daily check' }));
+
+ const provider = new MockProvider({}, () => 'done');
+ const controller = new AbortController();
+ const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
+
+ await waitFor(() => getUndeliveredMessages().length > 0, 2000);
+ controller.abort();
+
+ const out = getUndeliveredMessages();
+ expect(out).toHaveLength(1);
+ expect(out[0].platform_id).toBe('chan-1');
+
+ await loopPromise.catch(() => {});
+ });
+
it('should inject destination reminder after a compacted event', async () => {
// Two destinations — required for the reminder to fire (single-destination
// groups have a fallback path that works without wrapping).
diff --git a/src/host-core.test.ts b/src/host-core.test.ts
index 043b6b1..70669dd 100644
--- a/src/host-core.test.ts
+++ b/src/host-core.test.ts
@@ -19,6 +19,7 @@ import {
import {
resolveSession,
writeSessionMessage,
+ writeSessionRouting,
initSessionFolder,
sessionDir,
inboundDbPath,
@@ -26,7 +27,7 @@ import {
readOutboxFiles,
clearOutbox,
} from './session-manager.js';
-import { getSession, findSession } from './db/sessions.js';
+import { getSession, findSession, findSessionByAgentGroup } from './db/sessions.js';
import type { InboundEvent } from './channels/adapter.js';
// Mock container runner to prevent actual Docker spawning
@@ -595,6 +596,304 @@ 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();
+ });
+
+ // BUG (#2332): agent-shared resolveSession reuses an existing channel-bound
+ // session via findSessionByAgentGroup instead of creating a dedicated
+ // agent-shared session. The two cannot coexist today — the agent-shared
+ // call finds the channel session and returns it. This test documents the
+ // current (broken) behavior; fixing #2332 should make it pass as written.
+ it.skip('agent-shared and channel-bound sessions coexist for the same agent group', () => {
+ 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: shared } = resolveSession('ag-1', 'mg-1', null, 'shared');
+ const { session: agentShared } = resolveSession('ag-1', null, null, 'agent-shared');
+
+ expect(shared.id).not.toBe(agentShared.id);
+ expect(shared.messaging_group_id).toBe('mg-1');
+ expect(agentShared.messaging_group_id).toBeNull();
+ });
+
+ it('findSessionByAgentGroup returns existing channel-bound session (bug #2332)', () => {
+ // Documents the current behavior: findSessionByAgentGroup doesn't
+ // distinguish agent-shared from channel-bound. When a channel session
+ // exists, agent-shared resolution reuses it instead of creating a
+ // separate session. This is the root cause of A2A misrouting.
+ 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: channelSession } = resolveSession('ag-1', 'mg-1', null, 'shared');
+ const found = findSessionByAgentGroup('ag-1');
+
+ // Bug: picks the channel session — an agent-shared call would get this
+ // instead of a dedicated session.
+ expect(found).toBeDefined();
+ expect(found!.id).toBe(channelSession.id);
+ expect(found!.messaging_group_id).toBe('mg-1'); // should be null for agent-shared
+ });
+});
+
describe('delivery', () => {
it('should detect undelivered messages in outbound DB', () => {
createAgentGroup({