mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0e591f2fb | |||
| 14f528c895 | |||
| 366c5f06f1 | |||
| 077ce6fd9d | |||
| 0c0f4c2592 | |||
| 92635f1934 | |||
| f7a43ef881 | |||
| 1985fd6a5e | |||
| 0820d39424 | |||
| c752f9c065 | |||
| 0e73e4a782 |
@@ -14,7 +14,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { formatMessages, stripInternalTags } from './formatter.js';
|
||||
import { TIMEZONE } from './timezone.js';
|
||||
import { TIMEZONE, formatLocalTime } from './timezone.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
@@ -109,6 +109,14 @@ describe('timestamp formatting', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('task timestamps', () => {
|
||||
it('renders task time in the user TZ, same as chat rows', () => {
|
||||
insertMessage('t1', 'task', { prompt: 'do the thing' }, { timestamp: '2026-01-05T12:00:00.000Z' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain(`time="${formatLocalTime('2026-01-05T12:00:00.000Z', TIMEZONE)}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reply_to + quoted_message rendering', () => {
|
||||
it('renders reply_to attribute and quoted_message when all fields present', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.40",
|
||||
"version": "2.1.42",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="213k tokens, 106% of context window">
|
||||
<title>213k tokens, 106% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="224k tokens, 112% of context window">
|
||||
<title>224k tokens, 112% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">213k</text>
|
||||
<text x="71" y="14">213k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">224k</text>
|
||||
<text x="71" y="14">224k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// `groups.ts`'s postCreate calls `initGroupFilesystem`, which touches the
|
||||
// real filesystem (groups/<folder>, data/v2-sessions/<id>/.claude-shared).
|
||||
// We're not testing the filesystem layout here — we're testing that the
|
||||
// hook fires with the inserted row — so mock the FS-touching helper and
|
||||
// keep only the DB side effect (`ensureContainerConfig`) as the observable.
|
||||
const ensureContainerConfigSpy = vi.fn();
|
||||
vi.mock('../group-init.js', async () => {
|
||||
const { ensureContainerConfig } = await import('../db/container-configs.js');
|
||||
return {
|
||||
initGroupFilesystem: vi.fn((group: { id: string }) => {
|
||||
ensureContainerConfigSpy(group.id);
|
||||
ensureContainerConfig(group.id);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
// `wirings.ts`'s postCommit projects the new destination into every running
|
||||
// session's `inbound.db` via the agent-to-agent module's `writeDestinations`.
|
||||
// That helper opens on-disk session DB files we don't have in a unit test, so
|
||||
// mock it and observe that the projection is invoked per live session.
|
||||
const writeDestinationsSpy = vi.fn();
|
||||
vi.mock('../modules/agent-to-agent/write-destinations.js', () => ({
|
||||
writeDestinations: (...args: unknown[]) => writeDestinationsSpy(...args),
|
||||
}));
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from '../db/index.js';
|
||||
import { createSession } from '../db/sessions.js';
|
||||
import { getContainerConfig } from '../db/container-configs.js';
|
||||
import { getDestinations } from '../modules/agent-to-agent/db/agent-destinations.js';
|
||||
import { lookup } from './registry.js';
|
||||
|
||||
// Importing these for side effects: each calls `registerResource` at
|
||||
// module top-level, which wires up the `groups-create` / `wirings-create`
|
||||
// handlers we exercise below.
|
||||
import '../cli/resources/groups.js';
|
||||
import '../cli/resources/wirings.js';
|
||||
|
||||
const hostCtx = { caller: 'host' as const };
|
||||
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
ensureContainerConfigSpy.mockClear();
|
||||
writeDestinationsSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('genericCreate postCreate hook', () => {
|
||||
it('groups-create writes the companion container_configs row', async () => {
|
||||
const cmd = lookup('groups-create');
|
||||
expect(cmd, 'groups-create command must be registered').toBeDefined();
|
||||
|
||||
const result = (await cmd!.handler({ name: 'Test', folder: 'test' }, hostCtx)) as { id: string };
|
||||
|
||||
// Hook fired with the just-inserted row (incl. generated `id`).
|
||||
expect(ensureContainerConfigSpy).toHaveBeenCalledWith(result.id);
|
||||
|
||||
// Visible side effect: container_configs row exists for the new group.
|
||||
// Without postCreate, this was empty and the first spawn threw
|
||||
// "Container config not found" — issue #2415.
|
||||
const config = getContainerConfig(result.id);
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.agent_group_id).toBe(result.id);
|
||||
});
|
||||
|
||||
it('wirings-create writes the companion agent_destinations row', async () => {
|
||||
// Seed the FKs that the wiring references.
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent One',
|
||||
folder: 'agent-one',
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-1',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'channel-123',
|
||||
name: 'general',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const cmd = lookup('wirings-create');
|
||||
expect(cmd, 'wirings-create command must be registered').toBeDefined();
|
||||
|
||||
await cmd!.handler({ messaging_group_id: 'mg-1', agent_group_id: 'ag-1' }, hostCtx);
|
||||
|
||||
// Visible side effect: a destination row was created so the agent can
|
||||
// address this chat as a delivery target. Without postCreate, this was
|
||||
// empty and the agent's replies were silently dropped by the delivery
|
||||
// ACL — issue #2389.
|
||||
const destinations = getDestinations('ag-1');
|
||||
expect(destinations).toHaveLength(1);
|
||||
expect(destinations[0].target_type).toBe('channel');
|
||||
expect(destinations[0].target_id).toBe('mg-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('genericCreate postCommit hook', () => {
|
||||
it('wirings-create projects the new destination into live sessions', async () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent One',
|
||||
folder: 'agent-one',
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-1',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'channel-123',
|
||||
name: 'general',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
// A container is already running for this agent — its session inbound.db
|
||||
// holds a stale destination projection.
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'running',
|
||||
last_active: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const cmd = lookup('wirings-create');
|
||||
await cmd!.handler({ messaging_group_id: 'mg-1', agent_group_id: 'ag-1' }, hostCtx);
|
||||
|
||||
// Live-refresh parity with `ncl destinations add`: without postCommit the
|
||||
// running container keeps serving the stale projection and drops replies
|
||||
// to this chat as "unknown destination" until a restart — issue #2389.
|
||||
expect(writeDestinationsSpy).toHaveBeenCalledWith('ag-1', 'sess-1');
|
||||
});
|
||||
|
||||
it('wirings-create projection is a no-op when no sessions are running', async () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-2',
|
||||
name: 'Agent Two',
|
||||
folder: 'agent-two',
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-2',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'channel-456',
|
||||
name: 'ops',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const cmd = lookup('wirings-create');
|
||||
await cmd!.handler({ messaging_group_id: 'mg-2', agent_group_id: 'ag-2' }, hostCtx);
|
||||
|
||||
// No live session for ag-2 → nothing to project, and the central
|
||||
// destination row was still written (covered above).
|
||||
expect(writeDestinationsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+37
-3
@@ -83,6 +83,31 @@ export interface ResourceDef {
|
||||
};
|
||||
/** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */
|
||||
customOperations?: Record<string, CustomOperation>;
|
||||
/**
|
||||
* Runs after a successful `create` INSERT, with the row that was just
|
||||
* written. Used to wire in side effects that the central row alone
|
||||
* doesn't trigger — e.g. creating a `container_configs` row when a new
|
||||
* agent group is added, or the companion `agent_destinations` row when a
|
||||
* wiring is added. The hook receives the same `values` object that was
|
||||
* inserted, so generated fields like `id` and `created_at` are populated.
|
||||
*/
|
||||
postCreate?: (row: Record<string, unknown>) => void;
|
||||
/**
|
||||
* Runs AFTER the create transaction has committed, with the row that was
|
||||
* written. Use this — not `postCreate` — for side effects that live
|
||||
* OUTSIDE the central DB (filesystem writes, projecting rows into a
|
||||
* running agent's session `inbound.db`) or that are async: those must not
|
||||
* sit inside the better-sqlite3 transaction, which only covers central-DB
|
||||
* statements and is synchronous.
|
||||
*
|
||||
* The canonical case is live-refresh parity with `ncl destinations add`:
|
||||
* after `ncl wirings create` writes the companion `agent_destinations`
|
||||
* row, the change has to be projected into any running container's session
|
||||
* DB or the agent won't see the new delivery target until its next spawn
|
||||
* (#2389). Runs only if the transaction succeeds, so it never observes a
|
||||
* rolled-back row.
|
||||
*/
|
||||
postCommit?: (row: Record<string, unknown>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -174,9 +199,18 @@ function genericCreate(def: ResourceDef) {
|
||||
|
||||
const colNames = Object.keys(values);
|
||||
const placeholders = colNames.map((c) => `@${c}`);
|
||||
getDb()
|
||||
.prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`)
|
||||
.run(values);
|
||||
// Single transaction so a postCreate throw rolls back the parent INSERT —
|
||||
// closes the partial-state class this PR exists to fix (#2415, #2389).
|
||||
// better-sqlite3 .transaction() is sync, so `postCreate` is sync too and
|
||||
// must only touch the central DB (it's the atomic companion-row write).
|
||||
// Anything async or outside the central DB — filesystem, session-DB
|
||||
// projection — belongs in `postCommit`, which runs after commit below.
|
||||
const db = getDb();
|
||||
db.transaction(() => {
|
||||
db.prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`).run(values);
|
||||
if (def.postCreate) def.postCreate(values);
|
||||
})();
|
||||
if (def.postCommit) await def.postCommit(values);
|
||||
return values;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@ import { registerResource } from '../crud.js';
|
||||
* on `hasTable('agent_destinations')` and load `writeDestinations` lazily —
|
||||
* same pattern as container-runner.ts on container wake.
|
||||
*
|
||||
* Called from both `add` and `remove` so the live container picks up the
|
||||
* change without waiting for the next spawn. Without this, send_message to
|
||||
* the new local_name silently drops with "unknown destination" until restart.
|
||||
* Called from every destination-mutating ncl command — `add` and `remove`
|
||||
* here, plus `wirings create` (which writes a companion destination row in
|
||||
* its postCreate hook) — so the live container picks up the change without
|
||||
* waiting for the next spawn. Without this, send_message to the new
|
||||
* local_name silently drops with "unknown destination" until restart.
|
||||
* See the destination-projection invariant in
|
||||
* src/modules/agent-to-agent/db/agent-destinations.ts.
|
||||
*/
|
||||
async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
|
||||
export async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
|
||||
if (!hasTable(getDb(), 'agent_destinations')) return;
|
||||
const { writeDestinations } = await import('../../modules/agent-to-agent/write-destinations.js');
|
||||
for (const session of getSessionsByAgentGroup(agentGroupId)) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
} from '../../db/container-configs.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
import { createAgentFromTemplate } from '../../templates/create-agent.js';
|
||||
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
@@ -90,6 +91,15 @@ registerResource({
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
createAgentGroup(group);
|
||||
// Provision the workspace folder and the `container_configs` row that
|
||||
// `getContainerConfig` and the spawn path require. Without this, a
|
||||
// group created via `ncl groups create` would throw "Container config
|
||||
// not found" on first spawn and stay broken until the host restart
|
||||
// backfill ran (#2415). The template branch above provisions its own
|
||||
// config + folder in `createAgentFromTemplate`; this covers the bare
|
||||
// path. Mirrors what `setup/register.ts` does after creating an agent
|
||||
// group via the setup flow.
|
||||
initGroupFilesystem(group);
|
||||
return group;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ensureAgentDestinationForWiring } from '../../db/messaging-groups.js';
|
||||
import type { MessagingGroupAgent } from '../../types.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
import { projectDestinationsToSessions } from './destinations.js';
|
||||
|
||||
registerResource({
|
||||
name: 'wiring',
|
||||
@@ -67,4 +70,25 @@ registerResource({
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
postCreate: (row) => {
|
||||
// Create the companion `agent_destinations` row so the agent has a
|
||||
// local name it can address this chat by. Without this, the agent
|
||||
// generates a response, but delivery's ACL drops the outbound message
|
||||
// (no destination matches the target) and the reply is silently lost.
|
||||
// `createMessagingGroupAgent` does this automatically; the generic
|
||||
// CRUD path doesn't, hence this hook. See issue #2389.
|
||||
ensureAgentDestinationForWiring(row as unknown as MessagingGroupAgent);
|
||||
},
|
||||
postCommit: async (row) => {
|
||||
// Live-refresh parity with `ncl destinations add`: `postCreate` above
|
||||
// only wrote the central `agent_destinations` row. Any container already
|
||||
// running for this agent keeps serving its stale session projection, so
|
||||
// it would drop replies to this chat as "unknown destination" until the
|
||||
// next spawn (the exact symptom operators hit running `ncl wirings
|
||||
// create` against a live instance — it needed a group restart). Project
|
||||
// the new destination into live sessions now so the fix takes effect
|
||||
// without a restart. Runs after commit because it writes to session
|
||||
// `inbound.db` files (outside the central-DB transaction) and is async.
|
||||
await projectDestinationsToSessions((row as unknown as MessagingGroupAgent).agent_group_id);
|
||||
},
|
||||
});
|
||||
|
||||
+31
-20
@@ -183,26 +183,37 @@ export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
|
||||
)
|
||||
.run(mga);
|
||||
|
||||
// Auto-create an agent_destinations row so delivery's ACL doesn't block
|
||||
// outbound messages that target this chat. Guarded: when the agent-to-agent
|
||||
// module isn't installed the table doesn't exist — skip silently. Without
|
||||
// the module, the ACL check in delivery is also skipped (same guard), so
|
||||
// channel sends still work.
|
||||
//
|
||||
// ⚠️ DESTINATION PROJECTION NOTE: this function only writes the central
|
||||
// `agent_destinations` row. It does NOT project into any running
|
||||
// agent's session inbound.db (see top-of-file invariant in
|
||||
// src/modules/agent-to-agent/db/agent-destinations.ts). In practice this
|
||||
// is fine because the only real callers are one-shot setup scripts
|
||||
// (setup/register.ts, scripts/init-first-agent.ts, /manage-channels
|
||||
// skill) that run in a separate process from the host. Any already-
|
||||
// running container for `mga.agent_group_id` will keep serving the
|
||||
// stale projection until its next wake (idle timeout or next inbound
|
||||
// message) at which point spawnContainer's writeDestinations call
|
||||
// refreshes from central. If you call this from code that runs INSIDE
|
||||
// the host process and need the refresh to happen immediately,
|
||||
// explicitly call the module's `writeDestinations(mga.agent_group_id,
|
||||
// <sessionId>)` afterwards.
|
||||
ensureAgentDestinationForWiring(mga);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `agent_destinations` row that lets the agent address this chat
|
||||
* as a delivery target. Idempotent — no-op when the destination already
|
||||
* exists, the agent-to-agent module isn't installed, or the messaging group
|
||||
* has been deleted out from under the wiring.
|
||||
*
|
||||
* Split out from `createMessagingGroupAgent` so callers that already wrote
|
||||
* the `messaging_group_agents` row directly (e.g. the generic ncl CRUD path)
|
||||
* can still get the companion destination without re-inserting the wiring.
|
||||
*
|
||||
* ⚠️ DESTINATION PROJECTION NOTE: this function only writes the central
|
||||
* `agent_destinations` row. It does NOT project into any running agent's
|
||||
* session inbound.db (see top-of-file invariant in
|
||||
* src/modules/agent-to-agent/db/agent-destinations.ts). In practice this is
|
||||
* fine because the only real callers are one-shot setup paths
|
||||
* (setup/register.ts, scripts/init-first-agent.ts, /manage-channels skill,
|
||||
* ncl wirings create) that run in a separate process from the host. Any
|
||||
* already-running container for `mga.agent_group_id` will keep serving the
|
||||
* stale projection until its next wake (idle timeout or next inbound
|
||||
* message) at which point spawnContainer's writeDestinations call refreshes
|
||||
* from central. If you call this from code that runs INSIDE the host
|
||||
* process and need the refresh to happen immediately, explicitly call the
|
||||
* module's `writeDestinations(mga.agent_group_id, <sessionId>)` afterwards.
|
||||
*/
|
||||
export function ensureAgentDestinationForWiring(mga: MessagingGroupAgent): void {
|
||||
// Guarded: when the agent-to-agent module isn't installed the table
|
||||
// doesn't exist — skip silently. Without the module, the ACL check in
|
||||
// delivery is also skipped (same guard), so channel sends still work.
|
||||
if (!hasTable(getDb(), 'agent_destinations')) return;
|
||||
|
||||
const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id);
|
||||
|
||||
@@ -33,10 +33,11 @@ export function insertTaskRow(
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, timestamp, status, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content, series_id)
|
||||
VALUES (@id, @seq, datetime('now'), @status, 0, @processAfter, @recurrence, 'task', NULL, NULL, NULL, @content, @seriesId)`,
|
||||
VALUES (@id, @seq, @timestamp, @status, 0, @processAfter, @recurrence, 'task', NULL, NULL, NULL, @content, @seriesId)`,
|
||||
).run({
|
||||
status: 'pending',
|
||||
...row,
|
||||
timestamp: new Date().toISOString(),
|
||||
seq: nextEvenSeq(db),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user