moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
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;
}
});
});

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import type { CliBridge, MemoryHit } from '../src/cli-bridge.js';
import { recallPersonalAndWorkspace } from '../src/context-recall.js';
const personal: MemoryHit = {
id: 1, content: 'personal preference', importance: 'important', source: 'system',
score: 0.7, created_at: '2026-01-01T00:00:00.000Z', from: 'personal',
};
const workspace: MemoryHit = {
id: 2, content: 'workspace decision', importance: 'important', source: 'system',
score: 0.9, created_at: '2026-01-02T00:00:00.000Z', from: 'workspace:alpha',
};
function bridge(activeWorkspaceId?: string): CliBridge & { recallMemory: ReturnType<typeof vi.fn> } {
const recallMemory = vi.fn(async (_query: string, options?: { workspace?: string | null }) => {
return options?.workspace === null ? [personal] : [workspace, { ...personal, id: 99 }];
});
return {
recallMemory,
getActiveWorkspaceId: () => activeWorkspaceId,
} as unknown as CliBridge & { recallMemory: ReturnType<typeof vi.fn> };
}
describe('recallPersonalAndWorkspace', () => {
it('recalls only personal plus the selected workspace, ranks, and deduplicates', async () => {
const value = bridge('alpha');
const hits = await recallPersonalAndWorkspace(value, 'decision', { limit: 5 });
expect(value.recallMemory.mock.calls).toEqual([
['decision', { limit: 5, scope: 'personal', workspace: null }],
['decision', { limit: 5, scope: 'current', workspace: 'alpha' }],
]);
expect(hits).toEqual([workspace, personal]);
expect(value.recallMemory.mock.calls.flat().join(' ')).not.toContain("scope: 'all'");
});
it('does not query another workspace when no workspace is active', async () => {
const value = bridge();
const hits = await recallPersonalAndWorkspace(value, '', { limit: 1 });
expect(value.recallMemory).toHaveBeenCalledTimes(1);
expect(hits).toEqual([personal]);
});
});

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { encodeFrame, frameToSavePayload, type HookFrame } from '../src/frame-encoder.js';
import type { HookEvent } from '../src/hook-event-types.js';
function event(overrides: Partial<HookEvent> = {}): HookEvent {
return {
eventType: 'user-prompt-submit',
source: 'claude-code',
cwd: '/proj/foo',
timestamp_iso: '2026-04-28T10:00:00.000Z',
payload: {},
...overrides,
};
}
describe('encodeFrame', () => {
it('encodes a basic event with content from payload.content', () => {
const frame = encodeFrame(event({ payload: { content: 'hi there' } }));
expect(frame.content).toBe('hi there');
expect(frame.source).toBe('claude-code');
expect(frame.metadata.cwd).toBe('/proj/foo');
expect(frame.metadata.event_type).toBe('user-prompt-submit');
expect(frame.metadata.timestamp_iso).toBe('2026-04-28T10:00:00.000Z');
});
it('falls back to payload.text / payload.prompt / payload.message', () => {
expect(encodeFrame(event({ payload: { text: 't' } })).content).toBe('t');
expect(encodeFrame(event({ payload: { prompt: 'p' } })).content).toBe('p');
expect(encodeFrame(event({ payload: { message: 'm' } })).content).toBe('m');
});
it('classifies importance from content by default', () => {
expect(encodeFrame(event({ payload: { content: 'always run lint' } })).importance).toBe('critical');
expect(encodeFrame(event({ payload: { content: 'we decided X' } })).importance).toBe('important');
// Commit 1.4: substantive default raised from 'temporary' to 'normal'.
expect(encodeFrame(event({ payload: { content: 'hello' } })).importance).toBe('normal');
});
it('importance override beats classifier', () => {
const frame = encodeFrame(event({ payload: { content: 'hello' } }), { importance: 'critical' });
expect(frame.importance).toBe('critical');
});
it('extracts scope from payload.session_id (or aliases)', () => {
expect(encodeFrame(event({ payload: { session_id: 'abc' } })).scope).toBe('abc');
expect(encodeFrame(event({ payload: { sessionId: 'def' } })).scope).toBe('def');
});
it('falls back to scope="default" when no session id is present', () => {
expect(encodeFrame(event({ payload: {} })).scope).toBe('default');
});
it('scope option overrides payload-derived scope', () => {
const frame = encodeFrame(event({ payload: { session_id: 'a' } }), { scope: 'b' });
expect(frame.scope).toBe('b');
});
it('includes optional metadata only when present', () => {
const withProj = encodeFrame(event({ payload: { content: 'x', project: 'waggle' } }));
expect(withProj.metadata.project).toBe('waggle');
const withoutProj = encodeFrame(event({ payload: { content: 'x' } }));
expect(withoutProj.metadata.project).toBeUndefined();
});
it('attaches parent id when supplied', () => {
const frame = encodeFrame(event({ payload: { content: 'x' } }), { parent: 'frame-7' });
expect(frame.parent).toBe('frame-7');
});
it('omits parent when not supplied', () => {
const frame = encodeFrame(event({ payload: { content: 'x' } }));
expect(frame.parent).toBeUndefined();
});
it('content option overrides payload extraction', () => {
const frame = encodeFrame(event({ payload: { content: 'original' } }), { content: 'override' });
expect(frame.content).toBe('override');
});
});
describe('frameToSavePayload (Commit 1.4)', () => {
function makeFrame(overrides: Partial<HookFrame> = {}): HookFrame {
return {
content: 'the actual content',
importance: 'normal',
scope: 'sess-123',
source: 'claude-code',
metadata: {
cwd: '/proj',
timestamp_iso: '2026-04-28T10:00:00.000Z',
event_type: 'user-prompt-submit',
},
...overrides,
};
}
it('embeds session/source/event in a content prefix and defaults source to "system"', () => {
const out = frameToSavePayload(makeFrame());
expect(out.content).toBe('[hm session:sess-123 src:claude-code event:user-prompt-submit] the actual content');
expect(out.importance).toBe('normal');
expect(out.source).toBe('system');
});
it('drops session token when scope is "default"', () => {
const out = frameToSavePayload(makeFrame({ scope: 'default' }));
expect(out.content).not.toContain('session:');
expect(out.content).toContain('src:claude-code');
});
it('includes parent token when present', () => {
const out = frameToSavePayload(makeFrame({ parent: 'frame-99' }));
expect(out.content).toContain('parent:frame-99');
});
it('includes project token from metadata when present', () => {
const out = frameToSavePayload(makeFrame({ metadata: {
cwd: '/proj',
timestamp_iso: '2026-04-28T10:00:00.000Z',
event_type: 'stop',
project: 'waggle-os',
}}));
expect(out.content).toContain('project:waggle-os');
});
it('mcpSource override is honoured', () => {
const out = frameToSavePayload(makeFrame(), { mcpSource: 'agent_inferred' });
expect(out.source).toBe('agent_inferred');
});
it('omits prefix entirely when no metadata tokens would be added', () => {
const minimalFrame: HookFrame = {
content: 'plain',
importance: 'normal',
scope: 'default',
source: 'claude-code',
metadata: { cwd: '/p', timestamp_iso: 't' }, // no event_type, no project
};
const out = frameToSavePayload(minimalFrame);
// src token is always present, so we still get a prefix:
expect(out.content).toBe('[hm src:claude-code] plain');
});
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
ALL_EVENT_TYPES,
ALL_SOURCES,
isEventType,
isShimSource,
} from '../src/hook-event-types.js';
describe('hook-event-types', () => {
it('ALL_EVENT_TYPES contains the 7 canonical events', () => {
expect([...ALL_EVENT_TYPES].sort()).toEqual([
'post-tool-use',
'pre-compact',
'pre-tool-use',
'session-end',
'session-start',
'stop',
'user-prompt-submit',
]);
});
it('ALL_SOURCES contains the 6 supported IDEs', () => {
expect([...ALL_SOURCES].sort()).toEqual([
'claude-code',
'codex',
'cursor',
'hermes',
'openclaw',
'opencode',
]);
});
it('isEventType narrows known strings', () => {
expect(isEventType('session-start')).toBe(true);
expect(isEventType('user-prompt-submit')).toBe(true);
expect(isEventType('pre-tool-use')).toBe(true);
});
it('isEventType rejects unknown / non-string values', () => {
expect(isEventType('made-up-event')).toBe(false);
expect(isEventType('')).toBe(false);
expect(isEventType(null)).toBe(false);
expect(isEventType(42)).toBe(false);
expect(isEventType({ eventType: 'session-start' })).toBe(false);
});
it('isShimSource narrows known and rejects unknown', () => {
expect(isShimSource('claude-code')).toBe(true);
expect(isShimSource('codex')).toBe(true);
expect(isShimSource('not-a-real-ide')).toBe(false);
expect(isShimSource(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
classifyImportance,
classifyWithRules,
DEFAULT_RULES,
} from '../src/importance-classifier.js';
describe('classifyImportance — default rules', () => {
it('"always" pattern is critical', () => {
expect(classifyImportance('always run lint before commit')).toBe('critical');
});
it('"never" pattern is critical', () => {
expect(classifyImportance('never commit secrets')).toBe('critical');
});
it('MEMORY.md reference is critical', () => {
expect(classifyImportance('see MEMORY.md for the full list')).toBe('critical');
});
it('CLAUDE.md reference is critical', () => {
expect(classifyImportance('this rule is documented in CLAUDE.md')).toBe('critical');
});
it('"do not use X" prohibition is critical', () => {
expect(classifyImportance('do not use the legacy adapter')).toBe('critical');
expect(classifyImportance("don't run that command")).toBe('critical');
});
it('decision verbs land at important', () => {
expect(classifyImportance('we decided to use Postgres')).toBe('important');
expect(classifyImportance('chose Option A over B')).toBe('important');
});
it('action verbs land at important', () => {
expect(classifyImportance('implement the auth middleware')).toBe('important');
expect(classifyImportance('refactor the bridge module')).toBe('important');
});
it('failure signals land at important', () => {
expect(classifyImportance('the build failed on Windows')).toBe('important');
});
it('TODO / FIXME markers land at important', () => {
expect(classifyImportance('TODO: wire up retry')).toBe('important');
expect(classifyImportance('FIXME parameter is wrong')).toBe('important');
});
it('plain chatter floors at "normal" (Commit 1.4 — was "temporary")', () => {
expect(classifyImportance('hi there')).toBe('normal');
expect(classifyImportance('thanks')).toBe('normal');
});
it('empty / whitespace-only input is still temporary (early return short-circuits floor)', () => {
expect(classifyImportance('')).toBe('temporary');
expect(classifyImportance(' \n\t')).toBe('temporary');
});
it('session-start / session-end events floor at important', () => {
expect(classifyImportance('hi', { eventType: 'session-start' })).toBe('important');
expect(classifyImportance('thanks', { eventType: 'session-end' })).toBe('important');
});
it('critical patterns still beat the session-start floor', () => {
expect(classifyImportance('always test before push', { eventType: 'session-start' })).toBe('critical');
});
});
describe('classifyWithRules', () => {
it('returns fallback when content is empty', () => {
expect(classifyWithRules('', DEFAULT_RULES, 'temporary')).toBe('temporary');
expect(classifyWithRules('', DEFAULT_RULES, 'normal')).toBe('normal');
});
it('uses custom rule set (default fallback is "normal" in Commit 1.4)', () => {
const rules = [{ pattern: /xyzzy/i, importance: 'critical' as const, reason: 'magic word' }];
expect(classifyWithRules('the password is xyzzy', rules)).toBe('critical');
expect(classifyWithRules('the password is hunter2', rules)).toBe('normal');
// Explicit fallback override still works:
expect(classifyWithRules('the password is hunter2', rules, 'temporary')).toBe('temporary');
});
it('higher importance wins when multiple rules match', () => {
const rules = [
{ pattern: /always/i, importance: 'critical' as const, reason: 'directive' },
{ pattern: /implement/i, importance: 'important' as const, reason: 'verb' },
];
expect(classifyWithRules('always implement tests first', rules)).toBe('critical');
});
});

View File

@@ -0,0 +1,165 @@
/**
* Wire-format round-trip integration test.
*
* Exercises the cli-bridge against a REAL hive-mind-cli + real
* hive-mind MCP server, in a freshly-init'd tmpdir-isolated mind so
* the test never touches the user's actual ~/.hive-mind.
*
* Acts as a fitness function: if the upstream MCP surface drifts
* (tool renames, schema field changes, importance enum changes, etc.)
* this test fails before any shim ships. Mocked unit tests would
* silently keep passing — that's exactly the gap that produced
* Commit 1.4 in the first place.
*
* Skipped automatically when `hive-mind-cli` is not on PATH (CI
* matrices without the upstream installed). Look for the
* "integration: hive-mind-cli not on PATH" log line.
*/
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { createCliBridge } from '../../src/cli-bridge.js';
import { encodeFrame } from '../../src/frame-encoder.js';
import type { HookEvent } from '../../src/hook-event-types.js';
const PROBE_TIMEOUT_MS = 5000;
const INIT_TIMEOUT_MS = 15000;
const TEST_TIMEOUT_MS = 30000;
const CLI_BIN = 'hive-mind-cli';
/**
* Path resolution priority:
* 1. HIVE_MIND_CLI_JS env override (CI / offline testing)
* 2. In-monorepo package at packages/hive-mind-cli/dist/index.js
* 3. Sibling checkout at ../hive-mind/packages/cli/dist/index.js
* 4. Fall back to 'hive-mind-cli' on PATH (relies on cli-bridge's
* Windows shell:true codepath; acceptable for unit/integration but
* forced JS path keeps things hermetic).
*/
function resolveCliJsPath(): string | undefined {
const envOverride = process.env['HIVE_MIND_CLI_JS'];
if (envOverride && existsSync(envOverride)) return envOverride;
const monorepo = resolve(import.meta.dirname, '..', '..', '..', 'hive-mind-cli', 'dist', 'index.js');
if (existsSync(monorepo)) return monorepo;
const sibling = resolve(process.cwd(), '..', 'hive-mind', 'packages', 'cli', 'dist', 'index.js');
if (existsSync(sibling)) return sibling;
return undefined;
}
const RESOLVED_CLI_JS = resolveCliJsPath();
const SPAWN_CMD = RESOLVED_CLI_JS ?? CLI_BIN;
function probeCli(): boolean {
try {
const probeArgs = RESOLVED_CLI_JS ? [RESOLVED_CLI_JS, '--help'] : ['--help'];
const probeBin = RESOLVED_CLI_JS ? process.execPath : CLI_BIN;
const probe = spawnSync(probeBin, probeArgs, {
stdio: 'pipe',
timeout: PROBE_TIMEOUT_MS,
shell: !RESOLVED_CLI_JS,
});
return probe.status === 0;
} catch {
return false;
}
}
const cliReachable = probeCli();
let tmpHome: string | undefined;
let priorDataDir: string | undefined;
beforeAll(async () => {
if (!cliReachable) {
console.log('[integration] hive-mind-cli not on PATH — round-trip suite will skip.');
return;
}
tmpHome = await mkdtemp(join(tmpdir(), 'hmc-integration-'));
priorDataDir = process.env['HIVE_MIND_DATA_DIR'];
process.env['HIVE_MIND_DATA_DIR'] = tmpHome;
// Initialize an isolated mind file under the tmpdir.
const initBin = RESOLVED_CLI_JS ? process.execPath : CLI_BIN;
const initArgs = RESOLVED_CLI_JS ? [RESOLVED_CLI_JS, 'init'] : ['init'];
const init = spawnSync(initBin, initArgs, {
stdio: 'pipe',
timeout: INIT_TIMEOUT_MS,
shell: !RESOLVED_CLI_JS,
env: process.env,
});
if (init.status !== 0) {
throw new Error(`hive-mind-cli init failed: ${init.stderr?.toString() ?? '(no stderr)'}`);
}
}, INIT_TIMEOUT_MS);
afterAll(async () => {
if (!cliReachable) return;
if (priorDataDir === undefined) {
delete process.env['HIVE_MIND_DATA_DIR'];
} else {
process.env['HIVE_MIND_DATA_DIR'] = priorDataDir;
}
if (tmpHome) {
await rm(tmpHome, { recursive: true, force: true });
}
});
describe.skipIf(!cliReachable)('integration: cli-bridge ↔ hive-mind-cli round-trip', () => {
it('save_memory + recall_memory complete a wire-level round-trip with HookFrame inputs', async () => {
const bridge = createCliBridge({ max_retries: 0, timeout_ms: 15000, cli_path: SPAWN_CMD });
const marker = `roundtrip-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const event: HookEvent = {
eventType: 'user-prompt-submit',
source: 'claude-code',
cwd: '/integration/test',
timestamp_iso: new Date().toISOString(),
payload: {
content: `${marker} this is the integration save body`,
session_id: `int-sess-${marker}`,
},
};
const frame = encodeFrame(event, { importance: 'normal' });
const saveResult = await bridge.saveMemory(frame);
expect(saveResult.success).toBe(true);
expect(saveResult.id).not.toBe('');
expect(Number(saveResult.id)).toBeGreaterThan(0);
// Recall by the unique marker — content prefix carries it through.
const hits = await bridge.recallMemory(marker, { limit: 5 });
expect(hits.length).toBeGreaterThanOrEqual(1);
const hit = hits[0];
expect(hit.content).toContain(marker);
expect(hit.content).toContain('src:claude-code');
expect(hit.importance).toBe('normal');
expect(hit.from).toBe('personal');
}, TEST_TIMEOUT_MS);
it('cleanup_frames responds without error', async () => {
const bridge = createCliBridge({ max_retries: 0, timeout_ms: 15000, cli_path: SPAWN_CMD });
const out = await bridge.cleanupFrames();
expect(typeof out.pruned).toBe('number');
expect(out.pruned).toBeGreaterThanOrEqual(0);
}, TEST_TIMEOUT_MS);
// NOTE: an "empty recall" integration check would be too brittle —
// upstream hybrid search returns very-low-score hits even for
// unrelated queries (semantic vector recall has no zero-score floor).
// The plain-text "No memories found" envelope is exercised by the
// unit test in cli-bridge.test.ts with a mocked spawn instead.
it('save_memory rejects an invalid source (regression: enum mismatch must surface, not be silently swallowed)', async () => {
const bridge = createCliBridge({ max_retries: 0, timeout_ms: 15000, cli_path: SPAWN_CMD });
// Bypass frameToSavePayload — use raw callMcpTool to send a bad source.
await expect(bridge.callMcpTool('save_memory', {
content: 'should fail',
source: 'claude-code', // INVALID — must be one of the four provenance enum values
})).rejects.toThrow(/Invalid enum value/);
}, TEST_TIMEOUT_MS);
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { createLogger } from '../src/logger.js';
function captureLines(): { lines: string[]; write: (l: string) => void } {
const lines: string[] = [];
return { lines, write: (l) => lines.push(l) };
}
const FROZEN_NOW = new Date('2026-04-28T10:00:00.000Z');
describe('createLogger', () => {
it('emits a JSON line per call with name + level + msg + timestamp', () => {
const cap = captureLines();
const log = createLogger({ name: 'test', level: 'debug', write: cap.write, now: () => FROZEN_NOW });
log.info('hello');
expect(cap.lines).toHaveLength(1);
const entry = JSON.parse(cap.lines[0]) as Record<string, unknown>;
expect(entry).toMatchObject({
timestamp: '2026-04-28T10:00:00.000Z',
level: 'info',
name: 'test',
msg: 'hello',
});
});
it('filters levels below threshold', () => {
const cap = captureLines();
const log = createLogger({ level: 'warn', write: cap.write, now: () => FROZEN_NOW });
log.debug('d');
log.info('i');
log.warn('w');
log.error('e');
expect(cap.lines.map((l) => (JSON.parse(l) as { level: string }).level))
.toEqual(['warn', 'error']);
});
it('merges meta fields into the entry without clobbering core fields', () => {
const cap = captureLines();
const log = createLogger({ level: 'info', write: cap.write, now: () => FROZEN_NOW });
log.info('x', { tool: 'save_memory', latencyMs: 42 });
const entry = JSON.parse(cap.lines[0]) as Record<string, unknown>;
expect(entry['tool']).toBe('save_memory');
expect(entry['latencyMs']).toBe(42);
expect(entry['msg']).toBe('x');
});
it('honours HIVE_MIND_SHIM_LOG_LEVEL when no explicit level is provided', () => {
const prior = process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = 'error';
try {
const cap = captureLines();
const log = createLogger({ write: cap.write, now: () => FROZEN_NOW });
log.info('i');
log.error('e');
expect(cap.lines).toHaveLength(1);
expect((JSON.parse(cap.lines[0]) as { level: string }).level).toBe('error');
} finally {
if (prior === undefined) delete process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
else process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = prior;
}
});
it('explicit level option overrides env var', () => {
const prior = process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = 'error';
try {
const cap = captureLines();
const log = createLogger({ level: 'debug', write: cap.write, now: () => FROZEN_NOW });
log.debug('d');
expect(cap.lines).toHaveLength(1);
} finally {
if (prior === undefined) delete process.env['HIVE_MIND_SHIM_LOG_LEVEL'];
else process.env['HIVE_MIND_SHIM_LOG_LEVEL'] = prior;
}
});
it('falls back to default name "shim-core"', () => {
const cap = captureLines();
const log = createLogger({ write: cap.write, now: () => FROZEN_NOW });
log.info('hi');
const entry = JSON.parse(cap.lines[0]) as { name: string };
expect(entry.name).toBe('shim-core');
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { summarizeTurn } from '../src/prompt-summarizer.js';
describe('summarizeTurn', () => {
it('returns empty string for empty input', () => {
expect(summarizeTurn('')).toBe('');
expect(summarizeTurn(' \n ')).toBe('');
});
it('returns input unchanged when within budget (collapsed whitespace)', () => {
expect(summarizeTurn('Short reply.')).toBe('Short reply.');
expect(summarizeTurn('Hello world.\n\n\nNice.')).toBe('Hello world. Nice.');
});
it('truncates with ellipsis when over budget', () => {
const long = 'A'.repeat(800);
const out = summarizeTurn(long, { maxChars: 100 });
expect(out.length).toBeLessThanOrEqual(100);
expect(out.endsWith('…')).toBe(true);
});
it('keeps the leading sentence when possible', () => {
const text = 'First sentence here. Second one is longer and contains more words. Third.';
const out = summarizeTurn(text, { maxChars: 30 });
expect(out.startsWith('First sentence here.')).toBe(true);
expect(out.endsWith('…')).toBe(true);
});
it('replaces fenced code blocks with [code]', () => {
const text = 'Here is some code:\n```ts\nconst x = 1;\n```\nthat is all.';
const out = summarizeTurn(text);
expect(out).toContain('[code]');
expect(out).not.toContain('const x = 1;');
});
it('respects custom maxChars', () => {
const text = 'A'.repeat(50) + '. ' + 'B'.repeat(50);
expect(summarizeTurn(text, { maxChars: 200 })).toBe(text);
const small = summarizeTurn(text, { maxChars: 30 });
expect(small.length).toBeLessThanOrEqual(30);
});
it('hard-truncates when no sentence boundary fits', () => {
const blob = 'A'.repeat(500);
const out = summarizeTurn(blob, { maxChars: 50 });
expect(out.length).toBe(50);
expect(out.endsWith('…')).toBe(true);
});
it('is deterministic — same input -> same output', () => {
const a = summarizeTurn('Repeatable. Same. Output.', { maxChars: 30 });
const b = summarizeTurn('Repeatable. Same. Output.', { maxChars: 30 });
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest';
import { computeBackoff, withRetry } from '../src/retry-bridge.js';
describe('computeBackoff', () => {
it('exponentially increases with attempt count', () => {
const fixedRandom = (): number => 0.5; // jitter = 0
expect(computeBackoff(0, 100, 5000, 0, fixedRandom)).toBe(100);
expect(computeBackoff(1, 100, 5000, 0, fixedRandom)).toBe(200);
expect(computeBackoff(2, 100, 5000, 0, fixedRandom)).toBe(400);
expect(computeBackoff(3, 100, 5000, 0, fixedRandom)).toBe(800);
});
it('is clamped to maxBackoffMs', () => {
const fixedRandom = (): number => 0.5;
expect(computeBackoff(20, 100, 1000, 0, fixedRandom)).toBe(1000);
});
it('jitter is bounded by jitterFactor on either side', () => {
// random=0 -> -1 multiplier; random=1 -> +1 multiplier
const lower = computeBackoff(2, 100, 5000, 0.25, () => 0);
const upper = computeBackoff(2, 100, 5000, 0.25, () => 0.999999);
expect(lower).toBeGreaterThanOrEqual(Math.round(400 * 0.75));
expect(upper).toBeLessThanOrEqual(Math.round(400 * 1.25) + 1);
});
it('returns >= 0', () => {
expect(computeBackoff(0, 100, 5000, 5.0, () => 0)).toBeGreaterThanOrEqual(0);
});
});
describe('withRetry', () => {
it('returns the value on first-attempt success', async () => {
const fn = vi.fn(async () => 42);
const result = await withRetry(fn, { maxRetries: 3, delay: async () => undefined });
expect(result).toBe(42);
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries until success', async () => {
let calls = 0;
const fn = vi.fn(async () => {
calls += 1;
if (calls < 3) throw new Error('flaky');
return 'ok';
});
const result = await withRetry(fn, {
maxRetries: 5,
delay: async () => undefined,
random: () => 0.5,
});
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(3);
});
it('throws the last error after exhausting retries', async () => {
const fn = vi.fn(async () => { throw new Error('persistent'); });
await expect(withRetry(fn, { maxRetries: 2, delay: async () => undefined })).rejects.toThrow('persistent');
expect(fn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it('per-attempt timeout fires when fn never resolves', async () => {
const fn = vi.fn(() => new Promise<never>(() => { /* hang */ }));
await expect(withRetry(fn, {
maxRetries: 1,
timeoutMs: 20,
delay: async () => undefined,
})).rejects.toThrow(/timed out/);
expect(fn).toHaveBeenCalledTimes(2);
});
it('passes the configured delay through (test hook)', async () => {
const delays: number[] = [];
const fn = vi.fn(async () => { throw new Error('x'); });
await expect(withRetry(fn, {
maxRetries: 2,
baseBackoffMs: 100,
jitterFactor: 0,
delay: async (ms) => { delays.push(ms); },
random: () => 0.5,
})).rejects.toThrow();
expect(delays).toEqual([100, 200]);
});
it('wraps non-Error rejections', async () => {
const fn = vi.fn(async () => { throw 'plain string'; });
await expect(withRetry(fn, {
maxRetries: 0,
delay: async () => undefined,
})).rejects.toThrow('plain string');
});
});

View File

@@ -0,0 +1,302 @@
/**
* AI-OS Phase 1D — signal emitter tests.
*
* Verifies: URL resolution order, request shape, fail-open semantics
* on network errors, timeout enforcement, the maybeEmitDiscovery
* policy helper.
*/
import { describe, it, expect } from 'vitest';
import {
emitSignalToWaggleDance,
maybeEmitDiscovery,
type EmittedSignal,
} from '../src/signal-emitter.js';
/**
* Build a fake fetch that captures the request and returns a stub
* 201 response with a synthetic signal echo.
*/
function makeOkFetch(): typeof fetch & { calls: Array<{ url: string; init: RequestInit | undefined }> } {
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init });
const sig: EmittedSignal = {
id: 'srv-' + Math.random().toString(36).slice(2),
teamId: 'personal::test',
senderId: 'test',
type: 'broadcast',
subtype: 'discovery',
content: {},
referenceId: null,
routing: null,
createdAt: new Date().toISOString(),
};
return new Response(JSON.stringify({ dispatched: true, message: sig }), {
status: 201,
headers: { 'content-type': 'application/json' },
});
}) as typeof fetch & { calls: typeof calls };
impl.calls = calls;
return impl;
}
describe('emitSignalToWaggleDance', () => {
it('POSTs to the default sidecar URL when no override', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: { topic: 'x' },
fetchImpl: f,
});
expect(f.calls).toHaveLength(1);
expect(f.calls[0].url).toBe('http://127.0.0.1:3333/api/waggle-dance/signal');
});
it('honors the url option', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: f,
url: 'http://127.0.0.1:4000',
});
expect(f.calls[0].url).toBe('http://127.0.0.1:4000/api/waggle-dance/signal');
});
it('reads WAGGLE_SIDECAR_URL env when no url option', async () => {
const prev = process.env.WAGGLE_SIDECAR_URL;
process.env.WAGGLE_SIDECAR_URL = 'http://127.0.0.1:9999';
try {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: f,
});
expect(f.calls[0].url).toBe('http://127.0.0.1:9999/api/waggle-dance/signal');
} finally {
if (prev === undefined) delete process.env.WAGGLE_SIDECAR_URL;
else process.env.WAGGLE_SIDECAR_URL = prev;
}
});
it('serializes the body with the expected shape', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: { tool: 'claude-code', topic: 'rotation' },
senderId: 'claude-code-hook',
fetchImpl: f,
});
const body = JSON.parse(f.calls[0].init?.body as string);
expect(body).toMatchObject({
type: 'broadcast',
subtype: 'discovery',
senderId: 'claude-code-hook',
content: { tool: 'claude-code', topic: 'rotation' },
});
});
it('authenticates with the narrow run token without putting it in the body', async () => {
const f = makeOkFetch();
const token = 'run-token-with-at-least-thirty-two-bytes-1234';
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: { topic: 'safe' },
runToken: token,
fetchImpl: f,
});
expect((f.calls[0].init?.headers as Record<string, string>)['x-waggle-run-token']).toBe(token);
expect(f.calls[0].init?.body).not.toContain(token);
});
it('reads WAGGLE_RUN_TOKEN for installed hook processes', async () => {
const previous = process.env.WAGGLE_RUN_TOKEN;
const token = 'environment-run-token-with-enough-entropy-1234';
process.env.WAGGLE_RUN_TOKEN = token;
try {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast', subtype: 'discovery', content: {}, fetchImpl: f,
});
expect((f.calls[0].init?.headers as Record<string, string>)['x-waggle-run-token']).toBe(token);
} finally {
if (previous === undefined) delete process.env.WAGGLE_RUN_TOKEN;
else process.env.WAGGLE_RUN_TOKEN = previous;
}
});
it('defaults senderId to "hook" when not provided', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: f,
});
const body = JSON.parse(f.calls[0].init?.body as string);
expect(body.senderId).toBe('hook');
});
it('only includes optional fields when set', async () => {
const f = makeOkFetch();
await emitSignalToWaggleDance({
type: 'response',
subtype: 'knowledge_match',
content: { matched: 1 },
senderId: 'cursor',
referenceId: 'r-1',
fetchImpl: f,
});
const body = JSON.parse(f.calls[0].init?.body as string);
expect(body.referenceId).toBe('r-1');
expect(body).not.toHaveProperty('routing');
expect(body).not.toHaveProperty('teamId');
});
it('returns the server message on 201', async () => {
const f = makeOkFetch();
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: f,
});
expect(out).not.toBeNull();
expect(out!.id).toMatch(/^srv-/);
});
it('returns null and warns on network errors (ECONNREFUSED simulation)', async () => {
const warnCalls: string[] = [];
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: (async () => {
throw new Error('ECONNREFUSED 127.0.0.1:3333');
}) as typeof fetch,
onWarn: (m) => warnCalls.push(m),
});
expect(out).toBeNull();
expect(warnCalls).toHaveLength(1);
expect(warnCalls[0]).toContain('ECONNREFUSED');
});
it('returns null and warns on non-2xx response', async () => {
const warnCalls: string[] = [];
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: (async () => new Response('bad request', { status: 400 })) as typeof fetch,
onWarn: (m) => warnCalls.push(m),
});
expect(out).toBeNull();
expect(warnCalls[0]).toContain('400');
});
it('returns null and warns on malformed response body', async () => {
const warnCalls: string[] = [];
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
fetchImpl: (async () =>
new Response('{"no-message-field": true}', {
status: 201,
headers: { 'content-type': 'application/json' },
})) as typeof fetch,
onWarn: (m) => warnCalls.push(m),
});
expect(out).toBeNull();
expect(warnCalls[0]).toContain('malformed');
});
it('enforces a per-request timeout', async () => {
const warnCalls: string[] = [];
const out = await emitSignalToWaggleDance({
type: 'broadcast',
subtype: 'discovery',
content: {},
timeoutMs: 50,
fetchImpl: ((_url: string, init?: RequestInit) => {
// Return a never-resolving promise; the abort signal should trigger.
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
});
}) as typeof fetch,
onWarn: (m) => warnCalls.push(m),
});
expect(out).toBeNull();
expect(warnCalls[0]).toContain('sidecar unreachable');
});
});
describe('maybeEmitDiscovery', () => {
it('emits when Stop + high importance', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery(
'stop',
'high',
{ topic: 'rotation' },
{ fetchImpl: f, senderId: 'cc' },
);
expect(out).not.toBeNull();
expect(f.calls).toHaveLength(1);
const body = JSON.parse(f.calls[0].init?.body as string);
expect(body.subtype).toBe('discovery');
expect(body.content.eventType).toBe('stop');
expect(body.content.importance).toBe('high');
expect(body.content.topic).toBe('rotation');
});
it('emits when PreCompact + critical', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery(
'pre-compact',
'critical',
{},
{ fetchImpl: f },
);
expect(out).not.toBeNull();
});
it('does not emit on low importance', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery('stop', 'low', {}, { fetchImpl: f });
expect(out).toBeNull();
expect(f.calls).toHaveLength(0);
});
it('does not emit on normal importance', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery('stop', 'normal', {}, { fetchImpl: f });
expect(out).toBeNull();
expect(f.calls).toHaveLength(0);
});
it('does not emit on non-stop/pre-compact events', async () => {
const f = makeOkFetch();
const out = await maybeEmitDiscovery(
'user-prompt-submit',
'high',
{},
{ fetchImpl: f },
);
expect(out).toBeNull();
expect(f.calls).toHaveLength(0);
});
});
// Note: the end-to-end integration test against a real Waggle sidecar
// lives in packages/server/tests/signal-emitter-integration.test.ts —
// that's the correct layer to depend on @waggle/server. This module
// (shim-core) stays a leaf with no inbound deps from the server, which
// is what lets hook packages consume it without dragging the sidecar
// in.

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { isAbsoluteWorkspacePath, resolveWorkspace } from '../src/workspace-resolver.js';
import { join, resolve } from 'node:path';
// Resolve path fixtures against the current platform so tests work on
// both POSIX (e.g. /fake/home) and Windows (D:\fake\home). The resolver
// internally calls path.resolve on its cwd input — fixtures must match.
const FAKE_HOME = resolve('/fake/home');
function makeExists(present: readonly string[]): (p: string) => Promise<boolean> {
const set = new Set(present);
return async (p: string) => set.has(p);
}
describe('resolveWorkspace', () => {
it('falls back to global mode when no project marker exists', async () => {
const ws = await resolveWorkspace(resolve('/some/random/cwd'), {
home: FAKE_HOME,
exists: makeExists([]),
walkUp: false,
});
expect(ws.mode).toBe('global');
expect(ws.path).toBe(join(FAKE_HOME, '.hive-mind', 'global.mind'));
});
it('returns per-project mode when marker exists at cwd', async () => {
const cwd = resolve('/proj/foo');
const marker = join(cwd, '.hive-mind', 'workspace.mind');
const ws = await resolveWorkspace(cwd, {
home: FAKE_HOME,
exists: makeExists([marker]),
walkUp: false,
});
expect(ws.mode).toBe('per-project');
expect(ws.path).toBe(marker);
expect(ws.cwd).toBe(cwd);
});
it('walks up to a parent project marker', async () => {
const projectRoot = resolve('/proj/foo');
const cwd = join(projectRoot, 'src', 'deep', 'nested');
const marker = join(projectRoot, '.hive-mind', 'workspace.mind');
const ws = await resolveWorkspace(cwd, {
home: FAKE_HOME,
exists: makeExists([marker]),
walkUp: true,
});
expect(ws.mode).toBe('per-project');
expect(ws.path).toBe(marker);
});
it('skips the walk when walkUp is false', async () => {
const projectRoot = resolve('/proj/foo');
const cwd = join(projectRoot, 'src', 'deep');
const ancestorMarker = join(projectRoot, '.hive-mind', 'workspace.mind');
const ws = await resolveWorkspace(cwd, {
home: FAKE_HOME,
exists: makeExists([ancestorMarker]),
walkUp: false,
});
expect(ws.mode).toBe('global');
});
it('records the original cwd even when resolving to global', async () => {
const cwd = resolve('/proj/foo');
const ws = await resolveWorkspace(cwd, {
home: FAKE_HOME,
exists: makeExists([]),
walkUp: true,
});
expect(ws.cwd).toBe(cwd);
expect(ws.mode).toBe('global');
});
it('uses provided home override for the global fallback', async () => {
const customHome = resolve('/custom/home');
const ws = await resolveWorkspace(resolve('/proj/x'), {
home: customHome,
exists: makeExists([]),
walkUp: false,
});
expect(ws.path).toBe(join(customHome, '.hive-mind', 'global.mind'));
});
});
describe('isAbsoluteWorkspacePath', () => {
it('detects absolute paths on the current platform', () => {
expect(isAbsoluteWorkspacePath(resolve('/etc/foo'))).toBe(true);
expect(isAbsoluteWorkspacePath('relative/path')).toBe(false);
});
});