From 51bc5912ffacc07035c5ea0847c643d8637b5bf4 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 21:57:05 +1000 Subject: [PATCH] feat(api): companion route drives claude CLI + MCP tools (subscription auth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the runTurn/callModel/Anthropic-API-key path in POST /turn with runClaudeTurn (claude CLI) backed by a per-turn MCP config that spawns companion-stdio.js. Extracts pending_change_id from tool_result events defensively (structuredContent → text-JSON fallback). Rewrites companion test to inject fake-claude-draft.js via app.locals.claudeExe. Co-Authored-By: Claude Opus 4.8 --- lib/api/routes/companion.js | 153 +++++++++++++++++++++++----- tests/api/companion.test.js | 46 ++++++--- tests/fixtures/fake-claude-draft.js | 113 ++++++++++++++++++++ 3 files changed, 268 insertions(+), 44 deletions(-) create mode 100755 tests/fixtures/fake-claude-draft.js diff --git a/lib/api/routes/companion.js b/lib/api/routes/companion.js index ab2c0b3..9e7b797 100644 --- a/lib/api/routes/companion.js +++ b/lib/api/routes/companion.js @@ -1,13 +1,16 @@ import { Router } from 'express'; import { z } from 'zod'; +import { fileURLToPath } from 'url'; +import { writeFile, unlink } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; 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'; +import { runClaudeTurn } from '../../ai/claude_cli.js'; const COMPANION_SLUG = 'companion'; @@ -15,18 +18,17 @@ const SYSTEM = `You are the Void companion — a concise, helpful assistant embe 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.`; +/** Absolute path to the companion MCP stdio server. */ +const COMPANION_STDIO_PATH = fileURLToPath( + new URL('../../mcp/companion-stdio.js', import.meta.url) +); + 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 }; } -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) => { @@ -59,37 +61,132 @@ spacesScopedRouter.post('/turn', }); 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 }); + // Write a per-turn MCP config temp file declaring the companion stdio server. + // DB connection env is inherited from the void-server process — no creds needed here. + const mcpConfigPath = join(tmpdir(), `void-mcp-${randomUUID()}.json`); + const agentActor = { + kind: 'agent', + id: agent.id, + capabilities: agent.capabilities, + scopes: agent.scopes + }; + const mcpConfig = { + mcpServers: { + void: { + command: 'node', + args: [COMPANION_STDIO_PATH], + env: { + VOID_SPACE_ID: req.params.space_id, + VOID_AGENT_JSON: JSON.stringify(agentActor), + VOID_VIEW_JSON: view ? JSON.stringify(view) : '' + } + } + } + }; + await writeFile(mcpConfigPath, JSON.stringify(mcpConfig)); - const history = toAnthropicHistory(await messages.listByConversation(convo.id)); + const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude'; + const draftIds = []; 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) + result = await runClaudeTurn({ + sessionId: convo.id, + systemPrompt: SYSTEM, + userText: text, + mcpConfigPath, + allowedTools: [ + 'mcp__void__search', + 'mcp__void__read', + 'mcp__void__context', + 'mcp__void__propose_change' + ], + claudeExe, + home: process.env.VOID_CLAUDE_HOME || undefined, + onEvent: (e) => { + if (e.type === 'delta') { + send('delta', { type: 'delta', text: e.text }); + } else if (e.type === 'tool') { + send('tool', { type: 'tool', tool: e.tool, status: e.status }); + } else if (e.type === 'tool_result') { + // Extract pending_change_id from the MCP tool result. + // + // companion-stdio.js returns: + // { content: [{ type:'text', text: JSON.stringify(result) }], structuredContent: result } + // + // claude_cli.js surfaces this as: + // { type:'tool_result', name, result: raw.content } + // where result = the content array: [{ type:'text', text:'...' }] + // + // Defensive parsing: try structuredContent first (future-proof), then + // scan content array text blocks and JSON.parse them. + let parsed = null; + try { + // Shape A: structuredContent forwarded through (hypothetical future CLI) + if (e.result?.structuredContent?.pending_change_id) { + parsed = e.result.structuredContent; + } + // Shape B: array of content blocks (real current shape from companion-stdio.js) + if (!parsed && Array.isArray(e.result)) { + for (const block of e.result) { + if (block?.type === 'text' && block.text) { + try { + const candidate = JSON.parse(block.text); + if (candidate?.pending_change_id) { + parsed = candidate; + break; + } + } catch { + // not JSON or not a change result — skip + } + } + } + } + } catch { + // parsing failed — no draft to surface + } + + if (parsed?.pending_change_id) { + draftIds.push(parsed.pending_change_id); + send('draft', { + type: 'draft', + pending_change_id: parsed.pending_change_id, + summary: parsed.summary || 'a change' + }); + } + } else if (e.type === 'error') { + send('error', { type: 'error', message: e.message }); + } + // 'result' events are captured via the resolved return value; no SSE needed mid-stream. + } }); } catch (e) { send('error', { message: String(e?.message || e) }); - return res.end(); + res.end(); + // Clean up temp file even on error + unlink(mcpConfigPath).catch(() => {}); + return; } + // Clean up the temp MCP config file + unlink(mcpConfigPath).catch(() => {}); + 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 } + role: 'assistant', + body: result.text, + agent_id: agent.id, + metadata: { + tool_trace: result.toolTrace, + draft_ids: draftIds, + usage: result.usage + } }); - send('done', { assistant_message_id: assistant.id, draft_ids: result.draftIds, usage: result.usage }); + send('done', { + assistant_message_id: assistant.id, + draft_ids: draftIds, + usage: result.usage + }); res.end(); }) ); diff --git a/tests/api/companion.test.js b/tests/api/companion.test.js index 2141669..d889b77 100644 --- a/tests/api/companion.test.js +++ b/tests/api/companion.test.js @@ -1,10 +1,15 @@ import { describe, it, expect, beforeAll } from 'vitest'; +import { fileURLToPath } from 'url'; import request from 'supertest'; import { pool } from '../../lib/db/pool.js'; import { createApp } from '../../server.js'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; +const FAKE_CLAUDE = fileURLToPath( + new URL('../fixtures/fake-claude-draft.js', import.meta.url) +); + let app, spaceId; beforeAll(async () => { await resetDb(); await migrateUp(); @@ -12,13 +17,20 @@ beforeAll(async () => { ({ rows: [{ id: spaceId }] } = await pool.query( `INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`)); app = createApp(); - 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 } }; - }; + // Inject the fake claude binary — it emits a stream with a propose_change + // tool call and a pending_change_id in the tool_result content. + app.locals.claudeExe = process.execPath; // node + process.env.FAKE_CLAUDE_SCRIPT = FAKE_CLAUDE; // picked up by the wrapper below + // Override claudeExe to be a tiny node wrapper that runs the fixture script. + // runClaudeTurn passes all flags AFTER claudeExe, so we can't use the script + // directly as claudeExe (node can't take a script + unknown flags). + // Instead, inject a wrapper that ignores all args and just runs the fixture. + app.locals.claudeExe = process.execPath; + app.locals._claudeArgs = [FAKE_CLAUDE]; // Not used by the route — use env trick instead. + + // The cleanest injection: point claudeExe at the fixture directly (it has a shebang). + // Node will exec it as a script; since it ignores all CLI args, all --flags are harmless. + app.locals.claudeExe = FAKE_CLAUDE; }); const auth = (r) => r.set('Authorization', 'Bearer test-token'); @@ -31,27 +43,29 @@ describe('companion API', () => { expect(res.body.messages).toEqual([]); }); - it('POST /turn streams SSE events and persists messages + draft', async () => { + it('POST /turn streams SSE events and persists messages', 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/); + + // Verify SSE event types are present in the stream + expect(res.text).toMatch(/event: delta/); expect(res.text).toMatch(/event: tool/); expect(res.text).toMatch(/event: draft/); - expect(res.text).toMatch(/event: delta/); expect(res.text).toMatch(/event: done/); + // Verify messages persisted: user + assistant 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); + expect(msgs[1].metadata.draft_ids).toEqual(['pc-test-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 + // NOTE: Because the fake claude does NOT actually run the real MCP server, + // NO real pending_changes row is created in this test. That code path is + // covered by the companion-stdio B1 callMcpTool test and the live B5 smoke test. + // Do NOT assert on the pending_changes table here. }); }); diff --git a/tests/fixtures/fake-claude-draft.js b/tests/fixtures/fake-claude-draft.js new file mode 100755 index 0000000..b009bcf --- /dev/null +++ b/tests/fixtures/fake-claude-draft.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Fake claude CLI for companion B3 tests. + * + * Mimics stream-json output of claude CLI 2.1.159 for a turn that: + * 1. Emits text deltas "Drafted a task." + * 2. Calls the mcp__void__propose_change tool + * 3. Receives a tool_result whose content carries a pending_change_id + * in BOTH shapes that the route must handle defensively: + * - content[].text (the real shape from companion-stdio.js) + * - structuredContent on the bare tool_result event (hypothetical future shape) + * + * The tool_result shape mirrors what companion-stdio.js actually emits: + * MCP response: { content: [{ type:'text', text: JSON.stringify(result) }], structuredContent: result } + * The CLI surfaces that as a bare top-level event: + * { type:'tool_result', tool_use_id:'...', content:[{ type:'text', text:'...' }] } + * (structuredContent is NOT in the CLI's bare event in practice — the route + * must parse from the text block.) + */ + +const TOOL_USE_ID = 'toolu_draft_b3_01'; +const DRAFT_ID = 'pc-test-1'; + +const resultPayload = { + pending_change_id: DRAFT_ID, + applied: false, + summary: 'create task' +}; + +const lines = [ + // system init (ignored) + { type: 'system', subtype: 'init', session_id: 'fake-session-b3', tools: [], cwd: '/tmp' }, + + // --- text block --- + { type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } }, + { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Drafted ' } } }, + { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'a task.' } } }, + { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }, + + // --- tool_use block for mcp__void__propose_change --- + { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 1, + content_block: { + type: 'tool_use', + id: TOOL_USE_ID, + name: 'mcp__void__propose_change', + input: {} + } + } + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 1, + delta: { type: 'input_json_delta', partial_json: '{"entity_type":"task","action":"create","payload":{"title":"Validate CSV"}}' } + } + }, + // assistant snapshot (ignored) + { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'Drafted a task.' }, + { type: 'tool_use', id: TOOL_USE_ID, name: 'mcp__void__propose_change', input: { entity_type: 'task', action: 'create', payload: { title: 'Validate CSV' } } } + ] + } + }, + // content_block_stop for tool — this triggers the 'tool' event in claude_cli.js + { type: 'stream_event', event: { type: 'content_block_stop', index: 1 } }, + + // --- tool_result (bare top-level event) --- + // This is the shape from companion-stdio.js: + // content: [{ type:'text', text: JSON.stringify(result) }] + // ALSO include structuredContent for defensive parsing test coverage. + { + type: 'tool_result', + tool_use_id: TOOL_USE_ID, + content: [ + { type: 'text', text: JSON.stringify(resultPayload) } + ], + // structuredContent mirrors what companion-stdio.js returns — included here + // so the route's structuredContent branch is exercised if the CLI ever forwards it. + structuredContent: resultPayload + }, + + // --- final result --- + { + type: 'result', + subtype: 'success', + is_error: false, + result: 'Drafted a task.', + stop_reason: 'end_turn', + session_id: 'fake-session-b3', + total_cost_usd: 0.0005, + usage: { + input_tokens: 80, + output_tokens: 8, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0 + } + } +]; + +for (const line of lines) { + process.stdout.write(JSON.stringify(line) + '\n'); +} + +process.exit(0);