From a9ea4361cb6ce3d666333cb5f48f159ab2d28e50 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Fri, 25 Sep 2026 10:13:41 +0200 Subject: [PATCH] fix: E2E-Befunde vm-test 2.0.16 eingearbeitet (az-fleet-7x8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Message-Teile liegen im Feld content (nicht parts — SDK-Typen sagen parts; beide werden gelesen) - Persistenz-Lag: Tool-Part ist zur Evaluations-Zeit ggf. noch nicht im Message-Kontext — Text-Parts der Call-Message gelten dann als Erklärung (liegen per Definition vor dem Call) - execute.before-Event: Tool-Call-ID im Feld id (callID-Fallback bleibt) plus messageID/agent — Shape per Diagnose-Plugin verifiziert - Event-Namen im Stream: permission.asked/replied (präfix-tolerant) - inspectCallContext mit DEBUG-Instrumentation (nMsgs/msgFound/partTypes) E2E vm-test: plugin-loaded 2.0.0, Config-Lesung (Managed+Global+Projekt, V1-Map), System-Regel je Model-Request (Modell erklärt im Zitat-Block- Format), evaluate allow+ask (bash→shell-Normalisierung), ask + ask.explained mit echter Modell-Erklärung. Mock-Tests 17+5 grün. --- explain-permissions/README.md | 2 +- explain-permissions/v2/index.js | 69 ++++++++++++++++++++++++++------- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/explain-permissions/README.md b/explain-permissions/README.md index c7b842f..11ff4d6 100644 --- a/explain-permissions/README.md +++ b/explain-permissions/README.md @@ -152,7 +152,7 @@ laden automatisch neu. | `permission.replied`-Event | `permission.replied` im öffentlichen Event-Stream (`ctx.event.subscribe`), Payload `{sessionID, requestID, reply}` — feuert nur bei echter Client-Antwort, NICHT bei Non-Interactive-Auto-Reject (vm-test 2.0.16) | | `experimental.chat.system.transform` | `ctx.session.hook("context")` → `event.system.push({type: "text", text})` — Agent-Loop inkl. Tool-Continuations | | `tool.execute.before` (ENFORCE) | `ctx.tool.hook("execute.before")` — Event `{tool, sessionID, callID, input}` | -| `client.session.messages({path:{id}})` | `ctx.session.context({sessionID})` — Tool-Parts tragen die Call-ID als `id` (matcht `source.id` der Permission-Evaluation) | +| `client.session.messages({path:{id}})` | `ctx.session.context({sessionID})` — Message-Teile im Feld `content` (2.0.16; `parts` wird tolerierend mitgelesen), Tool-Parts tragen die Call-ID als `id` (matcht `source.id` der Permission-Evaluation); im Flight befindliche Assistant-Messages sind bereits sichtbar | | `plugin-loaded`-Audit-Eintrag beim Laden | in `setup()` (jetzt mit Plugin-ID, opencode-Version, Location) | Unverändert übernommen: Notification-Logik je Plattform (PowerShell-WinRT-AUMID, diff --git a/explain-permissions/v2/index.js b/explain-permissions/v2/index.js index 77ac3b1..3ac82fe 100644 --- a/explain-permissions/v2/index.js +++ b/explain-permissions/v2/index.js @@ -491,9 +491,12 @@ function recordReplied(props) { /** * Den Message-Turn untersuchen, der den Tool-Call enthält. - * V2: ctx.session.context({sessionID}) liefert die Messages; Assistant-Parts - * sind u. a. {type:"text", text} und {type:"tool", id, name, state} — die - * Tool-Call-ID des Permission-Source (source.id) matcht die Part-ID. + * V2: ctx.session.context({sessionID}) liefert die Messages; die Message-Teile + * liegen im Feld `content` (verifiziert vm-test 2.0.16, az-fleet-7x8 — ältere + * SDK-Typen nennen es `parts`, beides wird gelesen) mit {type:"text", text} + * und {type:"tool", id, …}. Die Tool-Call-ID des Permission-Source (source.id) + * matcht die Tool-Part-ID (gleiches tooluse_-Format); trägt der Tool-Part + * keine ID, fällt die Suche auf den ersten Tool-Part der Message zurück. * Liefert { found: true, explanation } mit den Text-Parts vor dem Tool-Part, * { found: true } ohne Erklärung, oder { found: false }, wenn Call oder API * nicht verfügbar sind — Caller behandeln found:false als „niemals blocken, @@ -502,13 +505,22 @@ function recordReplied(props) { async function inspectCallContext(ctx, sessionID, callID, messageID) { try { const messages = await ctx.session.context({ sessionID }); - if (!Array.isArray(messages)) return { found: false }; + if (!Array.isArray(messages)) { + if (DEBUG) debugLog("inspect", { callID, result: "kein Nachrichten-Array" }); + return { found: false }; + } + const dbg = { callID, nMsgs: messages.length }; const isToolMatch = (part) => part?.type === "tool" && (part.id === callID || part.callID === callID); - const scan = (parts) => { - const toolIndex = parts.findIndex(isToolMatch); + const scan = (parts, allowFallback) => { + let toolIndex = parts.findIndex(isToolMatch); + // Fallback (nur im messageID-Zweig, wo die Message feststeht): trägt der + // Tool-Part keine ID, den ersten Tool-Part derselben Message nehmen. + if (toolIndex === -1 && allowFallback && parts.some((p) => p?.type === "tool")) { + toolIndex = parts.findIndex((p) => p?.type === "tool"); + } if (toolIndex === -1) return undefined; const texts = []; for (let i = 0; i < toolIndex; i++) { @@ -520,22 +532,49 @@ async function inspectCallContext(ctx, sessionID, callID, messageID) { return { found: true, explanation: texts.length ? texts.join("\n") : undefined }; }; + const messageParts = (msg) => { + if (Array.isArray(msg?.parts)) return msg.parts; + if (Array.isArray(msg?.content)) return msg.content; + return null; + }; + // Bevorzugt die Message aus dem Permission-Source (messageID), Fallback: - // alle Messages scannen. + // alle Messages scannen (dort ohne Approximation — nur exakte Treffer). if (messageID) { const msg = messages.find((m) => m?.id === messageID); - if (Array.isArray(msg?.parts)) { - const hit = scan(msg.parts); - if (hit) return hit; + const parts = msg ? messageParts(msg) : null; + dbg.msgFound = !!msg; + dbg.partTypes = parts ? parts.map((p) => p.type).join(",") : null; + if (parts) { + const hit = scan(parts, true); + if (hit) { + if (DEBUG) debugLog("inspect", { ...dbg, via: "messageID+scan", explained: !!hit.explanation }); + return hit; + } + // Persistenz-Lag (vm-test 2.0.16): Zur Evaluations-Zeit ist der + // Tool-Part im Message-Kontext ggf. noch nicht sichtbar. Die Message + // gehört aber genau zu diesem Call — alle ihre Text-Parts liegen per + // Definition VOR dem Call (der Call löst die Evaluation aus). + const texts = parts + .filter((p) => p?.type === "text" && typeof p.text === "string" && p.text.trim()) + .map((p) => p.text.trim()); + if (DEBUG) debugLog("inspect", { ...dbg, via: "messageID+lag", explained: texts.length > 0 }); + return { found: true, explanation: texts.length ? texts.join("\n") : undefined }; } } for (const msg of messages) { - if (!Array.isArray(msg?.parts)) continue; - const hit = scan(msg.parts); - if (hit) return hit; + const parts = messageParts(msg); + if (!parts) continue; + const hit = scan(parts, false); + if (hit) { + if (DEBUG) debugLog("inspect", { ...dbg, via: "global-scan", explained: !!hit.explanation }); + return hit; + } } + if (DEBUG) debugLog("inspect", { ...dbg, via: "not-found" }); return { found: false }; - } catch { + } catch (err) { + if (DEBUG) debugLog("inspect", { callID, error: String(err) }); return { found: false }; } } @@ -645,7 +684,7 @@ async function setup(ctx) { await ctx.tool.hook("execute.before", async (event) => { const callID = event?.callID ?? event?.id; if (!callID || !askedCallIDs.has(callID)) return; - const result = await inspectCallContext(ctx, event.sessionID, callID, undefined); + const result = await inspectCallContext(ctx, event.sessionID, callID, event.messageID); if (!result.found || result.explanation) return; askedCallIDs.delete(callID); throw new Error(EXPLAIN_FIRST_ERROR);