mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
1352 lines
61 KiB
TypeScript
1352 lines
61 KiB
TypeScript
import "./setup-home";
|
|
/**
|
|
* Tests for the OpenCode TypeScript plugin entry point.
|
|
*
|
|
* Tests the ContextModePlugin factory and its three hooks:
|
|
* - tool.execute.before (routing enforcement)
|
|
* - tool.execute.after (session event capture)
|
|
* - experimental.session.compacting (snapshot generation)
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
|
|
import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { tmpdir } from "node:os";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
// ── Test helpers ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Create a plugin instance with DB in a temp directory.
|
|
* Uses dynamic import to resolve routing module from project root.
|
|
*/
|
|
async function createTestPlugin(tempDir: string) {
|
|
// Import the plugin module
|
|
const { ContextModePlugin } = await import("../src/adapters/opencode/plugin.js");
|
|
|
|
// Monkey-patch the session dir to use temp directory
|
|
// The plugin uses homedir() internally, but we can control the DB path
|
|
// by creating the plugin with a unique directory that produces a unique hash
|
|
return ContextModePlugin({
|
|
directory: tempDir,
|
|
client: {
|
|
app: {
|
|
log: async () => {},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────
|
|
|
|
// MCP readiness sentinel — routing.mjs checks process.ppid in-process
|
|
const _sentinelDir = process.platform === "win32" ? tmpdir() : "/tmp";
|
|
const mcpSentinel = resolve(_sentinelDir, `context-mode-mcp-ready-${process.pid}`);
|
|
|
|
beforeEach(() => { writeFileSync(mcpSentinel, String(process.pid)); });
|
|
afterEach(() => { try { unlinkSync(mcpSentinel); } catch {} });
|
|
|
|
describe("ContextModePlugin", () => {
|
|
let tempDir: string;
|
|
|
|
beforeAll(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "opencode-plugin-test-"));
|
|
});
|
|
|
|
afterAll(() => {
|
|
try {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
} catch { /* cleanup best effort */ }
|
|
});
|
|
|
|
// ── Factory ───────────────────────────────────────────
|
|
|
|
describe("factory", () => {
|
|
it("returns object with 5 hook handlers", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "factory-test"));
|
|
|
|
expect(plugin).toHaveProperty("tool.execute.before");
|
|
expect(plugin).toHaveProperty("tool.execute.after");
|
|
expect(plugin).toHaveProperty("experimental.session.compacting");
|
|
// SessionStart-equivalent (PR #376 / Mickey #1) — must be on
|
|
// chat.system.transform, NOT chat.messages.transform (whose input
|
|
// shape `{}` carries no sessionID and whose output {info,parts}[]
|
|
// does not accept the {role,content} shape we used to push).
|
|
expect(plugin).toHaveProperty("experimental.chat.system.transform");
|
|
expect(plugin).not.toHaveProperty("experimental.chat.messages.transform");
|
|
// OC-2 (Z2) — chat.message wired to capture user prompts.
|
|
expect(plugin).toHaveProperty("chat.message");
|
|
|
|
expect(typeof plugin["tool.execute.before"]).toBe("function");
|
|
expect(typeof plugin["tool.execute.after"]).toBe("function");
|
|
expect(typeof plugin["experimental.session.compacting"]).toBe("function");
|
|
expect(typeof plugin["experimental.chat.system.transform"]).toBe("function");
|
|
expect(typeof plugin["chat.message"]).toBe("function");
|
|
});
|
|
|
|
it("registers all ctx_* tools natively via the plugin tool map (#574)", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "factory-native-tools"));
|
|
expect(plugin).toHaveProperty("tool");
|
|
expect(Object.keys(plugin.tool ?? {}).sort()).toEqual([
|
|
"ctx_batch_execute",
|
|
"ctx_doctor",
|
|
"ctx_execute",
|
|
"ctx_execute_file",
|
|
"ctx_fetch_and_index",
|
|
"ctx_index",
|
|
"ctx_insight",
|
|
"ctx_purge",
|
|
"ctx_search",
|
|
"ctx_stats",
|
|
"ctx_upgrade",
|
|
]);
|
|
});
|
|
|
|
// Both KiloCode and recent OpenCode (≥ ~1.14.x) bundle Zod v4 in their
|
|
// bun-compiled host runtime. Pre-fix, raw Zod v3 schemas crashed the host
|
|
// on first message with `TypeError: undefined is not an object
|
|
// (evaluating 'n._zod.def')`. Invariant: every arg schema handed to the
|
|
// host must carry the `_zod` v4 marker. Asserted on both platforms.
|
|
it.each([
|
|
{ platform: "kilo" as const, dir: "kilo-zod-v4-args", setEnv: true },
|
|
{ platform: "opencode" as const, dir: "opencode-zod-v4-args", setEnv: false },
|
|
])(
|
|
"converts tool args to Zod v4 when platform is $platform (#_zod.def fix)",
|
|
async ({ dir, setEnv }) => {
|
|
const prevKiloPid = process.env.KILO_PID;
|
|
if (setEnv) process.env.KILO_PID = String(process.pid);
|
|
else delete process.env.KILO_PID;
|
|
try {
|
|
const plugin = await createTestPlugin(join(tempDir, dir));
|
|
expect(plugin).toHaveProperty("tool");
|
|
expect(Object.keys(plugin.tool ?? {}).length).toBeGreaterThan(0);
|
|
|
|
for (const [toolName, toolDef] of Object.entries(plugin.tool ?? {})) {
|
|
const args = (toolDef as any).args as Record<string, unknown>;
|
|
for (const [argName, argSchema] of Object.entries(args)) {
|
|
expect(
|
|
(argSchema as any)._zod,
|
|
`tool ${toolName} arg ${argName} missing _zod (Zod v4 marker)`,
|
|
).toBeDefined();
|
|
}
|
|
}
|
|
} finally {
|
|
if (prevKiloPid !== undefined) process.env.KILO_PID = prevKiloPid;
|
|
else delete process.env.KILO_PID;
|
|
}
|
|
},
|
|
);
|
|
|
|
it("ctx_stats native plugin tool executes without an MCP child (#574 smoke)", async () => {
|
|
const projectDir = join(tempDir, "factory-native-tool-exec");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
const result = await plugin.tool!.ctx_stats.execute({}, {
|
|
sessionID: "session-native-tool",
|
|
messageID: "msg-native-tool",
|
|
agent: "test-agent",
|
|
directory: projectDir,
|
|
worktree: projectDir,
|
|
abort: new AbortController().signal,
|
|
metadata: () => {},
|
|
ask: (() => ({}) as any) as any,
|
|
});
|
|
const output = typeof result === "string" ? result : result.output;
|
|
expect(output).toContain("context-mode");
|
|
});
|
|
|
|
// ── #621: native plugin must run Zod preprocessing on args ────────
|
|
// OpenCode's plugin tool registry (refs/platforms/opencode/packages/
|
|
// opencode/src/tool/registry.ts:127) only uses the Zod schema as a
|
|
// boolean type guard via .safeParse(u).success — it passes RAW args
|
|
// to def.execute(). Our handlers in server.ts rely on
|
|
// z.preprocess(coerceCommandsArray|coerceJsonArray, …) to coerce
|
|
// JSON-string args back into arrays and to fill defaults. Before
|
|
// the fix, registered.handler(args ?? {}) ran without parsing,
|
|
// so commands could arrive as `undefined` or `"[...]"`, and the
|
|
// handler crashed with `commands.map is not a function`.
|
|
describe("#621/#627: native plugin runs Zod preprocessing on args", () => {
|
|
const baseCtx = (projectDir: string) => ({
|
|
sessionID: "issue-621-sess",
|
|
messageID: "issue-621-msg",
|
|
agent: "test-agent",
|
|
directory: projectDir,
|
|
worktree: projectDir,
|
|
abort: new AbortController().signal,
|
|
metadata: () => {},
|
|
ask: (() => ({}) as any) as any,
|
|
});
|
|
|
|
it("ctx_batch_execute accepts well-formed args without crashing", async () => {
|
|
const projectDir = join(tempDir, "issue-621-baseline");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
const result = await plugin.tool!.ctx_batch_execute.execute(
|
|
{
|
|
commands: [
|
|
{ label: "echo test", command: "echo issue621-baseline" },
|
|
],
|
|
queries: ["issue621-baseline"],
|
|
concurrency: 1,
|
|
},
|
|
baseCtx(projectDir),
|
|
);
|
|
const output = typeof result === "string" ? result : result.output;
|
|
// Should succeed (no "commands.map is not a function" error).
|
|
expect(output).toContain("Executed");
|
|
});
|
|
|
|
it("ctx_batch_execute coerces JSON-stringified commands array (#621)", async () => {
|
|
const projectDir = join(tempDir, "issue-621-json-string");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
// Simulate OpenCode delivering JSON-stringified array — the
|
|
// coerceCommandsArray preprocessor must turn this back into an
|
|
// array via z.preprocess before the handler runs.
|
|
const result = await plugin.tool!.ctx_batch_execute.execute(
|
|
{
|
|
commands: JSON.stringify([
|
|
{ label: "echo coerced", command: "echo issue621-coerced" },
|
|
]) as unknown as Array<{ label: string; command: string }>,
|
|
queries: JSON.stringify(["issue621-coerced"]) as unknown as string[],
|
|
},
|
|
baseCtx(projectDir),
|
|
);
|
|
const output = typeof result === "string" ? result : result.output;
|
|
expect(output).toContain("Executed");
|
|
});
|
|
|
|
it("ctx_batch_execute coerces plain-string commands into {label,command} (#621)", async () => {
|
|
const projectDir = join(tempDir, "issue-621-string-cmds");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
// coerceCommandsArray also lifts bare strings into {label,command}.
|
|
const result = await plugin.tool!.ctx_batch_execute.execute(
|
|
{
|
|
commands: ["echo issue621-string-cmd"] as unknown as Array<{
|
|
label: string;
|
|
command: string;
|
|
}>,
|
|
queries: ["issue621-string-cmd"],
|
|
},
|
|
baseCtx(projectDir),
|
|
);
|
|
const output = typeof result === "string" ? result : result.output;
|
|
expect(output).toContain("Executed");
|
|
});
|
|
|
|
it("ctx_batch_execute surfaces actionable error when commands is missing (#621)", async () => {
|
|
const projectDir = join(tempDir, "issue-621-missing-commands");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
// Before the fix this crashed with "commands.map is not a function"
|
|
// — opaque, hard for the LLM to recover from. After the fix the
|
|
// Zod schema rejects the missing field with a clear error.
|
|
await expect(
|
|
plugin.tool!.ctx_batch_execute.execute(
|
|
{ queries: ["whatever"] } as unknown as Record<string, unknown>,
|
|
baseCtx(projectDir),
|
|
),
|
|
).rejects.toThrow(/Invalid arguments for ctx_batch_execute/);
|
|
});
|
|
|
|
it("ctx_search coerces JSON-stringified queries array (#621)", async () => {
|
|
const projectDir = join(tempDir, "issue-621-search-coerce");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
// ctx_search also uses z.preprocess(coerceJsonArray, …) on queries.
|
|
// Empty knowledge base is fine — we only assert the call returns
|
|
// without a TypeError (the original symptom).
|
|
const result = await plugin.tool!.ctx_search.execute(
|
|
{
|
|
queries: JSON.stringify(["issue621-search"]) as unknown as string[],
|
|
},
|
|
baseCtx(projectDir),
|
|
);
|
|
const output = typeof result === "string" ? result : result.output;
|
|
// Should not throw; output is a normal search response (possibly
|
|
// "No results" / guidance) but never the JS TypeError symptom.
|
|
expect(typeof output).toBe("string");
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// #627: same root cause class as #621, but for primitive coercion.
|
|
// OpenCode's plugin host (and several LLM providers' tool-call JSON)
|
|
// stringifies *primitive* args too — limit:"4" instead of limit:4,
|
|
// background:"false" instead of background:false. v1.0.139 (#621)
|
|
// started running inputSchema.parse() on the native path, which made
|
|
// these mismatches visible as "Invalid arguments" errors. The fix is
|
|
// to use z.coerce.* on the schemas (mirrors what ctx_batch_execute's
|
|
// timeout/concurrency and ctx_fetch_and_index's concurrency already
|
|
// do), AND to widen coerceJsonArray to lift bare-string queries.
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
it("ctx_search accepts stringified limit (#627 exact reporter case)", async () => {
|
|
const projectDir = join(tempDir, "issue-627-limit-string");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
// Reporter's exact call shape: queries arrives as JSON string AND
|
|
// limit arrives as a number-string. v1.0.140's plain z.number()
|
|
// rejects "4" with "Expected number, received string".
|
|
const result = await plugin.tool!.ctx_search.execute(
|
|
{
|
|
queries: JSON.stringify([
|
|
"HTML file mermaid rendering flowchart display issue",
|
|
]) as unknown as string[],
|
|
limit: "4" as unknown as number,
|
|
},
|
|
baseCtx(projectDir),
|
|
);
|
|
const output = typeof result === "string" ? result : result.output;
|
|
// Should NOT throw — z.coerce.number() turns "4" into 4 before
|
|
// the handler sees it; queries coercion turns the JSON string
|
|
// into an array. Output is a normal "no results yet" guidance.
|
|
expect(typeof output).toBe("string");
|
|
});
|
|
|
|
it("ctx_search lifts bare-string queries into single-element array (#627)", async () => {
|
|
const projectDir = join(tempDir, "issue-627-bare-query");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
// Some LLM providers send a single query as a bare string rather
|
|
// than a JSON-stringified array. Without widening, coerceJsonArray
|
|
// returns the string unchanged → z.array(z.string()) rejects it.
|
|
const result = await plugin.tool!.ctx_search.execute(
|
|
{
|
|
queries: "single bare query" as unknown as string[],
|
|
},
|
|
baseCtx(projectDir),
|
|
);
|
|
const output = typeof result === "string" ? result : result.output;
|
|
expect(typeof output).toBe("string");
|
|
});
|
|
|
|
it("ctx_execute accepts stringified background boolean (#627)", async () => {
|
|
// The project dir must exist on disk: the shell pipeline spawns
|
|
// /bin/zsh with cwd=projectDir, and Node's spawn() returns ENOENT
|
|
// for the shell binary itself when cwd doesn't exist (misleading
|
|
// — /bin/zsh is fine). The other tests in this block don't notice
|
|
// because ctx_batch_execute catches per-cmd executor errors and
|
|
// still returns its "Executed ..." wrapper text.
|
|
const projectDir = join(tempDir, "issue-627-background-bool");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
const plugin = await createTestPlugin(projectDir);
|
|
// background: z.boolean() rejects "false". z.coerce.boolean would
|
|
// coerce — but Boolean("false") is true. Instead we use a small
|
|
// preprocessor (coerceBoolean) that maps "true"/"false" literals
|
|
// back to booleans and leaves real booleans untouched.
|
|
const result = await plugin.tool!.ctx_execute.execute(
|
|
{
|
|
language: "shell",
|
|
code: "echo issue627-bg",
|
|
background: "false" as unknown as boolean,
|
|
},
|
|
baseCtx(projectDir),
|
|
);
|
|
const output = typeof result === "string" ? result : result.output;
|
|
// Should run synchronously (background coerced to false) and emit
|
|
// the echoed line — never throw "Invalid arguments for ctx_execute".
|
|
expect(output).toContain("issue627-bg");
|
|
});
|
|
|
|
it("ctx_purge accepts stringified confirm boolean (#627)", async () => {
|
|
const projectDir = join(tempDir, "issue-627-purge-confirm");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
// confirm: z.boolean() rejects "false". With the boolean coercion
|
|
// fix, "false" becomes false → ctx_purge returns "purge cancelled"
|
|
// (its documented refusal path) instead of throwing on validation.
|
|
const result = await plugin.tool!.ctx_purge.execute(
|
|
{
|
|
confirm: "false" as unknown as boolean,
|
|
},
|
|
baseCtx(projectDir),
|
|
);
|
|
const output = typeof result === "string" ? result : result.output;
|
|
expect(output).toMatch(/cancel/i);
|
|
});
|
|
});
|
|
|
|
it("native tool registry import does not leak process handlers or embedded env into OpenCode host", () => {
|
|
const childHome = mkdtempSync(join(tmpdir(), "opencode-plugin-side-effects-"));
|
|
try {
|
|
const tsx = resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs");
|
|
const pluginPath = pathToFileURL(resolve(process.cwd(), "src", "adapters", "opencode", "plugin.ts")).href;
|
|
const script = `
|
|
(async () => {
|
|
const { ContextModePlugin } = await import(${JSON.stringify(pluginPath)});
|
|
const before = {
|
|
unhandled: process.listenerCount("unhandledRejection"),
|
|
uncaught: process.listenerCount("uncaughtException"),
|
|
};
|
|
await ContextModePlugin({ directory: ${JSON.stringify(childHome)}, client: { app: { log: async () => {} } } });
|
|
const after = {
|
|
unhandled: process.listenerCount("unhandledRejection"),
|
|
uncaught: process.listenerCount("uncaughtException"),
|
|
embedded: process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS ?? null,
|
|
};
|
|
console.log(JSON.stringify({ before, after }));
|
|
})().catch((err) => { console.error(err); process.exit(1); });
|
|
`;
|
|
const run = spawnSync(process.execPath, [tsx, "-e", script], {
|
|
cwd: process.cwd(),
|
|
env: { ...process.env, HOME: childHome, USERPROFILE: childHome },
|
|
encoding: "utf-8",
|
|
});
|
|
expect(run.status, run.stderr).toBe(0);
|
|
const result = JSON.parse(run.stdout.trim());
|
|
expect(result.after.unhandled).toBe(result.before.unhandled);
|
|
expect(result.after.uncaught).toBe(result.before.uncaught);
|
|
expect(result.after.embedded).toBeNull();
|
|
} finally {
|
|
rmSync(childHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not write AGENTS.md routing instructions on startup", async () => {
|
|
const projectDir = join(tempDir, "factory-startup-routing");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
await createTestPlugin(projectDir);
|
|
|
|
const agentsPath = join(projectDir, "AGENTS.md");
|
|
expect(existsSync(agentsPath)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── tool.execute.before ───────────────────────────────
|
|
|
|
describe("tool.execute.before", () => {
|
|
it("modifies curl commands to block them", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "before-curl"));
|
|
const input = { tool: "Bash", sessionID: "test-session", callID: "call-1" };
|
|
const output = { args: { command: "curl https://example.com/data" } };
|
|
|
|
// Routing should throw for blocked commands (deny action)
|
|
// or modify the args to replace the command
|
|
try {
|
|
await plugin["tool.execute.before"](input, output);
|
|
// If it didn't throw, the command was modified in output.args
|
|
expect(output.args.command).toMatch(/^echo /);
|
|
expect(output.args.command).toContain("context-mode");
|
|
} catch (e: any) {
|
|
// deny/ask action throws — still correct behavior
|
|
expect(e.message).toContain("context-mode");
|
|
}
|
|
});
|
|
|
|
it("modifies wget commands to block them", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "before-wget"));
|
|
const input = { tool: "Bash", sessionID: "test-session", callID: "call-2" };
|
|
const output = { args: { command: "wget https://example.com/file" } };
|
|
|
|
try {
|
|
await plugin["tool.execute.before"](input, output);
|
|
expect(output.args.command).toMatch(/^echo /);
|
|
expect(output.args.command).toContain("context-mode");
|
|
} catch (e: any) {
|
|
expect(e.message).toContain("context-mode");
|
|
}
|
|
});
|
|
|
|
it("passes through normal tool calls", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "before-pass"));
|
|
|
|
// TaskCreate is not routed — should passthrough
|
|
const result = await plugin["tool.execute.before"](
|
|
{ tool: "TaskCreate", sessionID: "test-session", callID: "call-3" },
|
|
{ args: { subject: "test task" } },
|
|
);
|
|
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("handles empty input gracefully", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "before-empty"));
|
|
|
|
const result = await plugin["tool.execute.before"](
|
|
{} as any,
|
|
{ args: {} } as any,
|
|
);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("injects guidance for allowed grep commands", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "before-guidance"));
|
|
|
|
const input = { tool: "grep", sessionID: "test-session", callID: "call-4" };
|
|
const output = { args: { command: "grep hello", additionalContext: undefined } };
|
|
|
|
await plugin["tool.execute.before"](input, output);
|
|
|
|
// Guidance should be injected as additionalContext in args
|
|
expect(output.args).toHaveProperty("additionalContext");
|
|
expect(output.args.additionalContext).toContain("<context_guidance>");
|
|
});
|
|
});
|
|
|
|
// ── tool.execute.after ────────────────────────────────
|
|
|
|
describe("tool.execute.after", () => {
|
|
it("captures file read events without throwing", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "after-read"));
|
|
|
|
// Should not throw
|
|
await expect(
|
|
plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "test-session", callID: "call-1", args: { file_path: "/test/file.ts" } },
|
|
{ title: "Read", output: "file contents here", metadata: {} },
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("captures file write events", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "after-write"));
|
|
|
|
await expect(
|
|
plugin["tool.execute.after"](
|
|
{ tool: "Write", sessionID: "test-session", callID: "call-2", args: { file_path: "/test/new-file.ts", content: "code" } },
|
|
{ title: "Write", output: "", metadata: {} },
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("captures git events from Bash", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "after-git"));
|
|
|
|
await expect(
|
|
plugin["tool.execute.after"](
|
|
{ tool: "Bash", sessionID: "test-session", callID: "call-3", args: { command: "git commit -m 'test'" } },
|
|
{ title: "Bash", output: "[main abc1234] test", metadata: {} },
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("handles empty input gracefully", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "after-empty"));
|
|
|
|
await expect(
|
|
plugin["tool.execute.after"](
|
|
{} as any,
|
|
{ title: "", output: "", metadata: {} } as any,
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── experimental.session.compacting ───────────────────
|
|
|
|
describe("experimental.session.compacting", () => {
|
|
it("returns empty string when no events captured", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "compact-empty"));
|
|
|
|
const output = { context: [] as string[], prompt: undefined };
|
|
const snapshot = await plugin["experimental.session.compacting"](
|
|
{ sessionID: "test-session" },
|
|
output,
|
|
);
|
|
expect(snapshot).toBe("");
|
|
});
|
|
|
|
it("returns snapshot XML after events are captured", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "compact-snap"));
|
|
|
|
// Capture several events first
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "test-session", callID: "call-1", args: { file_path: "/src/index.ts" } },
|
|
{ title: "Read", output: "export default {}", metadata: {} },
|
|
);
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Edit", sessionID: "test-session", callID: "call-2", args: { file_path: "/src/index.ts", old_string: "{}", new_string: "{ foo: 1 }" } },
|
|
{ title: "Edit", output: "", metadata: {} },
|
|
);
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Bash", sessionID: "test-session", callID: "call-3", args: { command: "git status" } },
|
|
{ title: "Bash", output: "On branch main", metadata: {} },
|
|
);
|
|
|
|
const output = { context: [] as string[], prompt: undefined };
|
|
const snapshot = await plugin["experimental.session.compacting"](
|
|
{ sessionID: "test-session" },
|
|
output,
|
|
);
|
|
|
|
expect(snapshot.length).toBeGreaterThan(0);
|
|
expect(snapshot).toContain("session_resume");
|
|
expect(snapshot).toContain("<files");
|
|
expect(snapshot).toContain("index.ts");
|
|
});
|
|
|
|
it("can be called multiple times (increments compact count)", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "compact-multi"));
|
|
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "test-session", callID: "call-1", args: { file_path: "/test/a.ts" } },
|
|
{ title: "Read", output: "code", metadata: {} },
|
|
);
|
|
|
|
const output1 = { context: [] as string[], prompt: undefined };
|
|
const snap1 = await plugin["experimental.session.compacting"](
|
|
{ sessionID: "test-session" },
|
|
output1,
|
|
);
|
|
expect(snap1.length).toBeGreaterThan(0);
|
|
|
|
// Capture more events
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Write", sessionID: "test-session", callID: "call-2", args: { file_path: "/test/b.ts", content: "new file" } },
|
|
{ title: "Write", output: "", metadata: {} },
|
|
);
|
|
|
|
const output2 = { context: [] as string[], prompt: undefined };
|
|
const snap2 = await plugin["experimental.session.compacting"](
|
|
{ sessionID: "test-session" },
|
|
output2,
|
|
);
|
|
expect(snap2.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ── experimental.chat.system.transform ────────────────
|
|
// SessionStart-equivalent (PR #376 / Mickey 3-issue fix). Verifies:
|
|
// • Snapshot is prepended to output.system (NOT output.messages)
|
|
// • Per-session at-most-once gate (multi-session reuse — Mickey #2)
|
|
// • Cross-session lookup via DB.claimLatestUnconsumedResume
|
|
// • Race-safe atomic claim — two parallel transforms get distinct rows
|
|
|
|
describe("experimental.chat.system.transform", () => {
|
|
it("is a no-op when sessionID is missing", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "sysxform-no-sid"));
|
|
const out = { system: ["existing"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: undefined, model: {} } as any,
|
|
out,
|
|
);
|
|
expect(out.system).toEqual(["existing"]);
|
|
});
|
|
|
|
it("injects routing block but no resume snapshot when no prior row exists", async () => {
|
|
// v1.0.107 — routing-block injection (OC-1) is INDEPENDENT of resume
|
|
// snapshot. With no prior row, only the routing block lands.
|
|
const plugin = await createTestPlugin(join(tempDir, "sysxform-no-resume"));
|
|
const out = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "fresh-session", model: {} } as any,
|
|
out,
|
|
);
|
|
expect(out.system[0]).toBe("HEADER"); // header preserved
|
|
expect(out.system.length).toBe(2); // header + routing block (no resume)
|
|
expect(out.system[1]).toContain("<context_window_protection>");
|
|
expect(out.system.join("\n")).not.toContain("session_resume");
|
|
});
|
|
|
|
it("prepends a previously-recorded snapshot to output.system on first call", async () => {
|
|
const projectDir = join(tempDir, "sysxform-inject");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
// Build a snapshot in a *prior* session
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "prior-session", callID: "c1", args: { file_path: "/a.ts" } },
|
|
{ title: "Read", output: "content", metadata: {} },
|
|
);
|
|
const compactOut = { context: [] as string[], prompt: undefined };
|
|
await plugin["experimental.session.compacting"](
|
|
{ sessionID: "prior-session" } as any,
|
|
compactOut,
|
|
);
|
|
|
|
// New session enters via system.transform — must inherit the snapshot
|
|
// PLUS the OC-1 routing block (no header in this fixture, so both go
|
|
// in via splice(1, 0, ...) — array becomes [routing, snapshot]).
|
|
const out = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "new-session", model: {} } as any,
|
|
out,
|
|
);
|
|
expect(out.system[0]).toBe("HEADER");
|
|
expect(out.system.length).toBe(3); // HEADER + routing + snapshot
|
|
expect(out.system.some((s) => s.includes("session_resume"))).toBe(true);
|
|
expect(out.system.some((s) => s.includes("<context_window_protection>"))).toBe(true);
|
|
});
|
|
|
|
it("preserves system[0] header so OpenCode's prompt-cache fold survives", async () => {
|
|
// OpenCode (packages/opencode/src/session/llm.ts:117-128) preserves a
|
|
// 2-part `[header, body]` system structure for provider prompt caching.
|
|
// It saves `header = system[0]` BEFORE invoking this hook, then folds
|
|
// the rest into `[header, body]` only when `system[0] === header` after
|
|
// the hook returns. If we `unshift(snapshot)` we replace system[0] →
|
|
// cache-fold is skipped → each system block ships as a separate
|
|
// `role: "system"` message → provider prompt cache invalidates on every
|
|
// resume injection (token cost regression). We insert at index 1 instead.
|
|
// v1.0.107 — both routing block and resume snapshot now live between
|
|
// HEADER and BODY (4 elements total).
|
|
const projectDir = join(tempDir, "sysxform-cache-fold");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "seed", callID: "c1", args: { file_path: "/y.ts" } },
|
|
{ title: "Read", output: "y", metadata: {} },
|
|
);
|
|
await plugin["experimental.session.compacting"](
|
|
{ sessionID: "seed" } as any,
|
|
{ context: [] as string[], prompt: undefined },
|
|
);
|
|
|
|
const HEADER = "you are claude";
|
|
const BODY = "user system prompt here";
|
|
const out = { system: [HEADER, BODY] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "turn-cache", model: {} } as any,
|
|
out,
|
|
);
|
|
// The snapshot was inserted, but header at index 0 is preserved
|
|
// exactly as OpenCode saw it before the hook.
|
|
expect(out.system[0]).toBe(HEADER);
|
|
expect(out.system[out.system.length - 1]).toBe(BODY);
|
|
expect(out.system.length).toBe(4); // HEADER + routing + snapshot + BODY
|
|
const middle = out.system.slice(1, -1).join("\n");
|
|
expect(middle).toContain("session_resume");
|
|
expect(middle).toContain("<context_window_protection>");
|
|
});
|
|
|
|
it("does NOT re-inject resume snapshot on second call with the same sessionID (multi-turn)", async () => {
|
|
const projectDir = join(tempDir, "sysxform-once-per-session");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
// Seed snapshot
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "seed", callID: "c1", args: { file_path: "/x.ts" } },
|
|
{ title: "Read", output: "x", metadata: {} },
|
|
);
|
|
await plugin["experimental.session.compacting"](
|
|
{ sessionID: "seed" } as any,
|
|
{ context: [] as string[], prompt: undefined },
|
|
);
|
|
|
|
const out1 = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "turn-X", model: {} } as any,
|
|
out1,
|
|
);
|
|
// First turn: HEADER + routing + snapshot
|
|
expect(out1.system.length).toBe(3);
|
|
|
|
const out2 = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "turn-X", model: {} } as any,
|
|
out2,
|
|
);
|
|
// Same session — resume snapshot consumed from DB. Routing block re-injects (no dedup).
|
|
expect(out2.system.length).toBe(2); // HEADER + routing block
|
|
expect(out2.system[1]).toContain("<context_window_protection>");
|
|
expect(out2.system.join("\n")).not.toContain("session_resume");
|
|
});
|
|
|
|
// v1.0.106 — Mickey #376 follow-up: self-injection guard
|
|
it("does NOT inject snapshot back into the session that produced it (self-injection guard)", async () => {
|
|
const projectDir = join(tempDir, "sysxform-self-inject");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
// Session B does work and compacts — produces ITS OWN snapshot row.
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "B", callID: "c1", args: { file_path: "/p.ts" } },
|
|
{ title: "Read", output: "p", metadata: {} },
|
|
);
|
|
await plugin["experimental.session.compacting"](
|
|
{ sessionID: "B" } as any,
|
|
{ context: [] as string[], prompt: undefined },
|
|
);
|
|
|
|
// B's NEXT chat turn fires system.transform — must NOT splice B's
|
|
// own snapshot back into B's prompt (wasteful + would consume the
|
|
// row meant for the next fresh session). v1.0.107 — routing block
|
|
// STILL injects (it's session-agnostic, OC-1 contract).
|
|
const out = { system: ["HEADER", "BODY"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "B", model: {} } as any,
|
|
out,
|
|
);
|
|
// No resume snapshot for B (self-inject guard) but routing block lands.
|
|
expect(out.system.length).toBe(3);
|
|
expect(out.system[0]).toBe("HEADER");
|
|
expect(out.system[2]).toBe("BODY");
|
|
expect(out.system.join("\n")).not.toContain("session_resume");
|
|
expect(out.system[1]).toContain("<context_window_protection>");
|
|
});
|
|
|
|
// v1.0.106 — when no row exists, do NOT mark sessionId as injected,
|
|
// so a later call within the same session can still pick up a snapshot
|
|
// that arrived after the first attempt.
|
|
it("retries on next turn when no row exists (no premature gate)", async () => {
|
|
const projectDir = join(tempDir, "sysxform-retry");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
// First call — no snapshot in DB yet. Routing block still fires.
|
|
const out1 = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "C", model: {} } as any,
|
|
out1,
|
|
);
|
|
expect(out1.system.length).toBe(2); // HEADER + routing block
|
|
expect(out1.system.join("\n")).not.toContain("session_resume");
|
|
|
|
// Now a different session compacts and produces a snapshot
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "donor", callID: "c1", args: { file_path: "/q.ts" } },
|
|
{ title: "Read", output: "q", metadata: {} },
|
|
);
|
|
await plugin["experimental.session.compacting"](
|
|
{ sessionID: "donor" } as any,
|
|
{ context: [] as string[], prompt: undefined },
|
|
);
|
|
|
|
// C's next turn — routing block re-injects (every turn), plus resume
|
|
// snapshot from donor (no premature gate — DB claim still available).
|
|
const out2 = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "C", model: {} } as any,
|
|
out2,
|
|
);
|
|
expect(out2.system.length).toBe(3); // HEADER + snapshot + routing
|
|
expect(out2.system[1]).toContain("session_resume");
|
|
expect(out2.system[2]).toContain("<context_window_protection>");
|
|
});
|
|
|
|
// v1.0.106 — prefer next session over self-injection
|
|
it("snapshot from B is consumed by C, not by B itself", async () => {
|
|
const projectDir = join(tempDir, "sysxform-b-to-c");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
// Session B compacts → produces row
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "B", callID: "c1", args: { file_path: "/r.ts" } },
|
|
{ title: "Read", output: "r", metadata: {} },
|
|
);
|
|
await plugin["experimental.session.compacting"](
|
|
{ sessionID: "B" } as any,
|
|
{ context: [] as string[], prompt: undefined },
|
|
);
|
|
|
|
// B asks for inject — gets routing block but no snapshot (own row excluded)
|
|
const outB = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "B", model: {} } as any,
|
|
outB,
|
|
);
|
|
expect(outB.system.length).toBe(2);
|
|
expect(outB.system.join("\n")).not.toContain("session_resume");
|
|
expect(outB.system[1]).toContain("<context_window_protection>");
|
|
|
|
// C asks — gets B's snapshot AND routing block (both first-fire for C)
|
|
const outC = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "C", model: {} } as any,
|
|
outC,
|
|
);
|
|
expect(outC.system.length).toBe(3); // HEADER + routing
|
|
expect(outC.system.some((s) => s.includes("session_resume"))).toBe(true);
|
|
});
|
|
|
|
it("emits a snapshot", async () => {
|
|
const projectDir = join(tempDir, "sysxform-marker");
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "donor", callID: "c1", args: { file_path: "/m.ts" } },
|
|
{ title: "Read", output: "m", metadata: {} },
|
|
);
|
|
await plugin["experimental.session.compacting"](
|
|
{ sessionID: "donor" } as any,
|
|
{ context: [] as string[], prompt: undefined },
|
|
);
|
|
|
|
const out = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "consumer", model: {} } as any,
|
|
out,
|
|
);
|
|
// v1.0.107 — out.system is [HEADER, routing-block]
|
|
expect(out.system.length).toBe(3);
|
|
const snapshotEntry = out.system.find((s) => s.includes("session_resume"));
|
|
expect(snapshotEntry).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Integration: before + after + compact ─────────────
|
|
|
|
describe("end-to-end flow", () => {
|
|
it("captures events from allowed tools and generates snapshot", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "e2e-flow"));
|
|
|
|
// Normal tool call passes through before hook
|
|
await plugin["tool.execute.before"](
|
|
{ tool: "Read", sessionID: "test-session", callID: "call-1" },
|
|
{ args: { file_path: "/app/main.ts" } },
|
|
);
|
|
|
|
// After hook captures the event
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "test-session", callID: "call-1", args: { file_path: "/app/main.ts" } },
|
|
{ title: "Read", output: "console.log('hello')", metadata: {} },
|
|
);
|
|
|
|
// Compacting generates snapshot
|
|
const output = { context: [] as string[], prompt: undefined };
|
|
const snapshot = await plugin["experimental.session.compacting"](
|
|
{ sessionID: "test-session" },
|
|
output,
|
|
);
|
|
expect(snapshot).toContain("session_resume");
|
|
expect(snapshot).toContain("<files");
|
|
expect(snapshot).toContain("main.ts");
|
|
});
|
|
|
|
// ── OC-1: ROUTING_BLOCK injection in chat.system.transform ────
|
|
// Mickey ana şikayet (CCv1). v1.0.107 — adapter must inject the
|
|
// <context_window_protection> XML routing block on the first
|
|
// chat.system.transform call per session, INDEPENDENT of any
|
|
// resume snapshot row (which may or may not exist yet).
|
|
it("OC-1: injects <context_window_protection> routing block on first turn per session", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "oc1-routing"));
|
|
const out = { system: ["HEADER", "BODY"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "oc1-fresh", model: {} } as any,
|
|
out,
|
|
);
|
|
// header preserved at index 0 (cache-fold invariant)
|
|
expect(out.system[0]).toBe("HEADER");
|
|
// routing block spliced at index 1
|
|
const joined = out.system.join("\n");
|
|
expect(joined).toContain("<context_window_protection>");
|
|
expect(joined).toContain("<priority_instructions>");
|
|
// platform-specific tool name proves createToolNamer wired correctly
|
|
expect(joined).toContain("context-mode_ctx_search");
|
|
});
|
|
|
|
it("OC-1: re-injects routing block on every turn (per-turn reliability)", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "oc1-every-turn"));
|
|
const out1 = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "oc1-twice", model: {} } as any,
|
|
out1,
|
|
);
|
|
expect(out1.system.join("\n")).toContain("<context_window_protection>");
|
|
|
|
const out2 = { system: ["HEADER"] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "oc1-twice", model: {} } as any,
|
|
out2,
|
|
);
|
|
// Routing block injects every turn for reliability (no dedup set).
|
|
expect(out2.system.join("\n")).toContain("<context_window_protection>");
|
|
expect(out2.system.length).toBe(2);
|
|
});
|
|
|
|
it("OC-1: skips routing block when system prompt already contains context-mode instructions", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "oc1-dedup"));
|
|
// Simulate AGENTS.md already loaded by the host — contains routing markers.
|
|
// Post-#487: markers are <context_window_protection>, ctx_search, ctx_index
|
|
// (non-overlapping). Quorum requires 2-of-3 distinct.
|
|
const agentsContent = [
|
|
"# context-mode rules",
|
|
"<context_window_protection> applies to this project.",
|
|
"Use ctx_search for memory recall.",
|
|
"Use ctx_index to store new content.",
|
|
].join("\n");
|
|
const out = { system: ["HEADER", agentsContent] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "oc1-dedup-sess", model: {} } as any,
|
|
out,
|
|
);
|
|
// system unchanged — no routing block injected (2+ markers detected).
|
|
// Length stays 2 (HEADER + the existing AGENTS.md entry); the fixture
|
|
// itself contains <context_window_protection>, so we assert by structure
|
|
// (no new entry spliced in) rather than substring absence.
|
|
expect(out.system.length).toBe(2);
|
|
expect(out.system[0]).toBe("HEADER");
|
|
expect(out.system[1]).toBe(agentsContent);
|
|
});
|
|
|
|
it("OC-1: injects routing block when only one marker present (below quorum)", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "oc1-below-quorum"));
|
|
// Only one marker — below the 2-of-3 quorum — routing block still injects.
|
|
// Post-#487: the active markers are <context_window_protection>, ctx_search,
|
|
// ctx_index. Use exactly one to assert below-quorum behavior.
|
|
const partialContent = "Some text mentioning ctx_search but nothing else";
|
|
const out = { system: ["HEADER", partialContent] };
|
|
await plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "oc1-quorum-sess", model: {} } as any,
|
|
out,
|
|
);
|
|
expect(out.system.length).toBe(3); // HEADER + routing + partialContent
|
|
expect(out.system[1]).toContain("<context_window_protection>");
|
|
});
|
|
});
|
|
|
|
// ── OC-1 quorum substring overlap (#487) ────────────────
|
|
// RED proof: the marker set ["ctx_execute", "ctx_batch_execute", ...] uses
|
|
// overlapping substrings. `text.includes("ctx_execute")` matches ALSO on any
|
|
// `ctx_batch_execute` occurrence, so a SINGLE user paste mentioning
|
|
// ctx_batch_execute satisfies the 2-of-3 quorum and suppresses the routing
|
|
// block for the entire session. The fix replaces the markers with
|
|
// non-overlapping tokens (or word-boundary regex) while preserving the
|
|
// ≥2 distinct markers semantic.
|
|
describe("OC-1 quorum: single marker substring overlap", () => {
|
|
it("returns false for text mentioning ctx_batch_execute exactly once", async () => {
|
|
const { systemHasRoutingInstructions } = await import("../src/adapters/opencode/plugin.js");
|
|
const text = "what does ctx_batch_execute do?";
|
|
// 1 distinct marker → below quorum → false
|
|
expect(systemHasRoutingInstructions([text])).toBe(false);
|
|
});
|
|
|
|
it("returns true when two distinct (non-overlapping) markers are present", async () => {
|
|
const { systemHasRoutingInstructions } = await import("../src/adapters/opencode/plugin.js");
|
|
const text = "ctx_search and ctx_index help";
|
|
expect(systemHasRoutingInstructions([text])).toBe(true);
|
|
});
|
|
|
|
it("returns true when the routing-block XML tag is present alongside one tool", async () => {
|
|
const { systemHasRoutingInstructions } = await import("../src/adapters/opencode/plugin.js");
|
|
const text = "<context_window_protection> applies. use ctx_search.";
|
|
expect(systemHasRoutingInstructions([text])).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── OC-2: chat.message hook (Z2) ──────────────────────────
|
|
// Wires `chat.message` to capture user prompts. CCv2 inline filter
|
|
// skips synthetic system messages (<task-notification>, <system-reminder>,
|
|
// <context_guidance>, <tool-result>) so we don't flood the DB with noise.
|
|
|
|
describe("chat.message", () => {
|
|
it("OC-2: captures user prompt as user_prompt event", async () => {
|
|
const projectDir = join(tempDir, "oc2-capture");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
const msg = "switch to mission mode and prefer the elegant solution";
|
|
await plugin["chat.message"](
|
|
{ sessionID: "oc2-sess", agent: "build", messageID: "m1" } as any,
|
|
{ message: { role: "user" } as any, parts: [{ type: "text", text: msg }] } as any,
|
|
);
|
|
|
|
// Verify SessionDB has the event
|
|
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
|
|
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
|
|
const adapter = new OpenCodeAdapter("opencode");
|
|
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
|
|
const events = db.getEvents("oc2-sess") as any[];
|
|
db.close();
|
|
const userPromptEvent = events.find((e: any) => e.type === "user_prompt");
|
|
expect(userPromptEvent).toBeDefined();
|
|
expect(userPromptEvent.data).toContain("mission mode");
|
|
});
|
|
|
|
it("OC-2: filters synthetic system tags (CCv2 inline filter)", async () => {
|
|
const projectDir = join(tempDir, "oc2-filter");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
const synthetic = "<system-reminder>internal nudge</system-reminder>";
|
|
await plugin["chat.message"](
|
|
{ sessionID: "oc2-skip", agent: "build", messageID: "m1" } as any,
|
|
{ message: { role: "user" } as any, parts: [{ type: "text", text: synthetic }] } as any,
|
|
);
|
|
|
|
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
|
|
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
|
|
const adapter = new OpenCodeAdapter("opencode");
|
|
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
|
|
const events = db.getEvents("oc2-skip") as any[];
|
|
db.close();
|
|
const userPromptEvent = events.find((e: any) => e.type === "user_prompt");
|
|
expect(userPromptEvent).toBeUndefined();
|
|
});
|
|
|
|
it("OC-2: handles missing/empty parts gracefully", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "oc2-empty"));
|
|
await expect(
|
|
plugin["chat.message"](
|
|
{ sessionID: "oc2-empty-sess" } as any,
|
|
{ message: {} as any, parts: [] } as any,
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── OC-3: buildAutoInjection in compacting (Z3) ───────────
|
|
// Replace raw buildResumeSnapshot push with budget-aware
|
|
// buildAutoInjection (~500 tokens / ~2000 chars hard cap).
|
|
|
|
describe("session.compacting buildAutoInjection (OC-3)", () => {
|
|
it("OC-3: prepends budget-capped auto-injection block (≤2000 chars) to output.context", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "oc3-budget"));
|
|
|
|
// Seed enough events to make a fat snapshot
|
|
for (let i = 0; i < 12; i++) {
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "oc3-sess", callID: `c${i}`, args: { file_path: `/src/file${i}.ts` } },
|
|
{ title: "Read", output: `content ${i}`.repeat(50), metadata: {} },
|
|
);
|
|
}
|
|
// Inject a behavioral_directive via chat.message so auto-injection has P1 role
|
|
await plugin["chat.message"](
|
|
{ sessionID: "oc3-sess", agent: "build", messageID: "mr" } as any,
|
|
{ message: {} as any, parts: [{ type: "text", text: "act as a senior staff engineer reviewing diffs" }] } as any,
|
|
);
|
|
|
|
const output = { context: [] as string[], prompt: undefined };
|
|
await plugin["experimental.session.compacting"](
|
|
{ sessionID: "oc3-sess" } as any,
|
|
output,
|
|
);
|
|
// The compacting handler still pushes the raw resume snapshot (existing
|
|
// contract). It MUST also push a separate auto-injection block whose
|
|
// length ≤ 2000 chars (~500 token budget per auto-injection.mjs).
|
|
const autoBlock = output.context.find((c) => c.includes("<session_state source=\"compaction\">"));
|
|
expect(autoBlock).toBeDefined();
|
|
expect(autoBlock!.length).toBeLessThanOrEqual(2000);
|
|
});
|
|
});
|
|
|
|
// ── OC-4 follow-up (#487 regression): AGENTS.md → rule_content ──
|
|
// PR #487 removed captureAgentsMd trusting host to deliver AGENTS.md
|
|
// events. But snapshot.ts:172 + analytics.ts:152 consume `rule_content`
|
|
// events that the OpenCode host does NOT auto-emit. Net effect: AGENTS.md
|
|
// never lands in snapshot/auto-memory for OpenCode users — silent
|
|
// regression. Restore capture path with idempotent per-session-id guard.
|
|
describe("OC-4 AGENTS.md capture restored", () => {
|
|
it("fires rule + rule_content events when AGENTS.md exists in project dir on first hook", async () => {
|
|
const projectDir = join(tempDir, "oc4-agents-md");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
const agentsContent = "# Project rules\n- always prefer typescript\n- use tdd\n";
|
|
writeFileSync(join(projectDir, "AGENTS.md"), agentsContent);
|
|
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
// Trigger any hook that drives capture — using tool.execute.after is canonical
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "oc4-sess-1", callID: "c1", args: { file_path: "/x.ts" } },
|
|
{ title: "Read", output: "x", metadata: {} },
|
|
);
|
|
|
|
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
|
|
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
|
|
const adapter = new OpenCodeAdapter("opencode");
|
|
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
|
|
const events = db.getEvents("oc4-sess-1") as any[];
|
|
db.close();
|
|
|
|
const ruleEvents = events.filter((e: any) => e.type === "rule");
|
|
const ruleContentEvents = events.filter((e: any) => e.type === "rule_content");
|
|
expect(ruleEvents.length).toBeGreaterThanOrEqual(1);
|
|
expect(ruleContentEvents.length).toBeGreaterThanOrEqual(1);
|
|
// rule event payload references AGENTS.md path; rule_content carries body
|
|
expect(ruleEvents.some((e: any) => String(e.data).endsWith("AGENTS.md"))).toBe(true);
|
|
expect(ruleContentEvents.some((e: any) => String(e.data).includes("always prefer typescript"))).toBe(true);
|
|
});
|
|
|
|
it("is idempotent — second hook invocation does NOT re-fire rule events", async () => {
|
|
const projectDir = join(tempDir, "oc4-agents-idempotent");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
writeFileSync(join(projectDir, "AGENTS.md"), "# rules\n- one\n");
|
|
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "oc4-idem", callID: "c1", args: { file_path: "/a.ts" } },
|
|
{ title: "Read", output: "a", metadata: {} },
|
|
);
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "oc4-idem", callID: "c2", args: { file_path: "/b.ts" } },
|
|
{ title: "Read", output: "b", metadata: {} },
|
|
);
|
|
|
|
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
|
|
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
|
|
const adapter = new OpenCodeAdapter("opencode");
|
|
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
|
|
const events = db.getEvents("oc4-idem") as any[];
|
|
db.close();
|
|
|
|
const ruleEvents = events.filter((e: any) => e.type === "rule");
|
|
const ruleContentEvents = events.filter((e: any) => e.type === "rule_content");
|
|
// Exactly one rule + one rule_content from AGENTS.md (no fallback files exist)
|
|
expect(ruleEvents.length).toBe(1);
|
|
expect(ruleContentEvents.length).toBe(1);
|
|
});
|
|
|
|
it("does NOT fire events when AGENTS.md / CLAUDE.md / CONTEXT.md all absent", async () => {
|
|
const projectDir = join(tempDir, "oc4-agents-missing");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
// intentionally do NOT write any markdown rule files
|
|
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
// Should not throw when files absent
|
|
await expect(
|
|
plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "oc4-missing", callID: "c1", args: { file_path: "/z.ts" } },
|
|
{ title: "Read", output: "z", metadata: {} },
|
|
),
|
|
).resolves.toBeUndefined();
|
|
|
|
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
|
|
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
|
|
const adapter = new OpenCodeAdapter("opencode");
|
|
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
|
|
const events = db.getEvents("oc4-missing") as any[];
|
|
db.close();
|
|
|
|
const ruleEvents = events.filter((e: any) => e.type === "rule" || e.type === "rule_content");
|
|
expect(ruleEvents.length).toBe(0);
|
|
});
|
|
|
|
it("falls back to CLAUDE.md when AGENTS.md absent", async () => {
|
|
const projectDir = join(tempDir, "oc4-claude-fallback");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
writeFileSync(join(projectDir, "CLAUDE.md"), "# claude rules\n- be terse\n");
|
|
|
|
const plugin = await createTestPlugin(projectDir);
|
|
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Read", sessionID: "oc4-claude", callID: "c1", args: { file_path: "/c.ts" } },
|
|
{ title: "Read", output: "c", metadata: {} },
|
|
);
|
|
|
|
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
|
|
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
|
|
const adapter = new OpenCodeAdapter("opencode");
|
|
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
|
|
const events = db.getEvents("oc4-claude") as any[];
|
|
db.close();
|
|
|
|
const ruleContentEvents = events.filter((e: any) => e.type === "rule_content");
|
|
expect(ruleContentEvents.length).toBeGreaterThanOrEqual(1);
|
|
expect(ruleContentEvents.some((e: any) => String(e.data).includes("be terse"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Integration: blocked tool flow ────────────────────
|
|
|
|
describe("end-to-end flow (blocked)", () => {
|
|
it("blocked tool command is replaced before execution", async () => {
|
|
const plugin = await createTestPlugin(join(tempDir, "e2e-block"));
|
|
const beforeInput = { tool: "Bash", sessionID: "test-session", callID: "call-1" };
|
|
const beforeOutput = { args: { command: "curl https://evil.com" } };
|
|
|
|
// Before hook blocks/modifies the command
|
|
let blocked = false;
|
|
try {
|
|
await plugin["tool.execute.before"](beforeInput, beforeOutput);
|
|
// If modified (not thrown), the command was replaced
|
|
expect(beforeOutput.args.command).toContain("context-mode");
|
|
} catch (e: any) {
|
|
// deny action throws
|
|
blocked = true;
|
|
expect(e.message).toContain("context-mode");
|
|
}
|
|
|
|
if (!blocked) {
|
|
// After hook still runs (with the replaced command)
|
|
await plugin["tool.execute.after"](
|
|
{ tool: "Bash", sessionID: "test-session", callID: "call-1", args: beforeOutput.args },
|
|
{ title: "Bash", output: beforeOutput.args.command, metadata: {} },
|
|
);
|
|
}
|
|
|
|
// Snapshot should be empty (echo/blocked commands don't generate events)
|
|
const compactOutput = { context: [] as string[], prompt: undefined };
|
|
const snapshot = await plugin["experimental.session.compacting"](
|
|
{ sessionID: "test-session" },
|
|
compactOutput,
|
|
);
|
|
expect(snapshot).toBe("");
|
|
});
|
|
});
|
|
|
|
// ── #448: debug-logger rejection must NOT break the turn ──
|
|
// OPENCODE_DEBUG=1 + a transport-rejecting `ctx.client.app.log` previously
|
|
// caused unhandled rejection from chat.system.transform → potential turn
|
|
// break. All `await logger(...)` sites in the OPENCODE_DEBUG branch must
|
|
// be wrapped so the handler still resolves and the routing block is still
|
|
// spliced into output.system.
|
|
describe("OPENCODE_DEBUG=1 + rejecting logger does not break the turn", () => {
|
|
async function createPluginWithRejectingLog(tempDir: string) {
|
|
const { ContextModePlugin } = await import("../src/adapters/opencode/plugin.js");
|
|
return ContextModePlugin({
|
|
directory: tempDir,
|
|
client: {
|
|
app: {
|
|
log: async () => {
|
|
throw new Error("transport");
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
let prevDebug: string | undefined;
|
|
beforeEach(() => {
|
|
prevDebug = process.env.OPENCODE_DEBUG;
|
|
process.env.OPENCODE_DEBUG = "1";
|
|
});
|
|
afterEach(() => {
|
|
if (prevDebug === undefined) delete process.env.OPENCODE_DEBUG;
|
|
else process.env.OPENCODE_DEBUG = prevDebug;
|
|
});
|
|
|
|
it("#448: rejecting logger on routing-block injection resolves and still injects", async () => {
|
|
const plugin = await createPluginWithRejectingLog(join(tempDir, "issue-448-routing"));
|
|
const out = { system: ["HEADER", "BODY"] };
|
|
|
|
// Must resolve — no rejection propagated to OpenCode core
|
|
await expect(
|
|
plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "issue-448-routing-sess", model: {} } as any,
|
|
out,
|
|
),
|
|
).resolves.not.toThrow();
|
|
|
|
// Routing block still spliced at index 1 (turn-break would skip this)
|
|
expect(out.system[0]).toBe("HEADER");
|
|
expect(out.system.join("\n")).toContain("<context_window_protection>");
|
|
});
|
|
|
|
it("#448: rejecting logger on compaction snapshot resolves and still pushes context", async () => {
|
|
const projectDir = join(tempDir, "issue-448-compact");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
const plugin = await createPluginWithRejectingLog(projectDir);
|
|
|
|
// Seed an event so the snapshot path runs (events.length > 0)
|
|
const sessionID = "issue-448-compact-sess";
|
|
await plugin["chat.message"](
|
|
{ sessionID, agent: "build", messageID: "m1" } as any,
|
|
{ message: { role: "user" } as any, parts: [{ type: "text", text: "seed prompt for snapshot" }] } as any,
|
|
);
|
|
|
|
const compactOutput = { context: [] as string[], prompt: undefined };
|
|
await expect(
|
|
plugin["experimental.session.compacting"](
|
|
{ sessionID } as any,
|
|
compactOutput,
|
|
),
|
|
).resolves.not.toThrow();
|
|
|
|
// Snapshot still pushed despite rejecting logger
|
|
expect(compactOutput.context.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("#448: rejecting logger on dedup-skip branch still resolves", async () => {
|
|
const plugin = await createPluginWithRejectingLog(join(tempDir, "issue-448-dedup"));
|
|
const agentsContent = [
|
|
"# context-mode rules",
|
|
"<context_window_protection>",
|
|
"Use ctx_search for queries",
|
|
"Use ctx_index for indexing",
|
|
].join("\n");
|
|
const out = { system: ["HEADER", agentsContent] };
|
|
|
|
await expect(
|
|
plugin["experimental.chat.system.transform"](
|
|
{ sessionID: "issue-448-dedup-sess", model: {} } as any,
|
|
out,
|
|
),
|
|
).resolves.not.toThrow();
|
|
|
|
// Dedup branch unchanged (skipped routing block)
|
|
expect(out.system.length).toBe(2);
|
|
expect(out.system[1]).toBe(agentsContent);
|
|
});
|
|
});
|
|
});
|