Ctrl K
ComunidadGuías…

test/agent-guards.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use strict";

// Pengaman agent yang berjalan di kode, bukan di prompt: gerbang persetujuan perintah,
// penegak bukti todo, batasan subagent, gating orkestrasi, dan frame usage.
// Hermetik: tanpa provider, tanpa jaringan, tanpa server.

const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const AiChat = require("../lib/ai-chat");
const ProviderFactory = require("../lib/provider-factory");
const AgentOrchestrator = require("../lib/agent-orchestrator");
const AnthropicAgent = require("../lib/anthropic-agent");

function makeChat() {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "vrcloud-guards-"));
  const chat = new AiChat({
    workspace: root,
    storeFile: path.join(root, "sessions.json"),
    configFile: path.join(root, "config.json"),
    providerFactory: { cursorStatus: () => ({ available: true, error: "" }), create: async () => { throw new Error("not used"); } },
    orchestration: "off",
  });
  return { root, chat, cleanup: async () => { await chat.close(); fs.rmSync(root, { recursive: true, force: true }); } };
}

(async () => {
  // ---- Command approval -------------------------------------------------------------
  {
    const { chat, cleanup } = makeChat();
    try {
      assert.strictEqual(chat.getGuard().enabled, true, "guard is on by default");
      assert.deepStrictEqual(await chat.reviewShell({ command: "ls -la" }), { permission: "allow" }, "harmless command passes without a session");
      const denied = await chat.reviewShell({ command: "rm -rf /tmp/x" });
      assert.strictEqual(denied.permission, "deny", "dangerous command with no live session is denied, not allowed");

      // Sesi hidup: kartu izin muncul, keputusan pengguna menentukan.
      const s = chat.runtime("s1");
      s.busy = true; s.live = { frames: [], text: "", think: "", tools: [], plan: null, usage: null, touched: [] };
      const frames = [];
      s.listeners.add((kind, val) => frames.push({ kind, val }));
      const pending = chat.reviewShell({ command: "git push --force origin main" }, { sessionId: "s1" });
      await new Promise((r) => setTimeout(r, 10));
      const card = frames.find((f) => f.kind === "approval");
      assert.ok(card && card.val.status === "pending" && /git push --force/.test(card.val.command), "approval frame emitted");
      assert.ok(chat.decideApproval(card.val.id, true, "user"));
      assert.deepStrictEqual(await pending, { permission: "allow" });
      assert.strictEqual(frames.filter((f) => f.kind === "approval").pop().val.status, "allowed");

      const pending2 = chat.reviewShell({ command: "mkfs.ext4 /dev/sda" }, { sessionId: "s1" });
      await new Promise((r) => setTimeout(r, 10));
      const card2 = frames.filter((f) => f.kind === "approval").pop();
      chat.decideApproval(card2.val.id, false, "user");
      const d2 = await pending2;
      assert.strictEqual(d2.permission, "deny");
      assert.ok(/did not approve/.test(d2.agent_message));

      // Guard dimatikan: lolos tanpa kartu.
      chat.setGuard({ enabled: false });
      assert.deepStrictEqual(await chat.reviewShell({ command: "rm -rf /" }, { sessionId: "s1" }), { permission: "allow" });
      s.busy = false; s.live = null;
    } finally { await cleanup(); }
  }

  // Tool shell provider Anthropic/Grok menghormati beforeShell.
  {
    const ctx = { workspace: new AnthropicAgent.Workspace(os.tmpdir(), process.env.SHELL_BIN), customTools: {}, beforeShell: async () => ({ permission: "deny", agent_message: "nope" }) };
    const r = await AnthropicAgent.executeWorkspaceTool(ctx, "shell", { command: "echo hi" }, "c1");
    assert.strictEqual(r.isError, true);
    assert.ok(/Denied: nope/.test(r.content[0].text));
    assert.strictEqual(r.value.denied, true);
  }

  // ---- Evidence enforcer ---------------------------------------------------------------
  {
    const { chat, cleanup } = makeChat();
    try {
      const s = { evidence: { count: 0, started: {}, lastPlan: 0, accepted: null, notes: [] }, todos: null };
      const plan = (list) => chat.enforceTodos(s, list);
      // Rencana awal: satu in_progress.
      let r = plan([{ id: "a", content: "A", status: "in_progress" }, { id: "b", content: "B", status: "pending" }]);
      assert.strictEqual(r.note, "");
      // Selesai tanpa bukti apa pun: dikembalikan ke in_progress + catatan.
      r = plan([{ id: "a", content: "A", status: "completed" }, { id: "b", content: "B", status: "in_progress" }]);
      assert.strictEqual(r.todos[0].status, "in_progress", "no evidence -> reverted");
      assert.ok(/Verification required/.test(r.note) && /\u201cA\u201d/.test(r.note));
      assert.strictEqual(s.evidence.notes.length, 1);
      // Ada pemeriksaan (shell selesai): sekarang boleh completed.
      s.evidence.count++;
      r = plan([{ id: "a", content: "A", status: "completed" }, { id: "b", content: "B", status: "in_progress" }]);
      assert.strictEqual(r.todos[0].status, "completed", "evidence since start -> accepted");
      assert.strictEqual(r.note, "");
      // B selesai tanpa bukti baru sejak pembaruan terakhir: ditolak; setelah bukti: diterima.
      r = plan([{ id: "a", content: "A", status: "completed" }, { id: "b", content: "B", status: "completed" }]);
      assert.strictEqual(r.todos[1].status, "in_progress");
      s.evidence.count++;
      r = plan([{ id: "a", content: "A", status: "completed" }, { id: "b", content: "B", status: "completed" }]);
      assert.strictEqual(r.todos[1].status, "completed");
      // Sudah completed sebelumnya tidak dipersoalkan lagi; cancelled bebas.
      r = plan([{ id: "a", content: "A", status: "completed" }, { id: "b", content: "B", status: "cancelled" }]);
      assert.strictEqual(r.note, "");
      // Tool yang dihitung sebagai bukti.
      ["shell", "diagnostics", "browser_snapshot", "desktop_screenshot", "readLints"].forEach((n) => assert.ok(AiChat.isEvidenceTool(n), n));
      ["read", "edit", "grep", "ast_edit", "memory_upsert"].forEach((n) => assert.ok(!AiChat.isEvidenceTool(n), n));
      // Tanpa runtime bukti (orkestrasi / ask): lewat apa adanya.
      assert.strictEqual(chat.enforceTodos({ evidence: null }, [{ id: "z", status: "completed" }]).todos[0].status, "completed");
    } finally { await cleanup(); }
  }

  // updateTodos di executor bersama memanggil onTodos dan menyisipkan catatannya.
  {
    const ctx = { workspace: null, customTools: {}, todos: null, onTodos: (list) => ({ todos: list.map((t) => Object.assign({}, t, { status: "in_progress" })), note: "[Verification required] x" }) };
    const r = await AnthropicAgent.executeWorkspaceTool(ctx, "updateTodos", { todos: [{ id: "a", content: "A", status: "completed" }] }, "c2");
    assert.strictEqual(ctx.todos[0].status, "in_progress");
    assert.ok(/Verification required/.test(r.content[0].text));
    assert.strictEqual(r.value.todos[0].status, "in_progress");
  }

  // ---- noNativeTask + read-only ladders ------------------------------------------------
  {
    const ladder = ProviderFactory.disallowedCandidates("agent", { noNativeTask: true });
    assert.ok(ladder.length >= 1 && ladder.every((rung) => rung.indexOf("task") !== -1), "every rung forbids task");
    assert.ok(ProviderFactory.disallowedCandidates("agent", {}).some((rung) => rung.length === 0), "plain ladder still degrades to []");
    assert.deepStrictEqual(ProviderFactory.disallowedCandidates("ask", {}), [ProviderFactory.READ_ONLY_BUILTINS]);
    assert.ok(ProviderFactory.isReadOnlyCustomTool("diagnostics", {}), "diagnostics is read-only (allowed in Ask mode)");
  }

  // ---- Parallel-safe classification ----------------------------------------------------
  {
    assert.ok(["read", "grep", "glob", "ls", "ast_search", "memory_search", "diagnostics"].every((n) => AnthropicAgent.isParallelSafe(n)));
    assert.ok(["edit", "write", "shell", "ast_edit", "desktop_click", "browser_click"].every((n) => !AnthropicAgent.isParallelSafe(n)));
  }

  // ---- Orchestration gate -------------------------------------------------------------
  {
    const ex = (text, cfg) => AgentOrchestrator.explainOrchestration({ mode: "agent", text, config: cfg || { mode: "auto" } });
    assert.strictEqual(ex("perbaiki typo").decision, false);
    assert.strictEqual(ex("bagaimana cara kerja fungsi login di auth.js ini sebenarnya?").decision, false);
    assert.strictEqual(ex("tambahkan validasi email di form registrasi dan tampilkan pesan errornya").decision, "ambiguous");
    assert.strictEqual(ex("buat modul A\nubah modul B\nhapus modul C\ntes semuanya").decision, true);
    assert.strictEqual(ex("x".repeat(10), { mode: "off" }).reason, "Smart Agent is off");
    assert.strictEqual(ex("perbaiki a dan b di file c yang panjang sekali ini", { mode: "on" }).decision, true);
    assert.strictEqual(AgentOrchestrator.shouldOrchestrate({ mode: "agent", text: "tambahkan validasi email di form registrasi dan tampilkan pesan errornya", config: { mode: "auto" } }), false, "ambiguous without classifier = direct");
    assert.strictEqual(AgentOrchestrator.normalizeConfig({}).classifier, true);
    assert.strictEqual(AgentOrchestrator.normalizeConfig({ classifier: false }).classifier, false);
  }

  // ---- Usage frames: accumulation vs occupancy vs measured -----------------------------
  {
    const { chat, cleanup } = makeChat();
    try {
      const s = { live: { usage: null } };
      let u = chat.usageFrame(s, { inputTokens: 100, outputTokens: 10, totalTokens: 110 });
      assert.strictEqual(u.contextTokens, 110);
      s.live.usage = u;
      u = chat.usageFrame(s, { inputTokens: 150, outputTokens: 10, totalTokens: 160, cacheReadTokens: 40 });
      assert.strictEqual(u.inputTokens, 250, "input accumulates");
      assert.strictEqual(u.contextTokens, 200, "occupancy is the last round only");
      s.live.usage = u;
      u = chat.usageFrame(s, { measured: true, contextTokens: 123456 });
      assert.strictEqual(u.inputTokens, 250, "measured frame does not touch accumulation");
      assert.strictEqual(u.contextTokens, 123456);
      assert.strictEqual(u.measured, true);
      assert.ok(u.contextLimit > 0);
      s.live.usage = u;
      assert.ok(chat.contextUsedPercent(s) > 0);
    } finally { await cleanup(); }
  }

  // ---- Pill konteks: noEditor membuang file aktif & tab terbuka dari prompt ----
  {
    const { chat, cleanup } = makeChat();
    try {
      chat.openFiles = () => ({ active: "src/secret-active.js", open: ["src/secret-active.js", "src/other-tab.js"] });
      const withCtx = await chat.agentPrompt("perbaiki ini", "");
      assert.ok(/src\/secret-active\.js/.test(withCtx) && /src\/other-tab\.js/.test(withCtx), "editor context is included by default");
      const without = await chat.agentPrompt("perbaiki ini", "", { noEditor: true });
      assert.ok(!/secret-active|other-tab/.test(without), "noEditor drops the active file and open tabs");
      assert.ok(/perbaiki ini/.test(without));
    } finally { await cleanup(); }
  }

  // ---- Rewind: "Kembalikan ke sebelum pesan ini" menghapus pesan itu dan semua sesudahnya ----
  {
    const { chat, cleanup } = makeChat();
    try {
      const deleted = [];
      chat.providerFactory.deleteCursorAgent = async (id) => { deleted.push(id); };
      const disposed = [];
      chat.dispose = async (agent) => { disposed.push(agent); };
      chat.store.sessions.push({
        id: "r1", title: "t", updatedAt: 1,
        agentId: "cursor-agent-1", claudeHistory: [{ role: "user", content: "x" }], grokHistory: [], usage: { totalTokens: 10, contextTokens: 9000 },
        messages: [
          { role: "user", text: "pertama", checkpoint: "aaaaaaa1111111" },
          { role: "ai", text: "jawaban pertama" },
          { role: "user", text: "kedua: ubah login", checkpoint: "bbbbbbb2222222", images: [{ image: "/api/ai/image/x.png" }] },
          { role: "ai", text: "login diubah" },
          { role: "user", text: "ketiga", checkpoint: "ccccccc3333333" },
          { role: "ai", text: "selesai" },
        ],
      });
      const rt = chat.runtime("r1");
      const fakeAgent = { id: "live" };
      rt.agent = fakeAgent;
      const notes = [];
      chat.onNotify = (ev) => notes.push(ev);

      // Sedang bekerja: ditolak sebelum apa pun disentuh.
      rt.busy = true;
      assert.throws(() => chat.assertCanRewind("r1", "bbbbbbb"), (e) => e.status === 409);
      rt.busy = false;
      assert.throws(() => chat.assertCanRewind("r1", "deadbeef"), (e) => e.status === 404);
      assert.throws(() => chat.assertCanRewind("r1", "zz"), (e) => e.status === 404, "invalid hash");

      const r = await chat.rewind("r1", "bbbbbbb"); // hash pendek cocok dengan checkpoint penuh
      assert.deepStrictEqual(r, { removed: 4, text: "kedua: ubah login", hadImages: true });
      const meta = chat.store.sessions.find((m) => m.id === "r1");
      assert.deepStrictEqual(meta.messages.map((m) => m.text), ["pertama", "jawaban pertama"], "the message and everything below it are gone");
      assert.ok(!meta.agentId && !meta.claudeHistory && !meta.grokHistory, "agent memory is discarded");
      assert.strictEqual(meta.usage.contextTokens, undefined);
      assert.strictEqual(meta.usage.totalTokens, 10, "spend history is kept");
      assert.strictEqual(rt.agent, null);
      assert.deepStrictEqual(disposed, [fakeAgent]);
      assert.deepStrictEqual(deleted, ["cursor-agent-1"]);
      const prelude = chat.preludes.r1;
      assert.ok(/rewound the chat/.test(prelude) && /pertama/.test(prelude) && !/kedua|login diubah|ketiga/.test(prelude), "next agent starts from the remaining transcript only");
      assert.ok(notes.some((n) => n.event === "sessions-changed" && n.rewound && n.sessionId === "r1"), "other browsers are told to reload");

      // Mundur ke pesan pertama: percakapan kosong, tanpa prelude.
      const r0 = await chat.rewind("r1", "aaaaaaa1111111");
      assert.strictEqual(r0.removed, 2);
      assert.strictEqual(chat.store.sessions.find((m) => m.id === "r1").messages.length, 0);
      assert.strictEqual(chat.preludes.r1, undefined);
    } finally { await cleanup(); }
  }

  // ---- Rewind dengan hash checkpoint ganda (kasus nyata: "halo" lalu "buat skrip" tanpa file berubah) ----
  {
    const { chat, cleanup } = makeChat();
    try {
      chat.dispose = async () => {};
      const H = "2588cb3060aa38c106a9cb850a238c6b12e4c05d";
      const fresh = () => {
        chat.store.sessions = chat.store.sessions.filter((m) => m.id !== "dup");
        chat.store.sessions.push({ id: "dup", title: "halo", updatedAt: 1, messages: [
          { role: "user", text: "halo", checkpoint: H }, { role: "ai", text: "Halo!" },
          { role: "user", text: "buatakan python cek proxy", checkpoint: H }, { role: "ai", text: "Sudah dibuat." },
        ] });
      };
      fresh();
      // Tanpa petunjuk: kecocokan TERAKHIR, bukan pertama — tidak menghapus seluruh chat.
      assert.strictEqual(chat.rewindIndex("dup", H), 2);
      // Dengan urutan + teks pesan yang ditekan: tepat sasaran untuk kedua pesan.
      assert.strictEqual(chat.rewindIndex("dup", H, { ordinal: 1, text: "buatakan python cek proxy" }), 2);
      assert.strictEqual(chat.rewindIndex("dup", H, { ordinal: 0, text: "halo" }), 0);
      // Urutan tidak cocok dengan teks (UI dan server berbeda): jatuh ke pencocokan teks.
      assert.strictEqual(chat.rewindIndex("dup", H, { ordinal: 0, text: "buatakan python cek proxy" }), 2);
      const r = await chat.rewind("dup", H, { ordinal: 1, text: "buatakan python cek proxy" });
      assert.strictEqual(r.removed, 2);
      assert.deepStrictEqual(chat.store.sessions.find((m) => m.id === "dup").messages.map((m) => m.text), ["halo", "Halo!"], "the first exchange survives");
    } finally { await cleanup(); }
  }

  // ---- Anthropic: "text content blocks must be non-empty" diperbaiki otomatis ----
  {
    const broken = [
      { role: "user", content: [{ type: "text", text: "buat skrip" }] },
      { role: "assistant", content: [{ type: "text", text: "" }] },
      { role: "user", content: [{ type: "text", text: "  " }, { type: "text", text: "lanjut" }] },
      { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "read", input: {} }] },
      { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "text", text: "" }] }] },
      { role: "assistant", content: "" },
    ];
    const fixed = AnthropicAgent.repairEmptyBlocks(broken);
    assert.ok(fixed.changed >= 4);
    const allText = [];
    const walk = (c) => (Array.isArray(c) ? c : [c]).forEach((b) => { if (typeof b === "string") allText.push(b); else if (b && b.type === "text") allText.push(b.text); else if (b && b.type === "tool_result") walk(b.content); });
    fixed.messages.forEach((m) => walk(m.content));
    assert.ok(allText.every((t) => String(t).trim()), "no empty or whitespace-only text remains");
    assert.strictEqual(fixed.messages[3].content[0].type, "tool_use", "tool_use is untouched");
    assert.strictEqual(fixed.messages.length, broken.length, "turn order is preserved");
    assert.ok(AnthropicAgent.sanitizeHistory(broken).every((m) => (Array.isArray(m.content) ? m.content : [m.content]).length));

    // Run yang menemui 400 tersebut memperbaiki riwayat lalu mengirim ulang sendiri.
    const ag = new AnthropicAgent({ apiKey: "k", model: "claude-test", workspace: os.tmpdir() });
    ag.messages = [{ role: "user", content: [{ type: "text", text: "hai" }] }, { role: "assistant", content: [{ type: "text", text: "" }] }, { role: "user", content: [{ type: "text", text: "lanjut" }] }];
    const origFetch = global.fetch;
    const sent = [];
    global.fetch = async (url, init) => {
      const body = JSON.parse(init.body);
      sent.push(body.messages);
      if (sent.length === 1) return { ok: false, status: 400, text: async () => JSON.stringify({ error: { message: "messages: text content blocks must be non-empty" } }) };
      return { ok: false, status: 401, text: async () => JSON.stringify({ error: { message: "stop here" } }) };
    };
    try {
      await assert.rejects(ag._callModel({ cancelled: false, ac: new AbortController(), push() {} }, [], "sys"), /401/);
    } finally { global.fetch = origFetch; }
    assert.strictEqual(sent.length, 2, "retried once after repairing");
    assert.ok(sent[1][1].content[0].text.trim(), "the retry carried no empty text block");
  }

  // ---- Balasan Anthropic kosong: retry sekali, lalu catatan yang terlihat (bukan gelembung mati) ----
  {
    const sse = (events) => {
      const body = events.map((e) => "event: " + e.type + "\ndata: " + JSON.stringify(e) + "\n\n").join("");
      const bytes = new TextEncoder().encode(body);
      let sent = false;
      return { ok: true, status: 200, body: { getReader: () => ({ read: () => (sent ? Promise.resolve({ done: true }) : (sent = true, Promise.resolve({ value: bytes, done: false }))) }) } };
    };
    const emptyTurn = () => sse([
      { type: "message_start", message: { usage: { input_tokens: 5 } } },
      { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 0 } },
      { type: "message_stop" },
    ]);
    const textTurn = (t) => sse([
      { type: "message_start", message: { usage: { input_tokens: 5 } } },
      { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
      { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: t } },
      { type: "content_block_stop", index: 0 },
      { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } },
      { type: "message_stop" },
    ]);
    const drive = async (planFn) => {
      const ag = await AnthropicAgent.create({ apiKey: "k", model: "claude-test", workspace: os.tmpdir(), customTools: {} });
      let calls = 0; const origFetch = global.fetch;
      global.fetch = async () => planFn(++calls);
      try {
        const run = await ag.send("halo");
        const ev = []; for await (const e of run.stream()) ev.push(e);
        await run.wait();
        const text = ev.filter((e) => e.type === "assistant" && typeof e.text === "string").map((e) => e.text).join("");
        return { calls, text };
      } finally { global.fetch = origFetch; }
    };
    const dead = await drive(() => emptyTurn());
    assert.strictEqual(dead.calls, 2, "an empty turn is retried exactly once");
    assert.ok(dead.text.trim() && /kosong/i.test(dead.text), "a persistently empty turn surfaces a visible note, not silence: " + JSON.stringify(dead.text));
    const healed = await drive((n) => (n === 1 ? emptyTurn() : textTurn("Halo!")));
    assert.strictEqual(healed.calls, 2, "retry happens");
    assert.strictEqual(healed.text, "Halo!", "an empty turn that recovers on retry shows the real reply");
    const normal = await drive(() => textTurn("Langsung jawab"));
    assert.strictEqual(normal.calls, 1, "a normal turn is not retried");
    assert.strictEqual(normal.text, "Langsung jawab");
  }

  // ---- Percakapan vs tugas + mode Chat -----------------------------------------------
  {
    const { chat, cleanup } = makeChat();
    try {
      // Heuristik: obrolan/diskusi tidak masuk mesin tugas; perintah, path, dan kosakata kode masuk.
      assert.strictEqual(chat.intentOf("halo, lagi sibuk?"), "chat");
      assert.strictEqual(chat.intentOf("menurutmu microservices itu overrated nggak?"), "chat");
      assert.strictEqual(chat.intentOf("what do you think about stoicism as a way to handle stress?"), "chat");
      assert.strictEqual(chat.intentOf("perbaiki login yang gagal"), "task");
      assert.strictEqual(chat.intentOf("kenapa server.js error di baris 10"), "task");
      assert.strictEqual(chat.intentOf("lihat src/app/page.tsx"), "task");
      assert.strictEqual(chat.intentOf("lanjutkan"), "task");
      assert.strictEqual(chat.intentOf("apa kabar", { skills: ["review"] }), "task", "attached skill means work");
      assert.strictEqual(chat.intentOf("apa kabar", { intent: "task" }), "task", "explicit override wins");

      // Mode memaksa: Chat selalu percakapan, Plan selalu tugas.
      await chat.setConfig({ mode: "chat" });
      assert.strictEqual(chat.intentOf("perbaiki login yang gagal"), "chat");
      chat.customTools = () => ({ memory_upsert: {}, memory_search: {}, ast_edit: {}, desktop_click: {}, browser_navigate: {} });
      const names = Object.keys(chat.customToolSet()).join(" ");
      assert.ok(/memory_upsert/.test(names), "chat mode can remember facts about the user");
      assert.ok(!/ast_edit/.test(names) && !/desktop_/.test(names), "chat mode never edits code or drives the desktop");
      const ladder = ProviderFactory.disallowedCandidates("chat", Object.assign({}, AiChat.CHAT_RESTRICTIONS));
      assert.ok(ladder[0].includes("edit") && ladder[0].includes("shell") && !ladder[0].includes("generateImage"), "chat: no edit/shell builtins, images allowed");
      const kept = ProviderFactory.filterCustomTools({ browser_navigate: {}, ast_edit: {}, desktop_click: {} }, AiChat.CHAT_RESTRICTIONS);
      assert.deepStrictEqual(Object.keys(kept), ["browser_navigate"], "chat keeps the browser, drops workspace/desktop mutation");
      await chat.setConfig({ mode: "plan" });
      assert.strictEqual(chat.intentOf("halo"), "task");
      await chat.setConfig({ mode: "agent" });

      // Prompt percakapan: ringan, tanpa playbook engineering, dengan profil pengguna dari memori.
      const MemoryStore = require("../lib/memory-store");
      const store = new MemoryStore({ workspace: chat.workspace, dataDir: path.join(chat.workspace, "ctx") });
      chat.contextEngine = { memoryStore: store, buildContext: async () => "" };
      await store.ready;
      await store.upsert({ kind: "user", scope: "user", statement: "Prefers short answers in Indonesian; is building a VR cloud IDE." });
      const p = await chat.agentPrompt("menurutmu apa yang bikin produk itu terasa 'hidup'?", "", { intent: "chat" });
      assert.ok(p.includes("[Conversation]"), "conversation guidance is sent");
      assert.ok(!p.includes("[One step at a time]") && !p.includes("[Project snapshot]"), "no task machinery in a conversation");
      assert.ok(/Prefers short answers in Indonesian/.test(p), "facts about the user reach the prompt");
      assert.ok(p.length < 6000, "conversation prompt stays light: " + p.length);
      const t = await chat.agentPrompt("perbaiki login", "", { intent: "task" });
      assert.ok(t.includes("[One step at a time]") && /Prefers short answers in Indonesian/.test(t), "tasks keep the playbook and still know the user");
    } finally { await cleanup(); }
  }

  // ---- Skills & rules benar-benar sampai ke prompt untuk Anthropic/Grok ----------------
  // Hanya SDK Cursor yang membaca `.cursor/`; provider lain butuh indeks skill di prompt,
  // bahkan saat `.cursor` ada. Dulu bug: `.cursor` ada -> skills tak pernah dikirim ke Anthropic.
  {
    const { root, chat, cleanup } = makeChat();
    try {
      const skDir = path.join(root, ".vrcloud-agent", "skills", "deploy");
      fs.mkdirSync(skDir, { recursive: true });
      fs.writeFileSync(path.join(skDir, "SKILL.md"), "---\nname: deploy\ndescription: Deploy this app to production safely\n---\nSteps...\n");
      fs.mkdirSync(path.join(root, ".cursor"), { recursive: true }); // tautan SDK Cursor ada

      chat.provider = "anthropic";
      const anth = chat.projectContextText();
      assert.ok(/\[Available skills\]/.test(anth), "Anthropic gets the skills index even when .cursor exists");
      assert.ok(/deploy: Deploy this app to production safely/.test(anth), "the skill's description is included");
      assert.ok(/read its SKILL\.md/.test(anth) && /follow it before improvising/i.test(anth), "and an instruction to actually use it");

      chat.provider = "cursor";
      assert.strictEqual(chat.projectContextText(), "", "Cursor with .cursor present skips prompt injection (its SDK reads .cursor)");

      fs.rmSync(path.join(root, ".cursor"), { recursive: true, force: true });
      assert.ok(/\[Available skills\]/.test(chat.projectContextText()), "Cursor without .cursor falls back to prompt injection");
    } finally { await cleanup(); }
  }

  console.log("agent-guards: ok");
})().catch((error) => {
  console.error(error.stack || error);
  process.exitCode = 1;
});