Stabilize paired-room orchestration and Codex app-server flow

This commit is contained in:
Eyejoker
2026-03-19 02:57:14 +09:00
parent b710d4a33d
commit 779d57c392
32 changed files with 2517 additions and 185 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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> = {},

View File

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

View 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
View 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)),
);
}

View File

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

View File

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