1648 lines
62 KiB
Markdown
1648 lines
62 KiB
Markdown
# Companion Chat Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the Void 2.0 right-rail companion chat — an always-visible, per-Space AI assistant that answers questions over the Void's knowledge and proposes changes through the existing `pending_changes` approval chain.
|
||
|
||
**Architecture:** A lean agent runtime built directly on the Anthropic SDK (no Mastra) drives a tool-use loop over a small, shared, extensible tool registry (`search`, `read`, `context`, `propose_change`). An SSE endpoint streams tokens + tool-activity events to a rewritten `rightrail.js`. One ambient `conversation` per Space; drafts are `pending_changes` rows rendered inline in chat and in the Inbox.
|
||
|
||
**Tech Stack:** Node 22 / Express 5 (ESM), `@anthropic-ai/sdk`, Postgres 16 + pgvector, vitest + supertest, vanilla-JS SPA with the safe-DOM helpers in `public/dom.js`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-06-01-void-v2-plan5-companion-chat-design.md`
|
||
|
||
---
|
||
|
||
## Conventions (read before starting)
|
||
|
||
- **ESM only.** All `import`/`export`, `"type":"module"` is set.
|
||
- **Repos** live in `lib/db/repos/*.js`, use `import { pool } from '../pool.js'`, return rows. Mutations emit audit via `recordAudit(actor, action, type, id, before, after)` from `./audit_stub.js`.
|
||
- **Routes** use `Router()`, `validate({ body|query|params: zodSchema })` (parsed body lands on `req.body`, query on `req.validatedQuery`), and `asyncWrap` from `../errors.js`. `req.actor` is set by `agentOrOwner`: owner = `{ kind:'user', id:null }`, agent = `{ kind:'agent', id, capabilities, scopes }`.
|
||
- **Capability:** `canAct(actor, action, entity_type)` → `'allow' | 'suggest' | 'deny'` (`lib/auth/capability.js`).
|
||
- **Tests** live under `tests/` mirroring `lib/` paths. Run with `npm test` (vitest, `fileParallelism:false`). DB tests use `resetDb()` from `tests/helpers/db.js` + `migrateUp()` from `lib/db/migrate.js`. The owner token in tests is `process.env.OWNER_TOKEN = 'test-token'`.
|
||
- **Commit after every task** (the repo commits directly to `main`, no remote). End commit messages with the Co-Authored-By trailer used elsewhere in this repo.
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
**Create:**
|
||
- `lib/db/migrations/007_companion.sql` — `conversations.space_id` + index + seed default companion agent
|
||
- `lib/ai/secret.js` — `resolveSecret('env:KEY' | 'file:/path' | raw)`
|
||
- `lib/ai/anthropic.js` — Anthropic client factory + `makeCallModel({ client, model })`
|
||
- `lib/ai/agent/registry.js` — tool registry (`registerTool`, `getTool`, `listTools`, `toAnthropicTools`)
|
||
- `lib/ai/agent/tools/search.js`, `read.js`, `context.js`, `propose_change.js`
|
||
- `lib/ai/agent/tools/index.js` — registers all four tools, exports the registry
|
||
- `lib/ai/agent/runtime.js` — `runTurn(...)` tool-use loop
|
||
- `lib/api/routes/companion.js` — `GET /api/spaces/:space_id/companion`, `POST …/companion/turn` (SSE)
|
||
- `public/sse.js` — authenticated POST→SSE reader for the browser
|
||
- Tests mirroring each of the above under `tests/`
|
||
|
||
**Modify:**
|
||
- `lib/db/repos/conversations.js` — add `findOrCreateForSpace(...)`
|
||
- `lib/api/index.js` — mount the companion router
|
||
- `public/components/rightrail.js` — replace the stub with the chat UI
|
||
- `public/style.css` — rail/turn/chip/draft-card styles
|
||
- `package.json` / `CHANGELOG.md` — dependency + version bump (final task)
|
||
|
||
---
|
||
|
||
## Task 1: Add the Anthropic SDK dependency
|
||
|
||
**Files:**
|
||
- Modify: `package.json`
|
||
|
||
- [ ] **Step 1: Install the SDK**
|
||
|
||
Run:
|
||
```bash
|
||
cd /project/src/void-v2 && npm install @anthropic-ai/sdk@^0.40.0
|
||
```
|
||
Expected: `@anthropic-ai/sdk` appears in `dependencies`, `npm install` exits 0.
|
||
|
||
- [ ] **Step 2: Verify it imports**
|
||
|
||
Run:
|
||
```bash
|
||
node -e "import('@anthropic-ai/sdk').then(m => console.log(typeof m.default))"
|
||
```
|
||
Expected: prints `function` (the `Anthropic` class constructor).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json
|
||
git commit -m "chore(deps): add @anthropic-ai/sdk for companion runtime
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Secret resolver (`lib/ai/secret.js`)
|
||
|
||
The companion's API key is configured as a `vault_path`-style string. v1 supports `env:` and `file:` (the Vaultwarden swap is deferred — see the spec).
|
||
|
||
**Files:**
|
||
- Create: `lib/ai/secret.js`
|
||
- Test: `tests/ai/secret.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/ai/secret.test.js
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { writeFileSync, mkdtempSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { resolveSecret } from '../../lib/ai/secret.js';
|
||
|
||
describe('resolveSecret', () => {
|
||
beforeEach(() => { delete process.env.__SECRET_TEST; });
|
||
|
||
it('resolves env: specs', () => {
|
||
process.env.__SECRET_TEST = 'sk-from-env';
|
||
expect(resolveSecret('env:__SECRET_TEST')).toBe('sk-from-env');
|
||
});
|
||
|
||
it('resolves file: specs (trimmed)', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'sec-'));
|
||
const f = join(dir, 'key');
|
||
writeFileSync(f, 'sk-from-file\n');
|
||
expect(resolveSecret('file:' + f)).toBe('sk-from-file');
|
||
});
|
||
|
||
it('returns a raw value unchanged', () => {
|
||
expect(resolveSecret('sk-raw')).toBe('sk-raw');
|
||
});
|
||
|
||
it('returns null for empty/missing', () => {
|
||
expect(resolveSecret('')).toBeNull();
|
||
expect(resolveSecret('env:__SECRET_TEST')).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/ai/secret.test.js`
|
||
Expected: FAIL — `resolveSecret is not a function` / module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/ai/secret.js`**
|
||
|
||
```javascript
|
||
import { readFileSync } from 'node:fs';
|
||
|
||
// Resolve a vault_path-style secret reference. v1 supports:
|
||
// env:NAME -> process.env.NAME
|
||
// file:/path -> file contents (trimmed)
|
||
// <raw> -> returned as-is
|
||
// Vaultwarden item-id resolution is a future swap (see spec).
|
||
export function resolveSecret(spec) {
|
||
if (!spec) return null;
|
||
if (spec.startsWith('env:')) {
|
||
return process.env[spec.slice(4)] ?? null;
|
||
}
|
||
if (spec.startsWith('file:')) {
|
||
try { return readFileSync(spec.slice(5), 'utf8').trim(); }
|
||
catch { return null; }
|
||
}
|
||
return spec;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/ai/secret.test.js`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/ai/secret.js tests/ai/secret.test.js
|
||
git commit -m "feat(ai): vault_path secret resolver (env:/file:)
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Migration 007 — `conversations.space_id` + seed companion agent
|
||
|
||
`conversations` has no Space linkage and no agent is seeded. Add a nullable `space_id` FK + lookup index, and seed one default companion agent (`slug='companion'`, `kind='claude'`, default capabilities `read+suggest`, no write).
|
||
|
||
**Files:**
|
||
- Create: `lib/db/migrations/007_companion.sql`
|
||
- Test: `tests/db/migration_007.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/db/migration_007.test.js
|
||
import { describe, it, expect, beforeAll } from 'vitest';
|
||
import { pool } from '../../lib/pool.js';
|
||
import { resetDb } from '../helpers/db.js';
|
||
import { migrateUp } from '../../lib/db/migrate.js';
|
||
|
||
beforeAll(async () => { await resetDb(); await migrateUp(); });
|
||
|
||
describe('migration 007', () => {
|
||
it('adds conversations.space_id column', async () => {
|
||
const { rows } = await pool.query(
|
||
`SELECT column_name FROM information_schema.columns
|
||
WHERE table_name='conversations' AND column_name='space_id'`
|
||
);
|
||
expect(rows).toHaveLength(1);
|
||
});
|
||
|
||
it('seeds a default companion agent', async () => {
|
||
const { rows } = await pool.query(`SELECT slug, kind, capabilities FROM agents WHERE slug='companion'`);
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0].kind).toBe('claude');
|
||
expect(rows[0].capabilities).toMatchObject({ read: true, suggest: true, write: false });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/db/migration_007.test.js`
|
||
Expected: FAIL — column missing / no `companion` agent row.
|
||
|
||
- [ ] **Step 3: Write the migration**
|
||
|
||
```sql
|
||
-- lib/db/migrations/007_companion.sql
|
||
-- Plan 5: per-Space ambient companion conversation + default companion agent.
|
||
|
||
ALTER TABLE conversations
|
||
ADD COLUMN space_id uuid REFERENCES spaces(id) ON DELETE CASCADE;
|
||
|
||
CREATE INDEX idx_conversations_space ON conversations(space_id, started_at DESC);
|
||
|
||
INSERT INTO agents (slug, name, kind, model, capabilities)
|
||
VALUES (
|
||
'companion', 'Companion', 'claude', NULL,
|
||
'{"read":true,"suggest":true,"write":false}'::jsonb
|
||
)
|
||
ON CONFLICT (slug) DO NOTHING;
|
||
```
|
||
|
||
- [ ] **Step 4: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/db/migration_007.test.js`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/db/migrations/007_companion.sql tests/db/migration_007.test.js
|
||
git commit -m "feat(db): migration 007 — conversations.space_id + seed companion agent
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `conversations.findOrCreateForSpace`
|
||
|
||
One ambient conversation per Space, owned by a given agent. Reuse on subsequent turns.
|
||
|
||
**Files:**
|
||
- Modify: `lib/db/repos/conversations.js`
|
||
- Test: `tests/db/conversations_companion.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/db/conversations_companion.test.js
|
||
import { describe, it, expect, beforeAll } from 'vitest';
|
||
import { pool } from '../../lib/pool.js';
|
||
import { resetDb } from '../helpers/db.js';
|
||
import { migrateUp } from '../../lib/db/migrate.js';
|
||
import * as conversations from '../../lib/db/repos/conversations.js';
|
||
|
||
const ACTOR = { kind: 'user', id: null };
|
||
let spaceId, agentId;
|
||
|
||
beforeAll(async () => {
|
||
await resetDb(); await migrateUp();
|
||
({ rows: [{ id: spaceId }] } = await pool.query(
|
||
`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`));
|
||
({ rows: [{ id: agentId }] } = await pool.query(
|
||
`SELECT id FROM agents WHERE slug='companion'`));
|
||
});
|
||
|
||
describe('findOrCreateForSpace', () => {
|
||
it('creates once then returns the same row', async () => {
|
||
const a = await conversations.findOrCreateForSpace(spaceId, agentId, ACTOR);
|
||
const b = await conversations.findOrCreateForSpace(spaceId, agentId, ACTOR);
|
||
expect(a.id).toBe(b.id);
|
||
expect(a.space_id).toBe(spaceId);
|
||
expect(a.agent_id).toBe(agentId);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/db/conversations_companion.test.js`
|
||
Expected: FAIL — `findOrCreateForSpace is not a function`.
|
||
|
||
- [ ] **Step 3: Implement (append to `lib/db/repos/conversations.js`)**
|
||
|
||
```javascript
|
||
export async function findOrCreateForSpace(space_id, agent_id, actor) {
|
||
const { rows: [existing] } = await pool.query(
|
||
`SELECT * FROM conversations
|
||
WHERE space_id=$1 AND agent_id=$2 AND status='open'
|
||
ORDER BY started_at DESC LIMIT 1`,
|
||
[space_id, agent_id]
|
||
);
|
||
if (existing) return existing;
|
||
const { rows: [r] } = await pool.query(
|
||
`INSERT INTO conversations(title, space_id, agent_id, metadata)
|
||
VALUES($1,$2,$3,$4) RETURNING *`,
|
||
['Companion', space_id, agent_id, {}]
|
||
);
|
||
await recordAudit(actor, 'create', 'conversation', r.id, null, r);
|
||
return r;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/db/conversations_companion.test.js`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/db/repos/conversations.js tests/db/conversations_companion.test.js
|
||
git commit -m "feat(db): conversations.findOrCreateForSpace for the ambient companion
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Tool registry (`lib/ai/agent/registry.js`)
|
||
|
||
A registry of `{ name, description, input_schema, handler }`. The runtime reads it now; a future MCP server re-exposes the same defs. **Extensible by design** (registering a new tool needs no runtime change — see spec §7).
|
||
|
||
**Files:**
|
||
- Create: `lib/ai/agent/registry.js`
|
||
- Test: `tests/ai/agent/registry.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/ai/agent/registry.test.js
|
||
import { describe, it, expect } from 'vitest';
|
||
import { createRegistry } from '../../../lib/ai/agent/registry.js';
|
||
|
||
describe('tool registry', () => {
|
||
const def = {
|
||
name: 'echo',
|
||
description: 'echo back',
|
||
input_schema: { type: 'object', properties: { x: { type: 'string' } }, required: ['x'] },
|
||
handler: async ({ x }) => ({ ok: true, x })
|
||
};
|
||
|
||
it('registers and retrieves a tool', () => {
|
||
const r = createRegistry();
|
||
r.registerTool(def);
|
||
expect(r.getTool('echo')).toBe(def);
|
||
expect(r.listTools().map(t => t.name)).toEqual(['echo']);
|
||
});
|
||
|
||
it('rejects duplicate names', () => {
|
||
const r = createRegistry();
|
||
r.registerTool(def);
|
||
expect(() => r.registerTool(def)).toThrow(/already registered/);
|
||
});
|
||
|
||
it('serialises to the Anthropic tools shape (no handler leak)', () => {
|
||
const r = createRegistry();
|
||
r.registerTool(def);
|
||
expect(r.toAnthropicTools()).toEqual([
|
||
{ name: 'echo', description: 'echo back', input_schema: def.input_schema }
|
||
]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/ai/agent/registry.test.js`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/ai/agent/registry.js`**
|
||
|
||
```javascript
|
||
export function createRegistry() {
|
||
const tools = new Map();
|
||
return {
|
||
registerTool(def) {
|
||
if (!def?.name) throw new Error('tool def needs a name');
|
||
if (tools.has(def.name)) throw new Error(`tool "${def.name}" already registered`);
|
||
tools.set(def.name, def);
|
||
},
|
||
getTool(name) { return tools.get(name); },
|
||
listTools() { return [...tools.values()]; },
|
||
// Anthropic tool-use schema — handlers are intentionally stripped.
|
||
toAnthropicTools() {
|
||
return [...tools.values()].map(({ name, description, input_schema }) =>
|
||
({ name, description, input_schema }));
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/ai/agent/registry.test.js`
|
||
Expected: PASS (3 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/ai/agent/registry.js tests/ai/agent/registry.test.js
|
||
git commit -m "feat(ai): extensible agent tool registry
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `search` and `read` tools
|
||
|
||
Read-only grounding tools. `search` wraps the existing FTS repo (Space-scoped); `read` fetches an entity by type+id. Tool `ctx` = `{ agent, space_id, view, actor }`.
|
||
|
||
**Files:**
|
||
- Create: `lib/ai/agent/tools/search.js`, `lib/ai/agent/tools/read.js`
|
||
- Test: `tests/ai/agent/tools/read_search.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/ai/agent/tools/read_search.test.js
|
||
import { describe, it, expect, beforeAll } from 'vitest';
|
||
import { pool } from '../../../../lib/pool.js';
|
||
import { resetDb } from '../../../helpers/db.js';
|
||
import { migrateUp } from '../../../../lib/db/migrate.js';
|
||
import { searchTool } from '../../../../lib/ai/agent/tools/search.js';
|
||
import { readTool } from '../../../../lib/ai/agent/tools/read.js';
|
||
|
||
let spaceId, pageId;
|
||
beforeAll(async () => {
|
||
await resetDb(); await migrateUp();
|
||
({ rows: [{ id: spaceId }] } = await pool.query(
|
||
`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`));
|
||
({ rows: [{ id: pageId }] } = await pool.query(
|
||
`INSERT INTO pages(space_id,title,body) VALUES($1,'Telemetry export','export CSV via phone') RETURNING id`,
|
||
[spaceId]));
|
||
});
|
||
|
||
describe('search tool', () => {
|
||
it('returns FTS hits scoped to the space', async () => {
|
||
const ctx = { space_id: spaceId, actor: { kind: 'user', id: null } };
|
||
const out = await searchTool.handler({ q: 'telemetry' }, ctx);
|
||
expect(Array.isArray(out.results)).toBe(true);
|
||
expect(out.results.some(r => r.title?.includes('Telemetry'))).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('read tool', () => {
|
||
it('fetches a page by id', async () => {
|
||
const out = await readTool.handler({ kind: 'page', id: pageId }, { space_id: spaceId });
|
||
expect(out.title).toBe('Telemetry export');
|
||
});
|
||
it('reports not-found cleanly', async () => {
|
||
const out = await readTool.handler(
|
||
{ kind: 'page', id: '00000000-0000-0000-0000-000000000000' }, { space_id: spaceId });
|
||
expect(out.error).toMatch(/not found/i);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/ai/agent/tools/read_search.test.js`
|
||
Expected: FAIL — modules not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/ai/agent/tools/search.js`**
|
||
|
||
```javascript
|
||
import * as searchRepo from '../../../db/repos/search.js';
|
||
|
||
export const searchTool = {
|
||
name: 'search',
|
||
description: 'Full-text search across pages, refs, source docs and messages in the current Space. Use to find information before answering.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
q: { type: 'string', description: 'search query' },
|
||
kinds: {
|
||
type: 'array',
|
||
items: { type: 'string', enum: ['page', 'ref', 'source_doc', 'message'] },
|
||
description: 'optional filter of result kinds'
|
||
}
|
||
},
|
||
required: ['q']
|
||
},
|
||
async handler({ q, kinds }, ctx) {
|
||
const results = await searchRepo.fts({
|
||
q,
|
||
space_id: ctx.space_id ?? null,
|
||
kinds: kinds?.length ? kinds : null,
|
||
limit: 8,
|
||
offset: 0
|
||
});
|
||
return { results };
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `lib/ai/agent/tools/read.js`**
|
||
|
||
```javascript
|
||
import { pool } from '../../../pool.js';
|
||
|
||
const TABLE = { page: 'pages', ref: 'refs', task: 'tasks', conversation: 'conversations' };
|
||
|
||
export const readTool = {
|
||
name: 'read',
|
||
description: 'Read a single entity (page, ref, task, conversation) by id for grounding.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
kind: { type: 'string', enum: ['page', 'ref', 'task', 'conversation'] },
|
||
id: { type: 'string', description: 'uuid of the entity' }
|
||
},
|
||
required: ['kind', 'id']
|
||
},
|
||
async handler({ kind, id }, _ctx) {
|
||
const table = TABLE[kind];
|
||
if (!table) return { error: `unknown kind "${kind}"` };
|
||
const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [id]);
|
||
if (!row) return { error: `${kind} ${id} not found` };
|
||
return row;
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/ai/agent/tools/read_search.test.js`
|
||
Expected: PASS (3 tests).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add lib/ai/agent/tools/search.js lib/ai/agent/tools/read.js tests/ai/agent/tools/read_search.test.js
|
||
git commit -m "feat(ai): search + read grounding tools
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `context` tool
|
||
|
||
Resolves the current view's entity so the agent knows what the owner is looking at.
|
||
|
||
**Files:**
|
||
- Create: `lib/ai/agent/tools/context.js`
|
||
- Test: `tests/ai/agent/tools/context.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/ai/agent/tools/context.test.js
|
||
import { describe, it, expect, beforeAll } from 'vitest';
|
||
import { pool } from '../../../../lib/pool.js';
|
||
import { resetDb } from '../../../helpers/db.js';
|
||
import { migrateUp } from '../../../../lib/db/migrate.js';
|
||
import { contextTool } from '../../../../lib/ai/agent/tools/context.js';
|
||
|
||
let spaceId, taskId;
|
||
beforeAll(async () => {
|
||
await resetDb(); await migrateUp();
|
||
({ rows: [{ id: spaceId }] } = await pool.query(
|
||
`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`));
|
||
({ rows: [{ id: taskId }] } = await pool.query(
|
||
`INSERT INTO tasks(space_id,title) VALUES($1,'Wire telemetry') RETURNING id`, [spaceId]));
|
||
});
|
||
|
||
describe('context tool', () => {
|
||
it('summarises the current view entity', async () => {
|
||
const out = await contextTool.handler({}, { space_id: spaceId, view: { entityType: 'task', entityId: taskId } });
|
||
expect(out.entityType).toBe('task');
|
||
expect(out.title).toBe('Wire telemetry');
|
||
});
|
||
it('handles no active view', async () => {
|
||
const out = await contextTool.handler({}, { space_id: spaceId, view: null });
|
||
expect(out.note).toMatch(/no specific entity/i);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/ai/agent/tools/context.test.js`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/ai/agent/tools/context.js`**
|
||
|
||
```javascript
|
||
import { pool } from '../../../pool.js';
|
||
|
||
const TABLE = { page: 'pages', ref: 'refs', task: 'tasks', project: 'projects', space: 'spaces' };
|
||
|
||
export const contextTool = {
|
||
name: 'context',
|
||
description: "Resolve what the owner is currently looking at (the active view). Call this first to ground your answer in the right entity.",
|
||
input_schema: { type: 'object', properties: {} },
|
||
async handler(_args, ctx) {
|
||
const view = ctx.view;
|
||
if (!view?.entityType || !view?.entityId) {
|
||
return { note: 'The owner is not on a specific entity; only the Space context is available.', space_id: ctx.space_id };
|
||
}
|
||
const table = TABLE[view.entityType];
|
||
if (!table) return { entityType: view.entityType, entityId: view.entityId, note: 'unrecognised entity type' };
|
||
const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [view.entityId]);
|
||
if (!row) return { entityType: view.entityType, entityId: view.entityId, error: 'not found' };
|
||
return { entityType: view.entityType, ...row };
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/ai/agent/tools/context.test.js`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/ai/agent/tools/context.js tests/ai/agent/tools/context.test.js
|
||
git commit -m "feat(ai): context tool — resolve the active view entity
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `propose_change` tool (the action seam)
|
||
|
||
Writes one `pending_changes` row — **never** applies. Enforces the agent's capability tier via `canAct`; a denied agent gets a clean refusal.
|
||
|
||
**Files:**
|
||
- Create: `lib/ai/agent/tools/propose_change.js`
|
||
- Test: `tests/ai/agent/tools/propose_change.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/ai/agent/tools/propose_change.test.js
|
||
import { describe, it, expect, beforeAll } from 'vitest';
|
||
import { pool } from '../../../../lib/pool.js';
|
||
import { resetDb } from '../../../helpers/db.js';
|
||
import { migrateUp } from '../../../../lib/db/migrate.js';
|
||
import { proposeChangeTool } from '../../../../lib/ai/agent/tools/propose_change.js';
|
||
|
||
let spaceId, agentId;
|
||
beforeAll(async () => {
|
||
await resetDb(); await migrateUp();
|
||
({ rows: [{ id: spaceId }] } = await pool.query(
|
||
`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`));
|
||
({ rows: [{ id: agentId }] } = await pool.query(`SELECT id FROM agents WHERE slug='companion'`));
|
||
});
|
||
|
||
const suggestAgent = (id) => ({ kind: 'agent', id, capabilities: { read: true, suggest: true, write: false }, scopes: {} });
|
||
|
||
describe('propose_change tool', () => {
|
||
it('writes a pending_changes row and never applies', async () => {
|
||
const ctx = { agent: suggestAgent(agentId), space_id: spaceId };
|
||
const out = await proposeChangeTool.handler(
|
||
{ entity_type: 'task', action: 'create', payload: { space_id: spaceId, title: 'Validate CSV' }, reason: 'tracking' },
|
||
ctx
|
||
);
|
||
expect(out.pending_change_id).toBeTruthy();
|
||
expect(out.applied).toBe(false);
|
||
|
||
const { rows } = await pool.query(`SELECT * FROM pending_changes WHERE id=$1`, [out.pending_change_id]);
|
||
expect(rows[0].status).toBe('pending');
|
||
expect(rows[0].agent_id).toBe(agentId);
|
||
|
||
const { rows: tasks } = await pool.query(`SELECT * FROM tasks WHERE title='Validate CSV'`);
|
||
expect(tasks).toHaveLength(0); // not applied
|
||
});
|
||
|
||
it('refuses when the agent cannot even suggest', async () => {
|
||
const denied = { kind: 'agent', id: agentId, capabilities: { read: true, suggest: false, write: false }, scopes: {} };
|
||
const out = await proposeChangeTool.handler(
|
||
{ entity_type: 'task', action: 'create', payload: { title: 'x' } },
|
||
{ agent: denied, space_id: spaceId }
|
||
);
|
||
expect(out.error).toMatch(/not permitted/i);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/ai/agent/tools/propose_change.test.js`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/ai/agent/tools/propose_change.js`**
|
||
|
||
```javascript
|
||
import { canAct } from '../../../auth/capability.js';
|
||
import * as pendingChanges from '../../../db/repos/pending_changes.js';
|
||
|
||
const ENTITY_TYPES = ['task', 'page', 'project', 'ref', 'resource', 'source_doc'];
|
||
const ACTIONS = ['create', 'update', 'delete'];
|
||
|
||
export const proposeChangeTool = {
|
||
name: 'propose_change',
|
||
description: 'Propose a change to the Void. This NEVER applies directly — it creates a draft the owner must approve. Use for creating/updating/deleting tasks, pages, projects, refs, resources.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
entity_type: { type: 'string', enum: ENTITY_TYPES },
|
||
action: { type: 'string', enum: ACTIONS },
|
||
entity_id: { type: 'string', description: 'uuid; required for update/delete' },
|
||
payload: { type: 'object', description: 'fields for the change' },
|
||
reason: { type: 'string', description: 'one-line rationale shown to the owner' }
|
||
},
|
||
required: ['entity_type', 'action', 'payload']
|
||
},
|
||
async handler({ entity_type, action, entity_id, payload, reason }, ctx) {
|
||
const tier = canAct(ctx.agent, action, entity_type);
|
||
if (tier === 'deny') {
|
||
return { error: `not permitted to ${action} ${entity_type}` };
|
||
}
|
||
// v1: drafting always routes through approval, even for allow-tier agents.
|
||
const change = await pendingChanges.create({
|
||
agent_id: ctx.agent.id,
|
||
entity_type,
|
||
entity_id: entity_id ?? null,
|
||
action,
|
||
payload: payload ?? {},
|
||
reason: reason ?? null
|
||
});
|
||
return {
|
||
pending_change_id: change.id,
|
||
applied: false,
|
||
summary: `${action} ${entity_type}${payload?.title ? ` "${payload.title}"` : ''}`
|
||
};
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/ai/agent/tools/propose_change.test.js`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/ai/agent/tools/propose_change.js tests/ai/agent/tools/propose_change.test.js
|
||
git commit -m "feat(ai): propose_change tool — drafts to pending_changes, never applies
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Tool index (wire the four tools into a registry)
|
||
|
||
**Files:**
|
||
- Create: `lib/ai/agent/tools/index.js`
|
||
- Test: `tests/ai/agent/tools/index.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/ai/agent/tools/index.test.js
|
||
import { describe, it, expect } from 'vitest';
|
||
import { companionRegistry } from '../../../../lib/ai/agent/tools/index.js';
|
||
|
||
describe('companion registry', () => {
|
||
it('registers exactly the four v1 tools', () => {
|
||
expect(companionRegistry.listTools().map(t => t.name).sort())
|
||
.toEqual(['context', 'propose_change', 'read', 'search']);
|
||
});
|
||
it('exposes them in Anthropic shape', () => {
|
||
const tools = companionRegistry.toAnthropicTools();
|
||
expect(tools.every(t => t.name && t.input_schema && !('handler' in t))).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/ai/agent/tools/index.test.js`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/ai/agent/tools/index.js`**
|
||
|
||
```javascript
|
||
import { createRegistry } from '../registry.js';
|
||
import { searchTool } from './search.js';
|
||
import { readTool } from './read.js';
|
||
import { contextTool } from './context.js';
|
||
import { proposeChangeTool } from './propose_change.js';
|
||
|
||
// The shared registry. Adding a tool later is a one-line registerTool() call
|
||
// here (see spec §7 — extensible tool registry). A future MCP server can
|
||
// import this same registry and re-expose toAnthropicTools().
|
||
export const companionRegistry = createRegistry();
|
||
companionRegistry.registerTool(searchTool);
|
||
companionRegistry.registerTool(readTool);
|
||
companionRegistry.registerTool(contextTool);
|
||
companionRegistry.registerTool(proposeChangeTool);
|
||
```
|
||
|
||
- [ ] **Step 4: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/ai/agent/tools/index.test.js`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/ai/agent/tools/index.js tests/ai/agent/tools/index.test.js
|
||
git commit -m "feat(ai): wire the four v1 companion tools into a shared registry
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Anthropic client + `makeCallModel`
|
||
|
||
Decouple the SDK from the runtime behind a `callModel({ system, messages, tools, onTextDelta })` function. Production wraps `client.messages.stream(...)`; tests inject a fake. Default model `claude-sonnet-4-6` (good tool-use, cheaper than Opus for a chat companion); overridable per-agent via `agent.model` and globally via `ANTHROPIC_MODEL`.
|
||
|
||
**Files:**
|
||
- Create: `lib/ai/anthropic.js`
|
||
- Test: `tests/ai/anthropic.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test** (tests the pure shaping logic, not the network)
|
||
|
||
```javascript
|
||
// tests/ai/anthropic.test.js
|
||
import { describe, it, expect } from 'vitest';
|
||
import { makeCallModel } from '../../lib/ai/anthropic.js';
|
||
|
||
// Fake SDK client whose stream() yields text deltas then a tool_use block.
|
||
function fakeClient(events) {
|
||
return {
|
||
messages: {
|
||
stream() {
|
||
return {
|
||
async *[Symbol.asyncIterator]() { for (const e of events) yield e; }
|
||
};
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
describe('makeCallModel', () => {
|
||
it('accumulates text, forwards deltas, and collects tool_use', async () => {
|
||
const events = [
|
||
{ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hel' } },
|
||
{ type: 'content_block_delta', delta: { type: 'text_delta', text: 'lo' } },
|
||
{ type: 'content_block_start', content_block: { type: 'tool_use', id: 'tu_1', name: 'search', input: {} } },
|
||
{ type: 'input_json_delta_done', toolInput: { q: 'x' } }, // see impl note
|
||
{ type: 'message_delta', usage: { output_tokens: 5 } },
|
||
{ type: 'message_stop', message: { stop_reason: 'tool_use', usage: { input_tokens: 10, output_tokens: 5 } } }
|
||
];
|
||
const callModel = makeCallModel({ client: fakeClient(events), model: 'm' });
|
||
const deltas = [];
|
||
const out = await callModel({ system: 's', messages: [], tools: [], onTextDelta: t => deltas.push(t) });
|
||
expect(deltas.join('')).toBe('Hello');
|
||
expect(out.text).toBe('Hello');
|
||
expect(out.toolUses[0]).toMatchObject({ id: 'tu_1', name: 'search' });
|
||
expect(out.stopReason).toBe('tool_use');
|
||
});
|
||
});
|
||
```
|
||
|
||
> **Impl note:** the real Anthropic stream emits `input_json_delta` events whose partial JSON must be accumulated and parsed at `content_block_stop`. To keep this testable and robust, the implementation listens to the SDK's higher-level `inputJson`/`finalMessage()` helpers. Use the SDK's `stream.finalMessage()` to collect the assembled `tool_use` blocks rather than hand-assembling JSON. The fake above simulates the assembled result; adjust event names to match `@anthropic-ai/sdk` if the SDK helper API differs, keeping the **return shape** `{ text, toolUses, stopReason, usage }` stable.
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/ai/anthropic.test.js`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/ai/anthropic.js`**
|
||
|
||
```javascript
|
||
import Anthropic from '@anthropic-ai/sdk';
|
||
import { resolveSecret } from './secret.js';
|
||
|
||
export const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
|
||
|
||
export function getAnthropicClient() {
|
||
const key = resolveSecret(process.env.ANTHROPIC_API_KEY_REF || 'env:ANTHROPIC_API_KEY');
|
||
if (!key) throw new Error('Anthropic API key not configured (set ANTHROPIC_API_KEY or ANTHROPIC_API_KEY_REF)');
|
||
return new Anthropic({ apiKey: key });
|
||
}
|
||
|
||
// Returns callModel({ system, messages, tools, onTextDelta }) -> { text, toolUses, stopReason, usage }.
|
||
// Streams text deltas through onTextDelta; collects tool_use blocks from the
|
||
// final assembled message so partial-JSON handling is the SDK's problem.
|
||
export function makeCallModel({ client, model = DEFAULT_MODEL, maxTokens = 1024 }) {
|
||
return async function callModel({ system, messages, tools, onTextDelta }) {
|
||
const stream = client.messages.stream({
|
||
model, max_tokens: maxTokens, system, messages, tools
|
||
});
|
||
for await (const event of stream) {
|
||
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
||
onTextDelta?.(event.delta.text);
|
||
}
|
||
}
|
||
const final = await stream.finalMessage();
|
||
const text = final.content.filter(b => b.type === 'text').map(b => b.text).join('');
|
||
const toolUses = final.content
|
||
.filter(b => b.type === 'tool_use')
|
||
.map(b => ({ id: b.id, name: b.name, input: b.input }));
|
||
return { text, toolUses, stopReason: final.stop_reason, usage: final.usage };
|
||
};
|
||
}
|
||
```
|
||
|
||
> **If the SDK's streaming iterator/`finalMessage()` API differs** from the above in the installed version, adjust the event handling but keep the return shape identical so Task 11's runtime is unaffected. Verify against context7 docs for `@anthropic-ai/sdk` message streaming.
|
||
|
||
- [ ] **Step 4: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/ai/anthropic.test.js`
|
||
Expected: PASS. (If the fake event shape needs tweaking to match the SDK helper, update the test fake — the assertion on the return shape is what matters.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/ai/anthropic.js tests/ai/anthropic.test.js
|
||
git commit -m "feat(ai): Anthropic client + streaming callModel adapter
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Agent runtime (`runTurn`)
|
||
|
||
The tool-use loop. Pure orchestration — `callModel` and the registry are injected, so it unit-tests with no network. Emits events via `onEvent`; returns the final assistant text + trace.
|
||
|
||
**Files:**
|
||
- Create: `lib/ai/agent/runtime.js`
|
||
- Test: `tests/ai/agent/runtime.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/ai/agent/runtime.test.js
|
||
import { describe, it, expect } from 'vitest';
|
||
import { createRegistry } from '../../../lib/ai/agent/registry.js';
|
||
import { runTurn } from '../../../lib/ai/agent/runtime.js';
|
||
|
||
function scriptedCallModel(steps) {
|
||
let i = 0;
|
||
return async ({ onTextDelta }) => {
|
||
const step = steps[i++];
|
||
if (step.text) for (const ch of step.text) onTextDelta?.(ch);
|
||
return { text: step.text || '', toolUses: step.toolUses || [], stopReason: step.toolUses ? 'tool_use' : 'end_turn', usage: { output_tokens: 1 } };
|
||
};
|
||
}
|
||
|
||
describe('runTurn', () => {
|
||
it('runs a tool then produces a final answer', async () => {
|
||
const reg = createRegistry();
|
||
reg.registerTool({ name: 'search', description: 's', input_schema: { type: 'object', properties: {} },
|
||
handler: async () => ({ results: [{ title: 'Hit' }] }) });
|
||
|
||
const callModel = scriptedCallModel([
|
||
{ toolUses: [{ id: 'tu1', name: 'search', input: { q: 'x' } }] },
|
||
{ text: 'Found it.' }
|
||
]);
|
||
|
||
const events = [];
|
||
const out = await runTurn({
|
||
callModel, registry: reg, system: 'sys',
|
||
messages: [{ role: 'user', content: 'find x' }],
|
||
ctx: { agent: { id: 'a' }, space_id: 's' },
|
||
onEvent: e => events.push(e)
|
||
});
|
||
|
||
expect(out.text).toBe('Found it.');
|
||
expect(events.filter(e => e.type === 'tool').map(e => e.tool)).toEqual(['search']);
|
||
expect(events.some(e => e.type === 'delta' && e.text)).toBe(true);
|
||
expect(out.toolTrace[0]).toMatchObject({ tool: 'search' });
|
||
});
|
||
|
||
it('emits a draft event when propose_change runs', async () => {
|
||
const reg = createRegistry();
|
||
reg.registerTool({ name: 'propose_change', description: 'p', input_schema: { type: 'object', properties: {} },
|
||
handler: async () => ({ pending_change_id: 'pc1', applied: false, summary: 'create task "X"' }) });
|
||
|
||
const callModel = scriptedCallModel([
|
||
{ toolUses: [{ id: 'tu1', name: 'propose_change', input: {} }] },
|
||
{ text: 'Drafted.' }
|
||
]);
|
||
|
||
const events = [];
|
||
const out = await runTurn({ callModel, registry: reg, system: 's',
|
||
messages: [{ role: 'user', content: 'make a task' }],
|
||
ctx: { agent: { id: 'a' } }, onEvent: e => events.push(e) });
|
||
|
||
expect(out.draftIds).toEqual(['pc1']);
|
||
expect(events.find(e => e.type === 'draft')).toMatchObject({ pending_change_id: 'pc1' });
|
||
});
|
||
|
||
it('stops at the iteration guard', async () => {
|
||
const reg = createRegistry();
|
||
reg.registerTool({ name: 'search', description: 's', input_schema: { type: 'object', properties: {} },
|
||
handler: async () => ({ results: [] }) });
|
||
const always = async () => ({ text: '', toolUses: [{ id: 't', name: 'search', input: {} }], stopReason: 'tool_use', usage: {} });
|
||
const out = await runTurn({ callModel: always, registry: reg, system: 's',
|
||
messages: [{ role: 'user', content: 'loop' }], ctx: { agent: { id: 'a' } }, onEvent: () => {}, maxIterations: 3 });
|
||
expect(out.toolTrace.length).toBe(3);
|
||
expect(out.stoppedOnGuard).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/ai/agent/runtime.test.js`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/ai/agent/runtime.js`**
|
||
|
||
```javascript
|
||
// Tool-use loop. callModel + registry injected (no network here).
|
||
// Emits: { type:'tool', tool, args, status } | { type:'delta', text }
|
||
// | { type:'draft', pending_change_id, summary }
|
||
// Returns: { text, toolTrace, draftIds, usage, stoppedOnGuard }
|
||
export async function runTurn({ callModel, registry, system, messages, ctx, onEvent, maxIterations = 6 }) {
|
||
const convo = [...messages];
|
||
const toolTrace = [];
|
||
const draftIds = [];
|
||
let usage = {};
|
||
let stoppedOnGuard = false;
|
||
|
||
for (let i = 0; i < maxIterations; i++) {
|
||
const res = await callModel({
|
||
system,
|
||
messages: convo,
|
||
tools: registry.toAnthropicTools(),
|
||
onTextDelta: (t) => onEvent?.({ type: 'delta', text: t })
|
||
});
|
||
usage = res.usage || usage;
|
||
|
||
if (!res.toolUses?.length) {
|
||
return { text: res.text, toolTrace, draftIds, usage, stoppedOnGuard };
|
||
}
|
||
|
||
// Record the assistant turn (text + tool_use blocks) for the next round.
|
||
convo.push({
|
||
role: 'assistant',
|
||
content: [
|
||
...(res.text ? [{ type: 'text', text: res.text }] : []),
|
||
...res.toolUses.map(t => ({ type: 'tool_use', id: t.id, name: t.name, input: t.input }))
|
||
]
|
||
});
|
||
|
||
const toolResults = [];
|
||
for (const call of res.toolUses) {
|
||
onEvent?.({ type: 'tool', tool: call.name, args: call.input, status: 'running' });
|
||
const tool = registry.getTool(call.name);
|
||
let result;
|
||
try {
|
||
result = tool ? await tool.handler(call.input, ctx) : { error: `unknown tool ${call.name}` };
|
||
} catch (e) {
|
||
result = { error: String(e?.message || e) };
|
||
}
|
||
toolTrace.push({ tool: call.name, args: call.input, ok: !result?.error });
|
||
if (result?.pending_change_id) {
|
||
draftIds.push(result.pending_change_id);
|
||
onEvent?.({ type: 'draft', pending_change_id: result.pending_change_id, summary: result.summary });
|
||
}
|
||
onEvent?.({ type: 'tool', tool: call.name, status: result?.error ? 'error' : 'done' });
|
||
toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: JSON.stringify(result) });
|
||
}
|
||
convo.push({ role: 'user', content: toolResults });
|
||
|
||
if (i === maxIterations - 1) stoppedOnGuard = true;
|
||
}
|
||
|
||
return { text: '', toolTrace, draftIds, usage, stoppedOnGuard };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/ai/agent/runtime.test.js`
|
||
Expected: PASS (3 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lib/ai/agent/runtime.js tests/ai/agent/runtime.test.js
|
||
git commit -m "feat(ai): agent runtime tool-use loop with event streaming
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Companion API — history + SSE turn endpoint
|
||
|
||
`GET /api/spaces/:space_id/companion` → `{ conversation_id, agent, messages }`.
|
||
`POST /api/spaces/:space_id/companion/turn` (SSE) → persists the user message, runs `runTurn`, streams events, persists one assistant message with the trace in `metadata`.
|
||
|
||
The runtime's `callModel` is taken from `req.app.locals.callModel` when present (tests inject a fake) and otherwise built from the real client — so the integration test never hits the network.
|
||
|
||
**Files:**
|
||
- Create: `lib/api/routes/companion.js`
|
||
- Modify: `lib/api/index.js`
|
||
- Test: `tests/api/companion.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```javascript
|
||
// tests/api/companion.test.js
|
||
import { describe, it, expect, beforeAll } from 'vitest';
|
||
import request from 'supertest';
|
||
import { pool } from '../../lib/pool.js';
|
||
import { createApp } from '../../server.js';
|
||
import { resetDb } from '../helpers/db.js';
|
||
import { migrateUp } from '../../lib/db/migrate.js';
|
||
|
||
let app, spaceId;
|
||
beforeAll(async () => {
|
||
await resetDb(); await migrateUp();
|
||
process.env.OWNER_TOKEN = 'test-token';
|
||
({ rows: [{ id: spaceId }] } = await pool.query(
|
||
`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`));
|
||
app = createApp();
|
||
// Inject a scripted callModel: one propose_change call, then a final answer.
|
||
let step = 0;
|
||
app.locals.callModel = async ({ onTextDelta }) => {
|
||
if (step++ === 0) return { text: '', toolUses: [{ id: 't1', name: 'propose_change',
|
||
input: { entity_type: 'task', action: 'create', payload: { space_id: spaceId, title: 'Validate CSV' } } }], stopReason: 'tool_use', usage: {} };
|
||
for (const ch of 'Drafted a task.') onTextDelta?.(ch);
|
||
return { text: 'Drafted a task.', toolUses: [], stopReason: 'end_turn', usage: { output_tokens: 3 } };
|
||
};
|
||
});
|
||
|
||
const auth = (r) => r.set('Authorization', 'Bearer test-token');
|
||
|
||
describe('companion API', () => {
|
||
it('GET creates the conversation and returns empty history', async () => {
|
||
const res = await auth(request(app).get(`/api/spaces/${spaceId}/companion`));
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.conversation_id).toBeTruthy();
|
||
expect(res.body.messages).toEqual([]);
|
||
});
|
||
|
||
it('POST /turn streams SSE events and persists messages + draft', async () => {
|
||
const res = await auth(request(app).post(`/api/spaces/${spaceId}/companion/turn`))
|
||
.send({ text: 'make a task to validate the CSV' });
|
||
expect(res.status).toBe(200);
|
||
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
|
||
expect(res.text).toMatch(/event: tool/);
|
||
expect(res.text).toMatch(/event: draft/);
|
||
expect(res.text).toMatch(/event: delta/);
|
||
expect(res.text).toMatch(/event: done/);
|
||
|
||
const { rows: msgs } = await pool.query(
|
||
`SELECT role, body, metadata FROM messages ORDER BY created_at`);
|
||
expect(msgs.map(m => m.role)).toEqual(['user', 'assistant']);
|
||
expect(msgs[1].body).toBe('Drafted a task.');
|
||
expect(msgs[1].metadata.draft_ids).toHaveLength(1);
|
||
|
||
const { rows: pc } = await pool.query(`SELECT * FROM pending_changes`);
|
||
expect(pc).toHaveLength(1);
|
||
expect(pc[0].status).toBe('pending');
|
||
|
||
const { rows: tasks } = await pool.query(`SELECT * FROM tasks WHERE title='Validate CSV'`);
|
||
expect(tasks).toHaveLength(0); // draft only, not applied
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to confirm it fails**
|
||
|
||
Run: `npx vitest run tests/api/companion.test.js`
|
||
Expected: FAIL — route not mounted (404) / module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/api/routes/companion.js`**
|
||
|
||
```javascript
|
||
import { Router } from 'express';
|
||
import { z } from 'zod';
|
||
import { validate } from '../validate.js';
|
||
import { asyncWrap } from '../errors.js';
|
||
import * as conversations from '../../db/repos/conversations.js';
|
||
import * as messages from '../../db/repos/messages.js';
|
||
import * as agents from '../../db/repos/agents.js';
|
||
import { companionRegistry } from '../../ai/agent/tools/index.js';
|
||
import { runTurn } from '../../ai/agent/runtime.js';
|
||
import { makeCallModel, getAnthropicClient, DEFAULT_MODEL } from '../../ai/anthropic.js';
|
||
|
||
const COMPANION_SLUG = 'companion';
|
||
|
||
const SYSTEM = `You are the Void companion — a concise, helpful assistant embedded in a personal knowledge system.
|
||
Ground answers in the Void's content: call the context tool to see what the owner is looking at, and search/read before answering factual questions.
|
||
When the owner asks you to change something, use propose_change — it creates a draft they approve; you cannot apply changes directly. Be brief.`;
|
||
|
||
async function resolveConversation(space_id) {
|
||
const agent = await agents.getBySlug(COMPANION_SLUG);
|
||
const convo = await conversations.findOrCreateForSpace(space_id, agent.id, { kind: 'user', id: null });
|
||
return { agent, convo };
|
||
}
|
||
|
||
// Build Anthropic-format history from stored messages (text-only turns).
|
||
function toAnthropicHistory(rows) {
|
||
return rows
|
||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||
.map(m => ({ role: m.role, content: m.body }));
|
||
}
|
||
|
||
export const spacesScopedRouter = Router({ mergeParams: true });
|
||
|
||
spacesScopedRouter.get('/', asyncWrap(async (req, res) => {
|
||
const { agent, convo } = await resolveConversation(req.params.space_id);
|
||
const rows = await messages.listByConversation(convo.id);
|
||
res.json({
|
||
conversation_id: convo.id,
|
||
agent: { id: agent.id, slug: agent.slug, name: agent.name },
|
||
messages: rows
|
||
});
|
||
}));
|
||
|
||
const turnSchema = z.object({
|
||
text: z.string().min(1),
|
||
view: z.object({ entityType: z.string(), entityId: z.string() }).partial().optional()
|
||
});
|
||
|
||
spacesScopedRouter.post('/turn',
|
||
validate({ body: turnSchema }),
|
||
asyncWrap(async (req, res) => {
|
||
const { agent, convo } = await resolveConversation(req.params.space_id);
|
||
const { text, view } = req.body;
|
||
|
||
await messages.append(convo.id, { role: 'user', body: text });
|
||
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
Connection: 'keep-alive'
|
||
});
|
||
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||
|
||
const callModel = req.app.locals.callModel
|
||
|| makeCallModel({ client: getAnthropicClient(), model: agent.model || DEFAULT_MODEL });
|
||
|
||
const history = toAnthropicHistory(await messages.listByConversation(convo.id));
|
||
|
||
let result;
|
||
try {
|
||
result = await runTurn({
|
||
callModel,
|
||
registry: companionRegistry,
|
||
system: SYSTEM,
|
||
messages: history,
|
||
ctx: {
|
||
agent: { kind: 'agent', id: agent.id, capabilities: agent.capabilities, scopes: agent.scopes },
|
||
space_id: req.params.space_id,
|
||
view: view ?? null,
|
||
actor: req.actor
|
||
},
|
||
onEvent: (e) => send(e.type, e)
|
||
});
|
||
} catch (e) {
|
||
send('error', { message: String(e?.message || e) });
|
||
return res.end();
|
||
}
|
||
|
||
const assistant = await messages.append(convo.id, {
|
||
role: 'assistant', body: result.text, agent_id: agent.id,
|
||
metadata: { tool_trace: result.toolTrace, draft_ids: result.draftIds, usage: result.usage }
|
||
});
|
||
|
||
send('done', { assistant_message_id: assistant.id, draft_ids: result.draftIds, usage: result.usage });
|
||
res.end();
|
||
})
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 4: Add `getBySlug` to the agents repo if absent**
|
||
|
||
Check `lib/db/repos/agents.js`. If there is no `getBySlug`, add:
|
||
|
||
```javascript
|
||
export async function getBySlug(slug) {
|
||
const { rows: [r] } = await pool.query(`SELECT * FROM agents WHERE slug=$1`, [slug]);
|
||
return r;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Mount the router in `lib/api/index.js`**
|
||
|
||
Add the import near the other route imports:
|
||
```javascript
|
||
import { spacesScopedRouter as companionRouter } from './routes/companion.js';
|
||
```
|
||
Add the mount line alongside the other `/spaces/:space_id/...` mounts (e.g. after the resources line):
|
||
```javascript
|
||
api.use('/spaces/:space_id/companion', companionRouter);
|
||
```
|
||
|
||
- [ ] **Step 6: Run it to confirm it passes**
|
||
|
||
Run: `npx vitest run tests/api/companion.test.js`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 7: Run the full suite (no regressions)**
|
||
|
||
Run: `npm test`
|
||
Expected: all green (existing 247 Node tests + the new ones).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add lib/api/routes/companion.js lib/api/index.js lib/db/repos/agents.js tests/api/companion.test.js
|
||
git commit -m "feat(api): companion SSE turn endpoint + per-Space history
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Browser SSE reader (`public/sse.js`)
|
||
|
||
`EventSource` can't send the `Authorization` header, so read the SSE stream from a `fetch` POST body. Parses `event:`/`data:` frames and invokes a handler per event.
|
||
|
||
**Files:**
|
||
- Create: `public/sse.js`
|
||
|
||
> No automated test (browser-only); verified in the UI smoke (Task 15).
|
||
|
||
- [ ] **Step 1: Implement `public/sse.js`**
|
||
|
||
```javascript
|
||
// Authenticated POST -> SSE reader. Calls onEvent({ type, ...data }) per frame.
|
||
const TOKEN_KEY = 'void_token';
|
||
|
||
export async function streamTurn(path, body, onEvent) {
|
||
const res = await fetch(path, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': 'Bearer ' + (localStorage.getItem(TOKEN_KEY) || ''),
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (!res.ok || !res.body) throw new Error('stream failed: ' + res.status);
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buf = '';
|
||
for (;;) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += decoder.decode(value, { stream: true });
|
||
const frames = buf.split('\n\n');
|
||
buf = frames.pop(); // keep the trailing partial
|
||
for (const frame of frames) {
|
||
let event = 'message', data = '';
|
||
for (const line of frame.split('\n')) {
|
||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||
}
|
||
if (data) onEvent({ type: event, ...JSON.parse(data) });
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add public/sse.js
|
||
git commit -m "feat(ui): authenticated POST->SSE reader
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Right-rail chat UI (`public/components/rightrail.js`)
|
||
|
||
Replace the stub with the approved design: label-led turns (YOU / agent), left/right alignment with accent edges, live tool-activity chips, streamed answer, inline draft card. Uses safe-DOM (`el`/`mount`/`safeHref`); assistant markdown only via the sanitized `html:` path.
|
||
|
||
**Files:**
|
||
- Modify: `public/components/rightrail.js`
|
||
- Modify: `public/style.css`
|
||
- Depends on: `public/api.js` (existing `api.get`), `public/sse.js` (Task 13), `marked` + `DOMPurify` (already vendored — confirm `public/vendor/`)
|
||
|
||
- [ ] **Step 1: Confirm marked + DOMPurify are available to the browser**
|
||
|
||
Run: `ls public/vendor/`
|
||
Expected: marked + dompurify bundles present (used by `markdown_editor.js`). If a shared `renderMarkdown(src)` helper exists (grep `public` for `DOMPurify.sanitize`), reuse it; otherwise add `public/markdown.js`:
|
||
|
||
```javascript
|
||
import { marked } from './vendor/marked.esm.js';
|
||
import DOMPurify from './vendor/purify.es.js';
|
||
export function renderMarkdown(src) {
|
||
return DOMPurify.sanitize(marked.parse(src || ''));
|
||
}
|
||
```
|
||
(Match the exact vendor filenames from `ls public/vendor/`.)
|
||
|
||
- [ ] **Step 2: Implement `public/components/rightrail.js`**
|
||
|
||
```javascript
|
||
// Plan 5: per-Space companion chat. Safe-DOM only; markdown via sanitized html:.
|
||
import { el, mount, clear } from '../dom.js';
|
||
import { api } from '../api.js';
|
||
import { streamTurn } from '../sse.js';
|
||
import { renderMarkdown } from '../markdown.js';
|
||
import { state } from '../state.js';
|
||
|
||
const COLLAPSE_KEY = 'void_rail_collapsed';
|
||
|
||
function turnEl(role, agentName, bodyNode) {
|
||
return el('div', { class: 'turn ' + (role === 'user' ? 'you' : 'ai') },
|
||
el('span', { class: 'lbl' }, role === 'user' ? 'YOU' : (agentName || 'COMPANION').toUpperCase()),
|
||
el('span', { class: 'msg' }, bodyNode));
|
||
}
|
||
|
||
function chipEl(tool, status) {
|
||
const icon = tool === 'search' ? '🔍' : tool === 'read' ? '📄' : tool === 'context' ? '🧭' : '📝';
|
||
return el('div', { class: 'tools' }, el('span', { class: 'chip' + (status === 'error' ? ' err' : '') }, `${icon} ${tool}`));
|
||
}
|
||
|
||
function draftCardEl(d, onResolve) {
|
||
const card = el('div', { class: 'draftx', dataset: { pc: d.pending_change_id } },
|
||
el('div', { class: 'dh' }, 'Proposed change'),
|
||
el('div', { class: 'dt' }, d.summary || 'a change'),
|
||
el('div', { class: 'row' },
|
||
el('button', { class: 'ok', onclick: () => onResolve(d.pending_change_id, 'approved', card) }, 'Approve'),
|
||
el('button', { class: 'no', onclick: () => onResolve(d.pending_change_id, 'rejected', card) }, 'Reject')));
|
||
return card;
|
||
}
|
||
|
||
export async function renderRightrail(root) {
|
||
const shell = document.getElementById('shell');
|
||
let collapsed = localStorage.getItem(COLLAPSE_KEY) === 'true';
|
||
if (collapsed) shell.classList.add('rail-collapsed');
|
||
const toggle = () => {
|
||
collapsed = !collapsed;
|
||
localStorage.setItem(COLLAPSE_KEY, String(collapsed));
|
||
shell.classList.toggle('rail-collapsed', collapsed);
|
||
};
|
||
|
||
const log = el('div', { class: 'rail-log' });
|
||
const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Ask the companion…' });
|
||
const header = el('div', { class: 'rail-hd' },
|
||
el('span', { class: 'who' }, '◆ Companion'),
|
||
el('span', { class: 'chev', onclick: toggle, title: 'Collapse' }, '⟩'));
|
||
|
||
mount(root, el('div', { class: 'rail-toggle', onclick: toggle, title: 'Companion' }, 'CRADLE'),
|
||
el('div', { class: 'rail-chat' }, header, log, el('div', { class: 'rail-inputwrap' }, input)));
|
||
|
||
const spaceId = state.spaceId;
|
||
if (!spaceId) {
|
||
mount(log, el('p', { class: 'muted' }, 'Open a Space to chat with its companion.'));
|
||
return;
|
||
}
|
||
|
||
async function resolveDraft(id, status, cardNode) {
|
||
try {
|
||
await api.post(`/api/pending-changes/${id}/${status === 'approved' ? 'approve' : 'reject'}`);
|
||
cardNode.classList.add('resolved');
|
||
cardNode.appendChild(el('div', { class: 'resolved-tag' }, status));
|
||
} catch (e) { cardNode.appendChild(el('div', { class: 'err' }, 'failed: ' + e.message)); }
|
||
}
|
||
|
||
function addTurn(role, text) {
|
||
const body = role === 'assistant'
|
||
? el('span', { html: renderMarkdown(text) })
|
||
: el('span', {}, text);
|
||
const t = turnEl(role, 'Companion', body);
|
||
log.appendChild(t);
|
||
log.scrollTop = log.scrollHeight;
|
||
return body;
|
||
}
|
||
|
||
// Load history.
|
||
const { messages } = await api.get(`/api/spaces/${spaceId}/companion`);
|
||
clear(log);
|
||
for (const m of messages) {
|
||
addTurn(m.role, m.body);
|
||
for (const d of (m.metadata?.draft_ids || []))
|
||
log.appendChild(draftCardEl({ pending_change_id: d, summary: 'a change' }, resolveDraft));
|
||
}
|
||
|
||
async function send() {
|
||
const text = input.value.trim();
|
||
if (!text) return;
|
||
input.value = '';
|
||
addTurn('user', text);
|
||
let assistantBody = null, acc = '';
|
||
await streamTurn(`/api/spaces/${spaceId}/companion/turn`, { text, view: state.view || null }, (ev) => {
|
||
if (ev.type === 'tool' && ev.status !== 'done') log.appendChild(chipEl(ev.tool, ev.status));
|
||
else if (ev.type === 'delta') {
|
||
if (!assistantBody) assistantBody = addTurn('assistant', '');
|
||
acc += ev.text; assistantBody.innerHTML = renderMarkdown(acc);
|
||
} else if (ev.type === 'draft') log.appendChild(draftCardEl(ev, resolveDraft));
|
||
else if (ev.type === 'error') log.appendChild(el('div', { class: 'err' }, ev.message));
|
||
log.scrollTop = log.scrollHeight;
|
||
});
|
||
}
|
||
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||
});
|
||
}
|
||
```
|
||
|
||
> **Note:** confirm `public/state.js` exposes `spaceId` and `view`. If it currently only tracks one of these, extend it (small change) so the rail and router agree on the active Space + view. Check `router.js` for how the current Space is set and mirror it into `state`.
|
||
|
||
- [ ] **Step 3: Add styles to `public/style.css`**
|
||
|
||
Append rail/turn/chip/draft styles consistent with the approved mockup (label-led, left/right accent edges, dim monospace chips, violet draft card). Use the existing CSS variables in `style.css` for colours where present:
|
||
|
||
```css
|
||
.rail-chat { display:flex; flex-direction:column; height:100%; }
|
||
.rail-hd { display:flex; justify-content:space-between; align-items:center; padding:9px 12px; border-bottom:1px solid var(--border,#262b38); }
|
||
.rail-hd .who { font-weight:600; color:var(--accent,#b69cff); }
|
||
.rail-hd .chev { cursor:pointer; color:#5b6478; }
|
||
.rail-log { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
|
||
.turn { display:flex; flex-direction:column; max-width:84%; }
|
||
.turn .lbl { font-size:9.5px; letter-spacing:.12em; margin-bottom:3px; }
|
||
.turn .msg { line-height:1.4; padding:6px 10px; border-radius:8px; background:#141826; }
|
||
.turn.you { align-self:flex-end; align-items:flex-end; }
|
||
.turn.you .lbl { color:#6f7ce0; } .turn.you .msg { border-right:2px solid #6f7ce0; }
|
||
.turn.ai { align-self:flex-start; } .turn.ai .lbl { color:var(--accent,#b69cff); }
|
||
.turn.ai .msg { border-left:2px solid var(--accent,#b69cff); }
|
||
.tools { align-self:flex-start; }
|
||
.tools .chip { font-family:ui-monospace,Menlo,monospace; font-size:10.5px; color:#7d869b; }
|
||
.tools .chip.err { color:#e08a8a; }
|
||
.draftx { align-self:flex-start; max-width:90%; border:1px solid #3a2f5e; background:#1a1530; border-radius:9px; padding:9px 11px; }
|
||
.draftx .dh { font-size:9.5px; text-transform:uppercase; letter-spacing:.1em; color:#9b7dff; }
|
||
.draftx .dt { color:#e3e0f5; margin:4px 0 9px; }
|
||
.draftx .row { display:flex; gap:6px; }
|
||
.draftx .ok { background:#2a6f4f; color:#d9ffe9; border:none; border-radius:6px; padding:4px 12px; }
|
||
.draftx .no { background:#2a2f3d; color:#aeb6c7; border:none; border-radius:6px; padding:4px 12px; }
|
||
.draftx.resolved { opacity:.55; } .resolved-tag { font-size:10px; text-transform:uppercase; color:#7d869b; margin-top:6px; }
|
||
.rail-inputwrap { border-top:1px solid var(--border,#262b38); padding:9px 12px; }
|
||
.rail-input { width:100%; resize:none; background:#0c0e14; color:#c9d1e0; border:1px solid #262b38; border-radius:8px; padding:7px 9px; }
|
||
.err { color:#e08a8a; font-size:12px; }
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add public/components/rightrail.js public/markdown.js public/style.css public/state.js
|
||
git commit -m "feat(ui): right-rail companion chat — streaming, tool chips, inline drafts
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Verify the pending-changes approve/reject endpoints exist for the rail
|
||
|
||
The draft card POSTs to `/api/pending-changes/:id/approve` and `/reject`. Confirm those routes exist (Plan 2 built the inbox) and return the resolved row; if the paths differ, align the rail's `resolveDraft` calls to the real endpoints.
|
||
|
||
**Files:**
|
||
- Inspect: `lib/api/routes/pending_changes.js`
|
||
- Modify (only if needed): `public/components/rightrail.js`
|
||
|
||
- [ ] **Step 1: Inspect the routes**
|
||
|
||
Run: `grep -nE "approve|reject|router\.(post|patch)" lib/api/routes/pending_changes.js`
|
||
Expected: approve/reject endpoints exist. Note the exact method + path.
|
||
|
||
- [ ] **Step 2: Align the rail if the paths/methods differ**
|
||
|
||
If e.g. the API uses `PATCH /api/pending-changes/:id` with `{ status }`, change `resolveDraft` accordingly:
|
||
```javascript
|
||
await api.patch(`/api/pending-changes/${id}`, { status });
|
||
```
|
||
|
||
- [ ] **Step 3: Confirm the Inbox view reads the same rows**
|
||
|
||
Run: `grep -n "pending-changes" public/views/inbox.js`
|
||
Expected: the Inbox lists `/api/pending-changes` — confirming a draft approved in chat disappears from the Inbox (same row, shared state).
|
||
|
||
- [ ] **Step 4: Commit (only if the rail changed)**
|
||
|
||
```bash
|
||
git add public/components/rightrail.js
|
||
git commit -m "fix(ui): align draft card with the pending-changes resolve endpoint
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Manual UI smoke + deploy alpha-5
|
||
|
||
**Files:**
|
||
- Modify: `package.json` (version), `CHANGELOG.md`
|
||
- Create: `docs/plan-5-complete.md`
|
||
|
||
- [ ] **Step 1: Full test suite green**
|
||
|
||
Run: `npm test`
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 2: Local smoke against the dev DB**
|
||
|
||
Set `ANTHROPIC_API_KEY` in the dev `.env`, start the server (`npm start`), open the SPA, pick a Space, and:
|
||
- ask a question → confirm tool chips appear, the answer streams in, markdown renders.
|
||
- ask it to "create a task to X" → confirm an inline draft card appears, **and** the same row shows in the Inbox.
|
||
- approve from chat → confirm the task now exists (`/api/tasks`) and the Inbox row clears.
|
||
- reject another draft → confirm it does not apply.
|
||
|
||
- [ ] **Step 3: Bump version + changelog**
|
||
|
||
Set `package.json` version to `2.0.0-alpha.5`. Add a `CHANGELOG.md` entry summarising the companion chat (scope B). Write `docs/plan-5-complete.md` (mirror the structure of `docs/plan-4-complete.md`: what shipped, files, security notes, deferred items → C personas/MCP/Vaultwarden).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add package.json CHANGELOG.md docs/plan-5-complete.md
|
||
git commit -m "chore: version 2.0.0-alpha.5 + plan-5 completion doc
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||
```
|
||
|
||
- [ ] **Step 5: Deploy (standing rule: snapshot first)**
|
||
|
||
On Z: `pct snapshot 310 pre_alpha5_deploy_<ts>` and `pct snapshot 311 pre_alpha5_deploy_<ts>`.
|
||
Ensure `ANTHROPIC_API_KEY` is set in `/opt/void-server/.env` on CT 311 (mode 600, owned by `void`).
|
||
Then from the repo: `TARGET=root@192.168.1.216 ./deploy/push.sh`.
|
||
Verify: `curl http://192.168.1.216:3000/health` → `version: 2.0.0-alpha.5`, then run the Step-2 smoke against the deployed instance.
|
||
|
||
- [ ] **Step 6: Update memory**
|
||
|
||
Update `project_void_v2_execution.md`: Plan 5 complete + alpha-5 deployed; companion chat live; note deferred C personas / MCP / Vaultwarden.
|
||
|
||
---
|
||
|
||
## Self-review notes (for the planner)
|
||
|
||
- **Spec coverage:** runtime (T10–11), shared/extensible registry (T5, T9), four tools (T6–8), SSE turn lifecycle + one-assistant-message-with-trace (T12), per-Space ambient conversation (T3–4, T12), inline draft card synced with Inbox (T14–15), env/file key handling (T2, T10), prompt-injection containment via approval-only `propose_change` (T8 test asserts no apply), cost guardrail via `maxTokens` + `maxIterations` (T10–11), tests incl. security (T8, T12). All spec §s map to a task.
|
||
- **Type/name consistency:** `callModel({system,messages,tools,onTextDelta}) -> {text,toolUses,stopReason,usage}` is used identically in T10, T11, T12. `ctx = {agent,space_id,view,actor}` consistent across T6–8 and T12. Event types `tool|delta|draft|done|error` consistent across T11–14. `companionRegistry` name consistent T9/T12.
|
||
- **Known external-API risk:** the exact `@anthropic-ai/sdk` streaming/`finalMessage()` surface (T10) may differ by version — flagged inline with a context7 check and a stable return-shape contract so the rest of the plan is insulated.
|
||
- **Assumptions to verify during execution (flagged in-task):** `agents.getBySlug` may need adding (T12 S4); `pending-changes` resolve path/method (T15); `state.js` exposing `spaceId`/`view` (T14); exact vendor filenames for marked/DOMPurify (T14 S1).
|