Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@ coverage/

# Generated API docs (npm run docs:build)
docs/api/

# LOU-N runtime control server's local run state (checkpoints/approvals/
# saved agent specs) - see apps/agent-forge/server/{checkpointStore,
# approvalStore}.ts and src/persistence/fsAgentStore.ts.
.loushy/
2 changes: 1 addition & 1 deletion apps/agent-forge/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default tseslint.config(
},
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}'],
files: ['src/**/*.{ts,tsx}', 'server/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
Expand Down
17 changes: 15 additions & 2 deletions apps/agent-forge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,39 @@
"build": "tsc --noEmit -p tsconfig.json && vite build",
"preview": "vite preview",
"test": "vitest",
"lint": "eslint src",
"typecheck": "tsc --noEmit -p tsconfig.json"
"lint": "eslint src server",
"typecheck": "tsc --noEmit -p tsconfig.json",
"server:dev": "tsx watch server/index.ts",
"server:start": "tsx server/index.ts",
"test:server": "vitest run -c server/vitest.config.ts",
"typecheck:server": "tsc --noEmit -p server/tsconfig.json"
},
"dependencies": {
"@loushy/build-ai-agent": "file:../..",
"@xyflow/react": "^12.12.0",
"cors": "^2.8.5",
"dagre": "^0.8.5",
"express": "^5.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"ws": "^8.18.3",
"yaml": "^2.9.1"
},
"devDependencies": {
"@ai-sdk/anthropic": "^0.0.42",
"@ai-sdk/openai": "^0.0.42",
"@types/cors": "^2.8.19",
"@types/dagre": "^0.7.52",
"@types/express": "^5.0.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"ollama-ai-provider": "^1.2.0",
"supertest": "^7.3.0",
"tsx": "^4.23.15",
"typescript": "^5.9.3",
"vite": "^5.4.11",
"vitest": "^1.0.0"
Expand Down
165 changes: 165 additions & 0 deletions apps/agent-forge/server/__tests__/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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 request from 'supertest';
import type { AgentSpec } from '@loushy/build-ai-agent';
import { createApp } from '../app';
import { RunManager } from '../runRegistry';
import { FileCheckpointStore } from '../checkpointStore';
import { FileApprovalStore } from '../approvalStore';
import { createFsAgentStore } from '../../src/persistence/fsAgentStore';

const SPEC: AgentSpec = {
name: 'test-agent',
prompt: 'You are a helpful agent.',
provider: { type: 'mock', model: 'mock-1' },
};

function waitForStatus(
runManager: RunManager,
agentId: string,
predicate: (status: { status: string }) => boolean,
timeoutMs = 2000
): Promise<any> {
// The mock provider resolves near-instantly, so a run kicked off by an
// earlier `await request(app)...` call may already have reached its
// terminal status before this helper's listener is attached - check the
// current status first rather than only listening for a future
// transition that may never come.
const current = runManager.status(agentId);
if (predicate(current)) return Promise.resolve(current);

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
runManager.off('status', onStatus);
reject(new Error('Timed out waiting for status'));
}, timeoutMs);
function onStatus(payload: any) {
if (payload.agentId !== agentId) return;
if (predicate(payload)) {
clearTimeout(timer);
runManager.off('status', onStatus);
resolve(payload);
}
}
runManager.on('status', onStatus);
});
}

describe('LOU-N HTTP API', () => {
let baseDir: string;
let app: ReturnType<typeof createApp>;
let runManager: RunManager;

beforeEach(() => {
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-n-app-test-'));
const agentStore = createFsAgentStore(baseDir);
runManager = new RunManager({
baseDir,
checkpointStore: new FileCheckpointStore(baseDir),
approvalStore: new FileApprovalStore(baseDir),
loadSpec: (id) => agentStore.load(id),
saveSpec: (id, spec) => agentStore.save(id, spec),
});
app = createApp({ agentStore, runManager });
});

afterEach(() => {
fs.rmSync(baseDir, { recursive: true, force: true });
});

it('GET /health returns ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.text).toBe('ok');
});

it('GET /agents/:id/status returns idle before any run', async () => {
const res = await request(app).get('/agents/nope/status');
expect(res.status).toBe(200);
expect(res.body.status).toBe('idle');
});

it('rejects a path-traversal agent id instead of writing outside .loushy/agents', async () => {
// Express URL-decodes `:id` before handing it to the route, so a
// percent-encoded `..%2F..%2Fpwned` arrives as a plain string
// containing `/` and `..` - without the isValidAgentId guard in app.ts,
// this would let PUT /agents/:id escape `.loushy/agents/` via
// fsAgentStore.ts's path.join(agentsDir, `${id}.yaml`).
const traversalId = encodeURIComponent('../../pwned');
const put = await request(app).put(`/agents/${traversalId}`).send(SPEC);
expect(put.status).toBe(400);

const escapedFile = path.join(baseDir, '..', 'pwned.yaml');
expect(fs.existsSync(escapedFile)).toBe(false);

const run = await request(app).post(`/agents/${traversalId}/run`).send({ input: 'hi', spec: SPEC });
expect(run.status).toBe(400);

const status = await request(app).get(`/agents/${traversalId}/status`);
expect(status.status).toBe(400);
});

it('PUT then GET round-trips an AgentSpec', async () => {
const put = await request(app).put('/agents/foo').send(SPEC);
expect(put.status).toBe(204);

const get = await request(app).get('/agents/foo');
expect(get.status).toBe(200);
expect(get.body.name).toBe('test-agent');
});

it('PUT /agents/:id rejects a malformed spec', async () => {
const res = await request(app).put('/agents/foo').send({ nonsense: true });
expect(res.status).toBe(400);
});

it('POST /agents/:id/run starts a run and GET /status reflects it finishing', async () => {
const run = await request(app).post('/agents/bar/run').send({ spec: SPEC, input: 'hello' });
expect(run.status).toBe(202);
expect(['running', 'stopped']).toContain(run.body.status);

const final = await waitForStatus(runManager, 'bar', (s) => s.status === 'stopped');
expect(final.resultText).toBeTruthy();

const status = await request(app).get('/agents/bar/status');
expect(status.body.status).toBe('stopped');
});

it('POST /agents/:id/run without input is rejected', async () => {
const res = await request(app).post('/agents/bar/run').send({ spec: SPEC });
expect(res.status).toBe(400);
});

// The "already running" 409 guard itself is covered deterministically at
// the RunManager unit level (runRegistry.test.ts) by calling run() twice
// back-to-back with no intervening await. Reproducing that race through
// a real HTTP round-trip (supertest) against the near-instant mock
// provider is flaky: enough real event-loop turns elapse between the two
// requests that the first run can legitimately finish before the second
// one is even dispatched, so this is intentionally not re-tested here.

it('POST /agents/:id/run for an agent with no saved spec and no spec in the body returns 404', async () => {
const res = await request(app).post('/agents/ghost/run').send({ input: 'hi' });
expect(res.status).toBe(404);
});

it('POST /agents/:id/stop on an idle agent is a safe no-op (202)', async () => {
const res = await request(app).post('/agents/never-ran/stop');
expect(res.status).toBe(202);
expect(res.body.status).toBe('idle');
});

it('POST /agents/:id/approve with no pending approval returns 409', async () => {
const res = await request(app)
.post('/agents/none-pending/approve')
.send({ approvalId: 'x', approved: true });
expect(res.status).toBe(409);
});

it('POST /agents/:id/approve with a bad body returns 400', async () => {
const res = await request(app).post('/agents/foo/approve').send({ approvalId: 'x' });
expect(res.status).toBe(400);
});
});
142 changes: 142 additions & 0 deletions apps/agent-forge/server/__tests__/approvalFlow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Exercises the exact approval-gate pause/resume machinery
* RunManager.approve() (runRegistry.ts) drives - AgentExecutor.execute()
* pausing on a tool that needs approval, FileApprovalStore persisting the
* pending decision, and resumeAfterApproval() continuing the run - using
* the public SDK API directly. RunManager itself only wires these together
* (buildAgentFromSpec + a thin dispatch), so this is where the actual
* pause -> approve -> resume round trip is verified; runRegistry.test.ts and
* app.test.ts cover the run()/stop() and HTTP-layer behavior around it.
*
* None of the LOU-N server's spec-resolvable tools (http/current-date/
* day-name, see buildAgent.ts + specToAgent.ts's RESOLVABLE_BUILT_IN_TOOLS)
* set `needsApproval`, so a spec alone can't trigger this path end-to-end
* through the HTTP API in a test - a custom ToolDescriptor is used here
* instead, exactly mirroring what a real approval-gated tool would do.
*/
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 {
AgentExecutor,
ToolRegistry,
AgentBuilder,
AgentType,
createMockProvider,
resumeAfterApproval,
type ToolDescriptor,
} from '@loushy/build-ai-agent';
import { FileApprovalStore } from '../approvalStore';
import { FileCheckpointStore } from '../checkpointStore';

describe('approval-gate pause/resume (FileApprovalStore + FileCheckpointStore)', () => {
let baseDir: string;
let approvalStore: FileApprovalStore;
let checkpointStore: FileCheckpointStore;

beforeEach(() => {
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-n-approval-test-'));
approvalStore = new FileApprovalStore(baseDir);
checkpointStore = new FileCheckpointStore(baseDir);
});

afterEach(() => {
fs.rmSync(baseDir, { recursive: true, force: true });
});

function buildGatedAgentConfig(agentId: string) {
const dangerousTool: ToolDescriptor = {
displayName: 'Dangerous Tool',
tool: {
description: 'Does something dangerous',
parameters: { type: 'object', properties: {} },
execute: async () => ({ done: true }),
} as any,
needsApproval: true,
};
const toolRegistry = new ToolRegistry();
toolRegistry.register('dangerous', dangerousTool);

const provider = createMockProvider({
name: 'mock',
responses: ['calling the dangerous tool', 'all done'],
});

const agent = AgentBuilder.create()
.setId(agentId)
.setType(AgentType.SmartAssistant)
.setName('gated-agent')
.setPrompt('You are a helpful agent.')
.setTools({ dangerous: { tool: 'dangerous' } })
.build();

return { agent, provider, toolRegistry };
}

it('pauses on a needsApproval tool, persists the pending decision, and resumes on approve', async () => {
const agentId = 'gated-agent-1';
const { agent, provider, toolRegistry } = buildGatedAgentConfig(agentId);

const paused = await AgentExecutor.execute({
agent,
input: 'please call the dangerous tool',
provider,
toolRegistry,
approvalStore,
sessionId: agentId,
checkpointStore,
});

expect(paused.finishReason).toBe('awaiting-approval');
expect(paused.approvalId).toBeTruthy();

// Exactly what runRegistry.ts's handleRunSettled() reads to build the
// UI's pending-approval card.
const record = await approvalStore.peek(agentId, paused.approvalId!);
expect(record?.pending.toolName).toBe('dangerous');

const resumed = await resumeAfterApproval(
{ id: paused.approvalId!, approved: true },
approvalStore,
toolRegistry,
provider,
{},
checkpointStore
);

expect(resumed.finishReason).not.toBe('awaiting-approval');
expect(resumed.text).toBeTruthy();

// The approval record is delete-on-read.
expect(await approvalStore.peek(agentId, paused.approvalId!)).toBeNull();
});

it('a rejected approval resumes the run with a rejection tool-result instead of executing the tool', async () => {
const agentId = 'gated-agent-2';
const { agent, provider, toolRegistry } = buildGatedAgentConfig(agentId);

const paused = await AgentExecutor.execute({
agent,
input: 'please call the dangerous tool',
provider,
toolRegistry,
approvalStore,
sessionId: agentId,
checkpointStore,
});
expect(paused.finishReason).toBe('awaiting-approval');

const resumed = await resumeAfterApproval(
{ id: paused.approvalId!, approved: false, note: 'not today' },
approvalStore,
toolRegistry,
provider,
{},
checkpointStore
);

const toolMessage = resumed.messages.find((m) => m.role === 'tool');
expect(toolMessage?.content).toContain('rejected');
});
});
Loading
Loading