/**
* Subagent (pi-subagents child) sessions are read-only: memory/skill write
* tools return a fixed error and all automatic writes are skipped.
*
* Child detection keys on the `` tag pi-subagents
* stamps into `systemPromptOptions.customPrompt`; membership is tracked per
* sessionManager so a child in the same process never flips the parent.
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
isSubagentCustomPrompt,
isSubagentSession,
markSubagentSession,
markSubagentSessionFromPrompt,
SUBAGENT_WRITE_DISABLED_MESSAGE,
} from "../src/subagent-mode.js";
import { registerMemoryTool } from "../src/tools/memory-tool.js";
import { registerSkillTool } from "../src/tools/skill-tool.js";
import { setupSessionFlush } from "../src/handlers/session-flush.js";
import { MemoryStore } from "../src/store/memory-store.js";
import { SkillStore } from "../src/store/skill-store.js";
import type { MemoryConfig } from "../src/types.js";
const CHILD_PROMPT = 'You are the Explore agent.\n';
function beforeAgentStartEvent(customPrompt?: string) {
return { systemPromptOptions: customPrompt === undefined ? {} : { customPrompt } } as any;
}
describe("subagent detection", () => {
it("matches the pi-subagents contract on customPrompt only", () => {
assert.ok(isSubagentCustomPrompt(CHILD_PROMPT));
assert.ok(isSubagentCustomPrompt("intro tail"));
assert.ok(!isSubagentCustomPrompt("a normal custom prompt"));
assert.ok(!isSubagentCustomPrompt(""));
assert.ok(!isSubagentCustomPrompt(undefined));
assert.ok(!isSubagentCustomPrompt(null));
});
it("marks only sessions whose before_agent_start customPrompt carries the tag", () => {
const childManager = {};
const mainManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager: childManager } as any);
markSubagentSession(beforeAgentStartEvent(), { sessionManager: mainManager } as any);
markSubagentSession(beforeAgentStartEvent("no tag here"), { sessionManager: {} } as any);
assert.ok(isSubagentSession({ sessionManager: childManager }));
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
assert.ok(!isSubagentSession(undefined));
assert.ok(!isSubagentSession({}));
});
it("detects tag-leading rendered prompts early (session_start), not mid-prompt tags", () => {
const childManager = {};
const mainManager = {};
markSubagentSessionFromPrompt(
`\n\nYou are the Explore agent.\n\nrest`,
{ sessionManager: childManager } as any,
);
markSubagentSessionFromPrompt(
`preamble\n\nliteral inside project docs`,
{ sessionManager: mainManager } as any,
);
assert.ok(isSubagentSession({ sessionManager: childManager }));
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
});
});
describe("subagent write gating", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subagent-mode-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function makeMemoryStore(): MemoryStore {
return new MemoryStore({
memoryMode: "policy-only",
memoryCharLimit: 5000,
userCharLimit: 5000,
projectCharLimit: 5000,
nudgeInterval: 10,
reviewEnabled: false,
flushOnCompact: false,
flushOnShutdown: false,
flushMinTurns: 6,
autoConsolidate: false,
correctionDetection: false,
failureInjectionEnabled: false,
failureInjectionMaxAgeDays: 7,
failureInjectionMaxEntries: 5,
nudgeToolCalls: 15,
consolidationTimeoutMs: 60000,
memoryDir: tmpDir,
});
}
it("memory_add/replace/remove return the disabled error and perform no writes", async () => {
const tools = new Map();
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
const store = makeMemoryStore();
await store.loadFromDisk();
let bindCalls = 0;
registerMemoryTool(mockPi, store, null, null, null, () => { bindCalls++; });
const sessionManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
const ctx = { cwd: tmpDir, sessionManager };
for (const name of ["memory_add", "memory_replace", "memory_remove"]) {
const result = await tools.get(name).execute("tc", { target: "memory", content: "x", old_text: "x" }, undefined, undefined, ctx);
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
assert.strictEqual(result.details.success, false, name);
assert.strictEqual(result.details.error, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
}
assert.strictEqual(bindCalls, 0, "blocked writes must not bind projects");
assert.deepStrictEqual(store.getMemoryEntries(), [], "blocked writes must not mutate the store");
assert.ok(!fs.existsSync(path.join(tmpDir, "MEMORY.md")), "blocked writes must not write files");
});
it("memory tools still write in an unmarked (main) session", async () => {
const tools = new Map();
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
const store = makeMemoryStore();
await store.loadFromDisk();
registerMemoryTool(mockPi, store, null);
const result = await tools.get("memory_add").execute(
"tc",
{ target: "memory", content: "main session entry" },
undefined,
undefined,
{ cwd: tmpDir, sessionManager: {} },
);
assert.strictEqual(result.details.success, true);
assert.deepStrictEqual(store.getMemoryEntries(), ["main session entry"]);
});
it("skill_manage returns the disabled error in a subagent session", async () => {
let tool: any;
const mockPi = { registerTool: (def: any) => { tool = def; } } as any;
const store = new SkillStore({
globalSkillsDir: path.join(tmpDir, "skills"),
piGlobalSkillsDir: path.join(tmpDir, "pi-skills"),
projectSkillsDir: null,
projectName: null,
legacySkillsDir: path.join(tmpDir, "legacy-skills"),
migrationSentinelPath: path.join(tmpDir, ".migrated"),
});
registerSkillTool(mockPi, store);
const sessionManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
const result = await tool.execute(
"tc",
{ action: "create", name: "demo", description: "d", scope: "global", content: "body" },
undefined,
undefined,
{ sessionManager },
);
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE);
assert.strictEqual(result.details.success, false);
assert.ok(!fs.existsSync(path.join(tmpDir, "skills")), "blocked skill writes must not create the skills dir");
});
it("session flush skips compact and shutdown flushes in a subagent session", async () => {
const handlers: Record = {};
const mockPi = {
on(event: string, handler: Function) {
(handlers[event] ??= []).push(handler);
},
async exec() {
throw new Error("subagent flush must not spawn a child pi");
},
registerTool() {},
registerCommand() {},
} as any;
let directCalls = 0;
const config = {
flushOnCompact: true,
flushOnShutdown: true,
flushMinTurns: 1,
flushCompactTimeoutMs: 60000,
reviewTransport: "direct",
} as MemoryConfig;
const sessionManager = { getBranch: () => [] };
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
setupSessionFlush(mockPi, { getMemoryEntries: () => [], getUserEntries: () => [] } as any, null, config, null, null, {
runDirectMemoryCompletion: async () => {
directCalls++;
return { ok: true } as any;
},
});
const ctx = { sessionManager, cwd: tmpDir };
for (const handler of handlers["message_end"] ?? []) {
await handler({ message: { role: "user", content: "hi" } }, ctx);
}
for (const handler of handlers["session_before_compact"] ?? []) {
await handler({ signal: undefined }, ctx);
}
for (const handler of handlers["session_shutdown"] ?? []) {
await handler({ reason: "exit" }, ctx);
}
assert.strictEqual(directCalls, 0, "subagent sessions must not flush");
// Control: the same handlers must still flush an unmarked (main) session.
const mainCtx = { sessionManager: { getBranch: () => [] }, cwd: tmpDir };
for (const handler of handlers["message_end"] ?? []) {
await handler({ message: { role: "user", content: "hi" } }, mainCtx);
}
for (const handler of handlers["session_before_compact"] ?? []) {
await handler({ signal: undefined }, mainCtx);
}
assert.ok(directCalls > 0, "main sessions must still flush");
});
});