mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
b42329551d
The /debug skill tells operators to rm -rf a session folder to reset a stuck session, but the sessions row survives. The next message then takes the existing-session path and opens inbound.db in a directory that no longer exists — better-sqlite3 throws and the message is dropped forever. writeSessionMessage now calls the idempotent initSessionFolder when the inbound.db path is missing, so the documented reset actually re-provisions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
/**
|
|
* Tests for session-manager's direct outbound write path.
|
|
*
|
|
* Drives the real `writeOutboundDirect` entry against a real session folder
|
|
* on disk. A previous implementation opened the outbound DB through
|
|
* `openOutboundDb` (readonly: true), so every INSERT threw SQLITE_READONLY
|
|
* and the command-gate denial path silently never delivered. Goes red if the
|
|
* open call reverts to the readonly form.
|
|
*/
|
|
import fs from 'fs';
|
|
import Database from 'better-sqlite3';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('./config.js', async () => {
|
|
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
|
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-write-outbound' };
|
|
});
|
|
|
|
import {
|
|
initSessionFolder,
|
|
inboundDbPath,
|
|
outboundDbPath,
|
|
sessionDir,
|
|
writeOutboundDirect,
|
|
writeSessionMessage,
|
|
} from './session-manager.js';
|
|
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
|
|
import { createSession } from './db/sessions.js';
|
|
import type { Session } from './types.js';
|
|
|
|
const TEST_DIR = '/tmp/nanoclaw-test-write-outbound';
|
|
const AG = 'ag-test';
|
|
const SESS = 'sess-test';
|
|
|
|
function readMessagesOut(): Array<{ id: string; seq: number; kind: string; content: string }> {
|
|
const db = new Database(outboundDbPath(AG, SESS), { readonly: true });
|
|
try {
|
|
return db.prepare('SELECT id, seq, kind, content FROM messages_out ORDER BY seq').all() as Array<{
|
|
id: string;
|
|
seq: number;
|
|
kind: string;
|
|
content: string;
|
|
}>;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
|
fs.mkdirSync(TEST_DIR, { recursive: true });
|
|
initSessionFolder(AG, SESS);
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
|
});
|
|
|
|
describe('writeOutboundDirect', () => {
|
|
it('inserts into messages_out with an even host-side seq (requires a writable outbound.db)', () => {
|
|
// With a readonly open this very call throws SQLITE_READONLY.
|
|
writeOutboundDirect(AG, SESS, {
|
|
id: 'denial-1',
|
|
kind: 'chat',
|
|
platformId: 'slack:C1',
|
|
channelType: 'slack',
|
|
threadId: null,
|
|
content: JSON.stringify({ text: 'Admin commands are restricted.' }),
|
|
});
|
|
|
|
const rows = readMessagesOut();
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].id).toBe('denial-1');
|
|
expect(rows[0].seq).toBe(2);
|
|
expect(rows[0].seq % 2).toBe(0); // host uses even seq numbers
|
|
expect(JSON.parse(rows[0].content).text).toBe('Admin commands are restricted.');
|
|
});
|
|
|
|
it('keeps host seq numbers even across multiple writes and ignores duplicate ids', () => {
|
|
writeOutboundDirect(AG, SESS, {
|
|
id: 'denial-1',
|
|
kind: 'chat',
|
|
platformId: null,
|
|
channelType: null,
|
|
threadId: null,
|
|
content: '{"text":"first"}',
|
|
});
|
|
writeOutboundDirect(AG, SESS, {
|
|
id: 'denial-2',
|
|
kind: 'chat',
|
|
platformId: null,
|
|
channelType: null,
|
|
threadId: null,
|
|
content: '{"text":"second"}',
|
|
});
|
|
// INSERT OR IGNORE — a delivery retry with the same id must not throw or duplicate.
|
|
writeOutboundDirect(AG, SESS, {
|
|
id: 'denial-1',
|
|
kind: 'chat',
|
|
platformId: null,
|
|
channelType: null,
|
|
threadId: null,
|
|
content: '{"text":"retry"}',
|
|
});
|
|
|
|
const rows = readMessagesOut();
|
|
expect(rows.map((r) => r.id)).toEqual(['denial-1', 'denial-2']);
|
|
expect(rows.map((r) => r.seq)).toEqual([2, 4]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The `/debug` skill tells operators to `rm -rf` a session folder to reset a
|
|
* stuck session. The sessions row survives, so the next message takes the
|
|
* existing-session path and lands in `writeSessionMessage` with a missing
|
|
* inbound.db. Without re-provisioning, better-sqlite3 throws on open and the
|
|
* message is logged-and-dropped forever — the reset silently kills the chat.
|
|
*/
|
|
describe('writeSessionMessage re-provisions a deleted session folder', () => {
|
|
beforeEach(() => {
|
|
const db = initTestDb();
|
|
runMigrations(db);
|
|
createAgentGroup({
|
|
id: AG,
|
|
name: 'Reset',
|
|
folder: 'reset',
|
|
agent_provider: null,
|
|
created_at: new Date().toISOString(),
|
|
});
|
|
const sess: Session = {
|
|
id: SESS,
|
|
agent_group_id: AG,
|
|
messaging_group_id: null,
|
|
thread_id: null,
|
|
agent_provider: null,
|
|
status: 'active',
|
|
container_status: 'stopped',
|
|
last_active: null,
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
createSession(sess);
|
|
});
|
|
|
|
afterEach(() => {
|
|
closeDb();
|
|
});
|
|
|
|
it('re-creates the folder + inbound.db and does not throw when the row still exists', () => {
|
|
// Operator resets a stuck session by deleting its folder; the row survives.
|
|
fs.rmSync(sessionDir(AG, SESS), { recursive: true, force: true });
|
|
expect(fs.existsSync(inboundDbPath(AG, SESS))).toBe(false);
|
|
|
|
expect(() =>
|
|
writeSessionMessage(AG, SESS, {
|
|
id: 'after-reset-1',
|
|
kind: 'chat',
|
|
timestamp: new Date().toISOString(),
|
|
platformId: 'slack:C1',
|
|
channelType: 'slack',
|
|
threadId: null,
|
|
content: JSON.stringify({ text: 'still here?' }),
|
|
}),
|
|
).not.toThrow();
|
|
|
|
// The folder + inbound.db are back and the message landed.
|
|
expect(fs.existsSync(inboundDbPath(AG, SESS))).toBe(true);
|
|
const db = new Database(inboundDbPath(AG, SESS), { readonly: true });
|
|
try {
|
|
const row = db.prepare('SELECT id, content FROM messages_in WHERE id = ?').get('after-reset-1') as
|
|
| { id: string; content: string }
|
|
| undefined;
|
|
expect(row?.id).toBe('after-reset-1');
|
|
expect(JSON.parse(row!.content).text).toBe('still here?');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|