From 1b8dc918002184adc3da084ccccb493828938667 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 22:21:15 +1000 Subject: [PATCH] fix(companion): emit draft from user-turn tool_result + stamp space_id on created entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - driver: tool_results arrive as type:'user' content blocks (not bare); parse them - route: tool_result content is a JSON string; parse it for pending_change_id → draft event - propose_change: inject ctx.space_id into create payloads (model can't know the uuid; tables require it) Co-Authored-By: Claude Opus 4.8 --- lib/ai/agent/tools/propose_change.js | 11 ++++++++++- lib/ai/claude_cli.js | 21 +++++++++++++++++---- lib/api/routes/companion.js | 26 ++++++++++---------------- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/lib/ai/agent/tools/propose_change.js b/lib/ai/agent/tools/propose_change.js index f93e97c..19c39fd 100644 --- a/lib/ai/agent/tools/propose_change.js +++ b/lib/ai/agent/tools/propose_change.js @@ -23,13 +23,22 @@ export const proposeChangeTool = { if (tier === 'deny') { return { error: `not permitted to ${action} ${entity_type}` }; } + // Stamp the current Space onto newly-created space-scoped entities — the + // model doesn't know the Space uuid, and these tables require space_id NOT NULL. + const SPACE_SCOPED = ['task', 'page', 'project', 'resource']; + const finalPayload = { ...(payload ?? {}) }; + if (action === 'create' && ctx.space_id && SPACE_SCOPED.includes(entity_type) + && finalPayload.space_id == null) { + finalPayload.space_id = ctx.space_id; + } + // 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 ?? {}, + payload: finalPayload, reason: reason ?? null }); return { diff --git a/lib/ai/claude_cli.js b/lib/ai/claude_cli.js index 7919192..166c502 100644 --- a/lib/ai/claude_cli.js +++ b/lib/ai/claude_cli.js @@ -222,13 +222,26 @@ export async function runClaudeTurn(opts) { return; } + if (t === 'user') { + // Tool results arrive as a synthetic user turn: + // { type:"user", message:{ role:"user", content:[{ type:"tool_result", tool_use_id, content }] } } + const blocks = raw.message?.content; + if (Array.isArray(blocks)) { + for (const b of blocks) { + if (b?.type === 'tool_result') { + const name = toolById.get(b.tool_use_id) || null; + emit({ type: 'tool_result', name, result: b.content }); + } + } + } + return; + } + if (t === 'tool_result') { - // { type: "tool_result", tool_use_id: "...", content: [...] } + // Bare top-level variant (older/alt shape): { type:"tool_result", tool_use_id, content } const id = raw.tool_use_id; const name = toolById.get(id) || null; - const result = raw.content; - const ev = { type: 'tool_result', name, result }; - emit(ev); + emit({ type: 'tool_result', name, result: raw.content }); return; } diff --git a/lib/api/routes/companion.js b/lib/api/routes/companion.js index be0c372..021702d 100644 --- a/lib/api/routes/companion.js +++ b/lib/api/routes/companion.js @@ -137,26 +137,20 @@ spacesScopedRouter.post('/turn', // // Defensive parsing: try structuredContent first (future-proof), then // scan content array text blocks and JSON.parse them. + // The CLI delivers an MCP tool_result `content` as a JSON STRING, + // e.g. '{"pending_change_id":"...","applied":false,"summary":"..."}'. + // Be defensive: also accept a content-block array or a structuredContent object. let parsed = null; + const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } }; try { - // Shape A: structuredContent forwarded through (hypothetical future CLI) - if (e.result?.structuredContent?.pending_change_id) { + if (typeof e.result === 'string') { + parsed = tryParse(e.result); + } else 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)) { + } else if (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 - } - } + const candidate = block?.type === 'text' && block.text ? tryParse(block.text) : null; + if (candidate?.pending_change_id) { parsed = candidate; break; } } } } catch {