test/agent-guards.test.js
Rama main · réplica de lectura del repositorio original.
"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;
});