08f59baa70
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
677 lines
28 KiB
TypeScript
677 lines
28 KiB
TypeScript
/**
|
|
* Agent.createSession's Workspace handling and vault injection (no network needed; only
|
|
* constructs the Session, never sends a request).
|
|
*
|
|
* Regression: an explicitly given Workspace must be an existing directory. When it
|
|
* does not exist, a clear error must be thrown rather than auto-creating it, and bash must not
|
|
* be started with an invalid cwd after Session creation, which would throw a misleading
|
|
* `spawn bash ENOENT`. A temp directory is only created when no Workspace is specified.
|
|
*
|
|
* vault: the Agent vault's (agent_state/.vault.toml) **key names** are
|
|
* injected into the assembled system prompt; values are never injected.
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
addModel,
|
|
createAgent,
|
|
DEFAULT_AGENT_ID,
|
|
DEFAULT_PROJECT_ID,
|
|
installSkill,
|
|
setVaultEntry,
|
|
} from "../src/index.js";
|
|
import { effectiveMaxContextLength, metaMaxTokens } from "../src/agent.js";
|
|
import { mapThinkingLevel } from "../src/llm/index.js";
|
|
import { stubProviderKeys } from "./provider-keys.js";
|
|
import type { EnvironmentConfig, EnvironmentServices, SubagentRunner } from "../src/interfaces.js";
|
|
|
|
// Captures the services buildRuntime hands to each Environment, so tests can drive the REAL
|
|
// subagent runner (the spawn closure in agent.ts). Spawning only constructs the child Session,
|
|
// and pulling a single message from handle.run yields the child session_meta before any LLM
|
|
// request is issued — no network is ever touched. The wrapper is otherwise transparent, so
|
|
// every other test in this file behaves as with the real class.
|
|
const capturedEnvServices = vi.hoisted(() => ({ list: [] as unknown[] }));
|
|
vi.mock("../src/environment/index.js", async (importOriginal) => {
|
|
const mod = await importOriginal<typeof import("../src/environment/index.js")>();
|
|
class CapturingEnvironment extends mod.Environment {
|
|
constructor(config: EnvironmentConfig) {
|
|
super(config);
|
|
capturedEnvServices.list.push(config.services);
|
|
}
|
|
}
|
|
return { ...mod, Environment: CapturingEnvironment };
|
|
});
|
|
|
|
// Captures every GenerativeModelConfig buildRuntime constructs: the effective thinking level
|
|
// is no longer observable through session_meta (it holds invariants only) — assertions read
|
|
// the construction default from the captured config instead.
|
|
const capturedLLMConfigs = vi.hoisted(() => ({ list: [] as { thinkingLevel?: string }[] }));
|
|
vi.mock("../src/llm/index.js", async (importOriginal) => {
|
|
const mod = await importOriginal<typeof import("../src/llm/index.js")>();
|
|
class CapturingGenerativeModel extends mod.GenerativeModel {
|
|
constructor(config: ConstructorParameters<typeof mod.GenerativeModel>[0]) {
|
|
super(config);
|
|
capturedLLMConfigs.list.push(config as { thinkingLevel?: string });
|
|
}
|
|
}
|
|
return { ...mod, GenerativeModel: CapturingGenerativeModel };
|
|
});
|
|
|
|
let tmpRoot: string;
|
|
let prevHome: string | undefined;
|
|
let restoreKeys: () => void;
|
|
|
|
beforeEach(async () => {
|
|
prevHome = process.env.PENGUIN_HOME;
|
|
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-harness-"));
|
|
process.env.PENGUIN_HOME = tmpRoot;
|
|
restoreKeys = stubProviderKeys();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (prevHome === undefined) delete process.env.PENGUIN_HOME;
|
|
else process.env.PENGUIN_HOME = prevHome;
|
|
restoreKeys();
|
|
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("effectiveMaxContextLength (compaction threshold clamped to the model window)", () => {
|
|
it("clamps to 75% of a small model window; leaves big/unknown windows and off untouched", () => {
|
|
expect(effectiveMaxContextLength(128000, 32768)).toBe(24576); // small window: clamp to 75%
|
|
expect(effectiveMaxContextLength(128000, 200000)).toBe(128000); // ample window: unchanged
|
|
expect(effectiveMaxContextLength(-1, 32768)).toBe(-1); // off: no clamping
|
|
expect(effectiveMaxContextLength(0, 32768)).toBe(0); // off: no clamping
|
|
expect(effectiveMaxContextLength(128000, "unknown")).toBe(128000); // unknown window: no clamping
|
|
});
|
|
});
|
|
|
|
describe("metaMaxTokens (meta-request budget tightened by the per-model cap)", () => {
|
|
it("keeps the budget unless the per-model cap is smaller; never raises it", () => {
|
|
expect(metaMaxTokens(300, undefined)).toBe(300); // no per-model cap: the budget as-is
|
|
expect(metaMaxTokens(300, 8000)).toBe(300); // ample cap: the small budget stays
|
|
expect(metaMaxTokens(300, 128)).toBe(128); // pinned below the budget: the cap binds
|
|
expect(metaMaxTokens(2048, 1024)).toBe(1024); // vision-describer budget, same rule
|
|
expect(metaMaxTokens(300, -1)).toBe(300); // -1 = uncapped: never tightens to -1 (issue #55 sibling)
|
|
});
|
|
});
|
|
|
|
describe("Agent.createSession workspace handling", () => {
|
|
it("throws a clear error when the given workspace does not exist (no auto-create)", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "nested", "does-not-exist");
|
|
await expect(agent.createSession({ workspaceDir: ws })).rejects.toThrow(/does not exist/);
|
|
// Must not be auto-created.
|
|
await expect(fs.stat(ws)).rejects.toThrow();
|
|
});
|
|
|
|
it("throws when the given workspace path is not a directory", async () => {
|
|
const agent = await createAgent();
|
|
const filePath = path.join(tmpRoot, "a-file");
|
|
await fs.writeFile(filePath, "x", "utf8");
|
|
await expect(agent.createSession({ workspaceDir: filePath })).rejects.toThrow(
|
|
/is not a directory/,
|
|
);
|
|
});
|
|
|
|
it("accepts an existing directory and resolves it to an absolute path", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const session = await agent.createSession({ workspaceDir: ws });
|
|
expect(session.workspaceDir).toBe(ws);
|
|
expect(path.isAbsolute(session.workspaceDir)).toBe(true);
|
|
});
|
|
|
|
it("rejects a model reference that is not in the Project config with a clear error", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-bad-model");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
// A reference outside the config is not silently allowed (the unique key is provider +
|
|
// model_id); the error is thrown before creating the temp Workspace.
|
|
await expect(
|
|
agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "not-configured-model",
|
|
provider: "custom",
|
|
}),
|
|
).rejects.toThrow(/is not in the Project config/);
|
|
await expect(
|
|
agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-pro", provider: "openai" }),
|
|
).rejects.toThrow(/\(provider=openai, model_id=deepseek-v4-pro\)/);
|
|
});
|
|
|
|
it("passes model timeout from system_config to GenerativeModel", async () => {
|
|
const agent = await createAgent();
|
|
agent.state.systemConfig.model = {
|
|
...(agent.state.systemConfig.model ?? {}),
|
|
timeoutMs: 3456,
|
|
};
|
|
const ws = path.join(tmpRoot, "ws-timeout");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
|
|
const session = await agent.createSession({ workspaceDir: ws });
|
|
const llm = (session as unknown as { engine: { deps: { llm: unknown } } }).engine.deps.llm;
|
|
|
|
expect((llm as { requestTimeoutMs?: number }).requestTimeoutMs).toBe(3456);
|
|
});
|
|
});
|
|
|
|
describe("Agent.createSession model reference ((provider, model_id) pair)", () => {
|
|
it("records the pair reference in session_meta (default_model when unspecified)", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-ref-default");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const session = await agent.createSession({ workspaceDir: ws });
|
|
try {
|
|
// Defaults to the default_model reference; session_meta carries the pair reference
|
|
// (same source that Trace writes).
|
|
const meta = session.metaMessage.payload as { provider: string; model_id: string };
|
|
expect(meta.provider).toBe("deepseek");
|
|
expect(meta.model_id).toBe("deepseek-v4-pro");
|
|
expect(session.provider).toBe("deepseek");
|
|
expect(session.modelId).toBe("deepseek-v4-pro");
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
|
|
it("selects the entry named by the pair, even when a second group sells the same model_id", async () => {
|
|
// A user-run proxy resells claude-sonnet-4-6 under the same upstream id: the two entries
|
|
// coexist and the pair — not the bare id — decides which one (and therefore which
|
|
// credential and base_url) the Session runs on.
|
|
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
|
provider: "myproxy",
|
|
model_id: "claude-sonnet-4-6",
|
|
});
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-ref-pair");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const vendor = await agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "claude-sonnet-4-6",
|
|
provider: "anthropic",
|
|
});
|
|
try {
|
|
expect(vendor.provider).toBe("anthropic");
|
|
expect(vendor.modelId).toBe("claude-sonnet-4-6");
|
|
} finally {
|
|
vendor.dispose();
|
|
}
|
|
const proxied = await agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "claude-sonnet-4-6",
|
|
provider: "myproxy",
|
|
});
|
|
try {
|
|
expect(proxied.provider).toBe("myproxy");
|
|
expect(proxied.modelId).toBe("claude-sonnet-4-6");
|
|
} finally {
|
|
proxied.dispose();
|
|
}
|
|
});
|
|
|
|
it("rejects half a reference: modelId without provider, and provider without modelId", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-ref-half");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
// A bare model_id is never resolved against the config, not even when exactly one entry
|
|
// carries it (deepseek-v4-flash is unique here): the group is the caller's to name.
|
|
await expect(
|
|
agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-flash" }),
|
|
).rejects.toThrow(/must be given as a \(provider, model_id\) pair/);
|
|
// The mirror case: provider alone is not a reference either.
|
|
await expect(agent.createSession({ workspaceDir: ws, provider: "deepseek" })).rejects.toThrow(
|
|
/must be given as a \(provider, model_id\) pair/,
|
|
);
|
|
// Neither half given is the documented "use the Project default" path, not an error.
|
|
const session = await agent.createSession({ workspaceDir: ws });
|
|
try {
|
|
expect(session.provider).toBe("deepseek");
|
|
expect(session.modelId).toBe("deepseek-v4-pro");
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Agent.createSession session source (session_meta origin marker)", () => {
|
|
it("records the given source in session_meta; a user-created session carries no source key", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-source");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
|
|
const scheduled = await agent.createSession({ workspaceDir: ws, source: "schedule" });
|
|
try {
|
|
expect((scheduled.metaMessage.payload as { source?: string }).source).toBe("schedule");
|
|
} finally {
|
|
scheduled.dispose();
|
|
}
|
|
|
|
// Absent = user-created: the key must not appear at all (Trace consumers treat absence as the default).
|
|
const plain = await agent.createSession({ workspaceDir: ws });
|
|
try {
|
|
expect("source" in (plain.metaMessage.payload as unknown as Record<string, unknown>)).toBe(
|
|
false,
|
|
);
|
|
} finally {
|
|
plain.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Agent.createSession thinking level (explicit option wins over the Agent config)", () => {
|
|
// The session's default level lives on the LLM object (per-request overrides fall back to
|
|
// it); session_meta holds per-session invariants only and never records a thinking level.
|
|
const defaultLevelOf = (session: unknown): unknown =>
|
|
(session as { engine: { deps: { llm: { defaultThinkingLevel?: unknown } } } }).engine.deps.llm
|
|
.defaultThinkingLevel;
|
|
|
|
it("falls back to the Agent config for the llm default; session_meta records no level", async () => {
|
|
const agent = await createAgent();
|
|
// The seeded Agent config pins thinking_level "medium" — the only source when no option is given.
|
|
expect(agent.state.systemConfig.model?.thinking_level).toBe("medium");
|
|
const ws = path.join(tmpRoot, "ws-thinking-default");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const session = await agent.createSession({ workspaceDir: ws });
|
|
try {
|
|
// session_meta contains ONLY per-session invariants: the thinking level is per-turn.
|
|
expect(
|
|
"thinking_level" in (session.metaMessage.payload as unknown as Record<string, unknown>),
|
|
).toBe(false);
|
|
expect(defaultLevelOf(session)).toBe("medium");
|
|
expect(mapThinkingLevel("medium")).toBeDefined(); // the name maps onto the wire enum
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
|
|
it("uses an explicit thinkingLevel over the Agent config (subagent inheritance rides on this)", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-thinking-explicit");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const session = await agent.createSession({ workspaceDir: ws, thinkingLevel: "high" });
|
|
try {
|
|
expect(defaultLevelOf(session)).toBe("high");
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("run_subagent spawning follows the PARENT session (never the Project default)", () => {
|
|
/** The subagent runner captured from the most recently constructed real Environment. */
|
|
function lastSpawnedRunner(): SubagentRunner {
|
|
const services = capturedEnvServices.list.at(-1) as EnvironmentServices | undefined;
|
|
const runner = services?.subagentRunner;
|
|
expect(runner).toBeDefined();
|
|
return runner!;
|
|
}
|
|
|
|
/**
|
|
* Spawns through the real runner and reads the child session_meta — the first message
|
|
* handle.run yields, emitted before any LLM request; the run generator is closed right
|
|
* after, so nothing is ever sent upstream. The child's effective thinking level is no
|
|
* longer in session_meta (invariants only): it's read from the captured LLM config the
|
|
* spawn constructed (the last one pushed).
|
|
*/
|
|
async function spawnedChildMeta(
|
|
runner: SubagentRunner,
|
|
input: Parameters<SubagentRunner["spawn"]>[0],
|
|
): Promise<{
|
|
provider: string;
|
|
model_id: string;
|
|
workspace: string;
|
|
source?: string;
|
|
llm: { thinkingLevel?: string };
|
|
}> {
|
|
const handle = await runner.spawn(input);
|
|
const llm = capturedLLMConfigs.list.at(-1)!;
|
|
try {
|
|
const gen = handle.run({ prompt: "noop" });
|
|
const first = await gen.next();
|
|
expect(first.done).toBe(false);
|
|
const msg = first.value!;
|
|
expect(msg.type).toBe("session_meta");
|
|
// Child messages are stamped with the child Session id as the origin hop.
|
|
expect(msg.origin?.[0]).toBe(handle.sessionId);
|
|
await gen.return(undefined);
|
|
return {
|
|
...(msg.payload as {
|
|
provider: string;
|
|
model_id: string;
|
|
workspace: string;
|
|
source?: string;
|
|
}),
|
|
llm,
|
|
};
|
|
} finally {
|
|
handle.dispose();
|
|
}
|
|
}
|
|
|
|
it("inherits the parent's model pair, thinking level, and workspace when the args omit them", async () => {
|
|
const agent = await createAgent();
|
|
agent.state.systemConfig.model = {
|
|
...(agent.state.systemConfig.model ?? {}),
|
|
thinking_level: "high",
|
|
};
|
|
const ws = path.join(tmpRoot, "ws-inherit");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
// The parent runs a NON-default model: the Project default (deepseek pair) must not leak in.
|
|
const parent = await agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "claude-sonnet-4-6",
|
|
provider: "anthropic",
|
|
});
|
|
const runner = lastSpawnedRunner();
|
|
try {
|
|
const child = await spawnedChildMeta(runner, {});
|
|
expect(child.provider).toBe("anthropic");
|
|
expect(child.model_id).toBe("claude-sonnet-4-6");
|
|
expect(child.llm.thinkingLevel).toBe("high");
|
|
// Workspace inheritance (behavior that predates model/thinking inheritance): locked here.
|
|
expect(child.workspace).toBe(ws);
|
|
// The spawn site marks the child's own session_meta as subagent-created — the single
|
|
// source of truth the server derives from (its registration fallback cannot mask this).
|
|
expect(child.source).toBe("subagent");
|
|
} finally {
|
|
parent.dispose();
|
|
}
|
|
});
|
|
|
|
it("still honors an explicit (provider, model_id) pair over inheritance", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-inherit-explicit");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const parent = await agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "claude-sonnet-4-6",
|
|
provider: "anthropic",
|
|
});
|
|
const runner = lastSpawnedRunner();
|
|
try {
|
|
const child = await spawnedChildMeta(runner, {
|
|
modelId: "deepseek-v4-pro",
|
|
provider: "deepseek",
|
|
});
|
|
expect(child.provider).toBe("deepseek");
|
|
expect(child.model_id).toBe("deepseek-v4-pro");
|
|
// Thinking level and workspace are inherited implicitly even with an explicit model.
|
|
expect(child.llm.thinkingLevel).toBe("medium");
|
|
expect(child.workspace).toBe(ws);
|
|
} finally {
|
|
parent.dispose();
|
|
}
|
|
});
|
|
|
|
it("passes 'no thinking level' down when the parent has none (the child config never applies)", async () => {
|
|
// helper_agent's own seeded config pins "medium"; the parent's optional key is removed
|
|
// (hand-edited config). The tri-state null must reach the child: falling back to the
|
|
// child's own config here was the fallback hole — the child must show no level at all.
|
|
await createAgent({ agentId: "helper_agent" });
|
|
const agent = await createAgent();
|
|
delete agent.state.systemConfig.model?.thinking_level;
|
|
const ws = path.join(tmpRoot, "ws-inherit-none");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const parent = await agent.createSession({ workspaceDir: ws });
|
|
const parentLLM = capturedLLMConfigs.list.at(-1)!;
|
|
const runner = lastSpawnedRunner();
|
|
try {
|
|
expect("thinkingLevel" in parentLLM).toBe(false);
|
|
const child = await spawnedChildMeta(runner, { agentId: "helper_agent" });
|
|
// The tri-state null reached the child: no level at all, not helper_agent's "medium".
|
|
expect("thinkingLevel" in child.llm).toBe(false);
|
|
} finally {
|
|
parent.dispose();
|
|
}
|
|
});
|
|
|
|
it("rejects half a model reference at the runner layer (never completed from the parent's half)", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-inherit-half");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const parent = await agent.createSession({ workspaceDir: ws });
|
|
const runner = lastSpawnedRunner();
|
|
try {
|
|
await expect(runner.spawn({ modelId: "claude-sonnet-4-6" })).rejects.toThrow(
|
|
/must be given as a \(provider, model_id\) pair/,
|
|
);
|
|
await expect(runner.spawn({ provider: "anthropic" })).rejects.toThrow(
|
|
/must be given as a \(provider, model_id\) pair/,
|
|
);
|
|
} finally {
|
|
parent.dispose();
|
|
}
|
|
});
|
|
|
|
it("makes a cross-agent child follow the parent session, not its own Agent config", async () => {
|
|
// Seed the second Agent first (spawn verifies its system_config exists); its own seeded
|
|
// config (thinking "medium") and the Project default model must both lose to the parent.
|
|
await createAgent({ agentId: "helper_agent" });
|
|
const agent = await createAgent();
|
|
agent.state.systemConfig.model = {
|
|
...(agent.state.systemConfig.model ?? {}),
|
|
thinking_level: "xhigh",
|
|
};
|
|
const ws = path.join(tmpRoot, "ws-inherit-cross");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const parent = await agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "claude-sonnet-4-6",
|
|
provider: "anthropic",
|
|
});
|
|
const runner = lastSpawnedRunner();
|
|
try {
|
|
const child = await spawnedChildMeta(runner, { agentId: "helper_agent" });
|
|
expect(child.provider).toBe("anthropic");
|
|
expect(child.model_id).toBe("claude-sonnet-4-6");
|
|
expect(child.llm.thinkingLevel).toBe("xhigh");
|
|
expect(child.workspace).toBe(ws);
|
|
} finally {
|
|
parent.dispose();
|
|
}
|
|
});
|
|
|
|
it("forwards the run input as an origin-tagged user message, after session_meta and before any model output", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-input-forward");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
const parent = await agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "claude-sonnet-4-6",
|
|
provider: "anthropic",
|
|
});
|
|
const runner = lastSpawnedRunner();
|
|
const handle = await runner.spawn({});
|
|
try {
|
|
// The child's user side has no other live source: a session's normal caller typed its
|
|
// own input, but this caller is the PARENT — the frontend renders the child conversation
|
|
// purely from these forwarded messages. (Regression: the panel showed no user messages
|
|
// live, while a reload — child-Trace expansion — did show them.)
|
|
const gen = handle.run({ prompt: "count the TODO items" });
|
|
const first = (await gen.next()).value as { type: string };
|
|
expect(first.type).toBe("session_meta");
|
|
// The input yield precedes childSession.run: pulling it never issues an LLM request.
|
|
const second = await gen.next();
|
|
expect(second.done).toBe(false);
|
|
const msg = second.value as {
|
|
type: string;
|
|
origin?: string[];
|
|
payload: { type: string; role?: string; text?: string };
|
|
};
|
|
expect(msg.origin).toEqual([handle.sessionId]);
|
|
expect(msg.type).toBe("model_msg");
|
|
expect(msg.payload).toMatchObject({
|
|
type: "text",
|
|
role: "user",
|
|
text: "count the TODO items",
|
|
});
|
|
await gen.return(undefined);
|
|
} finally {
|
|
handle.dispose();
|
|
parent.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Agent.createSession max output tokens (per-model cap wins over the Agent config)", () => {
|
|
// Reads a constructed GenerativeModel's request config (private; runtime-accessible for assertion).
|
|
const uniConfigOf = (llm: unknown) =>
|
|
(llm as { uniConfig?: { max_tokens?: number } }).uniConfig ?? {};
|
|
|
|
it("uses the entry's max_tokens in llmConfig, and inherits the seeded 32000 when unset", async () => {
|
|
// A 32k-context local model: the seeded per-Agent default (32000 output tokens) cannot fit
|
|
// into its window together with any prompt — the pinned per-model cap must win.
|
|
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
|
provider: "custom",
|
|
model_id: "local-32k",
|
|
client_type: "openai",
|
|
context_window: 32768,
|
|
max_tokens: 8000,
|
|
});
|
|
const agent = await createAgent();
|
|
expect(agent.state.systemConfig.model?.max_tokens).toBe(32000);
|
|
const ws = path.join(tmpRoot, "ws-max-tokens");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
|
|
const pinned = await agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "local-32k",
|
|
provider: "custom",
|
|
});
|
|
try {
|
|
const llm = (pinned as unknown as { engine: { deps: { llm: unknown } } }).engine.deps.llm;
|
|
expect(uniConfigOf(llm).max_tokens).toBe(8000);
|
|
} finally {
|
|
pinned.dispose();
|
|
}
|
|
|
|
// An unannotated entry (the default model) inherits the Agent value, as before.
|
|
const inherited = await agent.createSession({ workspaceDir: ws });
|
|
try {
|
|
const llm = (inherited as unknown as { engine: { deps: { llm: unknown } } }).engine.deps.llm;
|
|
expect(uniConfigOf(llm).max_tokens).toBe(32000);
|
|
} finally {
|
|
inherited.dispose();
|
|
}
|
|
});
|
|
|
|
it("meta requests keep their small budget, tightened when the per-model cap is even smaller", async () => {
|
|
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
|
provider: "custom",
|
|
model_id: "local-32k",
|
|
client_type: "openai",
|
|
max_tokens: 8000,
|
|
});
|
|
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
|
provider: "custom",
|
|
model_id: "tiny-cap",
|
|
client_type: "openai",
|
|
max_tokens: 128,
|
|
});
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-meta-cap");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
|
|
// Ample per-model cap: the title one-shot keeps its own 300 budget (never raised to the cap).
|
|
const ample = await agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "local-32k",
|
|
provider: "custom",
|
|
});
|
|
try {
|
|
const bare = (ample as unknown as { createBareLLM?: () => unknown }).createBareLLM?.();
|
|
expect(uniConfigOf(bare).max_tokens).toBe(300);
|
|
} finally {
|
|
ample.dispose();
|
|
}
|
|
|
|
// Cap pinned below the budget: the meta request must respect it too.
|
|
const tiny = await agent.createSession({
|
|
workspaceDir: ws,
|
|
modelId: "tiny-cap",
|
|
provider: "custom",
|
|
});
|
|
try {
|
|
const bare = (tiny as unknown as { createBareLLM?: () => unknown }).createBareLLM?.();
|
|
expect(uniConfigOf(bare).max_tokens).toBe(128);
|
|
} finally {
|
|
tiny.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Agent.createSession vault injection", () => {
|
|
it("injects vault key names (never values) into the assembled system prompt", async () => {
|
|
// Write the Agent vault to disk first; createSession reads that Agent's own .vault.toml.
|
|
await setVaultEntry(
|
|
tmpRoot,
|
|
DEFAULT_PROJECT_ID,
|
|
DEFAULT_AGENT_ID,
|
|
"VAULT_ONLY_KEY",
|
|
"vault-secret-value",
|
|
);
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-vault");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
|
|
const session = await agent.createSession({ workspaceDir: ws });
|
|
try {
|
|
const meta = session.metaMessage.payload as { system_prompt: string };
|
|
// The "# Vault" statement is part of the template body; key names are injected at the
|
|
// placeholder as a `- KEY` list.
|
|
expect(meta.system_prompt).toContain("# Vault");
|
|
expect(meta.system_prompt).toContain("- VAULT_ONLY_KEY");
|
|
// Values never enter the model context.
|
|
expect(meta.system_prompt).not.toContain("vault-secret-value");
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
|
|
it("keeps the vault statement but lists no keys when the Agent has no vault", async () => {
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-no-vault");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
|
|
const session = await agent.createSession({ workspaceDir: ws });
|
|
try {
|
|
const meta = session.metaMessage.payload as { system_prompt: string };
|
|
// No vault: the "# Vault" section statement is kept, and the
|
|
// placeholder is replaced with an empty string, leaving no residue.
|
|
expect(meta.system_prompt).toContain("# Vault");
|
|
expect(meta.system_prompt).not.toContain("{{VAULT_KEYS}}");
|
|
expect(meta.system_prompt).not.toContain("VAULT_ONLY_KEY");
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Agent.createSession skill metadata injection", () => {
|
|
it("injects installed skill metadata lines (never bodies) into the assembled system prompt", async () => {
|
|
await installSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, {
|
|
name: "demo-skill",
|
|
content:
|
|
"---\nname: demo-skill\ndescription: Demo skill for tests.\nversion: 1\nupdated: 2026-07-16\n---\n\nSKILL_BODY_NOT_IN_PROMPT\n",
|
|
});
|
|
const agent = await createAgent();
|
|
const ws = path.join(tmpRoot, "ws-skills");
|
|
await fs.mkdir(ws, { recursive: true });
|
|
|
|
const session = await agent.createSession({ workspaceDir: ws });
|
|
try {
|
|
const meta = session.metaMessage.payload as { system_prompt: string };
|
|
expect(meta.system_prompt).toContain("# Skills");
|
|
expect(meta.system_prompt).toContain("- `demo-skill` — Demo skill for tests.");
|
|
// Only metadata is injected; the model reads the body on demand.
|
|
expect(meta.system_prompt).not.toContain("SKILL_BODY_NOT_IN_PROMPT");
|
|
expect(meta.system_prompt).not.toContain("{{SKILL_METADATA}}");
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
});
|