This commit is contained in:
75
packages/cli/tests/admin.test.ts
Normal file
75
packages/cli/tests/admin.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AdminClient, formatTable } from '../src/commands/admin.js';
|
||||
|
||||
describe('AdminClient', () => {
|
||||
it('constructs with defaults', () => {
|
||||
const client = new AdminClient();
|
||||
expect(client).toBeInstanceOf(AdminClient);
|
||||
});
|
||||
|
||||
it('constructs with custom base URL and token', () => {
|
||||
const client = new AdminClient('http://localhost:9999', 'my-token');
|
||||
expect(client).toBeInstanceOf(AdminClient);
|
||||
});
|
||||
|
||||
it('has all admin methods', () => {
|
||||
const client = new AdminClient('http://localhost:3100', 'test-token');
|
||||
expect(typeof client.listTeams).toBe('function');
|
||||
expect(typeof client.listJobs).toBe('function');
|
||||
expect(typeof client.listCron).toBe('function');
|
||||
expect(typeof client.listAudit).toBe('function');
|
||||
expect(typeof client.getStats).toBe('function');
|
||||
});
|
||||
|
||||
it('methods return promises', () => {
|
||||
const client = new AdminClient('http://localhost:3100', 'test-token');
|
||||
// These will fail to connect, but they should return promises
|
||||
const p = client.listTeams();
|
||||
expect(p).toBeInstanceOf(Promise);
|
||||
// Suppress unhandled rejection
|
||||
p.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTable', () => {
|
||||
it('returns "(no data)" for empty array', () => {
|
||||
expect(formatTable([])).toBe(' (no data)');
|
||||
});
|
||||
|
||||
it('formats rows into aligned columns', () => {
|
||||
const rows = [
|
||||
{ id: '1', name: 'Alice', role: 'admin' },
|
||||
{ id: '2', name: 'Bob', role: 'member' },
|
||||
];
|
||||
const result = formatTable(rows);
|
||||
expect(result).toContain('id');
|
||||
expect(result).toContain('name');
|
||||
expect(result).toContain('role');
|
||||
expect(result).toContain('Alice');
|
||||
expect(result).toContain('Bob');
|
||||
expect(result).toContain('admin');
|
||||
expect(result).toContain('member');
|
||||
});
|
||||
|
||||
it('respects explicit column selection', () => {
|
||||
const rows = [
|
||||
{ id: '1', name: 'Alice', secret: 'hidden' },
|
||||
];
|
||||
const result = formatTable(rows, ['id', 'name']);
|
||||
expect(result).toContain('id');
|
||||
expect(result).toContain('name');
|
||||
expect(result).not.toContain('secret');
|
||||
expect(result).not.toContain('hidden');
|
||||
});
|
||||
|
||||
it('handles missing values gracefully', () => {
|
||||
const rows = [
|
||||
{ id: '1', name: 'Alice' },
|
||||
{ id: '2' },
|
||||
];
|
||||
const result = formatTable(rows);
|
||||
expect(result).toContain('Alice');
|
||||
// Second row should have empty name
|
||||
expect(result).toContain('2');
|
||||
});
|
||||
});
|
||||
90
packages/cli/tests/auth.test.ts
Normal file
90
packages/cli/tests/auth.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { AuthManager } from '../src/auth.js';
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
describe('AuthManager', () => {
|
||||
let tempDir: string;
|
||||
let auth: AuthManager;
|
||||
|
||||
function setup() {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'waggle-auth-test-'));
|
||||
auth = new AuthManager(tempDir);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when no token stored', () => {
|
||||
setup();
|
||||
expect(auth.getToken()).toBeNull();
|
||||
expect(auth.getEmail()).toBeNull();
|
||||
});
|
||||
|
||||
it('saves and retrieves token', () => {
|
||||
setup();
|
||||
auth.saveToken('test-jwt-token', 'user@example.com');
|
||||
expect(auth.getToken()).toBe('test-jwt-token');
|
||||
expect(auth.getEmail()).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('clears token on logout', () => {
|
||||
setup();
|
||||
auth.saveToken('test-jwt-token', 'user@example.com');
|
||||
expect(auth.getToken()).toBe('test-jwt-token');
|
||||
auth.logout();
|
||||
expect(auth.getToken()).toBeNull();
|
||||
expect(auth.getEmail()).toBeNull();
|
||||
});
|
||||
|
||||
it('isLoggedIn returns true/false correctly', () => {
|
||||
setup();
|
||||
expect(auth.isLoggedIn()).toBe(false);
|
||||
auth.saveToken('test-jwt-token', 'user@example.com');
|
||||
expect(auth.isLoggedIn()).toBe(true);
|
||||
auth.logout();
|
||||
expect(auth.isLoggedIn()).toBe(false);
|
||||
});
|
||||
|
||||
it('stores token in config.json file', () => {
|
||||
setup();
|
||||
auth.saveToken('file-check-token', 'file@example.com');
|
||||
const configPath = join(tempDir, 'config.json');
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
expect(config.auth).toEqual({
|
||||
token: 'file-check-token',
|
||||
email: 'file@example.com',
|
||||
serverUrl: 'http://localhost:3000',
|
||||
});
|
||||
});
|
||||
|
||||
it('getServerUrl returns default', () => {
|
||||
setup();
|
||||
expect(auth.getServerUrl()).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('preserves existing config keys when saving auth', () => {
|
||||
setup();
|
||||
// Write some pre-existing config
|
||||
const configPath = join(tempDir, 'config.json');
|
||||
try {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
} catch {
|
||||
// tempDir already exists from setup(); recursive mkdir is idempotent.
|
||||
}
|
||||
writeFileSync(configPath, JSON.stringify({ apiKey: 'sk-existing', model: 'claude' }));
|
||||
|
||||
auth.saveToken('my-token', 'me@test.com');
|
||||
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
expect(config.apiKey).toBe('sk-existing');
|
||||
expect(config.model).toBe('claude');
|
||||
expect(config.auth.token).toBe('my-token');
|
||||
});
|
||||
});
|
||||
580
packages/cli/tests/cli-runtime.test.ts
Normal file
580
packages/cli/tests/cli-runtime.test.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
|
||||
const CLI_DIR = path.join(ROOT, 'packages', 'cli');
|
||||
|
||||
function bin(name: string): string {
|
||||
return process.platform === 'win32' ? `${name}.cmd` : name;
|
||||
}
|
||||
|
||||
function makeHome(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-cli-runtime-'));
|
||||
}
|
||||
|
||||
function run(
|
||||
command: string,
|
||||
args: string[],
|
||||
home: string,
|
||||
): Promise<AsyncRunResult> {
|
||||
return runInCwdAsync(command, args, ROOT, home);
|
||||
}
|
||||
|
||||
function runInCwd(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
home: string,
|
||||
): Promise<AsyncRunResult> {
|
||||
return runInCwdAsync(command, args, cwd, home);
|
||||
}
|
||||
|
||||
function spawnInCwd(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
home: string,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): ChildProcessWithoutNullStreams {
|
||||
return spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
NO_COLOR: '1',
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||
});
|
||||
}
|
||||
|
||||
interface AsyncRunResult {
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function runInCwdAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
home: string,
|
||||
): Promise<AsyncRunResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnInCwd(command, args, cwd, home);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('error', reject);
|
||||
child.on('exit', (status, signal) => resolve({ status, signal, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
interface MockLiteLlmRequest {
|
||||
url: string;
|
||||
authorization: string | undefined;
|
||||
body: {
|
||||
model?: string;
|
||||
messages?: Array<{ role: string; content?: string | null }>;
|
||||
stream?: boolean;
|
||||
stream_options?: { include_usage?: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
interface MockLiteLlm {
|
||||
baseUrl: string;
|
||||
requests: MockLiteLlmRequest[];
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<MockLiteLlmRequest['body']> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
return raw ? JSON.parse(raw) as MockLiteLlmRequest['body'] : {};
|
||||
}
|
||||
|
||||
function writeSse(res: ServerResponse, payload: unknown): void {
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
async function startMockLiteLlm(): Promise<MockLiteLlm> {
|
||||
const requests: MockLiteLlmRequest[] = [];
|
||||
const server: Server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readJson(req);
|
||||
requests.push({
|
||||
url: req.url,
|
||||
authorization: req.headers.authorization,
|
||||
body,
|
||||
});
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
const id = 'chatcmpl-installed-cli-test';
|
||||
writeSse(res, {
|
||||
id,
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta: { content: 'Mock installed ' }, finish_reason: null }],
|
||||
});
|
||||
writeSse(res, {
|
||||
id,
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta: { content: 'chat response' }, finish_reason: null }],
|
||||
});
|
||||
writeSse(res, {
|
||||
id,
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 12, completion_tokens: 5, total_tokens: 17 },
|
||||
});
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end((err as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
requests,
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function extractTarball(file: string, cwd: string): Promise<void> {
|
||||
const tar = await import('tar');
|
||||
await tar.x({ file, cwd });
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => child.once('exit', () => resolve())),
|
||||
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('process did not exit')), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => child.once('exit', () => resolve())),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
||||
]);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
const CLI_PACKAGE_CLOSURE = [
|
||||
'@waggle/shared',
|
||||
'@waggle/hive-mind-core',
|
||||
'@waggle/core',
|
||||
'@waggle/marketplace',
|
||||
'@waggle/agent',
|
||||
'@waggle/weaver',
|
||||
'@waggle/cli',
|
||||
] as const;
|
||||
|
||||
describe('@waggle/cli runtime UX', () => {
|
||||
it('runs built help without loading the REPL dependency graph', async () => {
|
||||
const home = makeHome();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle/cli'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const result = await run(process.execPath, [path.join(CLI_DIR, 'dist', 'index.js'), '--help'], home);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Waggle CLI');
|
||||
expect(result.stdout).toContain('Usage:');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('packs a tarball with a runnable bin help command', async () => {
|
||||
const home = makeHome();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle/cli'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', '@waggle/cli', '--pack-destination', home, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const extractDir = path.join(home, 'packed');
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
await extractTarball(path.join(home, packResult.filename), extractDir);
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(extractDir, 'package', 'package.json'), 'utf8'),
|
||||
);
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
[path.join(extractDir, 'package', 'bin', 'waggle.js'), '--help'],
|
||||
home,
|
||||
);
|
||||
|
||||
expect(pkg.bin.waggle).toBe('bin/waggle.js');
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Waggle CLI');
|
||||
expect(result.stdout).toContain('Usage:');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the local package closure and runs npx help', async () => {
|
||||
const home = makeHome();
|
||||
try {
|
||||
const packsDir = path.join(home, 'packs');
|
||||
const projectDir = path.join(home, 'project');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
for (const workspace of CLI_PACKAGE_CLOSURE) {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', workspace], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', workspace, '--pack-destination', packsDir, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const tarball = path.join(packsDir, packResult.filename).replace(/\\/g, '/');
|
||||
dependencies[workspace] = `file:${tarball}`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module', dependencies }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwd(
|
||||
bin('npm'),
|
||||
['install', '--no-audit', '--no-fund', '--ignore-scripts', '--prefer-offline'],
|
||||
projectDir,
|
||||
home,
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const result = await runInCwd(bin('npx'), ['waggle', '--help'], projectDir, home);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Waggle CLI');
|
||||
expect(result.stdout).toContain('Usage:');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it('installs the local package closure and starts the local REPL', async () => {
|
||||
const home = makeHome();
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
try {
|
||||
const packsDir = path.join(home, 'packs');
|
||||
const projectDir = path.join(home, 'project');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
for (const workspace of CLI_PACKAGE_CLOSURE) {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', workspace], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', workspace, '--pack-destination', packsDir, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const tarball = path.join(packsDir, packResult.filename).replace(/\\/g, '/');
|
||||
dependencies[workspace] = `file:${tarball}`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module', dependencies }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwdAsync(
|
||||
bin('npm'),
|
||||
// The REPL opens MindDB on startup, so this install must allow better-sqlite3's
|
||||
// native binding lifecycle rather than using the help-only --ignore-scripts path.
|
||||
['install', '--no-audit', '--no-fund', '--prefer-offline'],
|
||||
projectDir,
|
||||
home,
|
||||
);
|
||||
if (install.status !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
'Installed waggle REPL dependency install failed.',
|
||||
`status=${install.status ?? 'null'} signal=${install.signal ?? 'none'}`,
|
||||
`stdout:\n${install.stdout}`,
|
||||
`stderr:\n${install.stderr}`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child = spawnInCwd(bin('npx'), ['waggle', '--local'], projectDir, home);
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
try {
|
||||
await waitFor(() => stdout.includes('Type /help for commands') && stdout.includes('you >'), 30_000);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
[
|
||||
'Installed waggle REPL did not reach the prompt.',
|
||||
`exitCode=${child.exitCode ?? 'running'} signalCode=${child.signalCode ?? 'none'}`,
|
||||
`stdout:\n${stdout}`,
|
||||
`stderr:\n${stderr}`,
|
||||
].join('\n'),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
const writeCommand = async (command: string, expected: string | string[]) => {
|
||||
child!.stdin.write(`${command}\n`);
|
||||
const expectedText = Array.isArray(expected) ? expected : [expected];
|
||||
await waitFor(() => expectedText.every((text) => stdout.includes(text)), 10_000);
|
||||
};
|
||||
|
||||
await writeCommand('/help', ['Available commands:', '/models', '/whoami']);
|
||||
await writeCommand('/mode', 'Current mode: local');
|
||||
await writeCommand('/whoami', ['User:', 'not logged in', 'Server:']);
|
||||
await writeCommand('/models', ['Available models:', 'No models configured']);
|
||||
await writeCommand('/cost', ['Tokens: 0 in / 0 out', 'Est. cost: $0.0000']);
|
||||
await writeCommand('/clear', 'Conversation cleared.');
|
||||
|
||||
child.stdin.write('/exit\n');
|
||||
await waitForExit(child, 10_000);
|
||||
|
||||
expect(child.exitCode).toBe(0);
|
||||
expect(stdout).toContain('Waggle');
|
||||
expect(stdout).toContain('Mode:');
|
||||
expect(stdout).toContain('local');
|
||||
expect(stdout).toContain('Available commands:');
|
||||
expect(stdout).toContain('Current mode: local');
|
||||
expect(stdout).toContain('No models configured');
|
||||
expect(stdout).toContain('Conversation cleared.');
|
||||
expect(stdout).toContain('Goodbye!');
|
||||
expect(stderr).not.toContain('Fatal error');
|
||||
expect(fs.existsSync(path.join(home, '.waggle', 'default.mind'))).toBe(true);
|
||||
} finally {
|
||||
if (child) await stopProcess(child);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it('installs the local package closure and completes a streamed chat turn', async () => {
|
||||
const home = makeHome();
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
let mockLiteLlm: MockLiteLlm | undefined;
|
||||
try {
|
||||
const packsDir = path.join(home, 'packs');
|
||||
const projectDir = path.join(home, 'project');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
for (const workspace of CLI_PACKAGE_CLOSURE) {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', workspace], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', workspace, '--pack-destination', packsDir, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const tarball = path.join(packsDir, packResult.filename).replace(/\\/g, '/');
|
||||
dependencies[workspace] = `file:${tarball}`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module', dependencies }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwdAsync(
|
||||
bin('npm'),
|
||||
['install', '--no-audit', '--no-fund', '--prefer-offline'],
|
||||
projectDir,
|
||||
home,
|
||||
);
|
||||
if (install.status !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
'Installed waggle chat dependency install failed.',
|
||||
`status=${install.status ?? 'null'} signal=${install.signal ?? 'none'}`,
|
||||
`stdout:\n${install.stdout}`,
|
||||
`stderr:\n${install.stderr}`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
mockLiteLlm = await startMockLiteLlm();
|
||||
const waggleHome = path.join(home, '.waggle');
|
||||
const workspaceConfigDir = path.join(projectDir, '.waggle');
|
||||
fs.mkdirSync(waggleHome, { recursive: true });
|
||||
fs.mkdirSync(workspaceConfigDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(waggleHome, 'config.json'),
|
||||
JSON.stringify({
|
||||
defaultModel: 'mock-model',
|
||||
providers: {
|
||||
litellm: {
|
||||
apiKey: 'sk-test',
|
||||
models: ['mock-model'],
|
||||
},
|
||||
},
|
||||
}, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(workspaceConfigDir, 'workspace.json'),
|
||||
JSON.stringify({
|
||||
model: 'mock-model',
|
||||
litellmUrl: mockLiteLlm.baseUrl,
|
||||
}, null, 2),
|
||||
);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child = spawnInCwd(
|
||||
bin('npx'),
|
||||
['waggle', '--local'],
|
||||
projectDir,
|
||||
home,
|
||||
{
|
||||
LITELLM_API_KEY: 'sk-test',
|
||||
WAGGLE_LLM_TIMEOUT_MS: '30000',
|
||||
},
|
||||
);
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
try {
|
||||
await waitFor(() => stdout.includes('Type /help for commands') && stdout.includes('you >'), 30_000);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
[
|
||||
'Installed waggle REPL did not reach the prompt before chat.',
|
||||
`exitCode=${child.exitCode ?? 'running'} signalCode=${child.signalCode ?? 'none'}`,
|
||||
`stdout:\n${stdout}`,
|
||||
`stderr:\n${stderr}`,
|
||||
].join('\n'),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
child.stdin.write('Say hello from the installed CLI test.\n');
|
||||
await waitFor(
|
||||
() => stdout.includes('Mock installed chat response') && stdout.includes('[mock-model |'),
|
||||
30_000,
|
||||
);
|
||||
|
||||
child.stdin.write('/exit\n');
|
||||
await waitForExit(child, 10_000);
|
||||
|
||||
const chatRequest = mockLiteLlm.requests.find((request) => request.url === '/v1/chat/completions');
|
||||
expect(chatRequest).toBeDefined();
|
||||
expect(chatRequest?.authorization).toBe('Bearer sk-test');
|
||||
expect(chatRequest?.body.model).toBe('mock-model');
|
||||
expect(chatRequest?.body.stream).toBe(true);
|
||||
expect(chatRequest?.body.stream_options).toEqual({ include_usage: true });
|
||||
expect(JSON.stringify(chatRequest?.body.messages)).toContain('Say hello from the installed CLI test.');
|
||||
expect(child.exitCode).toBe(0);
|
||||
expect(stdout).toContain('Model:');
|
||||
expect(stdout).toContain('mock-model');
|
||||
expect(stdout).toContain('Mock installed chat response');
|
||||
expect(stdout).toContain('12');
|
||||
expect(stdout).toContain('5');
|
||||
expect(stdout).toContain('Goodbye!');
|
||||
expect(stderr).not.toContain('Fatal error');
|
||||
} finally {
|
||||
if (child) await stopProcess(child);
|
||||
if (mockLiteLlm) await mockLiteLlm.close();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 240_000);
|
||||
});
|
||||
36
packages/cli/tests/commands.test.ts
Normal file
36
packages/cli/tests/commands.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCommand } from '../src/commands.js';
|
||||
|
||||
describe('parseCommand', () => {
|
||||
it('parses /model command with args', () => {
|
||||
const result = parseCommand('/model gpt-4o');
|
||||
expect(result).toEqual({ name: 'model', args: 'gpt-4o' });
|
||||
});
|
||||
|
||||
it('parses /exit (no args)', () => {
|
||||
const result = parseCommand('/exit');
|
||||
expect(result).toEqual({ name: 'exit', args: '' });
|
||||
});
|
||||
|
||||
it('parses /help', () => {
|
||||
const result = parseCommand('/help');
|
||||
expect(result).toEqual({ name: 'help', args: '' });
|
||||
});
|
||||
|
||||
it('returns null for regular messages', () => {
|
||||
expect(parseCommand('hello world')).toBeNull();
|
||||
expect(parseCommand('what is the weather?')).toBeNull();
|
||||
expect(parseCommand('')).toBeNull();
|
||||
expect(parseCommand(' some text ')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses /clear', () => {
|
||||
const result = parseCommand('/clear');
|
||||
expect(result).toEqual({ name: 'clear', args: '' });
|
||||
});
|
||||
|
||||
it('parses /identity', () => {
|
||||
const result = parseCommand('/identity');
|
||||
expect(result).toEqual({ name: 'identity', args: '' });
|
||||
});
|
||||
});
|
||||
373
packages/cli/tests/comprehensive-e2e.test.ts
Normal file
373
packages/cli/tests/comprehensive-e2e.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Comprehensive E2E test — exercises the same code paths as the CLI REPL
|
||||
* but programmatically (no LiteLLM/API needed).
|
||||
*
|
||||
* Tests 14 scenarios covering identity, awareness, memory persistence,
|
||||
* knowledge graph, cross-session recall, tool execution, system prompt,
|
||||
* hooks, permissions, and more.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { MindDB } from '@waggle/core';
|
||||
import {
|
||||
Orchestrator,
|
||||
createSystemTools,
|
||||
createPlanTools,
|
||||
createGitTools,
|
||||
HookRegistry,
|
||||
PermissionManager,
|
||||
filterToolsForContext,
|
||||
needsConfirmation,
|
||||
} from '@waggle/agent';
|
||||
import { MockEmbedder } from '../../hive-mind-core/tests/mind/helpers/mock-embedder.js';
|
||||
|
||||
// Helper: create a file-backed .mind DB in temp dir
|
||||
function createTmpMind(): { path: string; db: MindDB } {
|
||||
const p = path.join(os.tmpdir(), `waggle-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}.mind`);
|
||||
return { path: p, db: new MindDB(p) };
|
||||
}
|
||||
|
||||
function cleanup(filePath: string) {
|
||||
for (const f of [filePath, filePath + '-wal', filePath + '-shm']) {
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Comprehensive CLI E2E Test', () => {
|
||||
let mindPath: string;
|
||||
let db: MindDB;
|
||||
let orchestrator: Orchestrator;
|
||||
const embedder = new MockEmbedder();
|
||||
|
||||
beforeEach(() => {
|
||||
const tmp = createTmpMind();
|
||||
mindPath = tmp.path;
|
||||
db = tmp.db;
|
||||
orchestrator = new Orchestrator({ db, embedder });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
cleanup(mindPath);
|
||||
});
|
||||
|
||||
// ─── Test 1: Identity CRUD ───
|
||||
it('T1: Create and retrieve agent identity', async () => {
|
||||
orchestrator.getIdentity().create({
|
||||
name: 'Waggle',
|
||||
role: 'Personal AI Assistant',
|
||||
department: 'Engineering',
|
||||
personality: 'Helpful and precise',
|
||||
capabilities: 'Memory, search, knowledge graph',
|
||||
system_prompt: 'You are Waggle.',
|
||||
});
|
||||
|
||||
const result = await orchestrator.executeTool('get_identity', {});
|
||||
expect(result).toContain('Waggle');
|
||||
expect(result).toContain('Personal AI Assistant');
|
||||
expect(result).toContain('Engineering');
|
||||
});
|
||||
|
||||
// ─── Test 2: Awareness Layer ───
|
||||
it('T2: Add tasks and flags to awareness', async () => {
|
||||
await orchestrator.executeTool('add_task', { content: 'Review PR #42', priority: 9 });
|
||||
await orchestrator.executeTool('add_task', { content: 'Deploy staging', priority: 5 });
|
||||
orchestrator.getAwareness().add('flag', 'User prefers dark mode', 10);
|
||||
|
||||
const result = await orchestrator.executeTool('get_awareness', {});
|
||||
expect(result).toContain('Review PR #42');
|
||||
expect(result).toContain('Deploy staging');
|
||||
expect(result).toContain('dark mode');
|
||||
});
|
||||
|
||||
// ─── Test 3: Save and Search Memory (within session) ───
|
||||
it('T3: Save memory and search it back', async () => {
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'The quarterly report deadline is March 15th',
|
||||
importance: 'important',
|
||||
});
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'Project Alpha uses React and TypeScript',
|
||||
importance: 'normal',
|
||||
});
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'Team standup is at 9:30 AM every day',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
// Search should find the memory
|
||||
const result = await orchestrator.executeTool('search_memory', { query: 'quarterly report' });
|
||||
expect(result).toContain('quarterly report');
|
||||
expect(result).toContain('March 15th');
|
||||
});
|
||||
|
||||
// ─── Test 4: Knowledge Graph ───
|
||||
it('T4: Create entities and relations, query them', async () => {
|
||||
const kg = orchestrator.getKnowledge();
|
||||
const alice = kg.createEntity('person', 'Alice', { role: 'Tech Lead' });
|
||||
const project = kg.createEntity('project', 'Phoenix', { status: 'active' });
|
||||
const react = kg.createEntity('technology', 'React', { version: '18' });
|
||||
|
||||
kg.createRelation(alice.id, project.id, 'leads', 0.95);
|
||||
kg.createRelation(project.id, react.id, 'uses', 0.9);
|
||||
|
||||
const result = await orchestrator.executeTool('query_knowledge', { query: 'Alice' });
|
||||
expect(result).toContain('Alice');
|
||||
expect(result).toContain('leads');
|
||||
expect(result).toContain('Phoenix');
|
||||
});
|
||||
|
||||
// ─── Test 5: Cross-Session Memory Persistence ───
|
||||
it('T5: Memories persist across sessions (file-backed .mind)', async () => {
|
||||
// Session 1: save memories
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'My name is Marko and I work on the Waggle project',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'The API key for production is stored in 1Password',
|
||||
importance: 'important',
|
||||
});
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: 'Python is used for data processing scripts',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
// Close DB (simulates CLI exit)
|
||||
db.close();
|
||||
|
||||
// Session 2: reopen same .mind file
|
||||
const db2 = new MindDB(mindPath);
|
||||
const orchestrator2 = new Orchestrator({ db: db2, embedder });
|
||||
|
||||
// Search for memories from previous session
|
||||
const result1 = await orchestrator2.executeTool('search_memory', { query: 'Marko Waggle' });
|
||||
expect(result1).toContain('Marko');
|
||||
expect(result1).toContain('Waggle');
|
||||
|
||||
const result2 = await orchestrator2.executeTool('search_memory', { query: 'API key production' });
|
||||
expect(result2).toContain('1Password');
|
||||
|
||||
const result3 = await orchestrator2.executeTool('search_memory', { query: 'Python data' });
|
||||
expect(result3).toContain('Python');
|
||||
|
||||
db2.close();
|
||||
|
||||
// Reassign so afterEach cleanup works
|
||||
db = new MindDB(mindPath);
|
||||
orchestrator = new Orchestrator({ db, embedder });
|
||||
});
|
||||
|
||||
// ─── Test 6: System Prompt Builder ───
|
||||
it('T6: System prompt includes identity, awareness, and tools', () => {
|
||||
orchestrator.getIdentity().create({
|
||||
name: 'TestBot',
|
||||
role: 'Tester',
|
||||
department: '',
|
||||
personality: 'Thorough',
|
||||
capabilities: 'Testing',
|
||||
system_prompt: 'You run tests.',
|
||||
});
|
||||
orchestrator.getAwareness().add('task', 'Run integration tests', 10);
|
||||
|
||||
const prompt = orchestrator.buildSystemPrompt();
|
||||
expect(prompt).toContain('TestBot');
|
||||
expect(prompt).toContain('Run integration tests');
|
||||
// System prompt includes self-awareness block with tool summary
|
||||
expect(prompt).toContain('# Self-Awareness');
|
||||
expect(prompt).toContain('tools available');
|
||||
});
|
||||
|
||||
// ─── Test 7: Tool Definitions ───
|
||||
it('T7: All required tools are defined with correct shape', () => {
|
||||
const tools = orchestrator.getTools();
|
||||
const required = ['get_identity', 'get_awareness', 'search_memory', 'save_memory', 'query_knowledge', 'add_task', 'correct_knowledge'];
|
||||
|
||||
for (const name of required) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
expect(tool, `Tool ${name} should exist`).toBeDefined();
|
||||
expect(tool!.description).toBeTruthy();
|
||||
expect(typeof tool!.execute).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 8: System Tools ───
|
||||
it('T8: System tools (bash, read_file, etc.) are created', () => {
|
||||
const systemTools = createSystemTools(process.cwd());
|
||||
const names = systemTools.map(t => t.name);
|
||||
|
||||
expect(names).toContain('bash');
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('write_file');
|
||||
expect(names).toContain('edit_file');
|
||||
expect(names).toContain('search_files');
|
||||
expect(names).toContain('search_content');
|
||||
});
|
||||
|
||||
// ─── Test 9: Plan Tools ───
|
||||
it('T9: Plan tools are created', () => {
|
||||
const planTools = createPlanTools(process.cwd());
|
||||
const names = planTools.map(t => t.name);
|
||||
|
||||
expect(names).toContain('create_plan');
|
||||
expect(names).toContain('add_plan_step');
|
||||
expect(names).toContain('show_plan');
|
||||
});
|
||||
|
||||
// ─── Test 10: Git Tools ───
|
||||
it('T10: Git tools are created', () => {
|
||||
const gitTools = createGitTools(process.cwd());
|
||||
const names = gitTools.map(t => t.name);
|
||||
|
||||
expect(names).toContain('git_status');
|
||||
expect(names).toContain('git_diff');
|
||||
expect(names).toContain('git_log');
|
||||
expect(names).toContain('git_commit');
|
||||
});
|
||||
|
||||
// ─── Test 11: Hook Registry ───
|
||||
it('T11: Hook registry fires pre/post hooks', async () => {
|
||||
const hooks = new HookRegistry();
|
||||
const events: string[] = [];
|
||||
|
||||
hooks.on('pre:tool', async (ctx) => { events.push(`pre:${ctx.toolName}`); });
|
||||
hooks.on('post:tool', async (ctx) => { events.push(`post:${ctx.toolName}`); });
|
||||
|
||||
await hooks.fire('pre:tool', { toolName: 'bash', args: {} });
|
||||
await hooks.fire('post:tool', { toolName: 'bash', args: {}, result: 'ok' });
|
||||
|
||||
expect(events).toEqual(['pre:bash', 'post:bash']);
|
||||
});
|
||||
|
||||
// ─── Test 12: Permission Manager ───
|
||||
it('T12: Permission manager filters tools', () => {
|
||||
const perms = new PermissionManager({
|
||||
blacklist: ['bash', 'write_file'],
|
||||
});
|
||||
|
||||
expect(perms.isAllowed('bash')).toBe(false);
|
||||
expect(perms.isAllowed('write_file')).toBe(false);
|
||||
expect(perms.isAllowed('read_file')).toBe(true);
|
||||
expect(perms.isAllowed('search_memory')).toBe(true);
|
||||
|
||||
// Sandbox mode only allows readonly tools
|
||||
const sandbox = PermissionManager.sandbox();
|
||||
expect(sandbox.isAllowed('bash')).toBe(false);
|
||||
expect(sandbox.isAllowed('write_file')).toBe(false);
|
||||
expect(sandbox.isAllowed('read_file')).toBe(true);
|
||||
expect(sandbox.isAllowed('search_memory')).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Test 13: Confirmation Gate ───
|
||||
it('T13: Confirmation gate identifies sensitive tools', () => {
|
||||
// Non-bash tools
|
||||
expect(needsConfirmation('write_file')).toBe(true);
|
||||
expect(needsConfirmation('edit_file')).toBe(true);
|
||||
expect(needsConfirmation('git_commit')).toBe(true);
|
||||
expect(needsConfirmation('read_file')).toBe(false);
|
||||
expect(needsConfirmation('search_memory')).toBe(false);
|
||||
|
||||
// Bash without args = unknown command = confirm
|
||||
expect(needsConfirmation('bash')).toBe(true);
|
||||
|
||||
// Safe bash commands (read-only)
|
||||
expect(needsConfirmation('bash', { command: 'date' })).toBe(false);
|
||||
expect(needsConfirmation('bash', { command: 'ls -la' })).toBe(false);
|
||||
expect(needsConfirmation('bash', { command: 'git status' })).toBe(false);
|
||||
expect(needsConfirmation('bash', { command: 'git log --oneline' })).toBe(false);
|
||||
expect(needsConfirmation('bash', { command: 'whoami' })).toBe(false);
|
||||
|
||||
// Destructive bash commands
|
||||
expect(needsConfirmation('bash', { command: 'rm -rf /tmp/foo' })).toBe(true);
|
||||
expect(needsConfirmation('bash', { command: 'git push origin main' })).toBe(true);
|
||||
expect(needsConfirmation('bash', { command: 'sudo apt install foo' })).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Test 14: Memory Stats ───
|
||||
it('T14: Memory stats reflect actual data', async () => {
|
||||
// Start with empty
|
||||
let stats = orchestrator.getMemoryStats();
|
||||
expect(stats.frameCount).toBe(0);
|
||||
expect(stats.sessionCount).toBe(0);
|
||||
expect(stats.entityCount).toBe(0);
|
||||
|
||||
// Save some memories
|
||||
await orchestrator.executeTool('save_memory', { content: 'Memory one' });
|
||||
await orchestrator.executeTool('save_memory', { content: 'Memory two' });
|
||||
orchestrator.getKnowledge().createEntity('test', 'Entity1', {});
|
||||
|
||||
stats = orchestrator.getMemoryStats();
|
||||
expect(stats.frameCount).toBeGreaterThanOrEqual(2);
|
||||
expect(stats.sessionCount).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.entityCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// ─── Test 15: Empty state handling ───
|
||||
it('T15: Graceful handling of empty state', async () => {
|
||||
const id = await orchestrator.executeTool('get_identity', {});
|
||||
expect(id).toContain('No identity configured');
|
||||
|
||||
const aw = await orchestrator.executeTool('get_awareness', {});
|
||||
expect(aw).toContain('No active awareness items');
|
||||
|
||||
const search = await orchestrator.executeTool('search_memory', { query: 'anything' });
|
||||
expect(search).toContain('No relevant memories');
|
||||
|
||||
const kg = await orchestrator.executeTool('query_knowledge', { query: 'nobody' });
|
||||
expect(kg).toContain('No entities found');
|
||||
});
|
||||
|
||||
// ─── Test 16: Unknown tool throws ───
|
||||
it('T16: Unknown tool name throws error', async () => {
|
||||
await expect(orchestrator.executeTool('nonexistent_tool', {})).rejects.toThrow('Unknown tool');
|
||||
});
|
||||
|
||||
// ─── Test 17: Heavy memory load (50 per session, rate-limited by W2.10) ───
|
||||
it('T17: memories up to session rate limit are all searchable', async () => {
|
||||
// W2.10: save_memory is rate-limited to 50 per session to prevent flooding.
|
||||
// Save 60 — first 50 succeed, remaining are rate-limited.
|
||||
const RATE_LIMIT = 50;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await orchestrator.executeTool('save_memory', {
|
||||
content: `Observation ${i}: topic-${i % 10} with detail about area-${i % 5}`,
|
||||
importance: i % 20 === 0 ? 'important' : 'normal',
|
||||
});
|
||||
}
|
||||
|
||||
// Search by topic (hyphens in queries should work after FTS5 sanitization)
|
||||
const result = await orchestrator.executeTool('search_memory', { query: 'topic-7' });
|
||||
expect(result).toContain('topic-7');
|
||||
|
||||
// Search by area
|
||||
const result2 = await orchestrator.executeTool('search_memory', { query: 'area-3' });
|
||||
expect(result2).toContain('area-3');
|
||||
|
||||
// Stats should show exactly the rate limit count (50 saved, rest blocked)
|
||||
const stats = orchestrator.getMemoryStats();
|
||||
expect(stats.frameCount).toBeGreaterThanOrEqual(RATE_LIMIT);
|
||||
});
|
||||
|
||||
// ─── Test 18: Cross-session knowledge graph persistence ───
|
||||
it('T18: Knowledge graph persists across sessions', async () => {
|
||||
const kg = orchestrator.getKnowledge();
|
||||
const alice = kg.createEntity('person', 'Alice', { role: 'Engineer' });
|
||||
const bob = kg.createEntity('person', 'Bob', { role: 'Designer' });
|
||||
kg.createRelation(alice.id, bob.id, 'collaborates_with', 0.85);
|
||||
|
||||
// Close and reopen
|
||||
db.close();
|
||||
const db2 = new MindDB(mindPath);
|
||||
const orchestrator2 = new Orchestrator({ db: db2, embedder });
|
||||
|
||||
const result = await orchestrator2.executeTool('query_knowledge', { query: 'Alice' });
|
||||
expect(result).toContain('Alice');
|
||||
expect(result).toContain('collaborates_with');
|
||||
expect(result).toContain('Bob');
|
||||
|
||||
db2.close();
|
||||
db = new MindDB(mindPath);
|
||||
orchestrator = new Orchestrator({ db, embedder });
|
||||
});
|
||||
});
|
||||
283
packages/cli/tests/memory-persistence-hard.test.ts
Normal file
283
packages/cli/tests/memory-persistence-hard.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* HARD memory persistence test.
|
||||
*
|
||||
* Simulates 3 real work sessions across a multi-day project,
|
||||
* then verifies a cold-start "session 4" can recall everything
|
||||
* that matters — not trivia, but the kind of context that makes
|
||||
* an assistant feel like it was there the whole time.
|
||||
*
|
||||
* This is the crown jewel test: .mind file = portable brain.
|
||||
*/
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { MindDB } from '@waggle/core';
|
||||
import { Orchestrator } from '@waggle/agent';
|
||||
import { MockEmbedder } from '../../hive-mind-core/tests/mind/helpers/mock-embedder.js';
|
||||
|
||||
const MIND_PATH = path.join(os.tmpdir(), `waggle-hard-memory-${Date.now()}.mind`);
|
||||
let lastDb: MindDB | null = null;
|
||||
|
||||
function cleanup() {
|
||||
lastDb?.close();
|
||||
lastDb = null;
|
||||
for (const f of [MIND_PATH, MIND_PATH + '-wal', MIND_PATH + '-shm']) {
|
||||
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(cleanup);
|
||||
|
||||
describe('Hard Memory Persistence — Real Work Simulation', () => {
|
||||
const embedder = new MockEmbedder();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SESSION 1: Monday morning — project kickoff
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
it('Session 1: Project kickoff — identity, decisions, architecture', async () => {
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
// Set up identity
|
||||
orch.getIdentity().create({
|
||||
name: 'Waggle',
|
||||
role: 'Senior Engineering Assistant',
|
||||
department: 'Platform Team',
|
||||
personality: 'Thorough, opinionated, remembers everything',
|
||||
capabilities: 'Code review, architecture, memory, search, knowledge graph',
|
||||
system_prompt: 'You are Waggle, a senior engineering assistant for Marko.',
|
||||
});
|
||||
|
||||
// User context
|
||||
orch.getAwareness().add('flag', 'User is Marko Markovic, prefers direct communication', 10);
|
||||
orch.getAwareness().add('flag', 'Project: Rewrite payment service from Python to Go', 10);
|
||||
orch.getAwareness().add('task', 'Design new payment service architecture', 9);
|
||||
orch.getAwareness().add('pending', 'Waiting for Stripe API credentials from DevOps (asked Alice)', 7);
|
||||
|
||||
// Memories from the kickoff meeting
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Architecture decision: Payment service will use Go with chi router, PostgreSQL, and connect to Stripe via their Go SDK. Rejected gRPC in favor of REST for simplicity. Team voted 4-1.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'The current Python payment service handles 3 endpoints: POST /payments/charge, POST /payments/refund, GET /payments/:id. All must be preserved in the rewrite. The charge endpoint also calls an internal fraud-check service at http://fraud.internal:8080/check.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Alice (DevOps lead) said Stripe credentials will be in Vault at secret/data/stripe/production. She needs 2 business days. ETA: Wednesday.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Marko raised concern about the fraud-check service being a single point of failure. Decision: implement circuit breaker with 5-second timeout, fallback to allowing the charge (business decision: false negatives are worse than false positives for fraud).',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Database schema for payments table: id (uuid), amount_cents (bigint), currency (varchar(3)), stripe_charge_id (text), status (enum: pending/completed/failed/refunded), customer_id (uuid FK), created_at, updated_at. Using bigint for amount to avoid floating point issues.',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// Knowledge graph — people and their roles
|
||||
const kg = orch.getKnowledge();
|
||||
const marko = kg.createEntity('person', 'Marko Markovic', { role: 'Tech Lead', preference: 'direct communication' });
|
||||
const alice = kg.createEntity('person', 'Alice Chen', { role: 'DevOps Lead', team: 'Infrastructure' });
|
||||
const bob = kg.createEntity('person', 'Bob Kumar', { role: 'Backend Engineer', expertise: 'Go' });
|
||||
const paymentSvc = kg.createEntity('service', 'payment-service', { language: 'Go', status: 'in-development', repo: 'github.com/acme/payment-service-go' });
|
||||
const fraudSvc = kg.createEntity('service', 'fraud-check-service', { url: 'http://fraud.internal:8080', owner: 'Risk Team' });
|
||||
const stripe = kg.createEntity('integration', 'Stripe', { sdk: 'stripe-go', env: 'production' });
|
||||
|
||||
kg.createRelation(marko.id, paymentSvc.id, 'leads', 0.95);
|
||||
kg.createRelation(alice.id, paymentSvc.id, 'provides_infra', 0.9);
|
||||
kg.createRelation(bob.id, paymentSvc.id, 'implements', 0.9);
|
||||
kg.createRelation(paymentSvc.id, fraudSvc.id, 'depends_on', 1.0);
|
||||
kg.createRelation(paymentSvc.id, stripe.id, 'integrates', 1.0);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SESSION 2: Tuesday — deep implementation work
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
it('Session 2: Implementation day — code decisions, bugs found, PR reviews', async () => {
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Started implementing the charge endpoint. Using chi router with middleware chain: logging → auth → rate-limit → handler. Bob suggested using errgroup for parallel Stripe + fraud-check calls — good idea, adopted it.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Found a bug in the old Python service: refund endpoint doesn\'t check if payment is already refunded, allowing double refunds. Filed as JIRA PAY-142. Must fix in Go rewrite.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'PR #87 review: Bob\'s implementation of the charge handler looks good but has a subtle race condition — if Stripe returns success but DB write fails, the charge is orphaned. Need to implement idempotency key pattern. Left detailed comment on the PR.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Decided on error handling strategy: all errors return standard JSON { "error": { "code": "...", "message": "...", "request_id": "..." } }. HTTP status codes: 400 for validation, 402 for Stripe declined, 409 for duplicate/already-refunded, 500 for internal, 503 for circuit breaker open.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Performance target from Marko: p99 latency under 200ms for charge endpoint (current Python service is 450ms). Go rewrite should easily beat this. Will add Prometheus metrics from day 1.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Integration test strategy: use Stripe test mode with test API keys (not production). Alice confirmed test keys are already in Vault at secret/data/stripe/test. Docker compose setup with Postgres + test Stripe env.',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
// Update knowledge graph
|
||||
const kg = orch.getKnowledge();
|
||||
kg.createEntity('bug', 'PAY-142: Double refund vulnerability', { severity: 'high', status: 'open', found_in: 'Python payment service' });
|
||||
kg.createEntity('pr', 'PR #87: Charge handler', { author: 'Bob Kumar', status: 'changes-requested', issue: 'race condition on DB write' });
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SESSION 3: Wednesday — blockers, decisions, progress
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
it('Session 3: Midweek check-in — credentials arrived, new blocker, scope change', async () => {
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Alice delivered Stripe production credentials to Vault as promised. Verified access works. Removed from pending items.',
|
||||
importance: 'normal',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'NEW BLOCKER: Legal team says we need PCI DSS compliance audit before go-live. This was not in the original scope. Meeting scheduled with compliance team Friday. Could delay launch by 2 weeks.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Bob fixed the race condition in PR #87 using Stripe idempotency keys. Approved and merged. The charge endpoint is now production-ready pending PCI review.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Marko decided to descope the refund endpoint from the initial launch. Reason: the double-refund bug (PAY-142) needs careful handling and the PCI blocker already delays us. Refund stays in Python service for now, will be migrated in phase 2.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Updated launch plan: Phase 1 = charge + get payment (Go). Phase 2 = refund migration + PAY-142 fix. Phase 3 = deprecate Python service entirely. Each phase is ~2 weeks.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Circuit breaker implementation complete. Using sony/gobreaker library. Settings: maxRequests=5, interval=60s, timeout=5s, trip after 3 consecutive failures. Tested with fault injection — works correctly.',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SESSION 4: Thursday — COLD START. Can the agent pick up where we left off?
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
it('Session 4 (COLD START): Agent must recall project state without being told', async () => {
|
||||
const db = new MindDB(MIND_PATH);
|
||||
lastDb = db; // Track for cleanup
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
// ─── Test A: Identity survives ───
|
||||
const identity = await orch.executeTool('get_identity', {});
|
||||
expect(identity).toContain('Waggle');
|
||||
expect(identity).toContain('Senior Engineering Assistant');
|
||||
|
||||
// ─── Test B: Awareness items survive ───
|
||||
const awareness = await orch.executeTool('get_awareness', {});
|
||||
expect(awareness).toContain('Marko Markovic');
|
||||
expect(awareness).toContain('payment service');
|
||||
|
||||
// ─── Test C: "What are we working on?" ───
|
||||
const projectContext = await orch.executeTool('search_memory', {
|
||||
query: 'payment service architecture Go',
|
||||
});
|
||||
expect(projectContext).toContain('Go');
|
||||
expect(projectContext).toContain('chi router');
|
||||
expect(projectContext).toContain('Stripe');
|
||||
|
||||
// ─── Test D: "What's blocking us?" ───
|
||||
const blockers = await orch.executeTool('search_memory', {
|
||||
query: 'blocker PCI compliance',
|
||||
});
|
||||
expect(blockers).toContain('PCI DSS');
|
||||
expect(blockers).toContain('compliance');
|
||||
|
||||
// ─── Test E: "What happened with the Stripe credentials?" ───
|
||||
const credentials = await orch.executeTool('search_memory', {
|
||||
query: 'Stripe credentials Vault Alice',
|
||||
});
|
||||
expect(credentials).toContain('Vault');
|
||||
expect(credentials).toContain('Alice');
|
||||
|
||||
// ─── Test F: "What's the current scope?" (must know about descoping) ───
|
||||
const scope = await orch.executeTool('search_memory', {
|
||||
query: 'launch plan phase refund descope',
|
||||
});
|
||||
expect(scope).toContain('Phase 1');
|
||||
expect(scope).toContain('refund');
|
||||
|
||||
// ─── Test G: "Tell me about the double-refund bug" ───
|
||||
const bug = await orch.executeTool('search_memory', {
|
||||
query: 'double refund bug PAY-142',
|
||||
});
|
||||
expect(bug).toContain('PAY-142');
|
||||
expect(bug).toContain('refund');
|
||||
|
||||
// ─── Test H: "What's Bob working on?" (knowledge graph) ───
|
||||
const bobInfo = await orch.executeTool('query_knowledge', {
|
||||
query: 'Bob',
|
||||
});
|
||||
expect(bobInfo).toContain('Bob Kumar');
|
||||
expect(bobInfo).toContain('implements');
|
||||
|
||||
// ─── Test I: "What does our service depend on?" ───
|
||||
const deps = await orch.executeTool('query_knowledge', {
|
||||
query: 'payment-service',
|
||||
});
|
||||
expect(deps).toContain('payment-service');
|
||||
expect(deps).toContain('depends_on');
|
||||
expect(deps).toContain('fraud-check');
|
||||
|
||||
// ─── Test J: "What was the error handling decision?" ───
|
||||
const errorHandling = await orch.executeTool('search_memory', {
|
||||
query: 'error handling JSON status codes',
|
||||
});
|
||||
expect(errorHandling).toContain('402');
|
||||
expect(errorHandling).toContain('circuit breaker');
|
||||
|
||||
// ─── Test K: "What are the performance requirements?" ───
|
||||
const perf = await orch.executeTool('search_memory', {
|
||||
query: 'performance latency p99 target',
|
||||
});
|
||||
expect(perf).toContain('200ms');
|
||||
expect(perf).toContain('Prometheus');
|
||||
|
||||
// ─── Test L: "What was decided about the race condition?" ───
|
||||
const raceCondition = await orch.executeTool('search_memory', {
|
||||
query: 'race condition idempotency PR 87',
|
||||
});
|
||||
expect(raceCondition).toContain('idempotency');
|
||||
|
||||
// ─── Test M: "What's the database schema?" ───
|
||||
const schema = await orch.executeTool('search_memory', {
|
||||
query: 'database schema payments table',
|
||||
});
|
||||
expect(schema).toContain('amount_cents');
|
||||
expect(schema).toContain('bigint');
|
||||
|
||||
// ─── Test N: Memory stats show realistic data ───
|
||||
const stats = orch.getMemoryStats();
|
||||
expect(stats.frameCount).toBeGreaterThanOrEqual(15); // We saved ~17 memories
|
||||
expect(stats.sessionCount).toBeGreaterThanOrEqual(1); // At least 1 session (CognifyPipeline reuses active)
|
||||
expect(stats.entityCount).toBeGreaterThanOrEqual(6); // 6+ entities in knowledge graph
|
||||
|
||||
// ─── Test O: System prompt has everything for a cold-start ───
|
||||
const prompt = orch.buildSystemPrompt();
|
||||
expect(prompt).toContain('Waggle');
|
||||
expect(prompt).toContain('payment service');
|
||||
expect(prompt).toContain('search_memory');
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
71
packages/cli/tests/mode-detector.test.ts
Normal file
71
packages/cli/tests/mode-detector.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { detectMode, type ModeDetectorDeps } from '../src/mode-detector.js';
|
||||
|
||||
function makeDeps(overrides: Partial<ModeDetectorDeps> = {}): ModeDetectorDeps {
|
||||
return {
|
||||
hasToken: false,
|
||||
serverUrl: 'http://localhost:3000',
|
||||
forceLocal: false,
|
||||
forceTeam: false,
|
||||
healthCheck: vi.fn().mockResolvedValue(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('detectMode', () => {
|
||||
it('returns local when no token', async () => {
|
||||
const result = await detectMode(makeDeps({ hasToken: false }));
|
||||
expect(result).toEqual({ type: 'local' });
|
||||
});
|
||||
|
||||
it('returns team when token + server reachable', async () => {
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: true,
|
||||
healthCheck: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
expect(result).toEqual({ type: 'team' });
|
||||
});
|
||||
|
||||
it('returns local with warning when token + server unreachable', async () => {
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: true,
|
||||
healthCheck: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
expect(result).toEqual({
|
||||
type: 'local',
|
||||
warning: 'Server unreachable — running in local mode.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns local when --local forced', async () => {
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: true,
|
||||
forceLocal: true,
|
||||
healthCheck: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
expect(result).toEqual({ type: 'local' });
|
||||
});
|
||||
|
||||
it('returns team when --team forced + token', async () => {
|
||||
const healthCheck = vi.fn().mockResolvedValue(false);
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: true,
|
||||
forceTeam: true,
|
||||
healthCheck,
|
||||
}));
|
||||
expect(result).toEqual({ type: 'team' });
|
||||
// Should not even check health when forced
|
||||
expect(healthCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when --team forced + no token', async () => {
|
||||
const result = await detectMode(makeDeps({
|
||||
hasToken: false,
|
||||
forceTeam: true,
|
||||
}));
|
||||
expect(result).toEqual({
|
||||
type: 'error',
|
||||
error: 'Team mode requires login. Run: waggle login',
|
||||
});
|
||||
});
|
||||
});
|
||||
317
packages/cli/tests/real-session-simulation.ts
Normal file
317
packages/cli/tests/real-session-simulation.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Real session simulation.
|
||||
*
|
||||
* Part 1: Populate the .mind file with actual project context
|
||||
* from today's work session (M3c completion, bug fixes, test improvements).
|
||||
*
|
||||
* Part 2: Cold-start a new orchestrator and query it —
|
||||
* what does the agent actually know?
|
||||
*
|
||||
* Uses the real ~/.waggle/default.mind file, not a temp file.
|
||||
*/
|
||||
import { MindDB } from '@waggle/core';
|
||||
import { Orchestrator } from '@waggle/agent';
|
||||
import { MockEmbedder } from '../../core/tests/mind/helpers/mock-embedder.js';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const MIND_PATH = 'C:/Users/MarkoMarkovic/.waggle/default.mind';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PART 1: Populate with real project context
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function populateSession() {
|
||||
console.log('\n═══ SESSION 1: Loading real project context ═══\n');
|
||||
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const embedder = new MockEmbedder();
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
// --- Identity ---
|
||||
orch.getIdentity().create({
|
||||
name: 'Waggle',
|
||||
role: 'Senior Engineering Assistant',
|
||||
department: 'Waggle Platform Team',
|
||||
personality: 'Direct, thorough, remembers everything. Never guesses — uses tools to find answers.',
|
||||
capabilities: 'Memory (search_memory, save_memory), knowledge graph (query_knowledge), system tools (bash, read_file, write_file, edit_file, search_files, search_content), git tools, plan tools. Persistent .mind file stores everything across sessions.',
|
||||
system_prompt: 'You are Waggle, a senior engineering assistant for Marko Markovic. You help build the Waggle platform — a personal AI agent swarm for every knowledge worker. You have persistent memory in a .mind file. Always search memory before answering questions about the project.',
|
||||
});
|
||||
|
||||
// --- Awareness: current state ---
|
||||
orch.getAwareness().add('flag', 'User: Marko Markovic, Windows 11, prefers direct communication, not deeply technical', 10);
|
||||
orch.getAwareness().add('flag', 'Project: Waggle — personal AI agent swarm platform. Open core, $9-15/user/mo Pro tier.', 10);
|
||||
orch.getAwareness().add('flag', 'Codebase: D:\\Projects\\MS Claw\\waggle-poc (monorepo, 11 packages, GitHub: marolinik/waggle)', 10);
|
||||
orch.getAwareness().add('flag', 'Tech stack: Node.js, TypeScript, better-sqlite3, Vitest, Fastify, Drizzle, BullMQ, Clerk', 8);
|
||||
orch.getAwareness().add('task', 'NEXT: M4 — Tauri 2.0 desktop app (Windows first)', 9);
|
||||
orch.getAwareness().add('task', 'THEN: M5 — Web app', 7);
|
||||
orch.getAwareness().add('task', 'LATER: Agent intelligence polish (system prompt, context management, smart tool use)', 6);
|
||||
|
||||
// --- Milestone history ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M0 (POC): COMPLETE. Scientific validation of all core components — .mind file, memory frames, knowledge graph, hybrid search, memory weaver, optimizer.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M1 (MVP Desktop App): COMPLETE. Basic Tauri app, 11 tasks. Proved the desktop concept.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M2 (Developer Platform): COMPLETE. 232 tests, 6 packages — @waggle/core, @waggle/agent, @waggle/optimizer, @waggle/weaver, @waggle/cli, @waggle/sdk. CLI with REPL, model router, config system, plugin system, skill SDK.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M3 (Team Pilot): COMPLETE. 417 tests, 5 new packages — @waggle/server (Fastify 5), @waggle/worker (BullMQ), @waggle/shared (Zod schemas), @waggle/admin-web (React), @waggle/waggle-dance (messaging). 16 Drizzle tables, Clerk auth, WebSocket gateway, role-based access, task board, cron scheduler, 3 daemon agents (Scout, Subconscious, Hive Mind).',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M3a (CLI→Server + Real LLM): COMPLETE. 475 tests. LiteLLM proxy for model-agnostic routing. System tools: bash, read_file, write_file, edit_file, search_files, search_content (Claude Code parity). Shared runAgentLoop() in @waggle/agent. Browser OAuth via Clerk. Mode detection: auto local/team. Worker wired to real agent loop. Streaming via WebSocket.',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// --- M3b and M3c (today's work) ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M3b (Agent Intelligence): COMPLETE. Self-awareness module (agent knows its own tools, model, memory stats). Auto-identity on first run. CostTracker for token/cost tracking. HookRegistry for pre/post tool events. LoopGuard to detect infinite tool call loops. Eval framework with promptfoo-style test runner. LiteLLM embeddings integration.',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Milestone M3c (Agent Power): COMPLETE. 17 tasks. HookRegistry event system with cancel support. PermissionManager with whitelist/blacklist and sandbox mode. ConfirmationGate for sensitive tools (bash, write_file, edit_file, git_commit). Plan tools (create_plan, add_plan_step, show_plan). Git tools (git_status, git_diff, git_log, git_commit). Ontology layer for .mind schema. AuditTools for traceability. MemoryLinker for cross-frame references. FeedbackHandler for knowledge graph corrections.',
|
||||
importance: 'critical',
|
||||
});
|
||||
|
||||
// --- Bug fixes from today ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'BUG FIX (critical): LiteLLM→Anthropic tool_calls format conversion was broken. Streaming tool call accumulation was missing type: "function" field, causing LiteLLM to drop assistant tool_use messages. Fix in packages/agent/src/agent-loop.ts. Also fixed content: null → content: "" when tool_calls present.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'BUG FIX (critical): Cross-session memory was completely broken. CognifyPipeline was never wired into Orchestrator — save_memory used raw frame creation without vector indexing. Fixed by wiring CognifyPipeline in Orchestrator constructor. Also added LIKE fallback scan in search_memory when hybrid search returns empty.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'BUG FIX: FTS5 query sanitization — queries with hyphens like "topic-7" crashed with SqliteError because FTS5 interpreted hyphens as NOT operator. Fixed in packages/core/src/mind/search.ts by auto-quoting each token. Try/catch fallback for FTS5 parse errors.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'BUG FIX: WS gateway Redis subscribe crash — join_team returned "Invalid message" when Redis PUBSUB call failed. Wrapped Redis subscribe in try/catch. Also fixed all test isolation issues: unique queue names for BullMQ, unique slugs/clerkIds, poll-based assertions instead of fixed sleeps.',
|
||||
importance: 'normal',
|
||||
});
|
||||
|
||||
// --- Current test status ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Test status after all fixes: 85 test files, 679 tests — ALL PASSING. Zero flaky tests. Key test files: packages/cli/tests/comprehensive-e2e.test.ts (18 tests), packages/cli/tests/memory-persistence-hard.test.ts (4 tests simulating 3 work sessions + cold start recall). Test suite runs in ~7 seconds.',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// --- Architecture decisions ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Architecture: .mind file is a single SQLite database (better-sqlite3) containing everything — memory frames, knowledge graph, identity, awareness, sessions, FTS5 index, sqlite-vec embeddings. Portable: copy the file = copy the brain. Format inspired by video codecs: I-frames (snapshots) + P-frames (deltas) + B-frames (cross-references).',
|
||||
importance: 'critical',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Architecture: CognifyPipeline is the memory enrichment pipeline. When save_memory is called: (1) create P-frame in current session, (2) extract entities via regex patterns, (3) upsert entities into knowledge graph, (4) create relations between entities, (5) index frame for vector search via embeddings, (6) index in FTS5 for keyword search.',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// --- What's missing / known issues ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'KNOWN GAPS: (1) No real embeddings in CLI — using MockEmbedder (deterministic hash), so semantic search does not work (color≠colour). Need LiteLLM embeddings endpoint. (2) No Memory Weaver daemon running — consolidation is manual. (3) No GraphContext integration yet — knowledge graph is basic, no SHACL validation. (4) Agent intelligence needs polish — system prompt is decent but not Claude Code level.',
|
||||
importance: 'important',
|
||||
});
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'DECISION: Build platforms first (M4 Tauri desktop, M5 web app), polish agent intelligence last. Reasoning: agent code is shared (@waggle/agent), improvements land everywhere at once. Can\'t know what "smart" means until real UX exists. Diminishing returns on agent tuning now vs compounding returns from having platforms.',
|
||||
importance: 'critical',
|
||||
});
|
||||
|
||||
// --- Plan file locations ---
|
||||
await orch.executeTool('save_memory', {
|
||||
content: 'Plan documents location: D:\\Projects\\MS Claw\\docs\\plans\\. Key files: 2026-03-09-waggle-full-roadmap.md (master roadmap), 2026-03-06-waggle-poc-design.md (original POC design), 2026-03-09-waggle-m3b-implementation.md (M3b plan), 2026-03-09-waggle-m3c-implementation.md (M3c plan). Architecture visualization: D:\\Projects\\MS Claw\\waggle-architecture.html',
|
||||
importance: 'important',
|
||||
});
|
||||
|
||||
// --- Knowledge graph ---
|
||||
const kg = orch.getKnowledge();
|
||||
const marko = kg.createEntity('person', 'Marko Markovic', { role: 'Founder & Tech Lead', platform: 'Windows 11' });
|
||||
const waggle = kg.createEntity('project', 'Waggle', { status: 'active', stage: 'M3c complete, M4 next', repo: 'marolinik/waggle', license: 'open-core' });
|
||||
const mindFile = kg.createEntity('technology', '.mind file', { format: 'SQLite', purpose: 'portable agent brain' });
|
||||
const litellm = kg.createEntity('technology', 'LiteLLM', { purpose: 'model-agnostic LLM routing', port: '4000' });
|
||||
const tauri = kg.createEntity('technology', 'Tauri 2.0', { purpose: 'desktop app framework', language: 'Rust + WebView2' });
|
||||
const core = kg.createEntity('package', '@waggle/core', { purpose: 'MindDB, identity, awareness, frames, sessions, search, knowledge graph' });
|
||||
const agent = kg.createEntity('package', '@waggle/agent', { purpose: 'Orchestrator, tools, agent loop, CognifyPipeline, hooks, permissions' });
|
||||
const cli = kg.createEntity('package', '@waggle/cli', { purpose: 'Interactive REPL, commands, rendering' });
|
||||
const server = kg.createEntity('package', '@waggle/server', { purpose: 'Fastify REST API, WebSocket gateway, Drizzle ORM' });
|
||||
const worker = kg.createEntity('package', '@waggle/worker', { purpose: 'BullMQ job processor, handler registry' });
|
||||
|
||||
kg.createRelation(marko.id, waggle.id, 'founded', 1.0);
|
||||
kg.createRelation(waggle.id, mindFile.id, 'uses', 1.0);
|
||||
kg.createRelation(waggle.id, litellm.id, 'uses', 0.9);
|
||||
kg.createRelation(waggle.id, tauri.id, 'will_use', 0.8);
|
||||
kg.createRelation(waggle.id, core.id, 'contains', 1.0);
|
||||
kg.createRelation(waggle.id, agent.id, 'contains', 1.0);
|
||||
kg.createRelation(waggle.id, cli.id, 'contains', 1.0);
|
||||
kg.createRelation(waggle.id, server.id, 'contains', 1.0);
|
||||
kg.createRelation(waggle.id, worker.id, 'contains', 1.0);
|
||||
kg.createRelation(agent.id, core.id, 'depends_on', 1.0);
|
||||
kg.createRelation(cli.id, agent.id, 'depends_on', 1.0);
|
||||
|
||||
const stats = orch.getMemoryStats();
|
||||
console.log(`Populated: ${stats.frameCount} frames, ${stats.sessionCount} sessions, ${stats.entityCount} entities`);
|
||||
db.close();
|
||||
console.log('Session 1 closed.\n');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PART 2: Cold start — what does the agent know?
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function coldStartTest() {
|
||||
console.log('═══ SESSION 2: COLD START — Testing recall ═══\n');
|
||||
|
||||
const db = new MindDB(MIND_PATH);
|
||||
const embedder = new MockEmbedder();
|
||||
const orch = new Orchestrator({ db, embedder });
|
||||
|
||||
const tests: { name: string; query: string; tool: string; mustContain: string[] }[] = [
|
||||
{
|
||||
name: 'Who am I?',
|
||||
query: '',
|
||||
tool: 'get_identity',
|
||||
mustContain: ['Waggle', 'Senior Engineering Assistant'],
|
||||
},
|
||||
{
|
||||
name: 'What\'s the current state?',
|
||||
query: '',
|
||||
tool: 'get_awareness',
|
||||
mustContain: ['Marko', 'Waggle', 'M4'],
|
||||
},
|
||||
{
|
||||
name: 'What project are we building?',
|
||||
query: 'Waggle project what is it',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['agent', 'waggle'],
|
||||
},
|
||||
{
|
||||
name: 'Which milestones are done?',
|
||||
query: 'milestones complete status',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['COMPLETE'],
|
||||
},
|
||||
{
|
||||
name: 'What did we do in M3c?',
|
||||
query: 'M3c Agent Power tasks',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['HookRegistry', 'Permission'],
|
||||
},
|
||||
{
|
||||
name: 'What bugs did we fix today?',
|
||||
query: 'bug fix critical today',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['LiteLLM', 'tool_calls'],
|
||||
},
|
||||
{
|
||||
name: 'Why was cross-session memory broken?',
|
||||
query: 'cross-session memory broken CognifyPipeline',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['CognifyPipeline', 'Orchestrator'],
|
||||
},
|
||||
{
|
||||
name: 'How many tests pass?',
|
||||
query: 'test status passing count',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['679', 'PASSING'],
|
||||
},
|
||||
{
|
||||
name: 'What\'s the .mind file architecture?',
|
||||
query: '.mind file SQLite architecture portable',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['SQLite', 'portable'],
|
||||
},
|
||||
{
|
||||
name: 'What\'s next after M3c?',
|
||||
query: 'next milestone M4 Tauri desktop',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['Tauri', 'desktop'],
|
||||
},
|
||||
{
|
||||
name: 'Why polish agent last?',
|
||||
query: 'decision build platforms first polish agent last',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['platforms first', 'shared'],
|
||||
},
|
||||
{
|
||||
name: 'What are the known gaps?',
|
||||
query: 'known gaps missing embeddings daemon',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['MockEmbedder', 'semantic'],
|
||||
},
|
||||
{
|
||||
name: 'Where are the plan documents?',
|
||||
query: 'plan documents location roadmap',
|
||||
tool: 'search_memory',
|
||||
mustContain: ['docs\\plans', 'roadmap'],
|
||||
},
|
||||
{
|
||||
name: 'What packages does Waggle have? (knowledge graph)',
|
||||
query: 'Waggle',
|
||||
tool: 'query_knowledge',
|
||||
mustContain: ['@waggle/core', '@waggle/agent'],
|
||||
},
|
||||
{
|
||||
name: 'Who is Marko? (knowledge graph)',
|
||||
query: 'Marko',
|
||||
tool: 'query_knowledge',
|
||||
mustContain: ['Marko Markovic', 'founded'],
|
||||
},
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
const args = test.tool === 'get_identity' || test.tool === 'get_awareness'
|
||||
? {}
|
||||
: { query: test.query };
|
||||
|
||||
const result = await orch.executeTool(test.tool, args);
|
||||
|
||||
// Case-insensitive check across full result
|
||||
const resultLower = result.toLowerCase();
|
||||
const missing = test.mustContain.filter(s => !resultLower.includes(s.toLowerCase()));
|
||||
|
||||
if (missing.length === 0) {
|
||||
console.log(` ✓ ${test.name}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(` ✗ ${test.name}`);
|
||||
console.log(` Missing: ${missing.join(', ')}`);
|
||||
console.log(` Got (first 300): ${result.substring(0, 300)}...`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n═══ RESULTS: ${passed}/${tests.length} passed, ${failed} failed ═══`);
|
||||
|
||||
// Show what the system prompt looks like on cold start
|
||||
console.log('\n═══ SYSTEM PROMPT (first 500 chars) ═══');
|
||||
const prompt = orch.buildSystemPrompt();
|
||||
console.log(prompt.substring(0, 500));
|
||||
console.log('...\n');
|
||||
|
||||
const stats = orch.getMemoryStats();
|
||||
console.log(`Memory stats: ${stats.frameCount} frames, ${stats.sessionCount} sessions, ${stats.entityCount} entities`);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function main() {
|
||||
// Clean start
|
||||
for (const f of [MIND_PATH, MIND_PATH + '-wal', MIND_PATH + '-shm']) {
|
||||
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
await populateSession();
|
||||
await coldStartTest();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
24
packages/cli/tests/renderer.test.ts
Normal file
24
packages/cli/tests/renderer.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderMarkdown } from '../src/renderer.js';
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it('renders bold text (no ** in output)', () => {
|
||||
const result = renderMarkdown('This is **bold** text');
|
||||
expect(result).not.toContain('**');
|
||||
expect(result).toContain('bold');
|
||||
});
|
||||
|
||||
it('renders list items (has bullet)', () => {
|
||||
const result = renderMarkdown('- first item\n- second item');
|
||||
// The bullet character used by chalk
|
||||
expect(result).toContain('\u2022');
|
||||
expect(result).toContain('first item');
|
||||
expect(result).toContain('second item');
|
||||
});
|
||||
|
||||
it('passes plain text through', () => {
|
||||
const input = 'Hello, this is just plain text.';
|
||||
const result = renderMarkdown(input);
|
||||
expect(result).toContain('Hello, this is just plain text.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user