fix(companion): emit draft from user-turn tool_result + stamp space_id on created entities

- 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 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-01 22:21:15 +10:00
parent 1e8bbca2a5
commit 1b8dc91800
3 changed files with 37 additions and 21 deletions

View File

@@ -23,13 +23,22 @@ export const proposeChangeTool = {
if (tier === 'deny') { if (tier === 'deny') {
return { error: `not permitted to ${action} ${entity_type}` }; 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. // v1: drafting always routes through approval, even for allow-tier agents.
const change = await pendingChanges.create({ const change = await pendingChanges.create({
agent_id: ctx.agent.id, agent_id: ctx.agent.id,
entity_type, entity_type,
entity_id: entity_id ?? null, entity_id: entity_id ?? null,
action, action,
payload: payload ?? {}, payload: finalPayload,
reason: reason ?? null reason: reason ?? null
}); });
return { return {

View File

@@ -222,13 +222,26 @@ export async function runClaudeTurn(opts) {
return; 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') { 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 id = raw.tool_use_id;
const name = toolById.get(id) || null; const name = toolById.get(id) || null;
const result = raw.content; emit({ type: 'tool_result', name, result: raw.content });
const ev = { type: 'tool_result', name, result };
emit(ev);
return; return;
} }

View File

@@ -137,26 +137,20 @@ spacesScopedRouter.post('/turn',
// //
// Defensive parsing: try structuredContent first (future-proof), then // Defensive parsing: try structuredContent first (future-proof), then
// scan content array text blocks and JSON.parse them. // 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; let parsed = null;
const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
try { try {
// Shape A: structuredContent forwarded through (hypothetical future CLI) if (typeof e.result === 'string') {
if (e.result?.structuredContent?.pending_change_id) { parsed = tryParse(e.result);
} else if (e.result?.structuredContent?.pending_change_id) {
parsed = e.result.structuredContent; parsed = e.result.structuredContent;
} } else if (Array.isArray(e.result)) {
// 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) { for (const block of e.result) {
if (block?.type === 'text' && block.text) { const candidate = block?.type === 'text' && block.text ? tryParse(block.text) : null;
try { if (candidate?.pending_change_id) { parsed = candidate; break; }
const candidate = JSON.parse(block.text);
if (candidate?.pending_change_id) {
parsed = candidate;
break;
}
} catch {
// not JSON or not a change result — skip
}
}
} }
} }
} catch { } catch {