Stabilize paired-room orchestration and Codex app-server flow
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { PassThrough } from 'stream';
|
||||
import fs from 'fs';
|
||||
|
||||
// Sentinel markers must match agent-runner.ts
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
@@ -212,4 +213,41 @@ describe('agent-runner timeout behavior', () => {
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.newSessionId).toBe('session-456');
|
||||
});
|
||||
|
||||
it('materializes repo-managed Claude rules into the session CLAUDE.md', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
|
||||
const path = String(p);
|
||||
return (
|
||||
path.includes('dist/index.js') ||
|
||||
path.endsWith('/prompts/claude-platform.md') ||
|
||||
path.endsWith('/global/CLAUDE.md')
|
||||
);
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation((p: fs.PathOrFileDescriptor) => {
|
||||
const path = String(p);
|
||||
if (path.endsWith('/prompts/claude-platform.md')) {
|
||||
return 'Platform Claude Rules';
|
||||
}
|
||||
if (path.endsWith('/global/CLAUDE.md')) {
|
||||
return 'Global Claude Memory';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const resultPromise = runAgentProcess(
|
||||
testGroup,
|
||||
testInput,
|
||||
() => {},
|
||||
undefined,
|
||||
);
|
||||
|
||||
fakeProc.emit('close', 0);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await resultPromise;
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/tmp/nanoclaw-test-data/sessions/test-group/.claude/CLAUDE.md',
|
||||
'Platform Claude Rules\n\n---\n\nGlobal Claude Memory\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { isPairedRoomJid } from './db.js';
|
||||
import {
|
||||
readPairedRoomPrompt,
|
||||
readPlatformPrompt,
|
||||
} from './platform-prompts.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||
@@ -39,6 +44,7 @@ export interface AgentInput {
|
||||
export interface AgentOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
phase?: 'progress' | 'final';
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -50,6 +56,7 @@ export interface AgentOutput {
|
||||
function prepareGroupEnvironment(
|
||||
group: RegisteredGroup,
|
||||
isMain: boolean,
|
||||
chatJid: string,
|
||||
): { env: Record<string, string>; groupDir: string; runnerDir: string } {
|
||||
const projectRoot = process.cwd();
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
@@ -115,6 +122,31 @@ function prepareGroupEnvironment(
|
||||
|
||||
// Global memory directory (for non-main groups)
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
|
||||
const isPairedRoom = isPairedRoomJid(chatJid);
|
||||
|
||||
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
|
||||
const claudePairedRoomPrompt = isPairedRoom
|
||||
? readPairedRoomPrompt('claude-code', projectRoot)
|
||||
: undefined;
|
||||
const globalClaudeMemory =
|
||||
!isMain && fs.existsSync(globalClaudeMdPath)
|
||||
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const sessionClaudeMd = [
|
||||
claudePlatformPrompt,
|
||||
claudePairedRoomPrompt,
|
||||
globalClaudeMemory,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n\n---\n\n')
|
||||
.trim();
|
||||
const sessionClaudeMdPath = path.join(groupSessionsDir, 'CLAUDE.md');
|
||||
if (sessionClaudeMd) {
|
||||
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
|
||||
} else if (fs.existsSync(sessionClaudeMdPath)) {
|
||||
fs.unlinkSync(sessionClaudeMdPath);
|
||||
}
|
||||
|
||||
// Additional mount directories (validated)
|
||||
const extraDirs: string[] = [];
|
||||
@@ -215,13 +247,27 @@ function prepareGroupEnvironment(
|
||||
const authSrc = path.join(hostCodexDir, 'auth.json');
|
||||
const authDst = path.join(sessionCodexDir, 'auth.json');
|
||||
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
|
||||
for (const file of ['config.toml', 'config.json', 'AGENTS.md']) {
|
||||
for (const file of ['config.toml', 'config.json']) {
|
||||
const src = path.join(hostCodexDir, file);
|
||||
const dst = path.join(sessionCodexDir, file);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
}
|
||||
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
|
||||
const codexPlatformPrompt = readPlatformPrompt('codex', projectRoot);
|
||||
const codexPairedRoomPrompt = isPairedRoom
|
||||
? readPairedRoomPrompt('codex', projectRoot)
|
||||
: undefined;
|
||||
const sessionAgents = [codexPlatformPrompt, codexPairedRoomPrompt]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n\n---\n\n')
|
||||
.trim();
|
||||
if (sessionAgents) {
|
||||
fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n');
|
||||
} else if (fs.existsSync(sessionAgentsPath)) {
|
||||
fs.unlinkSync(sessionAgentsPath);
|
||||
}
|
||||
// Sync skills into Codex session dir
|
||||
// SSOT: ~/.claude/skills/ (shared with Claude Code) + runners/skills/
|
||||
const codexSkillSources = [
|
||||
@@ -324,6 +370,7 @@ export async function runAgentProcess(
|
||||
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
||||
group,
|
||||
input.isMain,
|
||||
input.chatJid,
|
||||
);
|
||||
if (input.runId) {
|
||||
env.NANOCLAW_RUN_ID = input.runId;
|
||||
|
||||
@@ -313,11 +313,55 @@ describe('DiscordChannel', () => {
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
content: 'I am a bot',
|
||||
is_from_me: false,
|
||||
is_bot_message: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores self-authored bot messages', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
const msg = createMessage({
|
||||
authorId: '999888777',
|
||||
isBot: true,
|
||||
content: 'I am this bot',
|
||||
});
|
||||
await triggerMessage(msg);
|
||||
|
||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('identifies stored messages authored by the current bot', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
await channel.connect();
|
||||
|
||||
expect(
|
||||
channel.isOwnMessage?.({
|
||||
id: 'msg_bot',
|
||||
chat_jid: 'dc:1234567890123456',
|
||||
sender: '999888777',
|
||||
sender_name: 'Andy',
|
||||
content: 'bot message',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
is_bot_message: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
channel.isOwnMessage?.({
|
||||
id: 'msg_user',
|
||||
chat_jid: 'dc:1234567890123456',
|
||||
sender: '55512345',
|
||||
sender_name: 'Alice',
|
||||
content: 'user message',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses member displayName when available (server nickname)', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../config.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { formatOutbound } from '../router.js';
|
||||
|
||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
||||
@@ -158,6 +159,7 @@ import {
|
||||
AgentType,
|
||||
Channel,
|
||||
ChannelMeta,
|
||||
NewMessage,
|
||||
OnChatMetadata,
|
||||
OnInboundMessage,
|
||||
RegisteredGroup,
|
||||
@@ -446,6 +448,7 @@ export class DiscordChannel implements Channel {
|
||||
for (const [name, id] of Object.entries(mentionMap)) {
|
||||
cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`);
|
||||
}
|
||||
cleaned = formatOutbound(cleaned);
|
||||
|
||||
// Discord has a 2000 character limit per message — split if needed
|
||||
const MAX_LENGTH = 2000;
|
||||
@@ -454,6 +457,11 @@ export class DiscordChannel implements Channel {
|
||||
name: path.basename(f),
|
||||
}));
|
||||
|
||||
if (!cleaned && files.length === 0) {
|
||||
logger.debug({ jid }, 'Skipping empty Discord outbound message');
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleaned.length <= MAX_LENGTH) {
|
||||
await textChannel.send({
|
||||
content: cleaned || undefined,
|
||||
@@ -490,6 +498,10 @@ export class DiscordChannel implements Channel {
|
||||
return groupType === this.agentTypeFilter;
|
||||
}
|
||||
|
||||
isOwnMessage(msg: NewMessage): boolean {
|
||||
return !!this.client?.user?.id && msg.sender === this.client.user.id;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.destroy();
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
deleteTask,
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
getRegisteredAgentTypesForJid,
|
||||
getMessagesSince,
|
||||
getNewMessages,
|
||||
isPairedRoomJid,
|
||||
getSession,
|
||||
getTaskById,
|
||||
setSession,
|
||||
@@ -190,13 +192,12 @@ describe('getMessagesSince', () => {
|
||||
'2024-01-01T00:00:02.000Z',
|
||||
'Andy',
|
||||
);
|
||||
// Should exclude m1, m2 (before/at timestamp); m3 (bot) is now included
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs[0].content).toBe('bot reply');
|
||||
expect(msgs[1].content).toBe('third');
|
||||
});
|
||||
|
||||
it('includes bot messages with is_bot_message flag', () => {
|
||||
it('includes bot messages from other senders', () => {
|
||||
const msgs = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
@@ -206,9 +207,8 @@ describe('getMessagesSince', () => {
|
||||
expect(botMsgs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns all messages including bot when sinceTimestamp is empty', () => {
|
||||
it('returns all messages when sinceTimestamp is empty', () => {
|
||||
const msgs = getMessagesSince('group@g.us', '', 'Andy');
|
||||
// 3 user messages + 1 bot message
|
||||
expect(msgs).toHaveLength(4);
|
||||
});
|
||||
|
||||
@@ -279,7 +279,6 @@ describe('getNewMessages', () => {
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
// Includes bot message, returns all 4 messages
|
||||
expect(messages).toHaveLength(4);
|
||||
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
|
||||
});
|
||||
@@ -290,7 +289,6 @@ describe('getNewMessages', () => {
|
||||
'2024-01-01T00:00:02.000Z',
|
||||
'Andy',
|
||||
);
|
||||
// bot reply (00:00:03) + g1 msg2 (00:00:04) after ts
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0].content).toBe('bot reply');
|
||||
expect(messages[1].content).toBe('g1 msg2');
|
||||
@@ -497,3 +495,41 @@ describe('registered group isMain', () => {
|
||||
expect(group.isMain).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('paired room registration', () => {
|
||||
it('detects when both Claude and Codex are registered on the same jid', () => {
|
||||
setRegisteredGroup('dc:123', {
|
||||
name: 'Paired Room Claude',
|
||||
folder: 'paired-claude',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
agentType: 'claude-code',
|
||||
});
|
||||
setRegisteredGroup('dc:123', {
|
||||
name: 'Paired Room Codex',
|
||||
folder: 'paired-codex',
|
||||
trigger: '@Codex',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
});
|
||||
|
||||
expect(getRegisteredAgentTypesForJid('dc:123').sort()).toEqual([
|
||||
'claude-code',
|
||||
'codex',
|
||||
]);
|
||||
expect(isPairedRoomJid('dc:123')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark solo rooms as paired', () => {
|
||||
setRegisteredGroup('dc:solo', {
|
||||
name: 'Solo Claude Room',
|
||||
folder: 'solo-claude',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
agentType: 'claude-code',
|
||||
});
|
||||
|
||||
expect(getRegisteredAgentTypesForJid('dc:solo')).toEqual(['claude-code']);
|
||||
expect(isPairedRoomJid('dc:solo')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
139
src/db.ts
139
src/db.ts
@@ -13,6 +13,7 @@ import { isValidGroupFolder } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
NewMessage,
|
||||
AgentType,
|
||||
RegisteredGroup,
|
||||
ScheduledTask,
|
||||
TaskRunLog,
|
||||
@@ -82,13 +83,18 @@ function createSchema(database: Database.Database): void {
|
||||
PRIMARY KEY (group_folder, agent_type)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
jid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
folder TEXT NOT NULL,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
work_dir TEXT,
|
||||
PRIMARY KEY (jid, agent_type),
|
||||
UNIQUE (folder, agent_type)
|
||||
);
|
||||
`);
|
||||
|
||||
@@ -114,33 +120,80 @@ function createSchema(database: Database.Database): void {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add is_main column if it doesn't exist (migration for existing DBs)
|
||||
try {
|
||||
database.exec(
|
||||
`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`,
|
||||
// Migrate registered_groups to composite keys so Claude/Codex can share a jid/folder.
|
||||
const registeredGroupsSql = (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined
|
||||
)?.sql;
|
||||
if (
|
||||
registeredGroupsSql &&
|
||||
!registeredGroupsSql.includes('PRIMARY KEY (jid, agent_type)')
|
||||
) {
|
||||
const registeredGroupCols = database
|
||||
.prepare('PRAGMA table_info(registered_groups)')
|
||||
.all() as Array<{ name: string }>;
|
||||
const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main');
|
||||
const hasAgentType = registeredGroupCols.some(
|
||||
(col) => col.name === 'agent_type',
|
||||
);
|
||||
const hasWorkDir = registeredGroupCols.some((col) => col.name === 'work_dir');
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE registered_groups_new (
|
||||
jid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
work_dir TEXT,
|
||||
PRIMARY KEY (jid, agent_type),
|
||||
UNIQUE (folder, agent_type)
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
INSERT INTO registered_groups_new (
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
container_config,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
agent_type,
|
||||
work_dir
|
||||
)
|
||||
SELECT
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
container_config,
|
||||
requires_trigger,
|
||||
${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"},
|
||||
${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"},
|
||||
${hasWorkDir ? 'work_dir' : 'NULL'}
|
||||
FROM registered_groups;
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
DROP TABLE registered_groups;
|
||||
ALTER TABLE registered_groups_new RENAME TO registered_groups;
|
||||
`);
|
||||
} else {
|
||||
// Backfill: existing rows with folder = 'main' are the main group
|
||||
database.exec(
|
||||
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`,
|
||||
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`,
|
||||
);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add agent_type column if it doesn't exist (migration for Codex support)
|
||||
try {
|
||||
database.exec(
|
||||
`ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`,
|
||||
);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add work_dir column if it doesn't exist (migration for per-group working directory)
|
||||
try {
|
||||
database.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Migrate sessions table to composite PK (group_folder, agent_type)
|
||||
@@ -300,9 +353,9 @@ export function getNewMessages(
|
||||
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
||||
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
// Filter legacy prefixed outbound messages as a backstop for rows written
|
||||
// before explicit bot flags existed. Self-message filtering is channel-specific
|
||||
// and happens in message-runtime so cross-bot collaboration still works.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||
@@ -333,9 +386,9 @@ export function getMessagesSince(
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): NewMessage[] {
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
// Filter legacy prefixed outbound messages as a backstop for rows written
|
||||
// before explicit bot flags existed. Self-message filtering is channel-specific
|
||||
// and happens in message-runtime so cross-bot collaboration still works.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||
@@ -681,6 +734,28 @@ export function getAllRegisteredGroups(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
|
||||
if (!db) return [];
|
||||
|
||||
const rows = db
|
||||
.prepare('SELECT agent_type FROM registered_groups WHERE jid = ?')
|
||||
.all(jid) as Array<{ agent_type: string | null }>;
|
||||
|
||||
const types = new Set<AgentType>();
|
||||
for (const row of rows) {
|
||||
const agentType = row.agent_type as AgentType | null;
|
||||
if (agentType === 'claude-code' || agentType === 'codex') {
|
||||
types.add(agentType);
|
||||
}
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
|
||||
export function isPairedRoomJid(jid: string): boolean {
|
||||
const types = getRegisteredAgentTypesForJid(jid);
|
||||
return types.includes('claude-code') && types.includes('codex');
|
||||
}
|
||||
|
||||
// --- JSON migration ---
|
||||
|
||||
function migrateJsonState(): void {
|
||||
|
||||
@@ -166,6 +166,29 @@ describe('GroupQueue', () => {
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does not bypass retry backoff when new messages arrive', async () => {
|
||||
let callCount = 0;
|
||||
|
||||
const processMessages = vi.fn(async () => {
|
||||
callCount++;
|
||||
return false;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
// --- Shutdown prevents new enqueues ---
|
||||
|
||||
it('prevents new enqueues after shutdown', async () => {
|
||||
|
||||
@@ -32,6 +32,8 @@ interface GroupState {
|
||||
processName: string | null;
|
||||
groupFolder: string | null;
|
||||
retryCount: number;
|
||||
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||
retryScheduledAt: number | null;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
@@ -68,6 +70,8 @@ export class GroupQueue {
|
||||
processName: null,
|
||||
groupFolder: null,
|
||||
retryCount: 0,
|
||||
retryTimer: null,
|
||||
retryScheduledAt: null,
|
||||
startedAt: null,
|
||||
};
|
||||
this.groups.set(groupJid, state);
|
||||
@@ -101,6 +105,22 @@ export class GroupQueue {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
state.retryScheduledAt !== null &&
|
||||
Date.now() < state.retryScheduledAt
|
||||
) {
|
||||
state.pendingMessages = true;
|
||||
logger.debug(
|
||||
{
|
||||
groupJid,
|
||||
retryCount: state.retryCount,
|
||||
retryScheduledAt: state.retryScheduledAt,
|
||||
},
|
||||
'Retry backoff active, message queued until retry window opens',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeCount >= MAX_CONCURRENT_AGENTS) {
|
||||
state.pendingMessages = true;
|
||||
if (!this.waitingGroups.includes(groupJid)) {
|
||||
@@ -335,6 +355,7 @@ export class GroupQueue {
|
||||
});
|
||||
if (success) {
|
||||
state.retryCount = 0;
|
||||
state.retryScheduledAt = null;
|
||||
} else {
|
||||
outcome = 'retry_scheduled';
|
||||
this.scheduleRetry(groupJid, state, runId);
|
||||
@@ -415,6 +436,11 @@ export class GroupQueue {
|
||||
): void {
|
||||
state.retryCount++;
|
||||
if (state.retryCount > MAX_RETRIES) {
|
||||
if (state.retryTimer) {
|
||||
clearTimeout(state.retryTimer);
|
||||
state.retryTimer = null;
|
||||
}
|
||||
state.retryScheduledAt = null;
|
||||
logger.error(
|
||||
{ groupJid, runId, retryCount: state.retryCount },
|
||||
'Max retries exceeded, dropping messages (will retry on next incoming message)',
|
||||
@@ -424,11 +450,17 @@ export class GroupQueue {
|
||||
}
|
||||
|
||||
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
|
||||
state.retryScheduledAt = Date.now() + delayMs;
|
||||
logger.info(
|
||||
{ groupJid, runId, retryCount: state.retryCount, delayMs },
|
||||
'Scheduling retry with backoff',
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (state.retryTimer) {
|
||||
clearTimeout(state.retryTimer);
|
||||
}
|
||||
state.retryTimer = setTimeout(() => {
|
||||
state.retryTimer = null;
|
||||
state.retryScheduledAt = null;
|
||||
if (!this.shuttingDown) {
|
||||
this.enqueueMessageCheck(groupJid);
|
||||
}
|
||||
|
||||
461
src/message-runtime.test.ts
Normal file
461
src/message-runtime.test.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./agent-runner.js', () => ({
|
||||
runAgentProcess: vi.fn(),
|
||||
writeGroupsSnapshot: vi.fn(),
|
||||
writeTasksSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
isSessionCommandSenderAllowed: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('./db.js', () => ({
|
||||
getAllChats: vi.fn(() => []),
|
||||
getAllTasks: vi.fn(() => []),
|
||||
getLastHumanMessageTimestamp: vi.fn(() => null),
|
||||
getMessagesSince: vi.fn(),
|
||||
getNewMessages: vi.fn(() => ({ messages: [], newTimestamp: '' })),
|
||||
}));
|
||||
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./sender-allowlist.js', () => ({
|
||||
isTriggerAllowed: vi.fn(() => true),
|
||||
loadSenderAllowlist: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('./session-commands.js', () => ({
|
||||
extractSessionCommand: vi.fn(() => null),
|
||||
handleSessionCommand: vi.fn(async () => ({ handled: false })),
|
||||
isSessionCommandAllowed: vi.fn(() => true),
|
||||
isSessionCommandControlMessage: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import * as agentRunner from './agent-runner.js';
|
||||
import * as db from './db.js';
|
||||
import { createMessageRuntime } from './message-runtime.js';
|
||||
import type { Channel, RegisteredGroup } from './types.js';
|
||||
|
||||
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
|
||||
return {
|
||||
name: 'Test Group',
|
||||
folder: `test-${agentType}`,
|
||||
trigger: '@Andy',
|
||||
added_at: new Date().toISOString(),
|
||||
requiresTrigger: false,
|
||||
agentType,
|
||||
};
|
||||
}
|
||||
|
||||
function makeChannel(chatJid: string): Channel {
|
||||
return {
|
||||
name: 'discord',
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
isConnected: vi.fn(() => true),
|
||||
ownsJid: vi.fn((jid: string) => jid === chatJid),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
setTyping: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('createMessageRuntime', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('clears Claude sessions and closes stdin immediately on poisoned output', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('claude-code');
|
||||
const channel = makeChannel(chatJid);
|
||||
const closeStdin = vi.fn();
|
||||
const notifyIdle = vi.fn();
|
||||
const persistSession = vi.fn();
|
||||
const clearSession = vi.fn();
|
||||
const saveState = vi.fn();
|
||||
const lastAgentTimestamps: Record<string, string> = {};
|
||||
|
||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||
{
|
||||
id: 'msg-1',
|
||||
chat_jid: chatJid,
|
||||
sender: 'user@test',
|
||||
sender_name: 'User',
|
||||
content: 'hello',
|
||||
timestamp: '2026-03-18T09:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||
async (_group, _input, _onProcess, onOutput) => {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
result:
|
||||
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
|
||||
newSessionId: 'session-123',
|
||||
});
|
||||
return {
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId: 'session-123',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const runtime = createMessageRuntime({
|
||||
assistantName: 'Andy',
|
||||
idleTimeout: 1_000,
|
||||
pollInterval: 1_000,
|
||||
timezone: 'UTC',
|
||||
triggerPattern: /^@Andy\b/i,
|
||||
channels: [channel],
|
||||
queue: {
|
||||
registerProcess: vi.fn(),
|
||||
closeStdin,
|
||||
notifyIdle,
|
||||
} as any,
|
||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||
getSessions: () => ({}),
|
||||
getLastTimestamp: () => '',
|
||||
setLastTimestamp: vi.fn(),
|
||||
getLastAgentTimestamps: () => lastAgentTimestamps,
|
||||
saveState,
|
||||
persistSession,
|
||||
clearSession,
|
||||
});
|
||||
|
||||
const result = await runtime.processGroupMessages(chatJid, {
|
||||
runId: 'run-1',
|
||||
reason: 'messages',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(persistSession).toHaveBeenCalledWith(group.folder, 'session-123');
|
||||
expect(clearSession).toHaveBeenCalledWith(group.folder);
|
||||
expect(closeStdin).toHaveBeenCalledWith(chatJid, {
|
||||
runId: 'run-1',
|
||||
reason: 'poisoned-session',
|
||||
});
|
||||
expect(notifyIdle).not.toHaveBeenCalled();
|
||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||
chatJid,
|
||||
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
|
||||
);
|
||||
expect(lastAgentTimestamps[chatJid]).toBe('2026-03-18T09:00:00.000Z');
|
||||
expect(saveState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not apply the poisoned-session handling to Codex groups', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('codex');
|
||||
const channel = makeChannel(chatJid);
|
||||
const closeStdin = vi.fn();
|
||||
const notifyIdle = vi.fn();
|
||||
const clearSession = vi.fn();
|
||||
|
||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||
{
|
||||
id: 'msg-1',
|
||||
chat_jid: chatJid,
|
||||
sender: 'user@test',
|
||||
sender_name: 'User',
|
||||
content: 'hello',
|
||||
timestamp: '2026-03-18T09:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||
async (_group, _input, _onProcess, onOutput) => {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
result:
|
||||
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
|
||||
newSessionId: 'session-456',
|
||||
});
|
||||
return {
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId: 'session-456',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const runtime = createMessageRuntime({
|
||||
assistantName: 'Andy',
|
||||
idleTimeout: 1_000,
|
||||
pollInterval: 1_000,
|
||||
timezone: 'UTC',
|
||||
triggerPattern: /^@Andy\b/i,
|
||||
channels: [channel],
|
||||
queue: {
|
||||
registerProcess: vi.fn(),
|
||||
closeStdin,
|
||||
notifyIdle,
|
||||
} as any,
|
||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||
getSessions: () => ({}),
|
||||
getLastTimestamp: () => '',
|
||||
setLastTimestamp: vi.fn(),
|
||||
getLastAgentTimestamps: () => ({}),
|
||||
saveState: vi.fn(),
|
||||
persistSession: vi.fn(),
|
||||
clearSession,
|
||||
});
|
||||
|
||||
const result = await runtime.processGroupMessages(chatJid, {
|
||||
runId: 'run-2',
|
||||
reason: 'messages',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(clearSession).not.toHaveBeenCalled();
|
||||
expect(closeStdin).not.toHaveBeenCalled();
|
||||
expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-2');
|
||||
});
|
||||
|
||||
it('streams Codex progress as a normal room message and only marks idle after the turn settles', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('codex');
|
||||
const channel = makeChannel(chatJid);
|
||||
const notifyIdle = vi.fn();
|
||||
const persistSession = vi.fn();
|
||||
|
||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||
{
|
||||
id: 'msg-1',
|
||||
chat_jid: chatJid,
|
||||
sender: 'user@test',
|
||||
sender_name: 'User',
|
||||
content: 'hello',
|
||||
timestamp: '2026-03-19T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||
async (_group, _input, _onProcess, onOutput) => {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
result: 'CI 상태 확인 중입니다.',
|
||||
newSessionId: 'session-progress',
|
||||
});
|
||||
expect(notifyIdle).not.toHaveBeenCalled();
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId: 'session-progress',
|
||||
});
|
||||
return {
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId: 'session-progress',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const runtime = createMessageRuntime({
|
||||
assistantName: 'Andy',
|
||||
idleTimeout: 1_000,
|
||||
pollInterval: 1_000,
|
||||
timezone: 'UTC',
|
||||
triggerPattern: /^@Andy\b/i,
|
||||
channels: [channel],
|
||||
queue: {
|
||||
registerProcess: vi.fn(),
|
||||
closeStdin: vi.fn(),
|
||||
notifyIdle,
|
||||
} as any,
|
||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||
getSessions: () => ({}),
|
||||
getLastTimestamp: () => '',
|
||||
setLastTimestamp: vi.fn(),
|
||||
getLastAgentTimestamps: () => ({}),
|
||||
saveState: vi.fn(),
|
||||
persistSession,
|
||||
clearSession: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await runtime.processGroupMessages(chatJid, {
|
||||
runId: 'run-progress',
|
||||
reason: 'messages',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||
chatJid,
|
||||
'CI 상태 확인 중입니다.',
|
||||
);
|
||||
expect(notifyIdle).toHaveBeenCalledTimes(1);
|
||||
expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-progress');
|
||||
expect(persistSession).toHaveBeenCalledWith(
|
||||
group.folder,
|
||||
'session-progress',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps progress separate from the final Codex answer', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('codex');
|
||||
const channel = makeChannel(chatJid);
|
||||
const notifyIdle = vi.fn();
|
||||
|
||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||
{
|
||||
id: 'msg-1',
|
||||
chat_jid: chatJid,
|
||||
sender: 'user@test',
|
||||
sender_name: 'User',
|
||||
content: 'hello',
|
||||
timestamp: '2026-03-19T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||
async (_group, _input, _onProcess, onOutput) => {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
result: '테스트를 돌리는 중입니다.',
|
||||
newSessionId: 'session-final',
|
||||
});
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
phase: 'final',
|
||||
result: '테스트가 끝났습니다.',
|
||||
newSessionId: 'session-final',
|
||||
});
|
||||
return {
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId: 'session-final',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const runtime = createMessageRuntime({
|
||||
assistantName: 'Andy',
|
||||
idleTimeout: 1_000,
|
||||
pollInterval: 1_000,
|
||||
timezone: 'UTC',
|
||||
triggerPattern: /^@Andy\b/i,
|
||||
channels: [channel],
|
||||
queue: {
|
||||
registerProcess: vi.fn(),
|
||||
closeStdin: vi.fn(),
|
||||
notifyIdle,
|
||||
} as any,
|
||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||
getSessions: () => ({}),
|
||||
getLastTimestamp: () => '',
|
||||
setLastTimestamp: vi.fn(),
|
||||
getLastAgentTimestamps: () => ({}),
|
||||
saveState: vi.fn(),
|
||||
persistSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await runtime.processGroupMessages(chatJid, {
|
||||
runId: 'run-final',
|
||||
reason: 'messages',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(channel.sendMessage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
chatJid,
|
||||
'테스트를 돌리는 중입니다.',
|
||||
);
|
||||
expect(channel.sendMessage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
chatJid,
|
||||
'테스트가 끝났습니다.',
|
||||
);
|
||||
expect(notifyIdle).toHaveBeenCalledTimes(1);
|
||||
expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-final');
|
||||
});
|
||||
|
||||
it('does not roll back when a streamed progress message was already posted before an error', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('codex');
|
||||
const channel = makeChannel(chatJid);
|
||||
const saveState = vi.fn();
|
||||
const lastAgentTimestamps: Record<string, string> = {};
|
||||
|
||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||
{
|
||||
id: 'msg-1',
|
||||
chat_jid: chatJid,
|
||||
sender: 'user@test',
|
||||
sender_name: 'User',
|
||||
content: 'hello',
|
||||
timestamp: '2026-03-19T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||
async (_group, _input, _onProcess, onOutput) => {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
result: '중간 진행상황입니다.',
|
||||
newSessionId: 'session-error',
|
||||
});
|
||||
await onOutput?.({
|
||||
status: 'error',
|
||||
result: null,
|
||||
newSessionId: 'session-error',
|
||||
error: 'temporary failure',
|
||||
});
|
||||
return {
|
||||
status: 'error',
|
||||
result: null,
|
||||
newSessionId: 'session-error',
|
||||
error: 'temporary failure',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const runtime = createMessageRuntime({
|
||||
assistantName: 'Andy',
|
||||
idleTimeout: 1_000,
|
||||
pollInterval: 1_000,
|
||||
timezone: 'UTC',
|
||||
triggerPattern: /^@Andy\b/i,
|
||||
channels: [channel],
|
||||
queue: {
|
||||
registerProcess: vi.fn(),
|
||||
closeStdin: vi.fn(),
|
||||
notifyIdle: vi.fn(),
|
||||
} as any,
|
||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||
getSessions: () => ({}),
|
||||
getLastTimestamp: () => '',
|
||||
setLastTimestamp: vi.fn(),
|
||||
getLastAgentTimestamps: () => lastAgentTimestamps,
|
||||
saveState,
|
||||
persistSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await runtime.processGroupMessages(chatJid, {
|
||||
runId: 'run-progress-error',
|
||||
reason: 'messages',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||
chatJid,
|
||||
'중간 진행상황입니다.',
|
||||
);
|
||||
expect(lastAgentTimestamps[chatJid]).toBe('2026-03-19T00:00:00.000Z');
|
||||
expect(saveState).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,15 @@ import {
|
||||
} from './db.js';
|
||||
import { isSessionCommandSenderAllowed } from './config.js';
|
||||
import { GroupQueue, GroupRunContext } from './group-queue.js';
|
||||
import { findChannel, formatMessages } from './router.js';
|
||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
|
||||
import {
|
||||
extractSessionCommand,
|
||||
handleSessionCommand,
|
||||
isSessionCommandAllowed,
|
||||
isSessionCommandControlMessage,
|
||||
} from './session-commands.js';
|
||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
@@ -68,6 +70,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
} {
|
||||
let messageLoopRunning = false;
|
||||
|
||||
const filterOwnChannelMessages = (messages: NewMessage[]): NewMessage[] =>
|
||||
messages.filter((msg) => {
|
||||
const channel = findChannel(deps.channels, msg.chat_jid);
|
||||
if (channel?.isOwnMessage?.(msg)) {
|
||||
return false;
|
||||
}
|
||||
if (msg.is_bot_message && isSessionCommandControlMessage(msg.content)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const getCurrentAvailableGroups = (): AvailableGroup[] =>
|
||||
getAvailableGroups(deps.getRegisteredGroups());
|
||||
|
||||
@@ -79,6 +93,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||
): Promise<'success' | 'error'> => {
|
||||
const isMain = group.isMain === true;
|
||||
const isClaudeCodeAgent = (group.agentType || 'claude-code') === 'claude-code';
|
||||
const sessions = deps.getSessions();
|
||||
const sessionId = sessions[group.folder];
|
||||
|
||||
@@ -99,11 +114,19 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
|
||||
writeGroupsSnapshot(group.folder, isMain, getCurrentAvailableGroups());
|
||||
|
||||
let resetSessionRequested = false;
|
||||
|
||||
const wrappedOnOutput = onOutput
|
||||
? async (output: AgentOutput) => {
|
||||
if (output.newSessionId) {
|
||||
deps.persistSession(group.folder, output.newSessionId);
|
||||
}
|
||||
if (
|
||||
isClaudeCodeAgent &&
|
||||
shouldResetSessionOnAgentFailure(output)
|
||||
) {
|
||||
resetSessionRequested = true;
|
||||
}
|
||||
await onOutput(output);
|
||||
}
|
||||
: undefined;
|
||||
@@ -129,6 +152,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
deps.persistSession(group.folder, output.newSessionId);
|
||||
}
|
||||
|
||||
if (
|
||||
isClaudeCodeAgent &&
|
||||
(resetSessionRequested ||
|
||||
shouldResetSessionOnAgentFailure(output))
|
||||
) {
|
||||
deps.clearSession(group.folder);
|
||||
logger.warn(
|
||||
{ group: group.name, chatJid, runId },
|
||||
'Cleared poisoned agent session after unrecoverable error',
|
||||
);
|
||||
}
|
||||
|
||||
if (output.status === 'error') {
|
||||
logger.error(
|
||||
{ group: group.name, chatJid, runId, error: output.error },
|
||||
@@ -171,10 +206,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
const isMainGroup = group.isMain === true;
|
||||
const lastAgentTimestamps = deps.getLastAgentTimestamps();
|
||||
const sinceTimestamp = lastAgentTimestamps[chatJid] || '';
|
||||
const missedMessages = getMessagesSince(
|
||||
chatJid,
|
||||
sinceTimestamp,
|
||||
deps.assistantName,
|
||||
const missedMessages = filterOwnChannelMessages(
|
||||
getMessagesSince(
|
||||
chatJid,
|
||||
sinceTimestamp,
|
||||
deps.assistantName,
|
||||
),
|
||||
);
|
||||
|
||||
if (missedMessages.length === 0) {
|
||||
@@ -280,6 +317,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
);
|
||||
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let latestProgressText: string | null = null;
|
||||
|
||||
const resetIdleTimer = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
@@ -296,7 +334,20 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
};
|
||||
|
||||
let hadError = false;
|
||||
let outputSentToUser = false;
|
||||
let finalOutputSentToUser = false;
|
||||
let progressOutputSentToUser = false;
|
||||
let poisonedSessionDetected = false;
|
||||
const isClaudeCodeAgent = (group.agentType || 'claude-code') === 'claude-code';
|
||||
|
||||
const sendProgressMessage = async (text: string) => {
|
||||
if (!text || text === latestProgressText) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestProgressText = text;
|
||||
await channel.sendMessage(chatJid, text);
|
||||
progressOutputSentToUser = true;
|
||||
};
|
||||
|
||||
await channel.setTyping?.(chatJid, true);
|
||||
|
||||
@@ -306,14 +357,30 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
chatJid,
|
||||
runId,
|
||||
async (result) => {
|
||||
if (
|
||||
isClaudeCodeAgent &&
|
||||
shouldResetSessionOnAgentFailure(result) &&
|
||||
!poisonedSessionDetected
|
||||
) {
|
||||
poisonedSessionDetected = true;
|
||||
hadError = true;
|
||||
deps.clearSession(group.folder);
|
||||
deps.queue.closeStdin(chatJid, {
|
||||
runId,
|
||||
reason: 'poisoned-session',
|
||||
});
|
||||
logger.warn(
|
||||
{ chatJid, group: group.name, groupFolder: group.folder, runId },
|
||||
'Detected poisoned Claude session from streamed output, forcing close',
|
||||
);
|
||||
}
|
||||
|
||||
if (result.result) {
|
||||
const raw =
|
||||
typeof result.result === 'string'
|
||||
? result.result
|
||||
: JSON.stringify(result.result);
|
||||
const text = raw
|
||||
.replace(/<internal>[\s\S]*?<\/internal>/g, '')
|
||||
.trim();
|
||||
const text = formatOutbound(raw);
|
||||
logger.info(
|
||||
{
|
||||
chatJid,
|
||||
@@ -324,16 +391,26 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
},
|
||||
`Agent output: ${raw.slice(0, 200)}`,
|
||||
);
|
||||
if (result.phase === 'progress') {
|
||||
if (text) {
|
||||
await sendProgressMessage(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
await channel.sendMessage(chatJid, text);
|
||||
outputSentToUser = true;
|
||||
finalOutputSentToUser = true;
|
||||
}
|
||||
}
|
||||
|
||||
await channel.setTyping?.(chatJid, false);
|
||||
resetIdleTimer();
|
||||
|
||||
if (result.status === 'success') {
|
||||
if (!poisonedSessionDetected) {
|
||||
resetIdleTimer();
|
||||
}
|
||||
|
||||
if (result.status === 'success' && !poisonedSessionDetected) {
|
||||
deps.queue.notifyIdle(chatJid, runId);
|
||||
}
|
||||
|
||||
@@ -352,10 +429,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
|
||||
if (hadError) {
|
||||
if (outputSentToUser) {
|
||||
if (finalOutputSentToUser || progressOutputSentToUser) {
|
||||
logger.warn(
|
||||
{ chatJid, group: group.name, groupFolder: group.folder, runId },
|
||||
'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
|
||||
'Agent error after conversational output was sent, skipping cursor rollback to prevent duplicates',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -374,7 +451,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
outputSentToUser,
|
||||
outputSentToUser: finalOutputSentToUser,
|
||||
progressOutputSentToUser,
|
||||
},
|
||||
'Queued run completed successfully',
|
||||
);
|
||||
@@ -395,18 +473,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
try {
|
||||
const registeredGroups = deps.getRegisteredGroups();
|
||||
const jids = Object.keys(registeredGroups);
|
||||
const { messages, newTimestamp } = getNewMessages(
|
||||
const { messages: rawMessages, newTimestamp } = getNewMessages(
|
||||
jids,
|
||||
deps.getLastTimestamp(),
|
||||
deps.assistantName,
|
||||
);
|
||||
const messages = filterOwnChannelMessages(rawMessages);
|
||||
|
||||
if (messages.length > 0) {
|
||||
if (rawMessages.length > 0) {
|
||||
logger.info({ count: messages.length }, 'New messages');
|
||||
|
||||
deps.setLastTimestamp(newTimestamp);
|
||||
deps.saveState();
|
||||
|
||||
if (messages.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const messagesByGroup = new Map<string, NewMessage[]>();
|
||||
for (const msg of messages) {
|
||||
const existing = messagesByGroup.get(msg.chat_jid);
|
||||
@@ -484,10 +567,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
}
|
||||
|
||||
const lastAgentTimestamps = deps.getLastAgentTimestamps();
|
||||
const allPending = getMessagesSince(
|
||||
chatJid,
|
||||
lastAgentTimestamps[chatJid] || '',
|
||||
deps.assistantName,
|
||||
const allPending = filterOwnChannelMessages(
|
||||
getMessagesSince(
|
||||
chatJid,
|
||||
lastAgentTimestamps[chatJid] || '',
|
||||
deps.assistantName,
|
||||
),
|
||||
);
|
||||
const messagesToSend =
|
||||
allPending.length > 0 ? allPending : groupMessages;
|
||||
@@ -526,10 +611,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
const lastAgentTimestamps = deps.getLastAgentTimestamps();
|
||||
for (const [chatJid, group] of Object.entries(registeredGroups)) {
|
||||
const sinceTimestamp = lastAgentTimestamps[chatJid] || '';
|
||||
const pending = getMessagesSince(
|
||||
chatJid,
|
||||
sinceTimestamp,
|
||||
deps.assistantName,
|
||||
const pending = filterOwnChannelMessages(
|
||||
getMessagesSince(
|
||||
chatJid,
|
||||
sinceTimestamp,
|
||||
deps.assistantName,
|
||||
),
|
||||
);
|
||||
if (pending.length > 0) {
|
||||
logger.info(
|
||||
|
||||
58
src/platform-prompts.test.ts
Normal file
58
src/platform-prompts.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getPairedRoomPromptPath,
|
||||
getPlatformPromptPath,
|
||||
readPairedRoomPrompt,
|
||||
readPlatformPrompt,
|
||||
} from './platform-prompts.js';
|
||||
|
||||
describe('platform-prompts', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-prompts-'));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns undefined when the prompt file is missing', () => {
|
||||
expect(readPlatformPrompt('claude-code')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads and trims provider-specific prompt files', () => {
|
||||
const promptsDir = path.join(tempDir, 'prompts');
|
||||
fs.mkdirSync(promptsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(promptsDir, 'codex-platform.md'),
|
||||
'\nCodex platform prompt\n',
|
||||
);
|
||||
|
||||
expect(getPlatformPromptPath('codex')).toBe(
|
||||
path.join(promptsDir, 'codex-platform.md'),
|
||||
);
|
||||
expect(readPlatformPrompt('codex')).toBe('Codex platform prompt');
|
||||
});
|
||||
|
||||
it('reads and trims paired-room prompt files', () => {
|
||||
const promptsDir = path.join(tempDir, 'prompts');
|
||||
fs.mkdirSync(promptsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(promptsDir, 'claude-paired-room.md'),
|
||||
'\nClaude paired prompt\n',
|
||||
);
|
||||
|
||||
expect(getPairedRoomPromptPath('claude-code')).toBe(
|
||||
path.join(promptsDir, 'claude-paired-room.md'),
|
||||
);
|
||||
expect(readPairedRoomPrompt('claude-code')).toBe('Claude paired prompt');
|
||||
});
|
||||
});
|
||||
60
src/platform-prompts.ts
Normal file
60
src/platform-prompts.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import type { AgentType } from './types.js';
|
||||
|
||||
const PLATFORM_PROMPT_FILES: Record<AgentType, string> = {
|
||||
'claude-code': 'claude-platform.md',
|
||||
codex: 'codex-platform.md',
|
||||
};
|
||||
|
||||
const PAIRED_ROOM_PROMPT_FILES: Record<AgentType, string> = {
|
||||
'claude-code': 'claude-paired-room.md',
|
||||
codex: 'codex-paired-room.md',
|
||||
};
|
||||
|
||||
export function getPlatformPromptsDir(projectRoot = process.cwd()): string {
|
||||
return path.join(projectRoot, 'prompts');
|
||||
}
|
||||
|
||||
export function getPlatformPromptPath(
|
||||
agentType: AgentType,
|
||||
projectRoot = process.cwd(),
|
||||
): string {
|
||||
return path.join(
|
||||
getPlatformPromptsDir(projectRoot),
|
||||
PLATFORM_PROMPT_FILES[agentType],
|
||||
);
|
||||
}
|
||||
|
||||
export function readPlatformPrompt(
|
||||
agentType: AgentType,
|
||||
projectRoot = process.cwd(),
|
||||
): string | undefined {
|
||||
const promptPath = getPlatformPromptPath(agentType, projectRoot);
|
||||
if (!fs.existsSync(promptPath)) return undefined;
|
||||
|
||||
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
|
||||
return prompt || undefined;
|
||||
}
|
||||
|
||||
export function getPairedRoomPromptPath(
|
||||
agentType: AgentType,
|
||||
projectRoot = process.cwd(),
|
||||
): string {
|
||||
return path.join(
|
||||
getPlatformPromptsDir(projectRoot),
|
||||
PAIRED_ROOM_PROMPT_FILES[agentType],
|
||||
);
|
||||
}
|
||||
|
||||
export function readPairedRoomPrompt(
|
||||
agentType: AgentType,
|
||||
projectRoot = process.cwd(),
|
||||
): string | undefined {
|
||||
const promptPath = getPairedRoomPromptPath(agentType, projectRoot);
|
||||
if (!fs.existsSync(promptPath)) return undefined;
|
||||
|
||||
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
|
||||
return prompt || undefined;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
extractSessionCommand,
|
||||
handleSessionCommand,
|
||||
isSessionCommandControlMessage,
|
||||
isSessionCommandAllowed,
|
||||
} from './session-commands.js';
|
||||
import type { NewMessage } from './types.js';
|
||||
@@ -67,6 +68,28 @@ describe('isSessionCommandAllowed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSessionCommandControlMessage', () => {
|
||||
it('matches clear confirmation output', () => {
|
||||
expect(
|
||||
isSessionCommandControlMessage(
|
||||
'Current session cleared. The next message will start a new conversation.',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches admin denial output', () => {
|
||||
expect(
|
||||
isSessionCommandControlMessage('Session commands require admin access.'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match regular bot conversation', () => {
|
||||
expect(isSessionCommandControlMessage('좋네요. 필요해지면 바로 말씀드리겠습니다.')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function makeMsg(
|
||||
content: string,
|
||||
overrides: Partial<NewMessage> = {},
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { NewMessage } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
import { formatOutbound } from './router.js';
|
||||
|
||||
const SESSION_COMMAND_CONTROL_PATTERNS = [
|
||||
/^Current session cleared\. The next message will start a new conversation\.$/,
|
||||
/^Session commands require admin access\.$/,
|
||||
/^Failed to process messages before \/compact\. Try again\.$/,
|
||||
/^\/compact failed\. The session is unchanged\.$/,
|
||||
/^Conversation compacted\.$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
||||
@@ -28,10 +37,18 @@ export function isSessionCommandAllowed(
|
||||
return isMainGroup || isFromMe || isAdminSender;
|
||||
}
|
||||
|
||||
export function isSessionCommandControlMessage(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
return SESSION_COMMAND_CONTROL_PATTERNS.some((pattern) =>
|
||||
pattern.test(trimmed),
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal agent result interface — matches the subset of AgentOutput used here. */
|
||||
export interface AgentResult {
|
||||
status: 'success' | 'error';
|
||||
result?: string | object | null;
|
||||
phase?: 'progress' | 'final';
|
||||
}
|
||||
|
||||
/** Dependencies injected by the orchestrator. */
|
||||
@@ -54,7 +71,7 @@ export interface SessionCommandDeps {
|
||||
function resultToText(result: string | object | null | undefined): string {
|
||||
if (!result) return '';
|
||||
const raw = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
return raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
return formatOutbound(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +150,7 @@ export async function handleSessionCommand(opts: {
|
||||
|
||||
const preResult = await deps.runAgent(prePrompt, async (result) => {
|
||||
if (result.status === 'error') hadPreError = true;
|
||||
if (result.phase === 'progress') return;
|
||||
const text = resultToText(result.result);
|
||||
if (text) {
|
||||
await deps.sendMessage(text);
|
||||
@@ -169,6 +187,7 @@ export async function handleSessionCommand(opts: {
|
||||
let hadCmdError = false;
|
||||
const cmdOutput = await deps.runAgent(command, async (result) => {
|
||||
if (result.status === 'error') hadCmdError = true;
|
||||
if (result.phase === 'progress') return;
|
||||
const text = resultToText(result.result);
|
||||
if (text) await deps.sendMessage(text);
|
||||
});
|
||||
|
||||
34
src/session-recovery.test.ts
Normal file
34
src/session-recovery.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||
|
||||
describe('shouldResetSessionOnAgentFailure', () => {
|
||||
it('matches many-image dimension limit errors', () => {
|
||||
expect(
|
||||
shouldResetSessionOnAgentFailure({
|
||||
result:
|
||||
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
|
||||
error: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the error field too', () => {
|
||||
expect(
|
||||
shouldResetSessionOnAgentFailure({
|
||||
result: null,
|
||||
error:
|
||||
'fatal: An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match unrelated agent failures', () => {
|
||||
expect(
|
||||
shouldResetSessionOnAgentFailure({
|
||||
result: null,
|
||||
error: 'Claude Code process exited with code 1',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
26
src/session-recovery.ts
Normal file
26
src/session-recovery.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { AgentOutput } from './agent-runner.js';
|
||||
|
||||
const SESSION_RESET_PATTERNS = [
|
||||
/An image in the conversation exceeds the dimension limit for many-image requests \(2000px\)\./i,
|
||||
/Start a new session with fewer images\./i,
|
||||
];
|
||||
|
||||
function toText(value: string | object | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
if (typeof value === 'string') return [value];
|
||||
|
||||
try {
|
||||
return [JSON.stringify(value)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldResetSessionOnAgentFailure(
|
||||
output: Pick<AgentOutput, 'result' | 'error'>,
|
||||
): boolean {
|
||||
const texts = [...toText(output.result), ...toText(output.error)];
|
||||
return texts.some((text) =>
|
||||
SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)),
|
||||
);
|
||||
}
|
||||
@@ -183,6 +183,9 @@ async function runTask(
|
||||
(proc, processName) =>
|
||||
deps.onProcess(task.chat_jid, proc, processName, task.group_folder),
|
||||
async (streamedOutput: AgentOutput) => {
|
||||
if (streamedOutput.phase === 'progress') {
|
||||
return;
|
||||
}
|
||||
if (streamedOutput.result) {
|
||||
result = streamedOutput.result;
|
||||
// Forward result to user (sendMessage handles formatting)
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface Channel {
|
||||
sendMessage(jid: string, text: string): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
ownsJid(jid: string): boolean;
|
||||
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
|
||||
isOwnMessage?(msg: NewMessage): boolean;
|
||||
disconnect(): Promise<void>;
|
||||
// Optional: typing indicator. Channels that support it implement it.
|
||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user