fix: E2E-Befunde vm-test 2.0.16 eingearbeitet (az-fleet-7x8)

- 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.
This commit is contained in:
m3ta-chiron
2026-09-25 10:13:41 +02:00
parent 5be32a6fe6
commit a9ea4361cb
2 changed files with 55 additions and 16 deletions
+54 -15
View File
@@ -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);