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
+1 -1
View File
@@ -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) | | `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 | | `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}` | | `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) | | `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, Unverändert übernommen: Notification-Logik je Plattform (PowerShell-WinRT-AUMID,
+54 -15
View File
@@ -491,9 +491,12 @@ function recordReplied(props) {
/** /**
* Den Message-Turn untersuchen, der den Tool-Call enthält. * Den Message-Turn untersuchen, der den Tool-Call enthält.
* V2: ctx.session.context({sessionID}) liefert die Messages; Assistant-Parts * V2: ctx.session.context({sessionID}) liefert die Messages; die Message-Teile
* sind u. a. {type:"text", text} und {type:"tool", id, name, state} — die * liegen im Feld `content` (verifiziert vm-test 2.0.16, az-fleet-7x8 — ältere
* Tool-Call-ID des Permission-Source (source.id) matcht die Part-ID. * 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, * Liefert { found: true, explanation } mit den Text-Parts vor dem Tool-Part,
* { found: true } ohne Erklärung, oder { found: false }, wenn Call oder API * { found: true } ohne Erklärung, oder { found: false }, wenn Call oder API
* nicht verfügbar sind — Caller behandeln found:false als „niemals blocken, * 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) { async function inspectCallContext(ctx, sessionID, callID, messageID) {
try { try {
const messages = await ctx.session.context({ sessionID }); 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) => const isToolMatch = (part) =>
part?.type === "tool" && (part.id === callID || part.callID === callID); part?.type === "tool" && (part.id === callID || part.callID === callID);
const scan = (parts) => { const scan = (parts, allowFallback) => {
const toolIndex = parts.findIndex(isToolMatch); 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; if (toolIndex === -1) return undefined;
const texts = []; const texts = [];
for (let i = 0; i < toolIndex; i++) { 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 }; 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: // Bevorzugt die Message aus dem Permission-Source (messageID), Fallback:
// alle Messages scannen. // alle Messages scannen (dort ohne Approximation — nur exakte Treffer).
if (messageID) { if (messageID) {
const msg = messages.find((m) => m?.id === messageID); const msg = messages.find((m) => m?.id === messageID);
if (Array.isArray(msg?.parts)) { const parts = msg ? messageParts(msg) : null;
const hit = scan(msg.parts); dbg.msgFound = !!msg;
if (hit) return hit; 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) { for (const msg of messages) {
if (!Array.isArray(msg?.parts)) continue; const parts = messageParts(msg);
const hit = scan(msg.parts); if (!parts) continue;
if (hit) return hit; 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 }; return { found: false };
} catch { } catch (err) {
if (DEBUG) debugLog("inspect", { callID, error: String(err) });
return { found: false }; return { found: false };
} }
} }
@@ -645,7 +684,7 @@ async function setup(ctx) {
await ctx.tool.hook("execute.before", async (event) => { await ctx.tool.hook("execute.before", async (event) => {
const callID = event?.callID ?? event?.id; const callID = event?.callID ?? event?.id;
if (!callID || !askedCallIDs.has(callID)) return; 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; if (!result.found || result.explanation) return;
askedCallIDs.delete(callID); askedCallIDs.delete(callID);
throw new Error(EXPLAIN_FIRST_ERROR); throw new Error(EXPLAIN_FIRST_ERROR);