Files
waggle-os/packages/hive-mind-shim-core/tests/cli-bridge.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

293 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { createCliBridge, type SpawnFn } from '../src/cli-bridge.js';
import type { ChildProcess } from 'node:child_process';
import type { HookFrame } from '../src/frame-encoder.js';
interface MockChildOptions {
stdout?: string;
stderr?: string;
exitCode?: number;
emitError?: Error;
delayMs?: number;
}
interface MockSpawnRecord {
command: string;
args: readonly string[];
}
function mockChild(opts: MockChildOptions = {}): ChildProcess {
const emitter = new EventEmitter();
const stdout = Readable.from([Buffer.from(opts.stdout ?? '')]);
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
const child = Object.assign(emitter, {
stdout,
stderr,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
setImmediate(() => {
if (opts.emitError) {
emitter.emit('error', opts.emitError);
return;
}
if (opts.delayMs && opts.delayMs > 0) {
setTimeout(() => emitter.emit('exit', opts.exitCode ?? 0), opts.delayMs);
return;
}
emitter.emit('exit', opts.exitCode ?? 0);
});
return child;
}
function makeSpawnImpl(records: MockSpawnRecord[], childOpts: MockChildOptions): SpawnFn {
return ((command, args) => {
records.push({ command, args });
return mockChild(childOpts);
}) as SpawnFn;
}
function jsonResultEnvelope(payload: unknown, opts: { isError?: boolean } = {}): string {
const result = {
ok: true,
tool: 'test_tool',
content: [{ type: 'text', text: JSON.stringify(payload) }],
isError: opts.isError ?? false,
};
return JSON.stringify(result, null, 2);
}
function plainTextEnvelope(text: string): string {
return JSON.stringify({
ok: true,
tool: 'recall_memory',
content: [{ type: 'text', text }],
isError: false,
}, null, 2);
}
const SAMPLE_FRAME: HookFrame = {
content: 'hello world',
importance: 'normal',
scope: 'sess-7',
source: 'claude-code',
metadata: {
cwd: '/proj',
timestamp_iso: '2026-04-28T10:00:00.000Z',
event_type: 'user-prompt-submit',
},
};
describe('createCliBridge.callMcpTool', () => {
it('spawns hive-mind-cli with mcp call args and parses success', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 1 }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const result = await bridge.callMcpTool<{ id: number }>('save_memory', { content: 'x' });
expect(result.id).toBe(1);
expect(records).toHaveLength(1);
expect(records[0].command).toBe('hive-mind-cli');
expect(records[0].args).toEqual([
'mcp', 'call', 'save_memory',
'--args', JSON.stringify({ content: 'x' }),
'--json',
'--timeout-ms', '5000',
]);
});
it('honours custom cli_path', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ ok: 1 }) });
const bridge = createCliBridge({ spawnImpl, cli_path: '/usr/local/bin/hmc', max_retries: 0 });
await bridge.callMcpTool('any_tool', {});
expect(records[0].command).toBe('/usr/local/bin/hmc');
});
it('throws when CLI exits non-zero', async () => {
const spawnImpl = makeSpawnImpl([], {
stdout: '',
stderr: 'boom',
exitCode: 2,
});
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.callMcpTool('save_memory', {})).rejects.toThrow(/exited with code 2/);
});
it('throws when stdout is malformed JSON', async () => {
const spawnImpl = makeSpawnImpl([], { stdout: 'not-json{', exitCode: 0 });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.callMcpTool('any', {})).rejects.toThrow(/failed to parse CLI JSON output/);
});
it('throws when result.ok is false', async () => {
const stdout = JSON.stringify({ ok: false, tool: 'save_memory', error: 'tool missing' });
const spawnImpl = makeSpawnImpl([], { stdout });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.callMcpTool('save_memory', {})).rejects.toThrow(/save_memory failed/);
});
it('throws when result.isError is true (tool-reported error)', async () => {
const stdout = JSON.stringify({
ok: true,
tool: 'recall_memory',
isError: true,
content: [{ type: 'text', text: 'no such workspace' }],
});
const spawnImpl = makeSpawnImpl([], { stdout });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await expect(bridge.callMcpTool('recall_memory', {})).rejects.toThrow(/no such workspace/);
});
it('returns raw McpCallResult when content text is not JSON-parseable', async () => {
const stdout = JSON.stringify({
ok: true,
tool: 'plain',
content: [{ type: 'text', text: 'human-readable output' }],
isError: false,
});
const spawnImpl = makeSpawnImpl([], { stdout });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const result = await bridge.callMcpTool<{ ok: boolean; content?: unknown[] }>('plain', {});
expect(result.ok).toBe(true);
expect(Array.isArray(result.content)).toBe(true);
});
});
describe('createCliBridge.saveMemory (Commit 1.4 wire format)', () => {
it('passes only content + importance + source to save_memory; embeds scope/parent/source/event in content prefix', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 9, workspace: 'personal' }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const result = await bridge.saveMemory(SAMPLE_FRAME);
expect(result).toEqual({ id: '9', success: true, workspace: 'personal' });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(Object.keys(wireArgs).sort()).toEqual(['content', 'importance', 'source']);
expect(wireArgs['source']).toBe('system');
expect(wireArgs['importance']).toBe('normal');
expect(wireArgs['content']).toContain('[hm session:sess-7 src:claude-code event:user-prompt-submit] ');
expect(wireArgs['content']).toContain('hello world');
});
it('includes workspace arg when active workspace id is set', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 1, workspace: 'team-foo' }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
bridge.setWorkspaceById('team-foo');
await bridge.saveMemory(SAMPLE_FRAME);
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(wireArgs['workspace']).toBe('team-foo');
});
it('per-call workspace override beats setWorkspaceById', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ id: 2 }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
bridge.setWorkspaceById('default-ws');
await bridge.saveMemory(SAMPLE_FRAME, { workspace: 'override-ws' });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(wireArgs['workspace']).toBe('override-ws');
});
});
describe('createCliBridge.recallMemory', () => {
it('returns MemoryHit[] when upstream replies with a JSON array', async () => {
const hits = [{
id: 1,
content: 'past',
importance: 'normal',
source: 'system',
score: 0.91,
created_at: '2026-04-28T10:00:00.000Z',
from: 'personal',
}];
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope(hits) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const out = await bridge.recallMemory('past');
expect(out).toHaveLength(1);
expect(out[0].score).toBe(0.91);
});
it('returns [] when upstream responds with the "No memories found" plain-text envelope', async () => {
const spawnImpl = makeSpawnImpl([], { stdout: plainTextEnvelope('No memories found for query: "test"') });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const out = await bridge.recallMemory('test');
expect(out).toEqual([]);
});
it('passes query + limit + scope + profile through wire args', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: plainTextEnvelope('No memories found for query: "x"') });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
await bridge.recallMemory('x', { limit: 5, scope: 'all', profile: 'recent' });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(wireArgs).toMatchObject({ query: 'x', limit: 5, scope: 'all', profile: 'recent' });
});
it('can force personal recall while a workspace is active', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope([]) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0, initial_workspace_id: 'workspace-a' });
await bridge.recallMemory('', { scope: 'personal', workspace: null });
const wireArgs = JSON.parse(records[0].args[4] as string) as Record<string, unknown>;
expect(wireArgs).toEqual({ query: '', scope: 'personal' });
});
});
describe('createCliBridge.cleanupFrames', () => {
it('calls cleanup_frames and returns pruned count', async () => {
const records: MockSpawnRecord[] = [];
const spawnImpl = makeSpawnImpl(records, { stdout: jsonResultEnvelope({ pruned: 7 }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const out = await bridge.cleanupFrames();
expect(records[0].args[2]).toBe('cleanup_frames');
expect(out.pruned).toBe(7);
});
it('falls back to .deleted alias if upstream uses that field name', async () => {
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({ deleted: 3 }) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
const out = await bridge.cleanupFrames();
expect(out.pruned).toBe(3);
});
});
describe('createCliBridge workspace state', () => {
it('setWorkspaceById + getActiveWorkspaceId round-trip', () => {
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({}) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
expect(bridge.getActiveWorkspaceId()).toBeUndefined();
bridge.setWorkspaceById('foo');
expect(bridge.getActiveWorkspaceId()).toBe('foo');
bridge.setWorkspaceById(undefined);
expect(bridge.getActiveWorkspaceId()).toBeUndefined();
});
it('initial_workspace_id seeds the active id', () => {
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({}) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0, initial_workspace_id: 'startup-ws' });
expect(bridge.getActiveWorkspaceId()).toBe('startup-ws');
});
it('seeds the active workspace from WAGGLE_WORKSPACE_ID', () => {
const previous = process.env.WAGGLE_WORKSPACE_ID;
process.env.WAGGLE_WORKSPACE_ID = 'workspace-from-launch';
try {
const spawnImpl = makeSpawnImpl([], { stdout: jsonResultEnvelope({}) });
const bridge = createCliBridge({ spawnImpl, max_retries: 0 });
expect(bridge.getActiveWorkspaceId()).toBe('workspace-from-launch');
} finally {
if (previous === undefined) delete process.env.WAGGLE_WORKSPACE_ID;
else process.env.WAGGLE_WORKSPACE_ID = previous;
}
});
});