Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
1 parent
056bed7aeb
commit
45bfae6e94
543 files changed
+92949
No files matched your search
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Agent lifecycle: new session id format + no more `.penguin` symlink in the Workspace.
|
||||
*
|
||||
* - sessionId looks like `session-YYYY-MM-DD-HH-mm-ss-<8-digit hex>` (local time, zero-padded fields).
|
||||
* - createSession no longer creates any `.penguin` symlink inside the Workspace, nor touches existing
|
||||
* Workspace files; the model reaches Agent State and other absolute paths directly by combining the
|
||||
* Project Dir / Agent ID placeholders from the system prompt.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createAgent } from "../src/index.js";
|
||||
import { formatSessionId } from "../src/internal/session-support.js";
|
||||
import { projectDir } from "../src/state/paths.js";
|
||||
import { stubProviderKeys } from "./provider-keys.js";
|
||||
|
||||
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-lifecycle-"));
|
||||
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 });
|
||||
});
|
||||
|
||||
const SESSION_ID_RE = /^session-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-[0-9a-f]{8}$/;
|
||||
|
||||
describe("formatSessionId", () => {
|
||||
it("matches the session-YYYY-MM-DD-HH-mm-ss-<8hex> format", () => {
|
||||
expect(formatSessionId()).toMatch(SESSION_ID_RE);
|
||||
});
|
||||
|
||||
it("uses local time fields with zero padding", () => {
|
||||
// 2026-06-19 15:28:08 local time -> session-2026-06-19-15-28-08-<hex>.
|
||||
const d = new Date(2026, 5, 19, 15, 28, 8);
|
||||
expect(formatSessionId(d)).toMatch(/^session-2026-06-19-15-28-08-[0-9a-f]{8}$/);
|
||||
});
|
||||
|
||||
it("generates distinct ids on repeated calls", () => {
|
||||
expect(formatSessionId()).not.toBe(formatSessionId());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent.createSession session id + no .penguin symlink", () => {
|
||||
it("assigns a sessionId in the new format", 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.sessionId).toMatch(SESSION_ID_RE);
|
||||
});
|
||||
|
||||
it("does not create a .penguin entry in the workspace", async () => {
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
await agent.createSession({ workspaceDir: ws });
|
||||
await expect(fs.lstat(path.join(ws, ".penguin"))).rejects.toThrow();
|
||||
// agent_state also no longer creates traces/notes symlinks.
|
||||
await expect(fs.lstat(path.join(agent.state.stateDir, "traces"))).rejects.toThrow();
|
||||
await expect(fs.lstat(path.join(agent.state.stateDir, "notes"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("leaves a user's pre-existing .penguin file untouched", async () => {
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
const linkPath = path.join(ws, ".penguin");
|
||||
await fs.writeFile(linkPath, "user-data", "utf8");
|
||||
await agent.createSession({ workspaceDir: ws });
|
||||
expect(await fs.readFile(linkPath, "utf8")).toBe("user-data");
|
||||
});
|
||||
|
||||
it("is idempotent: repeated createSession registers no exit listeners", async () => {
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
const before = process.listenerCount("exit");
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await agent.createSession({ workspaceDir: ws });
|
||||
}
|
||||
expect(process.listenerCount("exit") - before).toBe(0);
|
||||
});
|
||||
|
||||
it("injects Project Dir and Agent ID into the assembled system prompt", async () => {
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
const session = await agent.createSession({ workspaceDir: ws });
|
||||
const prompt = (session.metaMessage.payload as { system_prompt: string }).system_prompt;
|
||||
expect(prompt).toContain(`Agent ID: ${agent.state.agentId}`);
|
||||
expect(prompt).toContain(`Project Dir: ${projectDir(tmpRoot, agent.state.projectId)}`);
|
||||
expect(prompt).not.toContain(".penguin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* On-disk behavior of an Agent's installed Skills: installSkill /
|
||||
* removeSkill / listInstalledSkills, and metadata injection via skillMetadataSection /
|
||||
* assembleSystemPrompt.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { librarySkill } from "@prismshadow/penguin-skills";
|
||||
import {
|
||||
AGENTS_MD_PLACEHOLDER,
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_PROJECT_ID,
|
||||
SKILL_METADATA_PLACEHOLDER,
|
||||
agentStateDir,
|
||||
assembleSystemPrompt,
|
||||
installSkill,
|
||||
listInstalledSkills,
|
||||
removeSkill,
|
||||
skillMetadataSection,
|
||||
skillsDir,
|
||||
} from "../src/state/index.js";
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-skills-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const install = (name: string, content: string, icon?: string) =>
|
||||
installSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, {
|
||||
name,
|
||||
content,
|
||||
...(icon !== undefined ? { icon } : {}),
|
||||
});
|
||||
const list = () => listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
const skillFile = (name: string, file: string) =>
|
||||
path.join(skillsDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), name, file);
|
||||
const skillMd = (name: string) => skillFile(name, "SKILL.md");
|
||||
const skillIcon = (name: string) => skillFile(name, "icon.svg");
|
||||
|
||||
describe("installSkill / removeSkill", () => {
|
||||
it("writes skills/<name>/SKILL.md verbatim with a trailing newline", async () => {
|
||||
const skill = librarySkill("penguin-cli")!;
|
||||
await install(skill.name, skill.content);
|
||||
expect(await fs.readFile(skillMd("penguin-cli"), "utf8")).toBe(skill.content);
|
||||
|
||||
// Content without a trailing newline gets one appended; reinstalling overwrites.
|
||||
await install("penguin-cli", "---\nname: penguin-cli\nversion: 2\n---\n\nNew body");
|
||||
expect(await fs.readFile(skillMd("penguin-cli"), "utf8")).toBe(
|
||||
"---\nname: penguin-cli\nversion: 2\n---\n\nNew body\n",
|
||||
);
|
||||
expect((await list()).map((s) => s.version)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("writes icon.svg alongside SKILL.md, and reinstalling without icon removes it", async () => {
|
||||
// A library skill with an icon: installing writes it to disk alongside SKILL.md.
|
||||
const skill = librarySkill("penguin-sdk")!;
|
||||
expect(skill.icon).toBeTruthy();
|
||||
await install(skill.name, skill.content, skill.icon);
|
||||
expect(await fs.readFile(skillIcon("penguin-sdk"), "utf8")).toBe(skill.icon);
|
||||
|
||||
// Overwrite semantics: this install has no icon -> the old icon.svg is removed, and the
|
||||
// directory matches this install's content exactly.
|
||||
await install("penguin-sdk", "---\nname: penguin-sdk\nversion: 2\n---\n\nNew body\n");
|
||||
await expect(fs.access(skillIcon("penguin-sdk"))).rejects.toThrow();
|
||||
expect(await fs.readFile(skillMd("penguin-sdk"), "utf8")).toContain("New body");
|
||||
});
|
||||
|
||||
it("rejects invalid skill names (path traversal safety)", async () => {
|
||||
await expect(install("../evil", "x")).rejects.toThrow(/skill_name/);
|
||||
await expect(install("a/b", "x")).rejects.toThrow(/skill_name/);
|
||||
await expect(removeSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "..")).rejects.toThrow(
|
||||
/skill_name/,
|
||||
);
|
||||
});
|
||||
|
||||
it("removeSkill deletes the whole skill directory and is idempotent", async () => {
|
||||
const skill = librarySkill("penguin-sdk")!;
|
||||
await install(skill.name, skill.content);
|
||||
await removeSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "penguin-sdk");
|
||||
await expect(fs.access(skillMd("penguin-sdk"))).rejects.toThrow();
|
||||
// Idempotent when it no longer exists: does not throw.
|
||||
await removeSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "penguin-sdk");
|
||||
expect(await list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listInstalledSkills", () => {
|
||||
it("returns [] when the skills directory does not exist", async () => {
|
||||
expect(await list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses frontmatter and sorts by name", async () => {
|
||||
await install(
|
||||
"zeta",
|
||||
"---\nname: zeta\ndescription: Z skill.\nversion: 3\nupdated: 2026-07-16\n---\n\nBody\n",
|
||||
);
|
||||
await install(
|
||||
"alpha",
|
||||
"---\nname: alpha\ndescription: A skill.\nversion: 1\nupdated: 2026-07-16\n---\n\nBody\n",
|
||||
);
|
||||
expect(await list()).toEqual([
|
||||
{ name: "alpha", description: "A skill.", version: 1, updated: "2026-07-16" },
|
||||
{ name: "zeta", description: "Z skill.", version: 3, updated: "2026-07-16" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns icon.svg content and passes short description fields through", async () => {
|
||||
const icon = '<svg viewBox="0 0 24 24"><path d="M4 4h16" /></svg>\n';
|
||||
await install(
|
||||
"with-extras",
|
||||
"---\nname: with-extras\ndescription: Long description here.\nshort_description: Short one.\nshort_description_zh: 短描述。\nversion: 1\nupdated: 2026-07-17\n---\n\nBody\n",
|
||||
icon,
|
||||
);
|
||||
await install(
|
||||
"plain",
|
||||
"---\nname: plain\ndescription: Plain skill.\nversion: 1\nupdated: 2026-07-17\n---\n\nBody\n",
|
||||
);
|
||||
const skills = await list();
|
||||
expect(skills).toEqual([
|
||||
{ name: "plain", description: "Plain skill.", version: 1, updated: "2026-07-17" },
|
||||
{
|
||||
name: "with-extras",
|
||||
description: "Long description here.",
|
||||
shortDescription: "Short one.",
|
||||
shortDescriptionZh: "短描述。",
|
||||
version: 1,
|
||||
updated: "2026-07-17",
|
||||
icon,
|
||||
},
|
||||
]);
|
||||
// Entries missing icon / short description omit the corresponding field (undefined does
|
||||
// not produce a key; the interface layer's conditional spread relies on this convention).
|
||||
expect("icon" in skills[0]!).toBe(false);
|
||||
expect("shortDescription" in skills[0]!).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the directory name as the skill identity even when frontmatter name disagrees", async () => {
|
||||
// A hand-written or network-sourced skill may have a frontmatter name that differs from
|
||||
// its directory name: the directory name is the addressing key used for install, uninstall,
|
||||
// and Prompt lookup, so the listing must follow it (frontmatter fields are display-only).
|
||||
await install(
|
||||
"local-name",
|
||||
"---\nname: upstream-name\ndescription: Fetched skill.\nversion: 2\nupdated: 2026-07-01\n---\n\nBody\n",
|
||||
);
|
||||
expect(await list()).toEqual([
|
||||
{ name: "local-name", description: "Fetched skill.", version: 2, updated: "2026-07-01" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to directory-name metadata for broken frontmatter and skips non-skill entries", async () => {
|
||||
// A SKILL.md without frontmatter: falls back to directory name + empty description + version 1.
|
||||
await install("broken", "# No frontmatter here\n");
|
||||
// Directories without a SKILL.md, and stray files, do not count as Skills.
|
||||
const dir = skillsDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
await fs.mkdir(path.join(dir, "empty-dir"), { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "stray.md"), "stray", "utf8");
|
||||
expect(await list()).toEqual([{ name: "broken", description: "", version: 1, updated: "" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skillMetadataSection / assembleSystemPrompt 注入", () => {
|
||||
it("renders one `- \\`name\\` — description` line per skill; empty input renders empty", () => {
|
||||
expect(skillMetadataSection([])).toBe("");
|
||||
expect(
|
||||
skillMetadataSection([
|
||||
{ name: "a", description: "Does A.", version: 1, updated: "2026-07-16" },
|
||||
{ name: "b", description: "", version: 1, updated: "" },
|
||||
]),
|
||||
).toBe("- `a` — Does A.\n- `b`");
|
||||
});
|
||||
|
||||
it("replaces {{SKILL_METADATA}} with metadata lines, or an empty string when absent", () => {
|
||||
const state = {
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: {
|
||||
system_prompt: ["before", AGENTS_MD_PLACEHOLDER, SKILL_METADATA_PLACEHOLDER, "after"].join(
|
||||
"\n",
|
||||
),
|
||||
},
|
||||
agentsMd: "# Agent Rules",
|
||||
};
|
||||
const prompt = assembleSystemPrompt(state, undefined, undefined, [
|
||||
{ name: "demo", description: "Demo skill.", version: 1, updated: "2026-07-16" },
|
||||
]);
|
||||
expect(prompt).toBe(["before", "# Agent Rules", "- `demo` — Demo skill.", "after"].join("\n"));
|
||||
// Not provided / empty list: the placeholder is replaced with an empty string, no residue left.
|
||||
const empty = assembleSystemPrompt(state);
|
||||
expect(empty).toBe(["before", "# Agent Rules", "", "after"].join("\n"));
|
||||
expect(empty).not.toContain(SKILL_METADATA_PLACEHOLDER);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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 } from "vitest";
|
||||
import {
|
||||
addModel,
|
||||
createAgent,
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_PROJECT_ID,
|
||||
installSkill,
|
||||
setVaultEntry,
|
||||
} from "../src/index.js";
|
||||
import { effectiveMaxContextLength } from "../src/agent.js";
|
||||
import { stubProviderKeys } from "./provider-keys.js";
|
||||
|
||||
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 (压缩阈值按模型窗口钳制)", () => {
|
||||
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("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(/不存在/);
|
||||
// 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(/不是目录/);
|
||||
});
|
||||
|
||||
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 modelId 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" }),
|
||||
).rejects.toThrow(/不在 Project 配置中/);
|
||||
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) 成对)", () => {
|
||||
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("resolves a unique bare model_id and accepts an explicit pair", async () => {
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws-ref-pair");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
// Provider omitted: model_id is a globally unique exact match in the config -> resolves to that entry.
|
||||
const bare = await agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-flash" });
|
||||
try {
|
||||
expect(bare.provider).toBe("deepseek");
|
||||
expect(bare.modelId).toBe("deepseek-v4-flash");
|
||||
} finally {
|
||||
bare.dispose();
|
||||
}
|
||||
const paired = await agent.createSession({
|
||||
workspaceDir: ws,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
provider: "anthropic",
|
||||
});
|
||||
try {
|
||||
expect(paired.provider).toBe("anthropic");
|
||||
expect(paired.modelId).toBe("claude-sonnet-4-6");
|
||||
} finally {
|
||||
paired.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an ambiguous bare model_id and a provider without modelId", async () => {
|
||||
// Two providers coexist with the same model_id: omitting provider throws an ambiguity
|
||||
// error (listing the candidate pair references).
|
||||
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-ambiguous");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
await expect(
|
||||
agent.createSession({ workspaceDir: ws, modelId: "claude-sonnet-4-6" }),
|
||||
).rejects.toThrow(/歧义.*\(provider=anthropic, model_id=claude-sonnet-4-6\)/);
|
||||
// Adding provider resolves it.
|
||||
const session = await agent.createSession({
|
||||
workspaceDir: ws,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
provider: "myproxy",
|
||||
});
|
||||
try {
|
||||
expect(session.provider).toBe("myproxy");
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
// provider cannot be used alone (the reference must be a pair).
|
||||
await expect(agent.createSession({ workspaceDir: ws, provider: "anthropic" })).rejects.toThrow(
|
||||
/provider 不能单独使用/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Behavior tests for BackgroundRegistry's idle reaping (a leak safety net).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BackgroundRegistry } from "../src/environment/tools/background/index.js";
|
||||
import type { BackgroundTask } from "../src/environment/tools/background/index.js";
|
||||
|
||||
type FakeTask = BackgroundTask & { killed: boolean };
|
||||
|
||||
function fakeTask(): FakeTask {
|
||||
const task: FakeTask = {
|
||||
lastUsed: 0,
|
||||
running: true,
|
||||
killed: false,
|
||||
kill() {
|
||||
task.killed = true;
|
||||
},
|
||||
killHard() {
|
||||
task.killed = true;
|
||||
},
|
||||
};
|
||||
return task;
|
||||
}
|
||||
|
||||
describe("BackgroundRegistry idle reaping", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reaps sessions idle past the TTL and keeps recently accessed ones", () => {
|
||||
const registry = new BackgroundRegistry<FakeTask>({ idPrefix: "proc", maxTasks: 4 });
|
||||
const stale = fakeTask();
|
||||
const fresh = fakeTask();
|
||||
const staleId = registry.register(stale);
|
||||
const freshId = registry.register(fresh);
|
||||
|
||||
// After 9 days, one access to fresh refreshes its lastUsed; stale is never accessed.
|
||||
vi.advanceTimersByTime(9 * 24 * 60 * 60_000);
|
||||
expect(registry.get(freshId)).toBe(fresh);
|
||||
|
||||
// stale, now idle a full 10 days, is reaped by the scheduled sweep and finalized;
|
||||
// fresh has been idle only 1 day and is kept.
|
||||
vi.advanceTimersByTime(24 * 60 * 60_000 + 60 * 60_000);
|
||||
expect(registry.get(staleId)).toBeUndefined();
|
||||
expect(stale.killed).toBe(true);
|
||||
expect(registry.get(freshId)).toBe(fresh);
|
||||
expect(fresh.killed).toBe(false);
|
||||
|
||||
registry.dispose();
|
||||
});
|
||||
|
||||
it("stops the reap timer on dispose", () => {
|
||||
const registry = new BackgroundRegistry<FakeTask>({ idPrefix: "proc", maxTasks: 4 });
|
||||
const task = fakeTask();
|
||||
registry.register(task);
|
||||
registry.dispose();
|
||||
expect(task.killed).toBe(true);
|
||||
// After dispose the sweep timer is cleared, so fast-forwarding no longer triggers any reaping logic.
|
||||
expect(() => vi.advanceTimersByTime(30 * 24 * 60 * 60_000)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Built-in agent provisioning and skill library install policy: the sole built-in agent
|
||||
* default_agent comes pre-installed with every skill in the library, an ordinary newly created
|
||||
* agent starts with zero skills, and the default AGENTS.md is an empty file; provisionProjectAgents
|
||||
* is idempotent and never overwrites existing config.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { librarySkill, loadLibrarySkills } from "@prismshadow/penguin-skills";
|
||||
import {
|
||||
agentsMdPath,
|
||||
assembleSystemPrompt,
|
||||
BUILTIN_AGENT_IDS,
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_PROJECT_ID,
|
||||
listInstalledSkills,
|
||||
loadOrInitAgentState,
|
||||
provisionProjectAgents,
|
||||
skillsDir,
|
||||
} from "../src/state/index.js";
|
||||
|
||||
let tmpRoot: string;
|
||||
let prevHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevHome = process.env.PENGUIN_HOME;
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-builtin-"));
|
||||
process.env.PENGUIN_HOME = tmpRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.PENGUIN_HOME;
|
||||
} else {
|
||||
process.env.PENGUIN_HOME = prevHome;
|
||||
}
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const skillMdPath = (agentId: string, skillName: string): string =>
|
||||
path.join(skillsDir(tmpRoot, DEFAULT_PROJECT_ID, agentId), skillName, "SKILL.md");
|
||||
|
||||
describe("Skill 安装策略", () => {
|
||||
it("普通新建 Agent 不预装任何 Skill,AGENTS.md 为空文件(指导在模板 Suggested workflows)", async () => {
|
||||
const state = await loadOrInitAgentState({ agentId: "some_agent" });
|
||||
expect(await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, "some_agent")).toEqual([]);
|
||||
|
||||
// The default AGENTS.md is empty: it carries no preset guidance (delegation and task
|
||||
// conventions live in the default template's Suggested workflows section, and skill
|
||||
// metadata is injected via {{SKILL_METADATA}}).
|
||||
expect(state.agentsMd).toBe("");
|
||||
const onDisk = await fs.readFile(
|
||||
agentsMdPath(tmpRoot, DEFAULT_PROJECT_ID, "some_agent"),
|
||||
"utf8",
|
||||
);
|
||||
expect(onDisk).toBe("");
|
||||
});
|
||||
|
||||
it("无 preset 直建的 default_agent(如 CLI 首次运行)同样预装库内全部 Skill", async () => {
|
||||
await loadOrInitAgentState({ agentId: DEFAULT_AGENT_ID });
|
||||
const names = (await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID)).map(
|
||||
(s) => s.name,
|
||||
);
|
||||
expect(names).toEqual(loadLibrarySkills().map((s) => s.name));
|
||||
});
|
||||
});
|
||||
|
||||
describe("provisionProjectAgents", () => {
|
||||
it("唯一内置 Agent default_agent:装库内全部 Skill、AGENTS.md 为空", async () => {
|
||||
const ids = await provisionProjectAgents();
|
||||
expect(ids).toEqual([DEFAULT_AGENT_ID]);
|
||||
expect(BUILTIN_AGENT_IDS).toEqual([DEFAULT_AGENT_ID]);
|
||||
|
||||
// name/description are written into system_config; AGENTS.md is an empty file.
|
||||
const state = await loadOrInitAgentState({ agentId: DEFAULT_AGENT_ID });
|
||||
expect(state.systemConfig.name).toBe("General Agent");
|
||||
expect(state.systemConfig.description).toBeTruthy();
|
||||
expect(state.agentsMd).toBe("");
|
||||
|
||||
const installed = await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
expect(installed.map((s) => s.name).sort()).toEqual(loadLibrarySkills().map((s) => s.name));
|
||||
// On-disk content matches the library's SKILL.md verbatim (install copies the full text).
|
||||
const sdkMd = await fs.readFile(skillMdPath(DEFAULT_AGENT_ID, "penguin-sdk"), "utf8");
|
||||
expect(sdkMd).toBe(librarySkill("penguin-sdk")!.content);
|
||||
});
|
||||
|
||||
it("provision 幂等:重复执行不改变结果", async () => {
|
||||
await provisionProjectAgents();
|
||||
const ids = await provisionProjectAgents();
|
||||
expect(ids).toEqual([DEFAULT_AGENT_ID]);
|
||||
const md = await fs.readFile(
|
||||
agentsMdPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
"utf8",
|
||||
);
|
||||
expect(md).toBe("");
|
||||
const installed = await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
expect(installed.map((s) => s.name).sort()).toEqual(loadLibrarySkills().map((s) => s.name));
|
||||
});
|
||||
|
||||
it("已存在的 Agent 不被覆盖(preset 仅初始化生效)", async () => {
|
||||
const custom = "# AGENTS.md\n\n用户自己改过的内容\n";
|
||||
await loadOrInitAgentState({ agentId: DEFAULT_AGENT_ID });
|
||||
await fs.writeFile(agentsMdPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), custom, "utf8");
|
||||
|
||||
await provisionProjectAgents();
|
||||
|
||||
const after = await fs.readFile(
|
||||
agentsMdPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
"utf8",
|
||||
);
|
||||
expect(after).toBe(custom);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project Dir / Agent ID 占位符", () => {
|
||||
it("assembleSystemPrompt 注入 Project Dir 与 Agent ID(Skill 定位改走项目相对路径,不依赖 .penguin)", async () => {
|
||||
const state = await loadOrInitAgentState({ agentId: "env_agent" });
|
||||
const prompt = assembleSystemPrompt(state, {
|
||||
sessionId: "session-x",
|
||||
cwd: "/tmp/ws",
|
||||
agentId: "env_agent",
|
||||
projectDir: "/tmp/proj",
|
||||
platform: "linux",
|
||||
osVersion: "test",
|
||||
date: "2026-07-08",
|
||||
});
|
||||
expect(prompt).toContain("Agent ID: env_agent");
|
||||
expect(prompt).toContain("Project Dir: /tmp/proj");
|
||||
expect(prompt).not.toContain("{{AGENT_ID}}");
|
||||
expect(prompt).not.toContain("{{PROJECT_DIR}}");
|
||||
// Skill execution conventions are built from project-relative paths (no longer reference .penguin).
|
||||
expect(prompt).not.toContain(".penguin");
|
||||
// Agent State, scratchpad and Skills are addressed under the Project's agents/ container.
|
||||
expect(prompt).toContain("<project_dir>/agents/<agent_id>/agent_state/");
|
||||
expect(prompt).toContain("<project_dir>/agents/<agent_id>/agent_state/skills/");
|
||||
// The pre-agents/ layout (an agent directly under the Project dir) must never be handed to the model.
|
||||
expect(prompt).not.toContain("<project_dir>/<agent_id>/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,683 @@
|
||||
/**
|
||||
* Context compaction tests.
|
||||
*
|
||||
* - Trigger: context usage (the request.total of the most recent token_usage) or the session's
|
||||
* cumulative turn count **reaching** the threshold (>=); the check runs after every LLM
|
||||
* request emits token_usage, both mid-task and at the wrap-up round (reaching the threshold at
|
||||
* task end triggers compaction immediately, without waiting for the next task).
|
||||
* - summarize: appends a compaction prompt to the old LLM (merging in all of this round's tool
|
||||
* results first if mid-task); the summary is wrapped as a `<context_summary>` user text and fed
|
||||
* as the first input to the new LLM instance; on failure the original context is kept, never downgraded to discard.
|
||||
* - discard: deferred until task end if mid-task; sends no compaction request, just swaps in a new LLM instance directly.
|
||||
* - Process visibility: the compaction request's streamed output is never surfaced to the human,
|
||||
* only the paired compaction events are emitted; the dialogue is written to the old trace, and
|
||||
* on success the trace rotates into a new file (index+1, the new file starts with session_meta;
|
||||
* rotation is deferred until the new context has its first message to write).
|
||||
*/
|
||||
import { mkdtemp, rm, access, readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
assistantText,
|
||||
sessionMeta,
|
||||
tokenUsage,
|
||||
toolCall,
|
||||
toolCallOutput,
|
||||
userText,
|
||||
} from "../src/omnimessage/index.js";
|
||||
import type {
|
||||
CompactionBeginPayload,
|
||||
CompactionEndPayload,
|
||||
OmniMessage,
|
||||
TextPayload,
|
||||
TokenCounts,
|
||||
TokenUsagePayload,
|
||||
} from "../src/omnimessage/index.js";
|
||||
import type {
|
||||
ApproveFn,
|
||||
EnvironmentInterface,
|
||||
GenerativeModelParameters,
|
||||
LLMInterface,
|
||||
LLMOutcome,
|
||||
} from "../src/interfaces.js";
|
||||
import { ContextEngine } from "../src/engine/context-engine.js";
|
||||
import type { CompactionSettings } from "../src/engine/context-engine.js";
|
||||
import { Writer, readTrace } from "../src/trace/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ScriptedResponse {
|
||||
messages: OmniMessage[];
|
||||
outcome?: LLMOutcome;
|
||||
}
|
||||
|
||||
/** Fake LLM that responds according to a script, recording each input it receives. */
|
||||
class ScriptedLLM implements LLMInterface {
|
||||
calls: OmniMessage[][] = [];
|
||||
constructor(
|
||||
private readonly responses: ScriptedResponse[],
|
||||
readonly label = "llm",
|
||||
) {}
|
||||
|
||||
async *streamGenerate(
|
||||
params: GenerativeModelParameters,
|
||||
): AsyncGenerator<OmniMessage, LLMOutcome> {
|
||||
this.calls.push(params.newMessages);
|
||||
const next = this.responses.shift();
|
||||
if (!next) {
|
||||
return { status: "failed", message: `${this.label}: no scripted response` };
|
||||
}
|
||||
for (const msg of next.messages) yield msg;
|
||||
return next.outcome ?? { status: "completed" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Fake Environment that never runs real commands: any tool call returns a fixed output. */
|
||||
const fakeEnvironment: EnvironmentInterface = {
|
||||
async listTools() {
|
||||
return [];
|
||||
},
|
||||
async *executeTool({ toolCall: tc }) {
|
||||
yield toolCallOutput({
|
||||
output: "tool ran",
|
||||
toolCallId: tc.payload.tool_call_id,
|
||||
});
|
||||
},
|
||||
toolPermission() {
|
||||
return "rw";
|
||||
},
|
||||
};
|
||||
|
||||
const allowAll: ApproveFn = async () => "allow";
|
||||
|
||||
/** Builds a token_usage: request.total is the context-usage figure, session.total is the cumulative one. */
|
||||
const usage = (requestTotal: number, sessionTotal: number): OmniMessage =>
|
||||
tokenUsage(
|
||||
{ cache_read: 0, cache_write: 0, output: 0, total: sessionTotal },
|
||||
{ cache_read: 0, cache_write: 0, output: 0, total: requestTotal },
|
||||
);
|
||||
|
||||
const settings = (over: Partial<CompactionSettings> = {}): CompactionSettings => ({
|
||||
maxContextLength: 100,
|
||||
maxSessionTurns: -1,
|
||||
mode: "summarize",
|
||||
prompt: "COMPACT NOW",
|
||||
...over,
|
||||
});
|
||||
|
||||
const metaMessage = sessionMeta({
|
||||
session_id: "sess_compact",
|
||||
provider: "custom",
|
||||
model_id: "test-model",
|
||||
model_context_window: 200000,
|
||||
system_prompt: "sp",
|
||||
tools: [],
|
||||
thinking_level: "default",
|
||||
agent_state: "/tmp/state",
|
||||
workspace: "/tmp/ws",
|
||||
});
|
||||
|
||||
async function collect(gen: AsyncGenerator<OmniMessage>): Promise<OmniMessage[]> {
|
||||
const all: OmniMessage[] = [];
|
||||
for await (const msg of gen) all.push(msg);
|
||||
return all;
|
||||
}
|
||||
|
||||
type CompactionEventPayload = CompactionBeginPayload | CompactionEndPayload;
|
||||
|
||||
const compactionEvents = (msgs: OmniMessage[]): CompactionEventPayload[] =>
|
||||
msgs
|
||||
.filter((m) => {
|
||||
const t = (m.payload as { type?: string }).type ?? "";
|
||||
return t === "compaction_begin" || t === "compaction_end";
|
||||
})
|
||||
.map((m) => m.payload as CompactionEventPayload);
|
||||
|
||||
const payloadTypes = (msgs: OmniMessage[]): (string | undefined)[] =>
|
||||
msgs.map((m) => (m.payload as { type?: string }).type);
|
||||
|
||||
const textOf = (m: OmniMessage): string => (m.payload as TextPayload).text;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("context compaction", () => {
|
||||
let traces: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
traces = await mkdtemp(join(tmpdir(), "penguin-compaction-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(traces, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("summarize at task boundary: paired events, hidden dialogue, trace rotation, summary joins next prompt", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
// Task 1's final reply: context usage 150 > threshold 100 -> triggers at the boundary.
|
||||
{ messages: [assistantText("answer one"), usage(150, 150)] },
|
||||
// Compaction request: summary + usage (counted into the session cumulative total).
|
||||
{
|
||||
messages: [assistantText("<summary>the distilled summary</summary>"), usage(160, 310)],
|
||||
},
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM(
|
||||
[{ messages: [assistantText("answer two"), usage(20, 330)] }],
|
||||
"llm2",
|
||||
);
|
||||
let factoryTokens: TokenCounts | null = null;
|
||||
const trace = new Writer({ tracesDir: traces, sessionId: "sess_compact" });
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
trace,
|
||||
sessionMeta: metaMessage,
|
||||
compaction: settings(),
|
||||
createLLM: (tokens) => {
|
||||
factoryTokens = tokens;
|
||||
return llm2;
|
||||
},
|
||||
});
|
||||
const oldPath = trace.currentPath();
|
||||
|
||||
const out1 = await collect(engine.run([userText("task one")], { approve: allowAll }));
|
||||
|
||||
// Paired compaction events: start carries reason/mode/context/turns, stop carries status.
|
||||
const events = compactionEvents(out1);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "compaction_begin",
|
||||
reason: "context",
|
||||
mode: "summarize",
|
||||
context: 150,
|
||||
turns: 1,
|
||||
});
|
||||
expect(events[1]).toMatchObject({ type: "compaction_end", status: "completed" });
|
||||
// The compaction process is invisible to the human: the compaction prompt and summary text are never pushed to the output stream.
|
||||
const texts = out1.filter((m) => (m.payload as { type?: string }).type === "text").map(textOf);
|
||||
expect(texts.some((t) => t.includes("COMPACT NOW"))).toBe(false);
|
||||
expect(texts.some((t) => t.includes("distilled"))).toBe(false);
|
||||
// Exception: the compaction request's token_usage IS pushed to the output stream, sitting between the paired compaction events (the frontend counts it in stats).
|
||||
const types1 = payloadTypes(out1);
|
||||
const between = out1.slice(
|
||||
types1.indexOf("compaction_begin") + 1,
|
||||
types1.lastIndexOf("compaction_end"),
|
||||
);
|
||||
const usageBetween = between.filter(
|
||||
(m) => (m.payload as { type?: string }).type === "token_usage",
|
||||
);
|
||||
expect(usageBetween).toHaveLength(1);
|
||||
expect((usageBetween[0]!.payload as TokenUsagePayload).request.total).toBe(160);
|
||||
|
||||
// The new LLM instance carries over the session's cumulative tokens (including compaction request usage).
|
||||
expect(factoryTokens).toMatchObject({ total: 310 });
|
||||
|
||||
// The summary is merged with the next user prompt as the new LLM instance's first input.
|
||||
await collect(engine.run([userText("task two")], { approve: allowAll }));
|
||||
expect(llm1.calls).toHaveLength(2);
|
||||
expect(llm2.calls).toHaveLength(1);
|
||||
const firstInput = llm2.calls[0]!.map(textOf);
|
||||
expect(firstInput[0]).toBe("<context_summary>\nthe distilled summary\n</context_summary>");
|
||||
expect(firstInput[1]).toBe("task two");
|
||||
|
||||
// Trace splits into files: the old file contains the compaction dialogue and paired events; the new file starts with session_meta.
|
||||
const oldTrace = await readTrace(oldPath);
|
||||
const oldTypes = payloadTypes(oldTrace);
|
||||
expect(oldTypes.filter((t) => t?.startsWith("compaction_"))).toHaveLength(2);
|
||||
expect(oldTrace.some((m) => (m.payload as { text?: string }).text === "COMPACT NOW")).toBe(
|
||||
true,
|
||||
);
|
||||
const newTrace = await readTrace(trace.currentPath());
|
||||
expect(trace.currentPath()).not.toBe(oldPath);
|
||||
expect(newTrace[0]!.type).toBe("session_meta");
|
||||
expect(
|
||||
newTrace.some((m) =>
|
||||
((m.payload as { text?: string }).text ?? "").startsWith("<context_summary>"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("summarize mid-task: tool outputs pair into the compaction request, summary alone feeds the new LLM", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
// Round 1: tool call + over-threshold usage -> triggers mid-task.
|
||||
{
|
||||
messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "c1" }), usage(150, 150)],
|
||||
},
|
||||
// Compaction request (should include c1's tool_call_output plus the compaction prompt).
|
||||
{ messages: [assistantText("<summary>continue: finish step 2</summary>")] },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM(
|
||||
[{ messages: [assistantText("task done"), usage(30, 200)] }],
|
||||
"llm2",
|
||||
);
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
sessionMeta: metaMessage,
|
||||
compaction: settings(),
|
||||
createLLM: () => llm2,
|
||||
});
|
||||
|
||||
const out = await collect(engine.run([userText("do task")], { approve: allowAll }));
|
||||
|
||||
// Compaction request: all of this round's tool results, paired with their tool_calls, are sent to the old instance along with the compaction prompt.
|
||||
expect(llm1.calls).toHaveLength(2);
|
||||
const compactionInput = llm1.calls[1]!;
|
||||
const inputTypes = payloadTypes(compactionInput);
|
||||
expect(inputTypes).toEqual(["tool_call_output", "text"]);
|
||||
expect((compactionInput[1]!.payload as TextPayload).text).toBe("COMPACT NOW");
|
||||
|
||||
// The summary itself is the new instance's first input (no hardcoded continuation instruction appended); the task is finished by the new context.
|
||||
expect(llm2.calls).toHaveLength(1);
|
||||
expect(llm2.calls[0]!.map(textOf)).toEqual([
|
||||
"<context_summary>\ncontinue: finish step 2\n</context_summary>",
|
||||
]);
|
||||
const finalTexts = out
|
||||
.filter((m) => (m.payload as { type?: string }).type === "text")
|
||||
.map(textOf);
|
||||
expect(finalTexts).toContain("task done");
|
||||
expect(compactionEvents(out).map((e) => e.type)).toEqual([
|
||||
"compaction_begin",
|
||||
"compaction_end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("summarize failure keeps the old context and does NOT downgrade to discard", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
{
|
||||
messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "c1" }), usage(150, 150)],
|
||||
},
|
||||
// Compaction request fails (not retryable).
|
||||
{ messages: [], outcome: { status: "failed", message: "auth error" } },
|
||||
// Original context is kept: the task continues, tool outputs feed back into the old instance as usual (context usage keeps growing).
|
||||
{ messages: [assistantText("finished on old context"), usage(190, 340)] },
|
||||
// Second trigger (context still over the limit) -> retries compaction at the boundary, this time succeeding.
|
||||
{ messages: [assistantText("<summary>second try</summary>")] },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM([], "llm2");
|
||||
let created = 0;
|
||||
const trace = new Writer({ tracesDir: traces, sessionId: "sess_keep" });
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
trace,
|
||||
sessionMeta: metaMessage,
|
||||
compaction: settings(),
|
||||
createLLM: () => {
|
||||
created += 1;
|
||||
return llm2;
|
||||
},
|
||||
});
|
||||
const oldPath = trace.currentPath();
|
||||
|
||||
const out = await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||
|
||||
// Two event pairs: the first has stop=failed (abandoned, original context kept), the second succeeds.
|
||||
const events = compactionEvents(out);
|
||||
expect(
|
||||
events.map((e) => `${e.type}:${(e as Partial<CompactionEndPayload>).status ?? ""}`),
|
||||
).toEqual([
|
||||
"compaction_begin:",
|
||||
"compaction_end:failed",
|
||||
"compaction_begin:",
|
||||
"compaction_end:completed",
|
||||
]);
|
||||
// No LLM swap and no trace file split at the moment of failure; rotation happens only after success.
|
||||
expect(created).toBe(1);
|
||||
expect(llm1.calls).toHaveLength(4);
|
||||
// After the failure, the input fed back into the old instance is this round's tool output.
|
||||
expect(payloadTypes(llm1.calls[2]!)).toEqual(["tool_call_output"]);
|
||||
const oldTrace = await readTrace(oldPath);
|
||||
// Still written to the same file after a failed stop (the failed compaction attempt stays auditable); the old file is closed off only after success.
|
||||
expect(payloadTypes(oldTrace).filter((t) => t?.startsWith("compaction_"))).toHaveLength(4);
|
||||
// Trace rotation is deferred until the next message to write: the current path is unchanged right after a successful compaction.
|
||||
expect(trace.currentPath()).toBe(oldPath);
|
||||
});
|
||||
|
||||
it("defers trace rotation after boundary compaction until the next run writes", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
{ messages: [assistantText("answer"), usage(150, 150)] },
|
||||
{ messages: [assistantText("<summary>s</summary>")] },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM(
|
||||
[{ messages: [assistantText("next done"), usage(10, 160)] }],
|
||||
"llm2",
|
||||
);
|
||||
const trace = new Writer({ tracesDir: traces, sessionId: "sess_lazy" });
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
trace,
|
||||
sessionMeta: metaMessage,
|
||||
compaction: settings(),
|
||||
createLLM: () => llm2,
|
||||
});
|
||||
const oldPath = trace.currentPath();
|
||||
|
||||
await collect(engine.run([userText("task 1")], { approve: allowAll }));
|
||||
// A new file is not created right after boundary compaction completes: the current path is unchanged, and only the old file exists on disk.
|
||||
expect(trace.currentPath()).toBe(oldPath);
|
||||
expect(await readdir(dirname(oldPath))).toEqual(["sess_lazy_001.jsonl"]);
|
||||
|
||||
await collect(engine.run([userText("task 2")], { approve: allowAll }));
|
||||
// Rotation happens only once the next round has a message to write: the new file opens with session_meta, followed by the summary and the new prompt.
|
||||
expect(trace.currentPath()).not.toBe(oldPath);
|
||||
const newTrace = await readTrace(trace.currentPath());
|
||||
expect(newTrace[0]!.type).toBe("session_meta");
|
||||
expect(
|
||||
((newTrace[1]!.payload as { text?: string }).text ?? "").startsWith("<context_summary>"),
|
||||
).toBe(true);
|
||||
expect((newTrace[2]!.payload as { text?: string }).text).toBe("task 2");
|
||||
});
|
||||
|
||||
it("reconnect exhaustion on the compaction request converges to failed", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
{ messages: [assistantText("answer"), usage(150, 150)] },
|
||||
{ messages: [], outcome: { status: "timeout" } },
|
||||
{ messages: [], outcome: { status: "timeout" } },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
compaction: settings(),
|
||||
createLLM: () => llm1,
|
||||
maxReconnects: 1,
|
||||
reconnectBackoffMs: 1,
|
||||
});
|
||||
|
||||
const out = await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||
const events = compactionEvents(out);
|
||||
expect(events[1]).toMatchObject({ type: "compaction_end", status: "failed" });
|
||||
// The retry resends the original input (tool results + prompt; here there are no tool results, just the prompt).
|
||||
expect(llm1.calls).toHaveLength(3);
|
||||
expect(payloadTypes(llm1.calls[2]!)).toEqual(["text"]);
|
||||
});
|
||||
|
||||
it("session turns reaching (==) the threshold compact at task end — no waiting for the next task", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
// Task 1: two LLM requests (a tool round + the final reply).
|
||||
// After round 1, turns=1 < 2 doesn't trigger; round 2 (task wrap-up), turns=2 >= 2 -> compacts immediately.
|
||||
{
|
||||
messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "c1" }), usage(10, 10)],
|
||||
},
|
||||
{ messages: [assistantText("t1 done"), usage(10, 20)] },
|
||||
// Compaction request (sent out immediately when task 1 ends).
|
||||
{ messages: [assistantText("<summary>s</summary>")] },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM([{ messages: [assistantText("t2 done"), usage(10, 30)] }], "llm2");
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
compaction: settings({ maxContextLength: -1, maxSessionTurns: 2 }),
|
||||
createLLM: () => llm2,
|
||||
});
|
||||
|
||||
// Triggers right at task 1's wrap-up (compacts as soon as the threshold is reached, without waiting for the next task); the summary request goes to the old instance.
|
||||
const out1 = await collect(engine.run([userText("task 1")], { approve: allowAll }));
|
||||
const events = compactionEvents(out1);
|
||||
expect(events[0]).toMatchObject({ type: "compaction_begin", reason: "turns", turns: 2 });
|
||||
expect(events[1]).toMatchObject({ type: "compaction_end", status: "completed" });
|
||||
expect(llm1.calls).toHaveLength(3);
|
||||
|
||||
// The counter resets after compaction completes: task 2 is picked up by the new instance (summary + new prompt), and no further trigger fires.
|
||||
const out2 = await collect(engine.run([userText("task 2")], { approve: allowAll }));
|
||||
expect(compactionEvents(out2)).toHaveLength(0);
|
||||
expect(llm2.calls).toHaveLength(1);
|
||||
expect(llm2.calls[0]!.map(textOf)).toEqual([
|
||||
"<context_summary>\ns\n</context_summary>",
|
||||
"task 2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("context usage exactly equal to the threshold triggers compaction (>=, not >)", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
// Wrap-up round context usage 100 == threshold 100 -> triggers.
|
||||
{ messages: [assistantText("answer"), usage(100, 100)] },
|
||||
{ messages: [assistantText("<summary>eq</summary>")] },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM([], "llm2");
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
compaction: settings(),
|
||||
createLLM: () => llm2,
|
||||
});
|
||||
|
||||
const out = await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||
const events = compactionEvents(out);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "compaction_begin",
|
||||
reason: "context",
|
||||
context: 100,
|
||||
});
|
||||
expect(events[1]).toMatchObject({ type: "compaction_end", status: "completed" });
|
||||
});
|
||||
|
||||
it("discard defers mid-task, then swaps the LLM at task end without a compaction request", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
// Round 1: over the limit, but the task is still in progress -> deferred.
|
||||
{
|
||||
messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "c1" }), usage(150, 150)],
|
||||
},
|
||||
// Round 2: task ends -> performs discard (no compaction request sent).
|
||||
{ messages: [assistantText("done"), usage(160, 310)] },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM([{ messages: [assistantText("fresh"), usage(10, 320)] }], "llm2");
|
||||
const trace = new Writer({ tracesDir: traces, sessionId: "sess_discard" });
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
trace,
|
||||
sessionMeta: metaMessage,
|
||||
compaction: settings({ mode: "discard" }),
|
||||
createLLM: () => llm2,
|
||||
});
|
||||
const oldPath = trace.currentPath();
|
||||
|
||||
const out = await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||
|
||||
// Triggers exactly once, at task end; the old LLM is called exactly twice (no compaction request).
|
||||
const events = compactionEvents(out);
|
||||
expect(events.map((e) => `${e.type}:${e.mode}`)).toEqual([
|
||||
"compaction_begin:discard",
|
||||
"compaction_end:discard",
|
||||
]);
|
||||
expect(llm1.calls).toHaveLength(2);
|
||||
|
||||
// The next round's input is used as-is as the new instance's first input (no <context_summary>).
|
||||
await collect(engine.run([userText("next task")], { approve: allowAll }));
|
||||
expect(llm2.calls[0]!.map(textOf)).toEqual(["next task"]);
|
||||
|
||||
// Trace splits into files: the new file starts with session_meta.
|
||||
const newTrace = await readTrace(trace.currentPath());
|
||||
expect(trace.currentPath()).not.toBe(oldPath);
|
||||
expect(newTrace[0]!.type).toBe("session_meta");
|
||||
});
|
||||
|
||||
it("lenient extraction: output without <summary> tags is used verbatim", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
{ messages: [assistantText("answer"), usage(150, 150)] },
|
||||
{ messages: [assistantText("plain summary text without tags")] },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM([{ messages: [assistantText("ok"), usage(10, 160)] }], "llm2");
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
compaction: settings(),
|
||||
createLLM: () => llm2,
|
||||
});
|
||||
|
||||
await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||
await collect(engine.run([userText("next")], { approve: allowAll }));
|
||||
expect(textOf(llm2.calls[0]![0]!)).toBe(
|
||||
"<context_summary>\nplain summary text without tags\n</context_summary>",
|
||||
);
|
||||
});
|
||||
|
||||
it("manual compaction skips threshold checks and reuses the same flow", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
// One ordinary task (well under the limit).
|
||||
{ messages: [assistantText("small"), usage(10, 10)] },
|
||||
// Manual compaction request.
|
||||
{ messages: [assistantText("<summary>manual s</summary>")] },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM([{ messages: [assistantText("after"), usage(5, 20)] }], "llm2");
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
compaction: settings(),
|
||||
createLLM: () => llm2,
|
||||
});
|
||||
|
||||
await collect(engine.run([userText("hi")], { approve: allowAll }));
|
||||
const out = await collect(engine.compact());
|
||||
const events = compactionEvents(out);
|
||||
expect(events[0]).toMatchObject({ type: "compaction_begin", reason: "manual" });
|
||||
expect(events[1]).toMatchObject({ type: "compaction_end", status: "completed" });
|
||||
|
||||
await collect(engine.run([userText("next")], { approve: allowAll }));
|
||||
expect(llm2.calls[0]!.map(textOf)).toEqual([
|
||||
"<context_summary>\nmanual s\n</context_summary>",
|
||||
"next",
|
||||
]);
|
||||
});
|
||||
|
||||
it("no compaction capability (createLLM missing) means thresholds never fire and compact() is a no-op", async () => {
|
||||
const llm1 = new ScriptedLLM(
|
||||
[{ messages: [assistantText("big"), usage(999999, 999999)] }],
|
||||
"llm1",
|
||||
);
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
compaction: settings(),
|
||||
});
|
||||
const out = await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||
expect(compactionEvents(out)).toHaveLength(0);
|
||||
expect(await collect(engine.compact())).toHaveLength(0);
|
||||
expect(llm1.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("compactability(): 逐一给出「不能压缩」的原因,而不是笼统的 false", async () => {
|
||||
// compact() emits **zero messages** when there is nothing compactable. The caller (Web / CLI)
|
||||
// must be able to ask ahead of time, otherwise it can only wait forever for a compaction
|
||||
// banner that never comes -- exactly how "no response from /compact after interrupting on the
|
||||
// web" happens: interrupt the first request -> token_usage is never received -> sessionTurns
|
||||
// stays at 0 -> compact() returns immediately.
|
||||
// The reason also needs to distinguish "just compacted" from "haven't chatted yet" -- both
|
||||
// have sessionTurns == 0, but they're two completely different messages to the user: telling
|
||||
// someone who just finished compacting that there's "no completed conversation turn yet" is
|
||||
// effectively saying nothing useful.
|
||||
// The compaction request goes to the **current** LLM (the script's second entry); createLLM
|
||||
// supplies the LLM used for the new context after compaction.
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
// Usage is kept under maxContextLength (100 per settings()) so automatic compaction doesn't jump in first and reset sessionTurns.
|
||||
{ messages: [assistantText("hi"), usage(10, 10)] },
|
||||
{ messages: [assistantText("<summary>s</summary>")] }, // manual compaction request
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const llm2 = new ScriptedLLM([{ messages: [assistantText("after"), usage(5, 20)] }], "llm2");
|
||||
|
||||
// (1) No compaction capability configured (no compaction / createLLM).
|
||||
const noCap = new ContextEngine({ llm: llm1, environment: fakeEnvironment });
|
||||
expect(noCap.compactability()).toBe("unsupported");
|
||||
|
||||
// (2) Capability configured, but the current context hasn't finished a single round yet: not compactable, and compact() indeed emits no messages.
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
compaction: settings(),
|
||||
createLLM: () => llm2,
|
||||
});
|
||||
expect(engine.compactability()).toBe("empty");
|
||||
expect(await collect(engine.compact())).toHaveLength(0);
|
||||
|
||||
// (3) Compactable only after a round finishes (token_usage received).
|
||||
await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||
expect(engine.compactability()).toBe("ok");
|
||||
expect(compactionEvents(await collect(engine.compact()))).not.toHaveLength(0);
|
||||
|
||||
// (4) Compacting again right after a compaction: also not compactable (the new context is
|
||||
// empty), but the reason is "just compacted" -- it must not say "no completed conversation
|
||||
// turn yet" again, since the user clearly just finished a whole round.
|
||||
expect(engine.compactability()).toBe("just_compacted");
|
||||
expect(await collect(engine.compact())).toHaveLength(0);
|
||||
|
||||
// (5) Compactable again once a round finishes in the new context.
|
||||
await collect(engine.run([userText("next")], { approve: allowAll }));
|
||||
expect(engine.compactability()).toBe("ok");
|
||||
});
|
||||
|
||||
it("user abort during the compaction request keeps the context and carries tool outputs over", async () => {
|
||||
const controller = new AbortController();
|
||||
const llm1 = new ScriptedLLM(
|
||||
[
|
||||
{
|
||||
messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "c1" }), usage(150, 150)],
|
||||
},
|
||||
{ messages: [], outcome: { status: "aborted" } },
|
||||
],
|
||||
"llm1",
|
||||
);
|
||||
const engine = new ContextEngine({
|
||||
llm: llm1,
|
||||
environment: fakeEnvironment,
|
||||
compaction: settings(),
|
||||
createLLM: () => llm1,
|
||||
});
|
||||
// The abort signal is already pending before the compaction request: the fake LLM finishes straight to aborted.
|
||||
const approveThenAbort: ApproveFn = async () => "allow";
|
||||
const runGen = engine.run([userText("go")], {
|
||||
approve: approveThenAbort,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const out: OmniMessage[] = [];
|
||||
for await (const msg of runGen) {
|
||||
out.push(msg);
|
||||
// Simulates a user abort right after the compaction start event (the compaction request then returns aborted).
|
||||
const p = msg.payload as { type?: string };
|
||||
if (p.type === "compaction_begin") controller.abort();
|
||||
}
|
||||
|
||||
const events = compactionEvents(out);
|
||||
expect(events[1]).toMatchObject({ type: "compaction_end", status: "aborted" });
|
||||
// Interrupt cleanup: the tool output is held as carry-over per case A, and the run wraps up with an abort event.
|
||||
expect(payloadTypes(out)).toContain("abort");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Unit tests (offline) for the read_image "vision-model describe" variant, driven by a fake LLM:
|
||||
* definition overrides (new prompt parameter, description mentioning the vision model id), a
|
||||
* single image + prompt sent to the vision model, text output and failure paths (no vision model
|
||||
* configured / vision model request fails), results carrying no images; and swapping on the
|
||||
* Environment side (injecting visionDescriber switches to the describe variant).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
DESCRIBE_IMAGE_NAME,
|
||||
createDescribeImageTool,
|
||||
} from "../src/environment/tools/describe-image.js";
|
||||
import { BUILTIN_TOOL_FACTORIES } from "../src/environment/tools/registry.js";
|
||||
import { Environment } from "../src/environment/environment.js";
|
||||
import { assistantText, partialText, toolCall } from "../src/omnimessage/index.js";
|
||||
import type { OmniMessage } from "../src/omnimessage/index.js";
|
||||
import type { ToolResult } from "../src/environment/tools/types.js";
|
||||
import type {
|
||||
GenerativeModelParameters,
|
||||
LLMInterface,
|
||||
LLMOutcome,
|
||||
ToolDefinitionConfig,
|
||||
VisionDescriberService,
|
||||
} from "../src/interfaces.js";
|
||||
|
||||
/** 1x1 transparent PNG (includes magic bytes, enough for mime sniffing). */
|
||||
const PNG_1X1 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
/** Config entry for describe_image (forModel: "text-only"; the definition comes entirely from config, the implementation never rewrites it at runtime). */
|
||||
const definition: ToolDefinitionConfig = {
|
||||
name: DESCRIBE_IMAGE_NAME,
|
||||
forModel: "text-only",
|
||||
description: "describe image via vision model",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { source: { type: "string" }, prompt: { type: "string" } },
|
||||
required: ["source"],
|
||||
},
|
||||
permission: "r",
|
||||
};
|
||||
|
||||
/**
|
||||
* Fake vision LLM: records the received newMessages, emits output following the real streaming
|
||||
* protocol (partial start -> word-by-word delta -> stop -> complete text), and finishes with the given outcome.
|
||||
*/
|
||||
function fakeLLM(reply: string, outcome: LLMOutcome = { status: "completed" }) {
|
||||
const calls: GenerativeModelParameters[] = [];
|
||||
const llm: LLMInterface = {
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async *streamGenerate(params: GenerativeModelParameters) {
|
||||
calls.push(params);
|
||||
if (reply) {
|
||||
yield partialText("start");
|
||||
// Split into two delta chunks to verify piecewise forwarding (rather than buffering the whole thing).
|
||||
const mid = Math.ceil(reply.length / 2);
|
||||
yield partialText("delta", reply.slice(0, mid));
|
||||
yield partialText("delta", reply.slice(mid));
|
||||
yield partialText("stop");
|
||||
yield assistantText(reply);
|
||||
}
|
||||
return outcome;
|
||||
},
|
||||
};
|
||||
return { llm, calls };
|
||||
}
|
||||
|
||||
async function run(
|
||||
args: Record<string, unknown>,
|
||||
workspaceDir: string,
|
||||
describer: VisionDescriberService,
|
||||
) {
|
||||
const tool = createDescribeImageTool(definition, describer);
|
||||
const gen = tool.execute(args, { workspaceDir, toolCallId: "c1" });
|
||||
const messages: OmniMessage[] = [];
|
||||
let result: ToolResult | void;
|
||||
for (;;) {
|
||||
const res = await gen.next();
|
||||
if (res.done) {
|
||||
result = res.value;
|
||||
break;
|
||||
}
|
||||
messages.push(res.value);
|
||||
}
|
||||
const text = messages.map((m) => (m.payload as { output?: string }).output ?? "").join("");
|
||||
return { messages, result, text };
|
||||
}
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(tmpdir(), "penguin-descimg-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("describe_image(read_image 的纯文本模型版)", () => {
|
||||
it("定义原样取自配置条目(不做运行期改写)", () => {
|
||||
const tool = createDescribeImageTool(definition, { modelId: "vis-1" });
|
||||
expect(tool.name).toBe(DESCRIBE_IMAGE_NAME);
|
||||
expect(tool.definition).toBe(definition);
|
||||
});
|
||||
|
||||
it("registry 按工具名装配;未注入 visionDescriber 时以 failed 说明收尾(而非回图片)", async () => {
|
||||
const factory = BUILTIN_TOOL_FACTORIES[DESCRIBE_IMAGE_NAME]!;
|
||||
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
||||
const describeTool = factory(definition, undefined);
|
||||
const gen = describeTool.execute({ source: "a.png" }, { workspaceDir: tmp, toolCallId: "c1" });
|
||||
let result: ToolResult | void;
|
||||
let text = "";
|
||||
for (;;) {
|
||||
const res = await gen.next();
|
||||
if (res.done) {
|
||||
result = res.value;
|
||||
break;
|
||||
}
|
||||
text += (res.value.payload as { output?: string }).output ?? "";
|
||||
}
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("No vision model");
|
||||
});
|
||||
|
||||
it("图片 + 自定义 prompt 单发视觉模型,回其文本,结果不携带 images", async () => {
|
||||
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
||||
const { llm, calls } = fakeLLM("图里是一只企鹅。");
|
||||
const describer: VisionDescriberService = { modelId: "vis-1", createLLM: () => llm };
|
||||
const { messages, result, text } = await run(
|
||||
{ source: "a.png", prompt: "图里是什么动物?" },
|
||||
tmp,
|
||||
describer,
|
||||
);
|
||||
|
||||
// Single message = prompt text + data URL image (same role user, merged into one request).
|
||||
expect(calls).toHaveLength(1);
|
||||
const payloads = calls[0]!.newMessages.map(
|
||||
(m) => m.payload as { type: string; text?: string; image_url?: string },
|
||||
);
|
||||
expect(payloads[0]!.type).toBe("text");
|
||||
expect(payloads[0]!.text).toBe("图里是什么动物?");
|
||||
expect(payloads[1]!.type).toBe("image_url");
|
||||
expect(payloads[1]!.image_url).toBe(`data:image/png;base64,${PNG_1X1.toString("base64")}`);
|
||||
|
||||
expect(text).toContain("described by vis-1");
|
||||
expect(text).toContain("图里是一只企鹅。");
|
||||
// Streaming forward: the header line and description deltas are emitted as separate chunks (not buffered as a whole), and the complete text is not forwarded again.
|
||||
const outputs = messages.map((m) => (m.payload as { output?: string }).output ?? "");
|
||||
expect(outputs.length).toBeGreaterThanOrEqual(3); // header + >=2 description delta chunks
|
||||
expect(outputs[0]).toContain("described by vis-1");
|
||||
expect(outputs.slice(1).join("")).toBe("图里是一只企鹅。");
|
||||
// Text-based description: the result carries no images (images never enter session history).
|
||||
expect(result?.images).toBeUndefined();
|
||||
expect(result?.stopReason).toBeUndefined(); // defaults to completed
|
||||
});
|
||||
|
||||
it("未给 prompt 时用默认问题", async () => {
|
||||
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
||||
const { llm, calls } = fakeLLM("desc");
|
||||
await run({ source: "a.png" }, tmp, { modelId: "vis-1", createLLM: () => llm });
|
||||
const first = calls[0]!.newMessages[0]!.payload as { text?: string };
|
||||
expect(first.text).toContain("Describe this image");
|
||||
});
|
||||
|
||||
it("未配置视觉模型:failed 并解释如何配置", async () => {
|
||||
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
||||
const { result, text } = await run({ source: "a.png" }, tmp, { modelId: null });
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("No vision model");
|
||||
expect(text).toContain("vision_model");
|
||||
});
|
||||
|
||||
it("视觉模型请求失败:failed 并带状态与消息", async () => {
|
||||
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
||||
const { llm } = fakeLLM("", { status: "failed", message: "401 unauthorized" });
|
||||
const { result, text } = await run({ source: "a.png" }, tmp, {
|
||||
modelId: "vis-1",
|
||||
createLLM: () => llm,
|
||||
});
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("failed");
|
||||
expect(text).toContain("401 unauthorized");
|
||||
});
|
||||
|
||||
it("图片校验复用 read_image:不支持的类型直接 failed,不请求视觉模型", async () => {
|
||||
await writeFile(path.join(tmp, "a.txt"), "not an image");
|
||||
const { llm, calls } = fakeLLM("desc");
|
||||
const { result } = await run({ source: "a.txt" }, tmp, {
|
||||
modelId: "vis-1",
|
||||
createLLM: () => llm,
|
||||
});
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Environment 装配 describe_image 条目走代读实现,定义与配置一致", async () => {
|
||||
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
||||
const { llm } = fakeLLM("代读结果");
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: {
|
||||
// Already filtered by selectBuiltinToolsForModel per session model before assembly; only the describe entry remains here.
|
||||
customTools: [definition],
|
||||
mcpServers: [],
|
||||
},
|
||||
services: { visionDescriber: { modelId: "vis-1", createLLM: () => llm } },
|
||||
});
|
||||
// Tool listing matches config: describe_image carries the prompt parameter.
|
||||
const tools = await env.listTools();
|
||||
const describeImage = tools.find((t) => t.name === DESCRIBE_IMAGE_NAME)!;
|
||||
const props = (describeImage.parameters as { properties: Record<string, unknown> }).properties;
|
||||
expect(Object.keys(props)).toContain("prompt");
|
||||
|
||||
// Execution goes through description: outputs text, the complete message carries no images.
|
||||
const out: OmniMessage[] = [];
|
||||
for await (const m of env.executeTool({
|
||||
toolCall: toolCall({
|
||||
name: DESCRIBE_IMAGE_NAME,
|
||||
arguments: '{"source":"a.png"}',
|
||||
toolCallId: "t1",
|
||||
}),
|
||||
})) {
|
||||
out.push(m);
|
||||
}
|
||||
const complete = out[out.length - 1]!.payload as {
|
||||
type?: string;
|
||||
output?: string;
|
||||
images?: string[];
|
||||
stop_reason?: string;
|
||||
};
|
||||
expect(complete.type).toBe("tool_call_output");
|
||||
expect(complete.stop_reason).toBe("completed");
|
||||
expect(complete.output).toContain("代读结果");
|
||||
expect(complete.images).toBeUndefined();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Example Benchmark provisioning tests:
|
||||
* default_agent initialization pre-seeds benchmarks/example-benchmark/ (a parseable config,
|
||||
* runs=2, a scoreboard with three self-consistent evaluations); an ordinary Agent gets none;
|
||||
* provisioning is skipped idempotently when benchmarks/ already exists.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_PROJECT_ID,
|
||||
EXAMPLE_BENCHMARK_ID,
|
||||
benchmarksDir,
|
||||
buildExampleScoreboard,
|
||||
loadOrInitAgentState,
|
||||
provisionProjectAgents,
|
||||
} from "../src/state/index.js";
|
||||
|
||||
let tmpRoot: string;
|
||||
let prevHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevHome = process.env.PENGUIN_HOME;
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-bench-"));
|
||||
process.env.PENGUIN_HOME = tmpRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevHome === undefined) delete process.env.PENGUIN_HOME;
|
||||
else process.env.PENGUIN_HOME = prevHome;
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface RunScore {
|
||||
score: number;
|
||||
cost: number;
|
||||
duration_ms: number;
|
||||
session_id: string;
|
||||
}
|
||||
interface CaseScore extends Omit<RunScore, "session_id"> {
|
||||
case: string;
|
||||
runs: RunScore[];
|
||||
}
|
||||
interface Evaluation extends Omit<CaseScore, "case" | "runs"> {
|
||||
time: string;
|
||||
version: number;
|
||||
provider: string;
|
||||
model_id: string;
|
||||
summary_title: string;
|
||||
summary: string;
|
||||
cases: CaseScore[];
|
||||
}
|
||||
|
||||
describe("example benchmark provisioning", () => {
|
||||
it("default_agent init creates a parseable example benchmark (config + scoreboard + cases)", async () => {
|
||||
await loadOrInitAgentState();
|
||||
const dir = path.join(
|
||||
benchmarksDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
EXAMPLE_BENCHMARK_ID,
|
||||
);
|
||||
|
||||
// benchmark_config.toml: title/description/runs=2; contains no model reference (the model
|
||||
// is recorded on each evaluation instead).
|
||||
const config = parseToml(await fs.readFile(path.join(dir, "benchmark_config.toml"), "utf8"));
|
||||
expect(config.title).toBe("Example Benchmark");
|
||||
expect(String(config.description)).toContain("built-in example");
|
||||
expect(String(config.description)).toContain("Replace it with your own");
|
||||
expect(Number(config.runs)).toBe(2);
|
||||
expect(config).not.toHaveProperty("provider");
|
||||
expect(config).not.toHaveProperty("model_id");
|
||||
|
||||
// Two cases: statement/ and rubric/ each use README.md as the index.
|
||||
for (const caseId of ["CASE-001-file-summary", "CASE-002-data-cleanup"]) {
|
||||
const statement = await fs.readFile(path.join(dir, caseId, "statement", "README.md"), "utf8");
|
||||
const rubric = await fs.readFile(path.join(dir, caseId, "rubric", "README.md"), "utf8");
|
||||
expect(statement.length).toBeGreaterThan(50);
|
||||
expect(rubric).toContain("pts");
|
||||
}
|
||||
|
||||
// scoreboard.yaml: 3 evaluations, with version/time increasing and scores rising, and
|
||||
// 2 runs per case.
|
||||
const scoreboard = parseYaml(await fs.readFile(path.join(dir, "scoreboard.yaml"), "utf8")) as {
|
||||
evaluations: Evaluation[];
|
||||
};
|
||||
expect(scoreboard.evaluations).toHaveLength(3);
|
||||
expect(scoreboard.evaluations.map((e) => e.version)).toEqual([1, 2, 3]);
|
||||
const times = scoreboard.evaluations.map((e) => new Date(e.time).getTime());
|
||||
expect(times[0]!).toBeLessThan(times[1]!);
|
||||
expect(times[1]!).toBeLessThan(times[2]!);
|
||||
const scores = scoreboard.evaluations.map((e) => e.score);
|
||||
expect(scores[0]!).toBeLessThan(scores[1]!);
|
||||
expect(scores[1]!).toBeLessThan(scores[2]!);
|
||||
// Each evaluation carries the actual model used for that run (as a pair) and a summary
|
||||
// title; the example consistently uses deepseek-v4-pro.
|
||||
expect(scoreboard.evaluations.map((e) => e.model_id)).toEqual([
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-pro",
|
||||
]);
|
||||
for (const e of scoreboard.evaluations) {
|
||||
expect(e.provider).toBe("deepseek");
|
||||
expect(e.summary_title.length).toBeGreaterThan(0);
|
||||
expect(e.summary.toLowerCase()).toContain("example");
|
||||
expect(e.cases).toHaveLength(2);
|
||||
for (const c of e.cases) {
|
||||
expect(c.runs).toHaveLength(2);
|
||||
for (const r of c.runs) {
|
||||
expect(r.session_id).toMatch(/^session-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-[0-9a-f]{8}$/);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("scoreboard numbers are self-consistent (case = avg of runs, evaluation = sum of cases)", async () => {
|
||||
const { evaluations } = buildExampleScoreboard();
|
||||
const avg = (vals: number[]): number => vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
const sum = (vals: number[]): number => vals.reduce((a, b) => a + b, 0);
|
||||
for (const e of evaluations) {
|
||||
for (const c of e.cases) {
|
||||
expect(c.score).toBeCloseTo(avg(c.runs.map((r) => r.score)), 6);
|
||||
expect(c.cost).toBeCloseTo(avg(c.runs.map((r) => r.cost)), 6);
|
||||
expect(c.duration_ms).toBeCloseTo(avg(c.runs.map((r) => r.duration_ms)), 6);
|
||||
}
|
||||
expect(e.score).toBeCloseTo(sum(e.cases.map((c) => c.score)), 6);
|
||||
expect(e.cost).toBeCloseTo(sum(e.cases.map((c) => c.cost)), 6);
|
||||
expect(e.duration_ms).toBeCloseTo(sum(e.cases.map((c) => c.duration_ms)), 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("provisionProjectAgents also seeds the example benchmark for default_agent", async () => {
|
||||
await provisionProjectAgents({ root: tmpRoot, projectId: "proj_x" });
|
||||
expect(
|
||||
await exists(
|
||||
path.join(benchmarksDir(tmpRoot, "proj_x", DEFAULT_AGENT_ID), EXAMPLE_BENCHMARK_ID),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not create benchmarks for a non-default agent", async () => {
|
||||
await loadOrInitAgentState({ agentId: "worker" });
|
||||
expect(await exists(benchmarksDir(tmpRoot, DEFAULT_PROJECT_ID, "worker"))).toBe(false);
|
||||
});
|
||||
|
||||
it("skips provisioning when benchmarks/ already exists (idempotent, no clobber)", async () => {
|
||||
const dir = benchmarksDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await loadOrInitAgentState();
|
||||
expect(await fs.readdir(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Behavior tests for long-running command sessions (exec_command yield + input_command).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { Environment, ManagedSession } from "../src/environment/index.js";
|
||||
import { toolCall } from "../src/omnimessage/index.js";
|
||||
import type { OmniMessage } from "../src/omnimessage/index.js";
|
||||
import type { ToolConfig, ToolDefinitionConfig } from "../src/interfaces.js";
|
||||
|
||||
function execTool(overrides: Partial<ToolDefinitionConfig> = {}): ToolDefinitionConfig {
|
||||
return {
|
||||
name: "exec_command",
|
||||
description: "Run a shell command.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cmd: { type: "string" },
|
||||
workdir: { type: "string" },
|
||||
yield_time_ms: { type: "number" },
|
||||
},
|
||||
required: ["cmd"],
|
||||
},
|
||||
permission: "rw",
|
||||
maxOutputLength: 16000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function inputCommandTool(overrides: Partial<ToolDefinitionConfig> = {}): ToolDefinitionConfig {
|
||||
return {
|
||||
name: "input_command",
|
||||
description: "Interact with a running command session.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
process_id: { type: "string" },
|
||||
chars: { type: "string" },
|
||||
yield_time_ms: { type: "number" },
|
||||
},
|
||||
required: ["process_id"],
|
||||
},
|
||||
permission: "rw",
|
||||
maxOutputLength: 16000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionConfig(): ToolConfig {
|
||||
return { customTools: [execTool(), inputCommandTool()], mcpServers: [] };
|
||||
}
|
||||
|
||||
interface FinalOutput {
|
||||
output: string;
|
||||
stopReason?: string;
|
||||
}
|
||||
|
||||
/** Runs one tool call and returns the final tool_call_output's content and stop_reason. */
|
||||
async function runTool(
|
||||
env: Environment,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FinalOutput> {
|
||||
let last: OmniMessage | null = null;
|
||||
for await (const msg of env.executeTool({
|
||||
toolCall: toolCall({ name, arguments: JSON.stringify(args), toolCallId: `call_${name}` }),
|
||||
...(signal ? { signal } : {}),
|
||||
})) {
|
||||
if ((msg.payload as { type?: string }).type === "tool_call_output") last = msg;
|
||||
}
|
||||
const p = (last?.payload ?? {}) as { output?: string; stop_reason?: string };
|
||||
return { output: p.output ?? "", stopReason: p.stop_reason };
|
||||
}
|
||||
|
||||
function extractProcessId(output: string): string {
|
||||
const m = output.match(/process_id (proc-[0-9a-f]+)/);
|
||||
expect(m, `expected a process_id in: ${JSON.stringify(output)}`).toBeTruthy();
|
||||
return m![1]!;
|
||||
}
|
||||
|
||||
let tmp: string;
|
||||
let env: Environment;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(tmpdir(), "penguin-exec-session-"));
|
||||
env = new Environment({ workspaceDir: tmp, toolConfig: sessionConfig() });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
env.dispose();
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("exec_command — long-running command sessions", () => {
|
||||
it("returns promptly when a command backgrounds a long-lived child", async () => {
|
||||
// node stays resident in the background and inherits the pipes; bash exits immediately
|
||||
// after the foreground echo. The old implementation waited for close (pipe EOF) -> stuck
|
||||
// for 5s; the new implementation goes by the foreground exit + a short drain, returning
|
||||
// within seconds, and reaps the leftover background process.
|
||||
const startedAt = Date.now();
|
||||
const res = await runTool(env, "exec_command", {
|
||||
cmd: 'node -e "setTimeout(()=>{},5000)" & echo hello',
|
||||
});
|
||||
const elapsed = Date.now() - startedAt;
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
expect(res.output).toContain("hello");
|
||||
expect(res.output).not.toContain("process running with process_id");
|
||||
expect(res.stopReason).toBe("completed");
|
||||
});
|
||||
|
||||
it("streams output incrementally while the command is running", async () => {
|
||||
// Two output chunks arrive 400ms apart: they should be produced as separate delta segments,
|
||||
// not returned all at once when the window ends.
|
||||
const deltas: string[] = [];
|
||||
for await (const msg of env.executeTool({
|
||||
toolCall: toolCall({
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "echo first; sleep 0.4; echo second" }),
|
||||
toolCallId: "call_stream",
|
||||
}),
|
||||
})) {
|
||||
const p = msg.payload as { type?: string; event_type?: string; output?: string };
|
||||
if (p.type === "partial_tool_call_output" && p.event_type === "delta" && p.output) {
|
||||
deltas.push(p.output);
|
||||
}
|
||||
}
|
||||
const firstIdx = deltas.findIndex((d) => d.includes("first"));
|
||||
expect(firstIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(deltas[firstIdx]).not.toContain("second"); // first arrives earlier, not in the same segment as second
|
||||
expect(deltas.slice(firstIdx + 1).some((d) => d.includes("second"))).toBe(true);
|
||||
});
|
||||
|
||||
it("yields a process_id when the command is still running past yield_time_ms", async () => {
|
||||
const res = await runTool(env, "exec_command", {
|
||||
cmd: "sleep 30",
|
||||
yield_time_ms: 300,
|
||||
});
|
||||
expect(res.stopReason).toBe("completed");
|
||||
expect(res.output).toContain("process running with process_id proc-");
|
||||
});
|
||||
|
||||
it("input_command drives a running session: write stdin, get output and exit status", async () => {
|
||||
const start = await runTool(env, "exec_command", {
|
||||
cmd: "read line; echo got:$line",
|
||||
yield_time_ms: 300,
|
||||
});
|
||||
const pid = extractProcessId(start.output);
|
||||
|
||||
const res = await runTool(env, "input_command", {
|
||||
process_id: pid,
|
||||
chars: "penguin\n",
|
||||
yield_time_ms: 2000,
|
||||
});
|
||||
expect(res.output).toContain("got:penguin");
|
||||
expect(res.stopReason).toBe("completed");
|
||||
});
|
||||
|
||||
it("input_command with an empty chars polls new output without writing", async () => {
|
||||
const start = await runTool(env, "exec_command", {
|
||||
cmd: "for i in 1 2 3; do echo line$i; sleep 0.2; done",
|
||||
yield_time_ms: 100,
|
||||
});
|
||||
const pid = extractProcessId(start.output);
|
||||
|
||||
const res = await runTool(env, "input_command", {
|
||||
process_id: pid,
|
||||
chars: "",
|
||||
yield_time_ms: 2000,
|
||||
});
|
||||
// The command finishes during polling, yielding the remaining output and exit status.
|
||||
expect(res.output).toContain("line3");
|
||||
expect(res.stopReason).toBe("completed");
|
||||
});
|
||||
|
||||
it("input_command sends Ctrl-C (U+0003) to interrupt a running session", async () => {
|
||||
const start = await runTool(env, "exec_command", {
|
||||
cmd: "sleep 30",
|
||||
yield_time_ms: 300,
|
||||
});
|
||||
const pid = extractProcessId(start.output);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const res = await runTool(env, "input_command", {
|
||||
process_id: pid,
|
||||
chars: String.fromCharCode(3), // U+0003 = Ctrl-C
|
||||
yield_time_ms: 2000,
|
||||
});
|
||||
const elapsed = Date.now() - startedAt;
|
||||
expect(elapsed).toBeLessThan(3000); // Did not wait for the full sleep 30
|
||||
expect(res.output).not.toContain("still running");
|
||||
expect(res.stopReason).toBe("failed"); // Interrupted by signal -> non-zero exit
|
||||
});
|
||||
|
||||
it("input_command rejects chars mixing U+0003 with other content", async () => {
|
||||
const start = await runTool(env, "exec_command", {
|
||||
cmd: "sleep 30",
|
||||
yield_time_ms: 300,
|
||||
});
|
||||
const pid = extractProcessId(start.output);
|
||||
|
||||
const res = await runTool(env, "input_command", {
|
||||
process_id: pid,
|
||||
chars: `q${String.fromCharCode(3)}`, // Mixed with other content: errors, neither writes nor sends the signal
|
||||
yield_time_ms: 2000,
|
||||
});
|
||||
expect(res.output).toContain('send "\\u0003" alone');
|
||||
expect(res.stopReason).toBe("failed");
|
||||
|
||||
// The session was not mistakenly killed: still running.
|
||||
const poll = await runTool(env, "input_command", { process_id: pid, yield_time_ms: 300 });
|
||||
expect(poll.output).toContain("still running");
|
||||
});
|
||||
|
||||
it("input_command reports an unknown process_id without throwing", async () => {
|
||||
const res = await runTool(env, "input_command", { process_id: "proc-deadbeef" });
|
||||
expect(res.output).toContain("unknown process_id proc-deadbeef");
|
||||
expect(res.stopReason).toBe("failed");
|
||||
});
|
||||
|
||||
it("input_command ignores writes to a closed stdin pipe without crashing", async () => {
|
||||
const start = await runTool(env, "exec_command", {
|
||||
cmd: "exec 0<&-; sleep 30",
|
||||
yield_time_ms: 300,
|
||||
});
|
||||
const pid = extractProcessId(start.output);
|
||||
|
||||
const res = await runTool(env, "input_command", {
|
||||
process_id: pid,
|
||||
chars: "ignored\n",
|
||||
yield_time_ms: 300,
|
||||
});
|
||||
expect(res.output).toContain(`process still running with process_id ${pid}`);
|
||||
expect(res.stopReason).toBe("completed");
|
||||
});
|
||||
|
||||
it("runs commands through pipes, not a TTY (isTTY=false)", async () => {
|
||||
const res = await runTool(env, "exec_command", {
|
||||
cmd: 'node -e "process.stdout.write(String(Boolean(process.stdout.isTTY)))"',
|
||||
yield_time_ms: 3000,
|
||||
});
|
||||
expect(res.output).toContain("false");
|
||||
expect(res.stopReason).toBe("completed");
|
||||
});
|
||||
|
||||
it("hardens the child env against interactive hangs (editor/credentials/pager)", async () => {
|
||||
const res = await runTool(env, "exec_command", {
|
||||
cmd: 'echo "$GIT_EDITOR|$GIT_TERMINAL_PROMPT|$PAGER|$TERM"',
|
||||
yield_time_ms: 3000,
|
||||
});
|
||||
expect(res.output).toContain("true|0|cat|dumb");
|
||||
expect(res.stopReason).toBe("completed");
|
||||
});
|
||||
|
||||
it("does not start new command sessions after the environment is disposed", async () => {
|
||||
env.dispose();
|
||||
const res = await runTool(env, "exec_command", {
|
||||
cmd: "echo should-not-run",
|
||||
yield_time_ms: 3000,
|
||||
});
|
||||
expect(res.output).toContain("command session manager disposed");
|
||||
expect(res.output).not.toContain("should-not-run");
|
||||
expect(res.stopReason).toBe("failed");
|
||||
});
|
||||
|
||||
it("delivers output arriving while the consumer is suspended without waiting out the window", async () => {
|
||||
// Wake-race regression: when data arrives while suspended at `yield`, its wakeup happens
|
||||
// before the next wait begins (so it would be missed). collect must re-check the buffer
|
||||
// right before sleeping, otherwise this batch of data would not be produced until the
|
||||
// window ends (here, 5s).
|
||||
const session = new ManagedSession({ cmd: "echo first; cat", cwd: tmp, env: process.env });
|
||||
try {
|
||||
const gen = session.collect(5000);
|
||||
const first = await gen.next();
|
||||
expect(first.done).toBe(false);
|
||||
expect(String(first.value)).toContain("first");
|
||||
// The generator is still suspended at the yield above: writing to stdin now, with cat
|
||||
// echoing it back, means both the data event and the wakeup have already happened.
|
||||
session.write("second\n");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const startedAt = Date.now();
|
||||
const next = await gen.next();
|
||||
expect(String(next.value)).toContain("second");
|
||||
expect(Date.now() - startedAt).toBeLessThan(1500);
|
||||
await gen.return(undefined);
|
||||
} finally {
|
||||
session.kill();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* imagesToScratchpadPaths unit tests: input conversion when the session model does not support
|
||||
* images -- data URL images are written to the session scratchpad and their paths appended to the
|
||||
* user text; http(s) URLs are referenced as-is; image messages are removed from the input;
|
||||
* image-free input is returned unchanged; images that fail to parse are replaced with an explanatory line.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { imagesToScratchpadPaths } from "../src/internal/session-support.js";
|
||||
import { imageUrlMessage, userText } from "../src/omnimessage/index.js";
|
||||
import type { TextPayload } from "../src/omnimessage/index.js";
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
const DATA_URL = `data:image/png;base64,${PNG_1X1.toString("base64")}`;
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(tmpdir(), "penguin-inputimg-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("imagesToScratchpadPaths", () => {
|
||||
it("data URL 图片落盘、路径拼接进用户文本,图片消息移除", async () => {
|
||||
const dir = path.join(tmp, "scratch", "session-1"); // auto-created if the directory doesn't exist
|
||||
const out = await imagesToScratchpadPaths(
|
||||
[userText("看看这两张图"), imageUrlMessage(DATA_URL), imageUrlMessage(DATA_URL)],
|
||||
dir,
|
||||
);
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const p = out[0]!.payload as TextPayload;
|
||||
expect(p.type).toBe("text");
|
||||
expect(p.role).toBe("user");
|
||||
expect(p.text.startsWith("看看这两张图\n\n")).toBe(true);
|
||||
const paths = [...p.text.matchAll(/\[attached image: ([^\]]+)\]/g)].map((m) => m[1]!);
|
||||
expect(paths).toHaveLength(2);
|
||||
|
||||
// The saved content matches the original image; filename = upload-<8-char random hex>.<extension by mime type>.
|
||||
const files = await readdir(dir);
|
||||
expect(files).toHaveLength(2);
|
||||
for (const f of paths) {
|
||||
expect(path.dirname(f)).toBe(dir);
|
||||
expect(path.basename(f)).toMatch(/^upload-[0-9a-f]{8}\.png$/);
|
||||
expect(await readFile(f)).toEqual(PNG_1X1);
|
||||
}
|
||||
// The two images' random names differ from each other.
|
||||
expect(new Set(paths).size).toBe(2);
|
||||
});
|
||||
|
||||
it("http(s) URL 不落盘,原样引用;仅图片输入时补一条纯路径文本", async () => {
|
||||
const out = await imagesToScratchpadPaths([imageUrlMessage("https://example.com/a.png")], tmp);
|
||||
expect(out).toHaveLength(1);
|
||||
const p = out[0]!.payload as TextPayload;
|
||||
expect(p.type).toBe("text");
|
||||
expect(p.text).toBe("[attached image: https://example.com/a.png]");
|
||||
expect(await readdir(tmp)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("无图片输入原样返回(不触碰文件系统)", async () => {
|
||||
const input = [userText("纯文本")];
|
||||
const out = await imagesToScratchpadPaths(input, path.join(tmp, "untouched"));
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
it("无法解析的图片以说明行代替,不静默丢失", async () => {
|
||||
const out = await imagesToScratchpadPaths(
|
||||
[userText("hi"), imageUrlMessage("data:text/plain,oops")],
|
||||
tmp,
|
||||
);
|
||||
const p = out[0]!.payload as TextPayload;
|
||||
expect(p.text).toContain("could not be saved");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* GenerativeModel live e2e. The whole suite is it.skip by default and stays offline;
|
||||
* requires an explicit opt-in to run against a real endpoint (this avoids ordinary unit
|
||||
* test runs firing real network requests just because an API key happens to be present):
|
||||
* PENGUIN_E2E=1 pnpm test # or pnpm test:e2e
|
||||
* The key comes from .env (gitignored) or an environment variable; the provider is picked
|
||||
* by whichever key is available (CI uses DeepSeek).
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GenerativeModel } from "../src/llm/index.js";
|
||||
import { toolCallOutput, userText } from "../src/omnimessage/index.js";
|
||||
import type { OmniMessage, ToolCallPayload } from "../src/omnimessage/index.js";
|
||||
|
||||
/** Picks a provider in order by available key; AgentHub routes by modelId and auto-reads the matching env var. */
|
||||
const PROVIDERS = [
|
||||
{ key: "ANTHROPIC_API_KEY", modelId: "claude-sonnet-4-6" },
|
||||
{ key: "DEEPSEEK_API_KEY", modelId: "deepseek-v4-flash" },
|
||||
] as const;
|
||||
const provider = PROVIDERS.find((p) => process.env[p.key]);
|
||||
const runLive = process.env.PENGUIN_E2E === "1" && provider !== undefined;
|
||||
const maybe = runLive ? it : it.skip;
|
||||
|
||||
describe(`GenerativeModel live e2e (${provider?.modelId ?? "skipped"})`, () => {
|
||||
maybe(
|
||||
"streams a short reply with partial text, a complete text, and token usage",
|
||||
async () => {
|
||||
const model = new GenerativeModel({
|
||||
modelId: provider!.modelId,
|
||||
tools: [],
|
||||
systemPrompt: "You are concise.",
|
||||
// Generous budget: a reasoning model's thinking may take up a lot of space, leave room for the reply to land reliably.
|
||||
maxTokens: 2048,
|
||||
});
|
||||
|
||||
const out: OmniMessage[] = [];
|
||||
for await (const msg of model.streamGenerate({
|
||||
newMessages: [userText("Say hello in exactly three words.")],
|
||||
})) {
|
||||
out.push(msg);
|
||||
}
|
||||
|
||||
const payloadType = (m: OmniMessage): string => (m.payload as { type: string }).type;
|
||||
|
||||
// At least 1 partial_text.
|
||||
const partialTexts = out.filter((m) => payloadType(m) === "partial_text");
|
||||
expect(partialTexts.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// At least 1 non-empty complete text.
|
||||
const completeTexts = out.filter((m) => payloadType(m) === "text");
|
||||
expect(completeTexts.length).toBeGreaterThanOrEqual(1);
|
||||
const text = (completeTexts[0]!.payload as { text: string }).text;
|
||||
expect(text.trim().length).toBeGreaterThan(0);
|
||||
|
||||
// Trailing token_usage, with request.total > 0.
|
||||
const last = out.at(-1)!;
|
||||
expect(payloadType(last)).toBe("token_usage");
|
||||
const usage = last.payload as { request: { total: number } };
|
||||
expect(usage.request.total).toBeGreaterThan(0);
|
||||
|
||||
// Session-cumulative tokens are also recorded on the instance.
|
||||
expect(model.sessionTokens.total).toBeGreaterThan(0);
|
||||
},
|
||||
// Live calls have inherent network/server jitter: allow a generous timeout and retries so
|
||||
// one slow API call doesn't fail CI (assertions stay strict; only transient timeouts are tolerated).
|
||||
{ timeout: 90_000, retry: 2 },
|
||||
);
|
||||
});
|
||||
|
||||
// --- Gemini tool_call_id uniqueness live regression (consecutive same-name tool calls made the
|
||||
// frontend tool cards overwrite each other) ---
|
||||
// Gemini's functionCall has no call id, so AgentHub uses the function name as tool_call_id; this group
|
||||
// verifies EventTranslator's in-Session uniqueness (#n suffix) and outbound restoration
|
||||
// (functionResponse paired by function name) round-trip on the real API. Runs only when explicitly
|
||||
// opted in with GEMINI_API_KEY set.
|
||||
const runGemini = process.env.PENGUIN_E2E === "1" && process.env.GEMINI_API_KEY !== undefined;
|
||||
const maybeGemini = runGemini ? it : it.skip;
|
||||
|
||||
describe(`GenerativeModel live e2e (gemini-3.5-flash: tool_call_id 唯一化${runGemini ? "" : ",skipped"})`, () => {
|
||||
maybeGemini(
|
||||
"连续两轮同名工具调用拿到不同 tool_call_id;带后缀 id 的 tool_result 回传被正确配对",
|
||||
async () => {
|
||||
const model = new GenerativeModel({
|
||||
modelId: "gemini-3.5-flash",
|
||||
tools: [
|
||||
{
|
||||
name: "get_time",
|
||||
description: "Get the current local time of a city.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string", description: "City name" } },
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
],
|
||||
systemPrompt:
|
||||
"You are a tool-driven assistant. Always use the get_time tool to answer time questions, one call per turn. Never guess.",
|
||||
maxTokens: 1024,
|
||||
thinkingLevel: "none",
|
||||
});
|
||||
|
||||
const round = async (
|
||||
input: OmniMessage[],
|
||||
): Promise<{ calls: ToolCallPayload[]; text: string }> => {
|
||||
const calls: ToolCallPayload[] = [];
|
||||
let text = "";
|
||||
const gen = model.streamGenerate({ newMessages: input });
|
||||
let res = await gen.next();
|
||||
while (!res.done) {
|
||||
const p = res.value.payload as { type: string };
|
||||
if (p.type === "tool_call") calls.push(res.value.payload as ToolCallPayload);
|
||||
if (p.type === "text") text += (res.value.payload as { text: string }).text;
|
||||
res = await gen.next();
|
||||
}
|
||||
expect((res.value as { status: string }).status).toBe("completed");
|
||||
return { calls, text };
|
||||
};
|
||||
|
||||
// Round 1: call get_time(Tokyo); the id keeps the function name.
|
||||
const r1 = await round([
|
||||
userText(
|
||||
"What time is it in Tokyo? After you get that result, also check Paris (one tool call at a time).",
|
||||
),
|
||||
]);
|
||||
expect(r1.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(r1.calls[0]!.tool_call_id).toBe("get_time");
|
||||
|
||||
// Round 2: after returning the result the model checks Paris next — the same-name call must get a new suffixed id.
|
||||
const r2 = await round(
|
||||
r1.calls.map((c) =>
|
||||
toolCallOutput({ output: "10:00 AM (mock)", toolCallId: c.tool_call_id }),
|
||||
),
|
||||
);
|
||||
expect(r2.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(r2.calls[0]!.tool_call_id).toBe("get_time#2");
|
||||
|
||||
// Round 3: after returning the tool_result carrying the #2 suffix (outbound strips it back to the
|
||||
// function name), the provider finishes normally. Reaching completed means the functionResponse
|
||||
// pairing was accepted; some models may keep appending calls, so don't hard-assert the body.
|
||||
const r3 = await round(
|
||||
r2.calls.map((c) =>
|
||||
toolCallOutput({ output: "3:00 AM (mock)", toolCallId: c.tool_call_id }),
|
||||
),
|
||||
);
|
||||
expect(r3.calls.length + r3.text.length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 120_000, retry: 2 },
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Built-in model catalog unit tests: unique ids, valid provider references, positive
|
||||
* three-bucket pricing, lookups, and preset entry generation.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODEL_CATALOG,
|
||||
MODEL_PROVIDERS,
|
||||
catalogEntryFor,
|
||||
inferProviderForUpstream,
|
||||
presetModelEntries,
|
||||
providerInfo,
|
||||
resolveModelEnv,
|
||||
} from "../src/state/index.js";
|
||||
|
||||
describe("model-catalog", () => {
|
||||
it("model id 全局唯一;DeepSeek 排在最前(默认模型所属厂商)", () => {
|
||||
const ids = MODEL_CATALOG.map((m) => m.modelId);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(MODEL_CATALOG[0]!.provider).toBe("deepseek");
|
||||
// Group order: DeepSeek first, followed by the OpenRouter and SiliconFlow gateways,
|
||||
// then Google Gemini before Anthropic, with custom last.
|
||||
expect(MODEL_PROVIDERS.map((p) => p.id)).toEqual([
|
||||
"deepseek",
|
||||
"openrouter",
|
||||
"siliconflow",
|
||||
"google",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"zhipu",
|
||||
"moonshot",
|
||||
"custom",
|
||||
]);
|
||||
expect(providerInfo("siliconflow")!.label).toBe("SiliconFlow");
|
||||
// The catalog no longer includes GLM-5-Turbo.
|
||||
expect(ids).not.toContain("glm-5-turbo");
|
||||
});
|
||||
|
||||
it("provider 均在 MODEL_PROVIDERS 内(custom 仅收拢自定义模型,目录不使用)", () => {
|
||||
const providerIds = new Set(MODEL_PROVIDERS.map((p) => p.id));
|
||||
for (const m of MODEL_CATALOG) {
|
||||
expect(providerIds.has(m.provider)).toBe(true);
|
||||
expect(m.provider).not.toBe("custom");
|
||||
}
|
||||
// Except for custom, every provider gives a console link to "get an API key" (shown in the
|
||||
// frontend's group header) and a model list / docs link to "get a model id" (shown in the add-model dialog).
|
||||
for (const p of MODEL_PROVIDERS) {
|
||||
if (p.id === "custom") {
|
||||
expect(p.apiKeyUrl).toBeUndefined();
|
||||
expect(p.modelsUrl).toBeUndefined();
|
||||
} else {
|
||||
expect(p.apiKeyUrl).toMatch(/^https:\/\//);
|
||||
expect(p.modelsUrl).toMatch(/^https:\/\//);
|
||||
}
|
||||
}
|
||||
// Provider ids are also unique, and each provider has an API key / base URL env var name.
|
||||
expect(new Set([...providerIds]).size).toBe(MODEL_PROVIDERS.length);
|
||||
for (const p of MODEL_PROVIDERS) {
|
||||
expect(p.envKey).toMatch(/_API_KEY$/);
|
||||
expect(p.envBaseUrlKey).toMatch(/_BASE_URL$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("三桶价格均为正数,context_window 为正整数", () => {
|
||||
for (const m of MODEL_CATALOG) {
|
||||
expect(m.pricing, m.modelId).toBeDefined();
|
||||
expect(m.pricing!.unit).toBe("usd_per_mtok");
|
||||
expect(m.pricing!.cache_read).toBeGreaterThan(0);
|
||||
expect(m.pricing!.cache_write).toBeGreaterThan(0);
|
||||
expect(m.pricing!.output).toBeGreaterThan(0);
|
||||
expect(Number.isInteger(m.contextWindow)).toBe(true);
|
||||
expect(m.contextWindow!).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("providerInfo 按 id 命中,未知返回 undefined", () => {
|
||||
expect(providerInfo("moonshot")?.envKey).toBe("MOONSHOT_API_KEY");
|
||||
expect(providerInfo("nonexistent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("成对匹配与分组推断:catalogEntryFor / inferProviderForUpstream", () => {
|
||||
// catalogEntryFor is the sole catalog lookup entry point: it matches on (group, upstream id)
|
||||
// pairs, so an identically named upstream id never matches across the wrong group.
|
||||
expect(catalogEntryFor("anthropic", "claude-sonnet-4-6")?.displayName).toBe(
|
||||
"Claude Sonnet 4.6",
|
||||
);
|
||||
expect(catalogEntryFor("openai", "claude-sonnet-4-6")).toBeUndefined();
|
||||
// The upstream id itself may contain / (gateway models); it is never split apart.
|
||||
expect(catalogEntryFor("openrouter", "xiaomi/mimo-v2.5")?.displayName).toBe("MiMo-V2.5");
|
||||
expect(catalogEntryFor("custom", "my-own")).toBeUndefined();
|
||||
|
||||
// Inference for `model add` when --provider is omitted: a catalog hit yields its provider, otherwise custom.
|
||||
expect(inferProviderForUpstream("deepseek-v4-pro")).toBe("deepseek");
|
||||
expect(inferProviderForUpstream("xiaomi/mimo-v2.5")).toBe("openrouter");
|
||||
expect(inferProviderForUpstream("my-own-model")).toBe("custom");
|
||||
});
|
||||
|
||||
it("presetModelEntries:provider 与纯上游 model_id 分列;网关模型内联 base_url", () => {
|
||||
const entries = presetModelEntries();
|
||||
expect(entries).toHaveLength(MODEL_CATALOG.length);
|
||||
for (const [i, entry] of entries.entries()) {
|
||||
const cat = MODEL_CATALOG[i]!;
|
||||
expect(entry.provider).toBe(cat.provider);
|
||||
expect(entry.model_id).toBe(cat.modelId);
|
||||
expect(entry.context_window).toBe(cat.contextWindow);
|
||||
expect(entry.pricing).toEqual(cat.pricing);
|
||||
expect(entry.vision).toBe(cat.supportsVision ? undefined : false);
|
||||
// Models that AgentHub can auto-route leave client_type unset; OpenRouter gateway models set it to openai.
|
||||
expect(entry.client_type).toBe(cat.clientType);
|
||||
// Gateway models inline a preset base URL (no credentials); other models carry no credential at all.
|
||||
expect(entry.base_url).toBe(cat.baseUrl);
|
||||
expect(entry.api_key).toBeUndefined();
|
||||
// The concatenated storage id and request_model_id have been removed and no longer appear.
|
||||
expect(Object.hasOwn(entry, "request_model_id")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("网关模型(OpenRouter / SiliconFlow):openai 协议 + 预置 base URL;env 兜底为 OPENAI_API_KEY", () => {
|
||||
const or = MODEL_CATALOG.filter((m) => m.provider === "openrouter");
|
||||
expect(or.map((m) => m.modelId)).toEqual([
|
||||
"xiaomi/mimo-v2.5",
|
||||
"tencent/hy3",
|
||||
"minimax/minimax-m3",
|
||||
"stepfun/step-3.7-flash",
|
||||
]);
|
||||
for (const m of or) {
|
||||
expect(m.clientType).toBe("openai");
|
||||
expect(m.baseUrl).toBe("https://openrouter.ai/api/v1");
|
||||
}
|
||||
const sf = MODEL_CATALOG.filter((m) => m.provider === "siliconflow");
|
||||
expect(sf.map((m) => m.modelId)).toEqual([
|
||||
"zai-org/GLM-5.2",
|
||||
"deepseek-ai/DeepSeek-V4-Pro",
|
||||
"meituan-longcat/LongCat-2.0",
|
||||
]);
|
||||
for (const m of sf) {
|
||||
expect(m.clientType).toBe("openai");
|
||||
expect(m.baseUrl).toBe("https://api.siliconflow.cn/v1");
|
||||
}
|
||||
// Routed through AgentHub's OpenAI client -> when the credential is left blank it reads OPENAI_API_KEY (not the provider's own env var name).
|
||||
for (const id of ["openrouter", "siliconflow", "custom"]) {
|
||||
expect(providerInfo(id)!.envKey).toBe("OPENAI_API_KEY");
|
||||
expect(providerInfo(id)!.envBaseUrlKey).toBe("OPENAI_BASE_URL");
|
||||
}
|
||||
// gatewayBaseUrl (prefilled by group in the frontend's "add model" dialog) is only carried by the two gateway providers.
|
||||
expect(providerInfo("openrouter")!.gatewayBaseUrl).toBe("https://openrouter.ai/api/v1");
|
||||
expect(providerInfo("siliconflow")!.gatewayBaseUrl).toBe("https://api.siliconflow.cn/v1");
|
||||
for (const p of MODEL_PROVIDERS) {
|
||||
if (p.id !== "openrouter" && p.id !== "siliconflow") {
|
||||
expect(p.gatewayBaseUrl, p.id).toBeUndefined();
|
||||
}
|
||||
}
|
||||
const gateway = [...or, ...sf];
|
||||
// Pricing (USD): MiMo v2.5 and Hy3.
|
||||
const mimo = MODEL_CATALOG.find((m) => m.modelId === "xiaomi/mimo-v2.5")!.pricing!;
|
||||
expect([mimo.cache_read, mimo.cache_write, mimo.output]).toEqual([0.0028, 0.14, 0.28]);
|
||||
const hy3 = MODEL_CATALOG.find((m) => m.modelId === "tencent/hy3")!.pricing!;
|
||||
expect([hy3.cache_read, hy3.cache_write, hy3.output]).toEqual([0.035, 0.14, 0.58]);
|
||||
|
||||
// In preset entries, exactly the gateway models (and only them) inline base_url (no credentials).
|
||||
const withBaseUrl = presetModelEntries().filter((e) => e.base_url !== undefined);
|
||||
expect(withBaseUrl.map((e) => [e.provider, e.model_id]).sort()).toEqual(
|
||||
gateway.map((m) => [m.provider, m.modelId]).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("DeepSeek 与 Kimi 按官方人民币价初始化(存储美元,×7 还原官方原价)", () => {
|
||||
const cnyOf = (usdV: number) => Math.round(usdV * 7 * 1000) / 1000;
|
||||
const pro = MODEL_CATALOG.find((m) => m.modelId === "deepseek-v4-pro")!.pricing!;
|
||||
expect([cnyOf(pro.cache_read), cnyOf(pro.cache_write), cnyOf(pro.output)]).toEqual([
|
||||
0.025, 3, 6,
|
||||
]);
|
||||
const k26 = MODEL_CATALOG.find((m) => m.modelId === "kimi-k2.6")!.pricing!;
|
||||
expect([cnyOf(k26.cache_read), cnyOf(k26.cache_write), cnyOf(k26.output)]).toEqual([
|
||||
1.1, 6.5, 27,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelEnv(PRN-021:env 兜底按 AgentHub 路由规则解析)", () => {
|
||||
it("一方厂商 id 按子串路由到厂商客户端的环境变量", () => {
|
||||
expect(resolveModelEnv("deepseek-v4-pro")?.envKey).toBe("DEEPSEEK_API_KEY");
|
||||
expect(resolveModelEnv("claude-opus-4-8")?.envKey).toBe("ANTHROPIC_API_KEY");
|
||||
expect(resolveModelEnv("claude-sonnet-4-6")?.envKey).toBe("ANTHROPIC_API_KEY");
|
||||
expect(resolveModelEnv("gemini-3.5-flash")?.envKey).toBe("GEMINI_API_KEY");
|
||||
expect(resolveModelEnv("gpt-5.5-pro")?.envKey).toBe("OPENAI_API_KEY");
|
||||
expect(resolveModelEnv("glm-5.2")?.envKey).toBe("ZAI_API_KEY");
|
||||
expect(resolveModelEnv("kimi-k2.6")?.envBaseUrlKey).toBe("MOONSHOT_BASE_URL");
|
||||
});
|
||||
|
||||
it("显式 client_type 优先于 id:openai 协议一律 OPENAI_*(与分组无关)", () => {
|
||||
expect(resolveModelEnv("deepseek-v4-pro", "openai")?.envKey).toBe("OPENAI_API_KEY");
|
||||
expect(resolveModelEnv("zai-org/GLM-5.2", "openai")?.envKey).toBe("OPENAI_API_KEY");
|
||||
});
|
||||
|
||||
it("无法路由的 id 返回 undefined(AgentHub 会拒绝,需显式 client_type 或 OpenAI 协议分组)", () => {
|
||||
expect(resolveModelEnv("totally-unknown-model")).toBeUndefined();
|
||||
expect(resolveModelEnv("xiaomi/mimo-v2.5")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("目录不变式:无 client_type 的条目按 id 可路由且 envKey 与厂商一致;网关条目经 client_type 解析为 OPENAI_*", () => {
|
||||
for (const m of MODEL_CATALOG) {
|
||||
const env = resolveModelEnv(m.modelId, m.clientType);
|
||||
expect(env, `${m.provider}/${m.modelId}`).toBeDefined();
|
||||
if (m.clientType === undefined) {
|
||||
expect(env!.envKey, m.modelId).toBe(providerInfo(m.provider)!.envKey);
|
||||
} else {
|
||||
expect(env!.envKey, m.modelId).toBe("OPENAI_API_KEY");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PartialAggregator,
|
||||
aggregateAll,
|
||||
abortEvent,
|
||||
addTokenCounts,
|
||||
approvalDecision,
|
||||
assistantText,
|
||||
emptyTokenCounts,
|
||||
isCompleteModelMessage,
|
||||
partialText,
|
||||
partialToolCall,
|
||||
partialToolCallOutput,
|
||||
toolCall,
|
||||
toolCallOutput,
|
||||
userText,
|
||||
} from "../src/omnimessage/index.js";
|
||||
import type {
|
||||
TextPayload,
|
||||
ToolCallOutputPayload,
|
||||
ToolCallPayload,
|
||||
} from "../src/omnimessage/index.js";
|
||||
|
||||
describe("builders", () => {
|
||||
it("stamps an ISO 8601 timestamp and correct shells", () => {
|
||||
const m = userText("hi");
|
||||
expect(m.type).toBe("model_msg");
|
||||
expect(m.payload.type).toBe("text");
|
||||
expect(m.payload.role).toBe("user");
|
||||
expect(new Date(m.timestamp).toISOString()).toBe(m.timestamp);
|
||||
});
|
||||
|
||||
it("builds event messages", () => {
|
||||
const a = approvalDecision("allow", "call_1");
|
||||
expect(a.type).toBe("event_msg");
|
||||
expect(a.payload).toMatchObject({
|
||||
type: "approval_decision",
|
||||
decision: "allow",
|
||||
tool_call_id: "call_1",
|
||||
});
|
||||
expect(abortEvent("stop").payload).toMatchObject({
|
||||
type: "abort",
|
||||
reason: "stop",
|
||||
});
|
||||
});
|
||||
|
||||
it("toolCallOutput carries optional images and round-trips through JSON", () => {
|
||||
const dataUrl = "data:image/png;base64,AAAA";
|
||||
const msg = toolCallOutput({
|
||||
output: "image/png, 4 B",
|
||||
toolCallId: "call_img",
|
||||
images: [dataUrl],
|
||||
});
|
||||
expect(msg.payload).toMatchObject({
|
||||
type: "tool_call_output",
|
||||
role: "user",
|
||||
output: "image/png, 4 B",
|
||||
images: [dataUrl],
|
||||
tool_call_id: "call_img",
|
||||
stop_reason: "completed",
|
||||
});
|
||||
// A JSON serialization round-trip preserves images (same shape as Trace persistence / replay).
|
||||
const revived = JSON.parse(JSON.stringify(msg)) as { payload: ToolCallOutputPayload };
|
||||
expect(revived.payload.images).toEqual([dataUrl]);
|
||||
// The images field is not produced when omitted or given an empty array (absence means no
|
||||
// images; serialization does not carry an empty field).
|
||||
expect("images" in toolCallOutput({ output: "x", toolCallId: "c" }).payload).toBe(false);
|
||||
expect("images" in toolCallOutput({ output: "x", toolCallId: "c", images: [] }).payload).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("partialToolCallOutput delta carries optional images (whole image in one delta)", () => {
|
||||
const dataUrl = "data:image/png;base64,AAAA";
|
||||
const msg = partialToolCallOutput({
|
||||
eventType: "delta",
|
||||
toolCallId: "call_img",
|
||||
images: [dataUrl],
|
||||
});
|
||||
expect(msg.payload).toMatchObject({
|
||||
type: "partial_tool_call_output",
|
||||
event_type: "delta",
|
||||
output: "",
|
||||
images: [dataUrl],
|
||||
tool_call_id: "call_img",
|
||||
});
|
||||
// The images field is not produced when omitted or given an empty array (same as the complete message).
|
||||
expect("images" in partialToolCallOutput({ eventType: "delta", toolCallId: "c" }).payload).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
"images" in
|
||||
partialToolCallOutput({ eventType: "delta", toolCallId: "c", images: [] }).payload,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("adds token counts", () => {
|
||||
const a = { cache_read: 1, cache_write: 2, output: 3, total: 6 };
|
||||
const b = { cache_read: 10, cache_write: 20, output: 30, total: 60 };
|
||||
expect(addTokenCounts(a, b)).toEqual({
|
||||
cache_read: 11,
|
||||
cache_write: 22,
|
||||
output: 33,
|
||||
total: 66,
|
||||
});
|
||||
expect(emptyTokenCounts()).toEqual({
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCompleteModelMessage", () => {
|
||||
it("distinguishes complete from partial model messages", () => {
|
||||
expect(isCompleteModelMessage(assistantText("done"))).toBe(true);
|
||||
expect(isCompleteModelMessage(partialText("delta", "x"))).toBe(false);
|
||||
expect(isCompleteModelMessage(approvalDecision("deny", "c"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PartialAggregator", () => {
|
||||
it("folds a partial_text stream into one complete text message", () => {
|
||||
const out = aggregateAll([
|
||||
partialText("start", "Hel"),
|
||||
partialText("delta", "lo "),
|
||||
partialText("delta", "world"),
|
||||
partialText("stop", "", "completed"),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(isCompleteModelMessage(out[0]!)).toBe(true);
|
||||
const p = out[0]!.payload as TextPayload;
|
||||
expect(p.type).toBe("text");
|
||||
expect(p.text).toBe("Hello world");
|
||||
expect(p.stop_reason).toBe("completed");
|
||||
});
|
||||
|
||||
it("accumulates partial_tool_call arguments and preserves tool_call_id", () => {
|
||||
const out = aggregateAll([
|
||||
partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c1" }),
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "exec_command",
|
||||
arguments: '{"cmd":"ls',
|
||||
toolCallId: "c1",
|
||||
}),
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "exec_command",
|
||||
arguments: ' -la"}',
|
||||
toolCallId: "c1",
|
||||
}),
|
||||
partialToolCall({ eventType: "stop", name: "exec_command", toolCallId: "c1" }),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
const p = out[0]!.payload as ToolCallPayload;
|
||||
expect(p.type).toBe("tool_call");
|
||||
expect(p.name).toBe("exec_command");
|
||||
expect(p.tool_call_id).toBe("c1");
|
||||
expect(p.arguments).toBe('{"cmd":"ls -la"}');
|
||||
});
|
||||
|
||||
it("folds tool output image deltas into the complete tool_call_output (拼接 == 完整)", () => {
|
||||
const dataUrl = "data:image/png;base64,AAAA";
|
||||
const out = aggregateAll([
|
||||
partialToolCallOutput({ eventType: "start", toolCallId: "c9" }),
|
||||
partialToolCallOutput({ eventType: "delta", output: "image/png, 4 B", toolCallId: "c9" }),
|
||||
partialToolCallOutput({ eventType: "delta", toolCallId: "c9", images: [dataUrl] }),
|
||||
partialToolCallOutput({ eventType: "stop", toolCallId: "c9" }),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
const p = out[0]!.payload as ToolCallOutputPayload;
|
||||
expect(p.type).toBe("tool_call_output");
|
||||
expect(p.output).toBe("image/png, 4 B");
|
||||
expect(p.images).toEqual([dataUrl]);
|
||||
});
|
||||
|
||||
it("passes through complete and event messages unchanged and keeps order", () => {
|
||||
const tc = toolCall({ name: "x", arguments: "{}", toolCallId: "c2" });
|
||||
const out = aggregateAll([userText("q"), tc, approvalDecision("allow", "c2")]);
|
||||
expect(out).toHaveLength(3);
|
||||
// The session_meta payload has no inner type field, so consumers must first narrow by the
|
||||
// outer type; here we assert order and passthrough with a loose read.
|
||||
const payloadTypes = out.map((m) => (m.payload as { type?: string }).type);
|
||||
expect(payloadTypes).toEqual(["text", "tool_call", "approval_decision"]);
|
||||
expect(out.map((m) => m.type)).toEqual(["model_msg", "model_msg", "event_msg"]);
|
||||
});
|
||||
|
||||
it("flush emits unterminated fragments", () => {
|
||||
const agg = new PartialAggregator();
|
||||
expect(agg.push(partialText("start", "abc"))).toEqual([]);
|
||||
expect(agg.push(partialText("delta", "def"))).toEqual([]);
|
||||
const flushed = agg.flush();
|
||||
expect(flushed).toHaveLength(1);
|
||||
expect((flushed[0]!.payload as TextPayload).text).toBe("abcdef");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Fake API keys for testing: the provider SDK requires a credential at **construction time**
|
||||
* (throwing "Missing credentials" if absent), and `createSession` constructs an LLM client for
|
||||
* the default model.
|
||||
*
|
||||
* The default model uses the OpenAI protocol (DeepSeek), so `OPENAI_API_KEY` must have a value;
|
||||
* Anthropic's is stubbed too, for test cases that explicitly specify a claude model. The keys
|
||||
* are only used to construct the client; tests never actually send a request. CI has no keys
|
||||
* at all, while most local dev machines do -- without stubbing, tests would "pass locally,
|
||||
* fail in CI."
|
||||
*/
|
||||
const KEYS = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const;
|
||||
|
||||
/** Stubs in fake keys and returns a restore function (call it in afterEach). */
|
||||
export function stubProviderKeys(): () => void {
|
||||
const prev = KEYS.map((k) => [k, process.env[k]] as const);
|
||||
for (const k of KEYS) process.env[k] = "test-key-not-used";
|
||||
return () => {
|
||||
for (const [k, v] of prev) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Unit tests for the read_image tool (no network): local file reading (relative-path
|
||||
* resolution / magic-number sniffing / missing file / over the size limit / unsupported type /
|
||||
* missing argument) and http(s) URL download (stubbing global fetch: success and non-2xx).
|
||||
* Directly drives BuiltinTool.execute and captures the generator's return value -- see
|
||||
* environment.test.ts for the Environment-side images assembly.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
MAX_IMAGE_BYTES,
|
||||
READ_IMAGE_NAME,
|
||||
createReadImageTool,
|
||||
} from "../src/environment/tools/read-image.js";
|
||||
import type { ToolResult } from "../src/environment/tools/types.js";
|
||||
import type { OmniMessage } from "../src/omnimessage/index.js";
|
||||
import type { ToolDefinitionConfig } from "../src/interfaces.js";
|
||||
|
||||
/** Full bytes of a 1x1 transparent PNG (including the magic number, enough for mime sniffing
|
||||
* and data URL assertions). */
|
||||
const PNG_1X1 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
const definition: ToolDefinitionConfig = {
|
||||
name: READ_IMAGE_NAME,
|
||||
description: "read image",
|
||||
permission: "r",
|
||||
};
|
||||
|
||||
/** Runs one tool execution: collects streamed messages, concatenates text deltas, and captures
|
||||
* the generator's return value. */
|
||||
async function run(args: Record<string, unknown>, workspaceDir: string) {
|
||||
const tool = createReadImageTool(definition);
|
||||
const gen = tool.execute(args, { workspaceDir, toolCallId: "c1" });
|
||||
const messages: OmniMessage[] = [];
|
||||
let result: ToolResult | void;
|
||||
for (;;) {
|
||||
const res = await gen.next();
|
||||
if (res.done) {
|
||||
result = res.value;
|
||||
break;
|
||||
}
|
||||
messages.push(res.value);
|
||||
}
|
||||
const text = messages.map((m) => (m.payload as { output?: string }).output ?? "").join("");
|
||||
return { messages, result, text };
|
||||
}
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(tmpdir(), "penguin-readimg-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("read_image — 本地文件", () => {
|
||||
it("按相对路径读取 png,输出 data URL 与一行 mime/大小说明", async () => {
|
||||
await writeFile(path.join(tmp, "img.png"), PNG_1X1);
|
||||
const { result, text } = await run({ source: "img.png" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined(); // Defaults to completed
|
||||
expect(result?.images).toEqual([`data:image/png;base64,${PNG_1X1.toString("base64")}`]);
|
||||
expect(text).toBe(`image/png, ${PNG_1X1.length} B`);
|
||||
});
|
||||
|
||||
it("扩展名与内容不符时以魔数嗅探为准", async () => {
|
||||
await writeFile(path.join(tmp, "photo.jpg"), PNG_1X1); // Content is actually PNG
|
||||
const { result } = await run({ source: "photo.jpg" }, tmp);
|
||||
expect(result?.images?.[0]).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
|
||||
it("文件不存在时以 failed 收尾并输出解释", async () => {
|
||||
const { result, text } = await run({ source: "missing.png" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(result?.images).toBeUndefined();
|
||||
expect(text).toContain("missing.png");
|
||||
});
|
||||
|
||||
it("超过大小上限时以 failed 收尾", async () => {
|
||||
const big = Buffer.alloc(MAX_IMAGE_BYTES + 1);
|
||||
PNG_1X1.copy(big); // Header carries the PNG magic number, ensuring the failure is due to size, not type
|
||||
await writeFile(path.join(tmp, "big.png"), big);
|
||||
const { result, text } = await run({ source: "big.png" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("too large");
|
||||
});
|
||||
|
||||
it("不支持的图片类型以 failed 收尾", async () => {
|
||||
await writeFile(path.join(tmp, "img.bmp"), Buffer.from("BM not really an image"));
|
||||
const { result, text } = await run({ source: "img.bmp" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("Unsupported image type");
|
||||
});
|
||||
|
||||
it("source 指向目录时以 failed 收尾并明确说明(不透传 EISDIR)", async () => {
|
||||
await mkdir(path.join(tmp, "subdir"));
|
||||
const { result, text } = await run({ source: "subdir" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("not a file");
|
||||
expect(text).not.toContain("EISDIR");
|
||||
});
|
||||
|
||||
it("空文件以 failed 收尾(扩展名兜底不得放行空 base64)", async () => {
|
||||
await writeFile(path.join(tmp, "empty.png"), Buffer.alloc(0));
|
||||
const { result, text } = await run({ source: "empty.png" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("empty");
|
||||
});
|
||||
|
||||
it("缺少 source 参数以 failed 收尾", async () => {
|
||||
const { result, text } = await run({}, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain('"source"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("read_image — http(s) URL", () => {
|
||||
it("经全局 fetch 下载并取响应头 content-type 作为 mime", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: unknown) =>
|
||||
new Response(PNG_1X1, { status: 200, headers: { "content-type": "image/png" } }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { result, text } = await run({ source: "https://example.com/a" }, tmp);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe("https://example.com/a");
|
||||
expect(result?.images).toEqual([`data:image/png;base64,${PNG_1X1.toString("base64")}`]);
|
||||
expect(text).toContain("image/png");
|
||||
});
|
||||
|
||||
it("非 2xx 响应以 failed 收尾并带状态码", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("nope", { status: 404 })),
|
||||
);
|
||||
const { result, text } = await run({ source: "https://example.com/missing.png" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("404");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Trace replay:
|
||||
*
|
||||
* - Per-round judgment: completed rounds enter history; uncommitted rounds (not completed / have
|
||||
* a start but no stop) are dropped entirely, keeping only outputs paired with already-committed
|
||||
* tool_calls; trailing input is kept as-is as carry-over.
|
||||
* - Pairing fallback: committed tool_calls with no paired output get an interrupted-state placeholder.
|
||||
* - Compaction wrap-up (file level): summarize rebuilds <context_summary>, discard leaves no
|
||||
* pending input; failed compaction rounds are dropped by the generic rule.
|
||||
* - Tolerates a truncated trailing line left by an abnormal process exit.
|
||||
* - Round-trip: a Trace written out by the engine, once replayed, matches the history the model actually received.
|
||||
*/
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
assistantText,
|
||||
compactionBegin,
|
||||
compactionEnd,
|
||||
emptyTokenCounts,
|
||||
requestBegin,
|
||||
requestEnd,
|
||||
sessionMeta,
|
||||
thinkingMessage,
|
||||
tokenUsage,
|
||||
toolCall,
|
||||
toolCallOutput,
|
||||
userText,
|
||||
} from "../src/omnimessage/index.js";
|
||||
import type { OmniMessage, TokenCounts } from "../src/omnimessage/index.js";
|
||||
import { parseTraceLines, resumeTrace } from "../src/trace/resume.js";
|
||||
import { ContextEngine } from "../src/engine/context-engine.js";
|
||||
import { Environment } from "../src/environment/index.js";
|
||||
import { Writer, readTrace } from "../src/trace/index.js";
|
||||
import type { ApproveFn, LLMInterface } from "../src/interfaces.js";
|
||||
|
||||
const usage = (total: number): TokenCounts => ({
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
output: 1,
|
||||
total,
|
||||
});
|
||||
|
||||
function meta(): OmniMessage {
|
||||
return sessionMeta({
|
||||
session_id: "session-2026-07-06-10-00-00-abcdef01",
|
||||
provider: "anthropic",
|
||||
model_id: "claude-sonnet-4-6",
|
||||
model_context_window: 1000000,
|
||||
system_prompt: "SP",
|
||||
tools: [],
|
||||
thinking_level: "default",
|
||||
agent_state: "/agent/state",
|
||||
workspace: "/ws",
|
||||
});
|
||||
}
|
||||
|
||||
function textsOf(msgs: OmniMessage[]): string[] {
|
||||
return msgs.map((m) => {
|
||||
const p = m.payload as {
|
||||
type?: string;
|
||||
text?: string;
|
||||
output?: string;
|
||||
name?: string;
|
||||
thinking?: string;
|
||||
};
|
||||
return p.text ?? p.output ?? p.thinking ?? p.name ?? p.type ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
describe("resumeTrace", () => {
|
||||
it("committed rounds enter history; trailing inputs become carry-over", () => {
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("hello"),
|
||||
requestBegin(),
|
||||
assistantText("hi"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
userText("tail input"), // the request never got a chance to start
|
||||
]);
|
||||
expect(textsOf(result.history)).toEqual(["hello", "hi"]);
|
||||
expect(textsOf(result.carryOver)).toEqual(["tail input"]);
|
||||
expect(result.sessionTurns).toBe(1);
|
||||
expect(result.sessionTokens.total).toBe(10);
|
||||
expect(result.lastRequestTotal).toBe(10);
|
||||
expect(result.contextClosed).toBe(false);
|
||||
});
|
||||
|
||||
it("re-carries the uncommitted round's raw input into the retried round", () => {
|
||||
// The synthesized carry-over (flatten) is never written to Trace: replay does its best,
|
||||
// merging the unanswered raw input as-is into the retried round (the history content differs
|
||||
// in wording from the flatten AgentHub actually received, but matches in structure and information).
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("A"),
|
||||
requestBegin(),
|
||||
requestEnd("timeout"), // failed with zero output
|
||||
requestBegin(),
|
||||
assistantText("ok"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(20), usage(20)),
|
||||
]);
|
||||
expect(textsOf(result.history)).toEqual(["A", "ok"]);
|
||||
expect(result.carryOver).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps structured outputs pairing committed tool_calls when a later round fails", () => {
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("run it"),
|
||||
requestBegin(),
|
||||
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
toolCallOutput({ output: "result-1", toolCallId: "tc1" }),
|
||||
requestBegin(),
|
||||
thinkingMessage("half", "aborted"), // the replay request was interrupted
|
||||
requestEnd("aborted"),
|
||||
]);
|
||||
expect(textsOf(result.history)).toEqual(["run it", "exec_command"]);
|
||||
// tc1 was committed but unanswered: its output is kept pending (structured re-delivery); the half-finished thinking is discarded.
|
||||
expect(textsOf(result.carryOver)).toEqual(["result-1"]);
|
||||
});
|
||||
|
||||
it("treats begin-without-end as uncommitted: raw input re-carried, half-products lost", () => {
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("A"),
|
||||
requestBegin(),
|
||||
thinkingMessage("half", "aborted"),
|
||||
// The process exited during the request: no end.
|
||||
]);
|
||||
expect(result.history).toEqual([]);
|
||||
// The last unanswered input is resent as-is; the model's half-finished output is allowed to be lost.
|
||||
expect(textsOf(result.carryOver)).toEqual(["A"]);
|
||||
// The render view still shows every complete message.
|
||||
expect(textsOf(result.renderMessages)).toEqual(["A", "half"]);
|
||||
});
|
||||
|
||||
it("backfills placeholder outputs for committed tool_calls with no paired output", () => {
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("go"),
|
||||
requestBegin(),
|
||||
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }),
|
||||
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc2" }),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
toolCallOutput({ output: "done-1", toolCallId: "tc1" }),
|
||||
// tc2's output was lost along with the process.
|
||||
]);
|
||||
// carry-over = real trailing output + in-memory synthesized placeholder (never written to Trace), pairing complete.
|
||||
expect(result.carryOver).toHaveLength(2);
|
||||
const backfill = result.carryOver[1]!.payload as { tool_call_id: string; output: string };
|
||||
expect(backfill.tool_call_id).toBe("tc2");
|
||||
expect(backfill.output).toContain("interrupted");
|
||||
expect(textsOf(result.carryOver)).toEqual(["done-1", backfill.output]);
|
||||
});
|
||||
|
||||
it("routes user-side messages inside a request span to the next round's input", () => {
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("go"),
|
||||
requestBegin(),
|
||||
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }),
|
||||
// A parallel tool finished during the request: its output lands between start and stop.
|
||||
toolCallOutput({ output: "early", toolCallId: "tc1" }),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
]);
|
||||
expect(textsOf(result.history)).toEqual(["go", "exec_command"]);
|
||||
expect(textsOf(result.carryOver)).toEqual(["early"]);
|
||||
});
|
||||
|
||||
it("closed context (summarize): empty history, summary rebuilt from compaction output", () => {
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("hello"),
|
||||
requestBegin(),
|
||||
assistantText("hi"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
compactionBegin({ reason: "context", mode: "summarize", context: 10, turns: 1 }),
|
||||
userText("please summarize"),
|
||||
requestBegin(),
|
||||
assistantText("<summary>the gist</summary>"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(20), usage(20)),
|
||||
compactionEnd({ reason: "context", mode: "summarize", status: "completed" }),
|
||||
]);
|
||||
expect(result.contextClosed).toBe(true);
|
||||
expect(result.history).toEqual([]);
|
||||
expect(result.renderMessages).toEqual([]);
|
||||
const summary = result.pendingSummary!.payload as { text: string };
|
||||
expect(summary.text).toBe("<context_summary>\nthe gist\n</context_summary>");
|
||||
expect(result.sessionTurns).toBe(0);
|
||||
expect(result.sessionTokens.total).toBe(20); // Token carry-over includes compaction consumption
|
||||
});
|
||||
|
||||
it("closed context (discard): empty history and no pending summary", () => {
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("hello"),
|
||||
requestBegin(),
|
||||
assistantText("hi"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
compactionBegin({ reason: "manual", mode: "discard", context: 10, turns: 1 }),
|
||||
compactionEnd({ reason: "manual", mode: "discard", status: "completed" }),
|
||||
]);
|
||||
expect(result.contextClosed).toBe(true);
|
||||
expect(result.history).toEqual([]);
|
||||
expect(result.pendingSummary).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops failed compaction rounds via the generic rule (prompt not in history)", () => {
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("hello"),
|
||||
requestBegin(),
|
||||
assistantText("hi"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
compactionBegin({ reason: "context", mode: "summarize", context: 10, turns: 1 }),
|
||||
userText("please summarize"),
|
||||
requestBegin(),
|
||||
requestEnd("failed"),
|
||||
compactionEnd({ reason: "context", mode: "summarize", status: "failed" }),
|
||||
userText("continue"),
|
||||
requestBegin(),
|
||||
assistantText("sure"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(30), usage(30)),
|
||||
]);
|
||||
expect(result.contextClosed).toBe(false);
|
||||
expect(textsOf(result.history)).toEqual(["hello", "hi", "continue", "sure"]);
|
||||
expect(result.carryOver).toEqual([]);
|
||||
expect(result.sessionTurns).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTraceLines", () => {
|
||||
it("tolerates a torn trailing line (process crash mid-write)", () => {
|
||||
const content = `${JSON.stringify(userText("a"))}\n{"timestamp":"2026-07-06T`;
|
||||
const msgs = parseTraceLines(content);
|
||||
expect(msgs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws on mid-file corruption", () => {
|
||||
const content = `not-json\n${JSON.stringify(userText("a"))}\n`;
|
||||
expect(() => parseTraceLines(content)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("engine trace round-trip", () => {
|
||||
let workspace: string;
|
||||
let traces: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workspace = await mkdtemp(join(tmpdir(), "penguin-replay-ws-"));
|
||||
traces = await mkdtemp(join(tmpdir(), "penguin-replay-tr-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(traces, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("replaying an engine-written trace reconstructs the committed history", async () => {
|
||||
// Two rounds of dialogue: the first round issues and executes a tool call, the second round
|
||||
// wraps up -- the engine writes Trace (including request events); replay should reconstruct
|
||||
// history matching what was actually committed to AgentHub, with no leftover carry-over.
|
||||
let call = 0;
|
||||
const llm: LLMInterface = {
|
||||
async *streamGenerate() {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
yield toolCall({
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "printf ok" }),
|
||||
toolCallId: "rt1",
|
||||
});
|
||||
yield tokenUsage(usage(10), usage(10));
|
||||
return { status: "completed" };
|
||||
}
|
||||
yield assistantText("done");
|
||||
yield tokenUsage(usage(20), usage(20));
|
||||
return { status: "completed" };
|
||||
},
|
||||
};
|
||||
const environment = new Environment({
|
||||
workspaceDir: workspace,
|
||||
toolConfig: {
|
||||
customTools: [
|
||||
{
|
||||
name: "exec_command",
|
||||
description: "Run a shell command.",
|
||||
parameters: { type: "object", properties: { cmd: { type: "string" } } },
|
||||
permission: "rw" as const,
|
||||
},
|
||||
],
|
||||
mcpServers: [],
|
||||
},
|
||||
});
|
||||
const trace = new Writer({ tracesDir: traces, sessionId: "sess-roundtrip" });
|
||||
const engine = new ContextEngine({ llm, environment, trace });
|
||||
const allow: ApproveFn = async () => "allow";
|
||||
for await (const _ of engine.run([userText("go")], { approve: allow })) {
|
||||
// consume
|
||||
}
|
||||
|
||||
const recorded = await readTrace(trace.currentPath());
|
||||
// request boundary events are written in pairs: one pair per round, two rounds total.
|
||||
const requestEvents = recorded.filter((m) =>
|
||||
((m.payload as { type?: string }).type ?? "").startsWith("request_"),
|
||||
);
|
||||
expect(requestEvents.map((m) => (m.payload as { type?: string }).type)).toEqual([
|
||||
"request_begin",
|
||||
"request_end",
|
||||
"request_begin",
|
||||
"request_end",
|
||||
]);
|
||||
|
||||
const result = resumeTrace(recorded);
|
||||
expect(result.carryOver).toEqual([]);
|
||||
expect(result.sessionTurns).toBe(2);
|
||||
// History = input -> tool_call -> tool output -> final reply, in the same order as committed.
|
||||
const kinds = result.history.map((m) => (m.payload as { type?: string }).type);
|
||||
expect(kinds).toEqual(["text", "tool_call", "tool_call_output", "text"]);
|
||||
});
|
||||
|
||||
it("aborted run leaves a replayable trace: the raw input is re-carried (flatten not persisted)", async () => {
|
||||
let call = 0;
|
||||
const llm: LLMInterface = {
|
||||
async *streamGenerate() {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
yield thinkingMessage("half", "aborted");
|
||||
return { status: "aborted" };
|
||||
}
|
||||
yield assistantText("ok");
|
||||
yield tokenUsage(usage(5), usage(5));
|
||||
return { status: "completed" };
|
||||
},
|
||||
};
|
||||
const environment = new Environment({
|
||||
workspaceDir: workspace,
|
||||
toolConfig: { customTools: [], mcpServers: [] },
|
||||
});
|
||||
const trace = new Writer({ tracesDir: traces, sessionId: "sess-abort" });
|
||||
const engine = new ContextEngine({ llm, environment, trace });
|
||||
for await (const _ of engine.run([userText("go")], { approve: async () => "deny" })) {
|
||||
// consume
|
||||
}
|
||||
|
||||
// If the process exits here: Trace has no synthesized flatten; replay treats the raw input as
|
||||
// pending input as-is, and the original round never enters history.
|
||||
const recorded = await readTrace(trace.currentPath());
|
||||
expect(
|
||||
recorded.some((m) =>
|
||||
((m.payload as { text?: string }).text ?? "").includes("<turn_aborted>"),
|
||||
),
|
||||
).toBe(false);
|
||||
const result = resumeTrace(recorded);
|
||||
expect(result.history).toEqual([]);
|
||||
expect(textsOf(result.carryOver)).toEqual(["go"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeTrace regressions (PR #39 review)", () => {
|
||||
it("drops the aborted round's own tool outputs regardless of landing before or after the end", () => {
|
||||
// Tools and the LLM stream run concurrently: an orphaned output may land on disk before or
|
||||
// after that round's end, and neither may be re-delivered as a structured result (its
|
||||
// tool_call isn't in history, so re-delivering it would produce an orphan tool_result with
|
||||
// no preceding tool_use); the raw input is resent as-is.
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("go"),
|
||||
requestBegin(),
|
||||
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }),
|
||||
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc2" }),
|
||||
toolCallOutput({ output: "early", toolCallId: "tc1" }),
|
||||
requestEnd("aborted"),
|
||||
toolCallOutput({ output: "late", toolCallId: "tc2", stopReason: "aborted" }),
|
||||
]);
|
||||
expect(result.history).toEqual([]);
|
||||
expect(textsOf(result.carryOver)).toEqual(["go"]);
|
||||
});
|
||||
|
||||
it("filters the dropped round's tool outputs out of the next committed round's input snapshot", () => {
|
||||
// Main reconnect flow: a tool executes during a timed-out attempt (its output lands on disk),
|
||||
// and the retry round completes. The dropped round's tool_call is not in history -- when its
|
||||
// output is snapshotted into the retry round's input it must be filtered out, otherwise the
|
||||
// history injected via setHistory contains an orphan tool_result with no preceding tool_use,
|
||||
// and every request after resume gets rejected by the provider (400).
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("go"),
|
||||
requestBegin(),
|
||||
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }),
|
||||
toolCallOutput({ output: "ran-during-timeout", toolCallId: "tc1" }),
|
||||
requestEnd("timeout"), // this round is dropped: tc1 never entered AgentHub history
|
||||
requestBegin(),
|
||||
assistantText("recovered"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(20), usage(20)),
|
||||
]);
|
||||
// History = original input + retry round output; the orphaned tool_call_output neither enters history nor is re-delivered.
|
||||
expect(textsOf(result.history)).toEqual(["go", "recovered"]);
|
||||
expect(
|
||||
result.history.some((m) => (m.payload as { type?: string }).type === "tool_call_output"),
|
||||
).toBe(false);
|
||||
expect(result.carryOver).toEqual([]);
|
||||
});
|
||||
|
||||
it("repairs history structure with in-memory placeholders for previously unpaired committed calls", () => {
|
||||
// Scenario: the placeholder synthesized on a previous resume was sent out with the request but
|
||||
// never written to Trace; a subsequent committed round in Trace is therefore missing that
|
||||
// pairing. Replay must re-synthesize the placeholder and inject it into history before that
|
||||
// round's input, guaranteeing every assistant tool_use is followed by a tool_result.
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("go"),
|
||||
requestBegin(),
|
||||
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
// (process exits -> resume -> placeholder sent out with the next round but never persisted)
|
||||
userText("next"),
|
||||
requestBegin(),
|
||||
assistantText("ok"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(20), usage(20)),
|
||||
]);
|
||||
const kinds = result.history.map((m) => (m.payload as { type?: string }).type);
|
||||
expect(kinds).toEqual(["text", "tool_call", "tool_call_output", "text", "text"]);
|
||||
const repaired = result.history[2]!.payload as { tool_call_id: string; output: string };
|
||||
expect(repaired.tool_call_id).toBe("tc1");
|
||||
expect(repaired.output).toContain("interrupted");
|
||||
expect(result.carryOver).toEqual([]);
|
||||
});
|
||||
|
||||
it("closed context (summarize) with a textless compaction output yields an empty summary", () => {
|
||||
// The compaction request completed but produced no text (e.g. thinking-only): the summary is
|
||||
// empty, and must not fall back to an earlier round's ordinary answer (consistent with the
|
||||
// in-process extractSummary("") behavior).
|
||||
const result = resumeTrace([
|
||||
meta(),
|
||||
userText("hello"),
|
||||
requestBegin(),
|
||||
assistantText("The answer is 42."),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
compactionBegin({ reason: "context", mode: "summarize", context: 10, turns: 1 }),
|
||||
userText("please summarize"),
|
||||
requestBegin(),
|
||||
thinkingMessage("thinking only, no text"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(20), usage(20)),
|
||||
compactionEnd({ reason: "context", mode: "summarize", status: "completed" }),
|
||||
]);
|
||||
expect(result.contextClosed).toBe(true);
|
||||
const summary = result.pendingSummary!.payload as { text: string };
|
||||
expect(summary.text).toBe("<context_summary>\n\n</context_summary>");
|
||||
expect(summary.text).not.toContain("42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Session resume: `agent.resumeSession` and setHistory injection.
|
||||
*
|
||||
* - Resume source is the Trace file with the latest index; config carries over from session_meta
|
||||
* (Workspace / Model cannot be swapped).
|
||||
* - Pairing-fallback placeholders, once constructed, are written into the original trace file;
|
||||
* session_meta is never written twice.
|
||||
* - Errors when the session doesn't exist / the workspace is missing / the model is no longer in the project config.
|
||||
* - `groupHistoryToUniMessages` groups by adjacent same role; `GenerativeModel.setHistory` injects into AgentHub.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createAgent } from "../src/index.js";
|
||||
import {
|
||||
abortEvent,
|
||||
assistantText,
|
||||
requestBegin,
|
||||
requestEnd,
|
||||
sessionMeta,
|
||||
tokenUsage,
|
||||
toolCall,
|
||||
userText,
|
||||
} from "../src/omnimessage/index.js";
|
||||
import type { OmniMessage, TokenCounts } from "../src/omnimessage/index.js";
|
||||
import { GenerativeModel, groupHistoryToUniMessages } from "../src/llm/index.js";
|
||||
import { readTrace } from "../src/trace/index.js";
|
||||
import { tracesDir } from "../src/state/paths.js";
|
||||
import { stubProviderKeys } from "./provider-keys.js";
|
||||
|
||||
// The default project config ships with this model ((provider, model_id) pair reference; model_id is the upstream id).
|
||||
const MODEL = { provider: "anthropic", model_id: "claude-sonnet-4-6" };
|
||||
|
||||
let tmpRoot: string;
|
||||
let workspace: string;
|
||||
let prevHome: string | undefined;
|
||||
let restoreKeys: () => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevHome = process.env.PENGUIN_HOME;
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-resume-"));
|
||||
workspace = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-resume-ws-"));
|
||||
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 });
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const usage = (total: number): TokenCounts => ({
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
output: 1,
|
||||
total,
|
||||
});
|
||||
|
||||
/** Manually constructs a session's trace file (simulating a record left behind by a previous process). */
|
||||
async function writeTraceFile(
|
||||
root: string,
|
||||
sessionId: string,
|
||||
messages: OmniMessage[],
|
||||
opts?: { dateDir?: string; index?: string },
|
||||
): Promise<string> {
|
||||
const dir = path.join(
|
||||
tracesDir(root, "default_project", "default_agent"),
|
||||
opts?.dateDir ?? "2026-07-06",
|
||||
);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, `${sessionId}_${opts?.index ?? "001"}.jsonl`);
|
||||
await fs.writeFile(file, messages.map((m) => JSON.stringify(m)).join("\n") + "\n", "utf8");
|
||||
return file;
|
||||
}
|
||||
|
||||
function metaFor(sessionId: string, workspaceDir: string, model = MODEL): OmniMessage {
|
||||
return sessionMeta({
|
||||
session_id: sessionId,
|
||||
provider: model.provider,
|
||||
model_id: model.model_id,
|
||||
model_context_window: 1000000,
|
||||
system_prompt: "ORIGINAL SYSTEM PROMPT",
|
||||
tools: [],
|
||||
thinking_level: "default",
|
||||
agent_state: "/agent/state",
|
||||
workspace: workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
describe("agent.resumeSession", () => {
|
||||
const SID = "session-2026-07-06-10-00-00-abcdef01";
|
||||
|
||||
it("resumes from the latest trace file and exposes render history", async () => {
|
||||
const agent = await createAgent({});
|
||||
await writeTraceFile(tmpRoot, SID, [
|
||||
metaFor(SID, workspace),
|
||||
userText("hello"),
|
||||
requestBegin(),
|
||||
assistantText("hi there"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(42), usage(42)),
|
||||
]);
|
||||
|
||||
const session = await agent.resumeSession({ sessionId: SID });
|
||||
expect(session.sessionId).toBe(SID);
|
||||
expect(session.provider).toBe(MODEL.provider);
|
||||
expect(session.modelId).toBe(MODEL.model_id);
|
||||
expect(session.workspaceDir).toBe(workspace);
|
||||
const texts = (session.resumedHistory ?? []).map(
|
||||
(m) => (m.payload as { text?: string }).text ?? "",
|
||||
);
|
||||
expect(texts).toEqual(["hello", "hi there"]);
|
||||
});
|
||||
|
||||
it("keeps abort events in resumed render history", async () => {
|
||||
const agent = await createAgent({});
|
||||
await writeTraceFile(tmpRoot, SID, [
|
||||
metaFor(SID, workspace),
|
||||
userText("long task"),
|
||||
requestBegin(),
|
||||
assistantText("partial answer", "aborted"),
|
||||
requestEnd("aborted"),
|
||||
abortEvent("aborted by user"),
|
||||
]);
|
||||
|
||||
const session = await agent.resumeSession({ sessionId: SID });
|
||||
expect(
|
||||
(session.resumedHistory ?? []).map((m) => (m.payload as { type?: string }).type),
|
||||
).toEqual(["text", "text", "abort"]);
|
||||
});
|
||||
|
||||
it("does not write pairing placeholders to the trace file (resume is side-effect free)", async () => {
|
||||
const agent = await createAgent({});
|
||||
const file = await writeTraceFile(tmpRoot, SID, [
|
||||
metaFor(SID, workspace),
|
||||
userText("go"),
|
||||
requestBegin(),
|
||||
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(10), usage(10)),
|
||||
// tc1's output was lost along with the process: the pairing placeholder is synthesized in memory and sent out with the next run, never persisted.
|
||||
]);
|
||||
const before = await readTrace(file);
|
||||
|
||||
await agent.resumeSession({ sessionId: SID });
|
||||
await agent.resumeSession({ sessionId: SID }); // resuming again has no side effects either
|
||||
const after = await readTrace(file);
|
||||
expect(after).toHaveLength(before.length);
|
||||
expect(
|
||||
after.filter((m) =>
|
||||
((m.payload as { output?: string }).output ?? "").includes("interrupted"),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
// session_meta is never written twice.
|
||||
expect(after.filter((m) => m.type === "session_meta")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("picks the latest index when the context was compacted into multiple files", async () => {
|
||||
const agent = await createAgent({});
|
||||
await writeTraceFile(tmpRoot, SID, [metaFor(SID, workspace), userText("old context")], {
|
||||
index: "001",
|
||||
});
|
||||
await writeTraceFile(
|
||||
tmpRoot,
|
||||
SID,
|
||||
[
|
||||
metaFor(SID, workspace),
|
||||
userText("<context_summary>gist</context_summary>"),
|
||||
requestBegin(),
|
||||
assistantText("resumed context"),
|
||||
requestEnd("completed"),
|
||||
tokenUsage(usage(5), usage(5)),
|
||||
],
|
||||
{ index: "002" },
|
||||
);
|
||||
|
||||
const session = await agent.resumeSession({ sessionId: SID });
|
||||
const texts = (session.resumedHistory ?? []).map(
|
||||
(m) => (m.payload as { text?: string }).text ?? "",
|
||||
);
|
||||
expect(texts).toEqual(["<context_summary>gist</context_summary>", "resumed context"]);
|
||||
});
|
||||
|
||||
it("errors when the session does not exist", async () => {
|
||||
const agent = await createAgent({});
|
||||
await expect(agent.resumeSession({ sessionId: "session-none" })).rejects.toThrow(
|
||||
/Session 不存在/,
|
||||
);
|
||||
});
|
||||
|
||||
it("errors when the recorded workspace no longer exists (PRN-004: no auto-create)", async () => {
|
||||
const agent = await createAgent({});
|
||||
const gone = path.join(workspace, "gone");
|
||||
await writeTraceFile(tmpRoot, SID, [metaFor(SID, gone), userText("x")]);
|
||||
await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(/Workspace 已不存在/);
|
||||
});
|
||||
|
||||
it("errors when the recorded model is no longer in the project config", async () => {
|
||||
const agent = await createAgent({});
|
||||
await writeTraceFile(tmpRoot, SID, [
|
||||
metaFor(SID, workspace, { provider: "custom", model_id: "vanished-model" }),
|
||||
userText("x"),
|
||||
]);
|
||||
await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(/不在 Project 配置/);
|
||||
});
|
||||
|
||||
it("errors clearly when session_meta lacks provider (old-format trace, no migration)", async () => {
|
||||
// An old-format trace's session_meta only has model_id (from the composite-id era): no backward compat, just a clear error.
|
||||
const agent = await createAgent({});
|
||||
const legacy = metaFor(SID, workspace);
|
||||
delete (legacy.payload as { provider?: string }).provider;
|
||||
await writeTraceFile(tmpRoot, SID, [legacy, userText("x")]);
|
||||
await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(/旧版本/);
|
||||
});
|
||||
|
||||
it("latestSessionId returns the newest session by embedded timestamp", async () => {
|
||||
const agent = await createAgent({});
|
||||
expect(await agent.latestSessionId()).toBeNull();
|
||||
const older = "session-2026-07-05-09-00-00-aaaaaaaa";
|
||||
const newer = "session-2026-07-06-11-00-00-bbbbbbbb";
|
||||
await writeTraceFile(tmpRoot, older, [metaFor(older, workspace), userText("older")], {
|
||||
dateDir: "2026-07-05",
|
||||
});
|
||||
await writeTraceFile(tmpRoot, newer, [metaFor(newer, workspace), userText("newer")], {
|
||||
dateDir: "2026-07-06",
|
||||
});
|
||||
expect(await agent.latestSessionId()).toBe(newer);
|
||||
});
|
||||
|
||||
it("latestSessionId ignores empty traces that only contain session_meta", async () => {
|
||||
const agent = await createAgent({});
|
||||
const older = "session-2026-07-05-09-00-00-aaaaaaaa";
|
||||
const emptyNewer = "session-2026-07-06-11-00-00-bbbbbbbb";
|
||||
await writeTraceFile(tmpRoot, older, [metaFor(older, workspace), userText("older")], {
|
||||
dateDir: "2026-07-05",
|
||||
});
|
||||
await writeTraceFile(tmpRoot, emptyNewer, [metaFor(emptyNewer, workspace)], {
|
||||
dateDir: "2026-07-06",
|
||||
});
|
||||
expect(await agent.latestSessionId()).toBe(older);
|
||||
});
|
||||
|
||||
it("manual compact on a new empty session does not create a resumable trace", async () => {
|
||||
const agent = await createAgent({});
|
||||
const session = await agent.createSession({ workspaceDir: workspace });
|
||||
const messages = [];
|
||||
for await (const msg of session.compact()) messages.push(msg);
|
||||
expect(messages).toHaveLength(0);
|
||||
expect(await agent.latestSessionId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setHistory injection", () => {
|
||||
it("groupHistoryToUniMessages groups adjacent same-role messages into UniMessages", () => {
|
||||
const uni = groupHistoryToUniMessages([
|
||||
userText("hello"),
|
||||
assistantText("hi"),
|
||||
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "tc1" }),
|
||||
{
|
||||
...userText("ignored-shape"),
|
||||
payload: {
|
||||
type: "tool_call_output",
|
||||
role: "user",
|
||||
output: "out",
|
||||
tool_call_id: "tc1",
|
||||
stop_reason: "completed",
|
||||
},
|
||||
} as OmniMessage,
|
||||
userText("next"),
|
||||
assistantText("done"),
|
||||
]);
|
||||
expect(uni.map((m) => m.role)).toEqual(["user", "assistant", "user", "assistant"]);
|
||||
expect(uni[1]!.content_items.map((c) => c.type)).toEqual(["text", "tool_call"]);
|
||||
expect(uni[2]!.content_items.map((c) => c.type)).toEqual(["tool_result", "text"]);
|
||||
});
|
||||
|
||||
it("GenerativeModel.setHistory seeds the AgentHub client history", () => {
|
||||
// GenerativeModel takes the request id sent to AgentHub (the upstream id), not the storage id.
|
||||
const model = new GenerativeModel({ modelId: "claude-sonnet-4-6", tools: [] });
|
||||
model.setHistory([userText("hello"), assistantText("hi")]);
|
||||
const client = (model as unknown as { client: { getHistory(): unknown[] } }).client;
|
||||
expect(client.getHistory()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Session title generation unit tests: prompt shape, sanitization rules, single-shot request
|
||||
* driving (fake LLM), and Session.generateTitle's composition-layer wiring (no real requests sent).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
assistantText,
|
||||
buildTitlePrompt,
|
||||
emptyTokenCounts,
|
||||
generateTitleWithLLM,
|
||||
sanitizeTitle,
|
||||
Session,
|
||||
thinkingMessage,
|
||||
tokenUsage,
|
||||
userText,
|
||||
} from "../src/index.js";
|
||||
import type {
|
||||
EnvironmentInterface,
|
||||
LLMInterface,
|
||||
LLMOutcome,
|
||||
OmniMessage,
|
||||
SessionMetaPayload,
|
||||
} from "../src/index.js";
|
||||
|
||||
/** A fake LLM: yields the given messages and finishes with the outcome; records the prompt received. */
|
||||
function fakeLLM(
|
||||
outputs: OmniMessage[],
|
||||
outcome: LLMOutcome = { status: "completed" },
|
||||
seenPrompts: string[] = [],
|
||||
): LLMInterface {
|
||||
return {
|
||||
async *streamGenerate({ newMessages }) {
|
||||
const first = newMessages[0];
|
||||
if (first) seenPrompts.push((first.payload as { text: string }).text);
|
||||
for (const msg of outputs) yield msg;
|
||||
return outcome;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fakeEnvironment: EnvironmentInterface = {
|
||||
listTools: async () => [],
|
||||
// eslint-disable-next-line require-yield
|
||||
executeTool: async function* () {
|
||||
throw new Error("not used");
|
||||
},
|
||||
toolPermission: () => undefined,
|
||||
};
|
||||
|
||||
const META: SessionMetaPayload = {
|
||||
session_id: "session-title-1",
|
||||
provider: "custom",
|
||||
model_id: "m1",
|
||||
model_context_window: 1000,
|
||||
system_prompt: "sp",
|
||||
tools: [],
|
||||
thinking_level: "default",
|
||||
agent_state: "/tmp/state",
|
||||
workspace: "/tmp/w",
|
||||
};
|
||||
|
||||
describe("session-title", () => {
|
||||
it("generateTitleWithLLM:收集模型 text 与用量,清洗后返回", async () => {
|
||||
const seen: string[] = [];
|
||||
const result = await generateTitleWithLLM(
|
||||
fakeLLM(
|
||||
[
|
||||
thinkingMessage("想一下"), // thinking does not count
|
||||
assistantText("「Tailwind 主题配置」。"),
|
||||
tokenUsage(emptyTokenCounts(), { cache_read: 1, cache_write: 2, output: 3, total: 6 }),
|
||||
],
|
||||
{ status: "completed" },
|
||||
seen,
|
||||
),
|
||||
{ userText: "解释 @theme", assistantText: "好的……" },
|
||||
);
|
||||
expect(result.title).toBe("Tailwind 主题配置");
|
||||
expect(result.usage).toEqual({ cache_read: 1, cache_write: 2, output: 3, total: 6 });
|
||||
expect(seen[0]).toBe(buildTitlePrompt("解释 @theme", "好的……"));
|
||||
expect(seen[0]).toContain("SAME language");
|
||||
});
|
||||
|
||||
it("素材为空不发请求;outcome 非 completed 时 title 为 null(usage 保留)", async () => {
|
||||
const seen: string[] = [];
|
||||
const empty = await generateTitleWithLLM(fakeLLM([], { status: "completed" }, seen), {
|
||||
userText: " ",
|
||||
assistantText: "a",
|
||||
});
|
||||
expect(empty).toEqual({ title: null, usage: null });
|
||||
expect(seen).toHaveLength(0);
|
||||
|
||||
const failed = await generateTitleWithLLM(
|
||||
fakeLLM(
|
||||
[
|
||||
assistantText("半截"),
|
||||
tokenUsage(emptyTokenCounts(), { cache_read: 0, cache_write: 0, output: 1, total: 1 }),
|
||||
],
|
||||
{ status: "failed", message: "401" },
|
||||
),
|
||||
{ userText: "u", assistantText: "a" },
|
||||
);
|
||||
expect(failed.title).toBeNull();
|
||||
expect(failed.usage?.total).toBe(1);
|
||||
});
|
||||
|
||||
it("助手素材为空也生成(纯工具轮次):只据用户请求,prompt 省去助手段", async () => {
|
||||
const seen: string[] = [];
|
||||
const result = await generateTitleWithLLM(
|
||||
fakeLLM([assistantText("配置 Tailwind 主题")], { status: "completed" }, seen),
|
||||
{ userText: "帮我配置 @theme", assistantText: "" },
|
||||
);
|
||||
expect(result.title).toBe("配置 Tailwind 主题");
|
||||
expect(seen[0]).toBe(buildTitlePrompt("帮我配置 @theme", ""));
|
||||
expect(seen[0]).not.toContain("[Assistant]");
|
||||
});
|
||||
|
||||
it("sanitizeTitle:剥引号与句读到稳定、折叠空白、超长截断、空返回 null", () => {
|
||||
expect(sanitizeTitle("“ 构建配置 说明 。”")).toBe("构建配置 说明");
|
||||
expect(sanitizeTitle("『标题』!")).toBe("标题");
|
||||
expect(sanitizeTitle(" \n ")).toBeNull();
|
||||
expect(sanitizeTitle("x".repeat(50))).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("Session.generateTitle:经 createBareLLM 发起;未提供工厂时返回 null", async () => {
|
||||
const withFactory = new Session({
|
||||
meta: META,
|
||||
llm: fakeLLM([]),
|
||||
environment: fakeEnvironment,
|
||||
createBareLLM: () => fakeLLM([assistantText("标题 A")]),
|
||||
});
|
||||
expect(
|
||||
await withFactory.generateTitle({ material: { userText: "u", assistantText: "a" } }),
|
||||
).toEqual({
|
||||
title: "标题 A",
|
||||
usage: null,
|
||||
});
|
||||
|
||||
const withoutFactory = new Session({
|
||||
meta: META,
|
||||
llm: fakeLLM([]),
|
||||
environment: fakeEnvironment,
|
||||
});
|
||||
expect(await withoutFactory.generateTitle()).toEqual({
|
||||
title: null,
|
||||
usage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("Session.generateTitle:素材自采(run 收集用户输入与模型正文),无需调用方提供", async () => {
|
||||
const seen: string[] = [];
|
||||
const session = new Session({
|
||||
meta: META,
|
||||
llm: fakeLLM([thinkingMessage("想想"), assistantText("答案正文")]),
|
||||
environment: fakeEnvironment,
|
||||
createBareLLM: () => fakeLLM([assistantText("标题 B")], { status: "completed" }, seen),
|
||||
});
|
||||
for await (const _ of session.run([userText("用户问题")])) {
|
||||
void _; // Drains the output stream; once run finishes, the material is settled
|
||||
}
|
||||
const res = await session.generateTitle();
|
||||
expect(res.title).toBe("标题 B");
|
||||
// Material = the first Task's user text + model text (thinking does not count), matching
|
||||
// buildTitlePrompt's shape.
|
||||
expect(seen[0]).toBe(buildTitlePrompt("用户问题", "答案正文"));
|
||||
|
||||
// No request is sent when no material has been collected (run was never called).
|
||||
const idle = new Session({
|
||||
meta: META,
|
||||
llm: fakeLLM([]),
|
||||
environment: fakeEnvironment,
|
||||
createBareLLM: () => fakeLLM([assistantText("不应产生")]),
|
||||
});
|
||||
expect(await idle.generateTitle()).toEqual({ title: null, usage: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,988 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AGENT_ID_PLACEHOLDER,
|
||||
AGENTS_MD_PLACEHOLDER,
|
||||
VAULT_KEYS_PLACEHOLDER,
|
||||
SKILL_METADATA_PLACEHOLDER,
|
||||
CWD_PLACEHOLDER,
|
||||
DATE_PLACEHOLDER,
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_PROJECT_ID,
|
||||
MODEL_CATALOG,
|
||||
OS_VERSION_PLACEHOLDER,
|
||||
PLATFORM_PLACEHOLDER,
|
||||
PROJECT_DIR_PLACEHOLDER,
|
||||
SESSION_ID_PLACEHOLDER,
|
||||
addModel,
|
||||
setVisionModel,
|
||||
agentsMdPath,
|
||||
agentStateDir,
|
||||
agentVaultPath,
|
||||
loadAgentVault,
|
||||
assembleSystemPrompt,
|
||||
buildToolConfig,
|
||||
selectBuiltinToolsForModel,
|
||||
defaultProjectConfig,
|
||||
getModel,
|
||||
isValidVaultKey,
|
||||
loadOrInitAgentState,
|
||||
loadProjectConfig,
|
||||
memoryDir,
|
||||
scratchpadDir,
|
||||
projectConfigPath,
|
||||
removeVaultEntry,
|
||||
resolveModelRef,
|
||||
resolveRoot,
|
||||
setDefaultModel,
|
||||
setVaultEntry,
|
||||
skillsDir,
|
||||
systemConfigPath,
|
||||
toolsDir,
|
||||
type ProjectConfig,
|
||||
} from "../src/state/index.js";
|
||||
import { sessionEnvironment } from "../src/internal/session-support.js";
|
||||
|
||||
let tmpRoot: string;
|
||||
let prevHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevHome = process.env.PENGUIN_HOME;
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-state-"));
|
||||
process.env.PENGUIN_HOME = tmpRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.PENGUIN_HOME;
|
||||
} else {
|
||||
process.env.PENGUIN_HOME = prevHome;
|
||||
}
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("paths / resolveRoot", () => {
|
||||
it("honors PENGUIN_HOME", () => {
|
||||
expect(resolveRoot()).toBe(tmpRoot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadOrInitAgentState", () => {
|
||||
it("initializes an empty agent directory with the full state layout", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
expect(state.root).toBe(tmpRoot);
|
||||
expect(state.projectId).toBe(DEFAULT_PROJECT_ID);
|
||||
expect(state.agentId).toBe(DEFAULT_AGENT_ID);
|
||||
|
||||
const root = tmpRoot;
|
||||
expect(await exists(systemConfigPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(agentsMdPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(toolsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(skillsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
// The scratchpad/ directory alongside agent_state (model temp files get a subdirectory per Session id).
|
||||
expect(await exists(scratchpadDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
|
||||
expect(state.stateDir).toBe(agentStateDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID));
|
||||
|
||||
// The default system Prompt states the Agent's identity, without repeating tool details
|
||||
// already in the tool schema (Suggested workflows only points to the run_subagent
|
||||
// delegation entry point).
|
||||
expect(state.systemConfig.system_prompt).toContain("PenguinHarness");
|
||||
expect(state.systemConfig.system_prompt).not.toContain("exec_command");
|
||||
// Suggested workflows absorbs Subagent delegation and task conventions (self-reported
|
||||
// identity as a soft convention, parallelism, file exchange).
|
||||
expect(state.systemConfig.system_prompt).toContain("# Suggested workflows");
|
||||
expect(state.systemConfig.system_prompt).toContain("run_subagent");
|
||||
expect(state.systemConfig.system_prompt).toContain("Caller agent");
|
||||
// The default AGENTS.md is empty: it carries no preset guidance.
|
||||
expect(state.agentsMd).toBe("");
|
||||
expect(state.systemConfig.system_prompt).toContain(AGENTS_MD_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(SESSION_ID_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(CWD_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(PLATFORM_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(OS_VERSION_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(DATE_PLACEHOLDER);
|
||||
// AGENTS.md and the Environment injection sit at the end of the template, with AGENTS.md
|
||||
// before Environment; the <developer_instructions> wrapper text is written directly into
|
||||
// the template (the Prompt is transparent about the config).
|
||||
expect(state.systemConfig.system_prompt).toContain("<developer_instructions>");
|
||||
expect(state.systemConfig.system_prompt).toContain("</developer_instructions>");
|
||||
// The default template explains the semantics of system-synthesized markers to the model,
|
||||
// and recommends preferring tool use.
|
||||
expect(state.systemConfig.system_prompt).toContain("<turn_aborted>");
|
||||
expect(state.systemConfig.system_prompt).toContain("<turn_retried>");
|
||||
expect(state.systemConfig.system_prompt).toContain("<context_summary>");
|
||||
expect(state.systemConfig.system_prompt).toContain("# Tool use");
|
||||
// Privacy hardening: explicitly forbids reading .project_config.toml (the sole config file,
|
||||
// which holds API keys) and each Agent's .vault.toml, and states that config can only be
|
||||
// changed via the CLI (penguin config ...).
|
||||
expect(state.systemConfig.system_prompt).toContain("Never read");
|
||||
expect(state.systemConfig.system_prompt).toContain(".project_config.toml");
|
||||
expect(state.systemConfig.system_prompt).toContain("agent_state/.vault.toml");
|
||||
expect(state.systemConfig.system_prompt).toContain("CLI-only");
|
||||
expect(state.systemConfig.system_prompt).toContain("penguin config");
|
||||
expect(state.systemConfig.system_prompt).not.toContain(".credentials.toml");
|
||||
expect(state.systemConfig.system_prompt.indexOf(AGENTS_MD_PLACEHOLDER)).toBeLessThan(
|
||||
state.systemConfig.system_prompt.indexOf("# Environment"),
|
||||
);
|
||||
// The # Vault and # Skills body sections plus their placeholders: the default template
|
||||
// places them after </developer_instructions> and before # Environment, in the order
|
||||
// Vault -> Skills (the statement text is part of the template body, kept even with no
|
||||
// keys/skills).
|
||||
const tpl = state.systemConfig.system_prompt;
|
||||
expect(tpl).toContain("# Vault");
|
||||
expect(tpl).toContain(VAULT_KEYS_PLACEHOLDER);
|
||||
expect(tpl).toContain("# Skills");
|
||||
expect(tpl).toContain(SKILL_METADATA_PLACEHOLDER);
|
||||
expect(tpl).toContain("<use_skills>");
|
||||
expect(tpl.indexOf("</developer_instructions>")).toBeLessThan(tpl.indexOf("# Vault"));
|
||||
expect(tpl.indexOf("# Vault")).toBeLessThan(tpl.indexOf(VAULT_KEYS_PLACEHOLDER));
|
||||
expect(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Skills"));
|
||||
expect(tpl.indexOf("# Skills")).toBeLessThan(tpl.indexOf(SKILL_METADATA_PLACEHOLDER));
|
||||
expect(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Environment"));
|
||||
expect(state.systemConfig.model?.max_tokens).toBe(32000);
|
||||
expect(state.systemConfig.model?.thinking_level).toBe("medium");
|
||||
expect(state.systemConfig.model?.timeoutMs).toBe(120000);
|
||||
expect(state.systemConfig.tools?.mcpServers).toEqual([]);
|
||||
expect(Object.hasOwn(state.systemConfig, "description")).toBe(false);
|
||||
expect(Object.hasOwn(state.systemConfig, "subagents")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads an existing agent directory and returns the same system prompt", async () => {
|
||||
const first = await loadOrInitAgentState();
|
||||
const second = await loadOrInitAgentState();
|
||||
expect(second.systemConfig.system_prompt).toBe(first.systemConfig.system_prompt);
|
||||
expect(second.systemConfig.system_prompt).toContain("PenguinHarness");
|
||||
expect(second.agentsMd).toBe(first.agentsMd);
|
||||
// The tool config is fully preserved on the load path.
|
||||
expect(second.systemConfig.tools?.builtin?.[0]?.name).toBe("exec_command");
|
||||
});
|
||||
|
||||
it("respects custom agentId / projectId", async () => {
|
||||
const state = await loadOrInitAgentState({ agentId: "agent_x", projectId: "proj_y" });
|
||||
expect(state.agentId).toBe("agent_x");
|
||||
expect(state.projectId).toBe("proj_y");
|
||||
expect(await exists(systemConfigPath(tmpRoot, "proj_y", "agent_x"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildToolConfig", () => {
|
||||
it("exposes exec/input command, run/input subagent (rw) and read_image (r)", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
const cfg = buildToolConfig(state);
|
||||
expect(cfg.mcpServers).toEqual([]);
|
||||
expect(cfg.customTools.map((t) => t.name)).toEqual([
|
||||
"exec_command",
|
||||
"input_command",
|
||||
"run_subagent",
|
||||
"input_subagent",
|
||||
"read_image",
|
||||
"describe_image",
|
||||
]);
|
||||
const exec = cfg.customTools.find((t) => t.name === "exec_command")!;
|
||||
expect(exec.permission).toBe("rw");
|
||||
expect(exec.timeoutMs).toBe(120000);
|
||||
expect(exec.maxOutputLength).toBe(16000);
|
||||
expect((exec.parameters as { required?: string[] }).required).toEqual(["cmd"]);
|
||||
const write = cfg.customTools.find((t) => t.name === "input_command")!;
|
||||
expect(write.permission).toBe("rw");
|
||||
expect((write.parameters as { required?: string[] }).required).toEqual(["process_id"]);
|
||||
const sub = cfg.customTools.find((t) => t.name === "run_subagent")!;
|
||||
expect(sub.permission).toBe("rw");
|
||||
expect((sub.parameters as { required?: string[] }).required).toEqual(["prompt"]);
|
||||
const writeSub = cfg.customTools.find((t) => t.name === "input_subagent")!;
|
||||
expect(writeSub.permission).toBe("rw");
|
||||
expect((writeSub.parameters as { required?: string[] }).required).toEqual(["subagent_id"]);
|
||||
// Both image-reading tool entries are explicitly in the config, each declaring its
|
||||
// applicable model kind via the forModel annotation.
|
||||
const readImage = cfg.customTools.find((t) => t.name === "read_image")!;
|
||||
expect(readImage.forModel).toBe("vision");
|
||||
expect(readImage.permission).toBe("r");
|
||||
expect(Object.keys((readImage.parameters as { properties: object }).properties)).toEqual([
|
||||
"source",
|
||||
]);
|
||||
const describeImage = cfg.customTools.find((t) => t.name === "describe_image")!;
|
||||
expect(describeImage.forModel).toBe("text-only");
|
||||
expect(describeImage.permission).toBe("r");
|
||||
expect(Object.keys((describeImage.parameters as { properties: object }).properties)).toEqual([
|
||||
"source",
|
||||
"prompt",
|
||||
]);
|
||||
expect((describeImage.parameters as { required?: string[] }).required).toEqual(["source"]);
|
||||
});
|
||||
|
||||
it("selectBuiltinToolsForModel picks the matching image tool per model kind", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
const all = buildToolConfig(state).customTools;
|
||||
// Vision model: read_image is kept, describe_image is filtered out; unannotated tools are unaffected.
|
||||
const forVision = selectBuiltinToolsForModel(all, true);
|
||||
expect(forVision.some((t) => t.name === "read_image")).toBe(true);
|
||||
expect(forVision.some((t) => t.name === "describe_image")).toBe(false);
|
||||
expect(forVision.filter((t) => t.name === "exec_command")).toHaveLength(1);
|
||||
// Text-only model: describe_image is kept.
|
||||
const forText = selectBuiltinToolsForModel(all, false);
|
||||
expect(forText.some((t) => t.name === "read_image")).toBe(false);
|
||||
expect(forText.some((t) => t.name === "describe_image")).toBe(true);
|
||||
expect(forText.filter((t) => t.name === "exec_command")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("loads MCP Server config from system_config.yaml", () => {
|
||||
const state = {
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: {
|
||||
system_prompt: "x",
|
||||
tools: {
|
||||
builtin: [],
|
||||
mcpServers: [{ name: "fs", config: { command: "mcp-fs" } }],
|
||||
},
|
||||
},
|
||||
agentsMd: "y",
|
||||
};
|
||||
|
||||
const cfg = buildToolConfig(state);
|
||||
expect(cfg.customTools).toEqual([]);
|
||||
expect(cfg.mcpServers).toEqual([{ name: "fs", config: { command: "mcp-fs" } }]);
|
||||
});
|
||||
|
||||
it("falls back to default builtin tools when config omits them", () => {
|
||||
const state = {
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: { system_prompt: "x" },
|
||||
agentsMd: "y",
|
||||
};
|
||||
const cfg = buildToolConfig(state);
|
||||
expect(cfg.customTools.map((t) => t.name)).toEqual([
|
||||
"exec_command",
|
||||
"input_command",
|
||||
"run_subagent",
|
||||
"input_subagent",
|
||||
"read_image",
|
||||
"describe_image",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleSystemPrompt", () => {
|
||||
it("renders default system prompt placeholders", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
const prompt = assembleSystemPrompt(
|
||||
state,
|
||||
sessionEnvironment("/tmp/penguin-ws", "session-test-1", {
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
projectDir: "/tmp/proj",
|
||||
}),
|
||||
);
|
||||
expect(prompt).toContain("AGENTS.md");
|
||||
expect(prompt).toContain("PenguinHarness");
|
||||
// File system's two file-delivery conventions: a workspace file is mentioned in the reply
|
||||
// by its **workspace-relative path** in backticks (the frontend renders a message file card
|
||||
// from this); scratchpad only holds intermediate artifacts, and final deliverables must
|
||||
// land in the Workspace.
|
||||
expect(prompt).toContain("mention its workspace-relative path in backticks");
|
||||
expect(prompt).toContain("always place final deliverables in the workspace");
|
||||
// The default template wraps AGENTS.md in a <developer_instructions> XML block.
|
||||
expect(prompt).toContain("<developer_instructions>");
|
||||
expect(prompt).toContain("</developer_instructions>");
|
||||
expect(prompt.indexOf("<developer_instructions>")).toBeLessThan(
|
||||
prompt.indexOf("# Environment"),
|
||||
);
|
||||
expect(prompt).not.toContain(AGENTS_MD_PLACEHOLDER);
|
||||
expect(prompt).not.toContain(SESSION_ID_PLACEHOLDER);
|
||||
expect(prompt).not.toContain(CWD_PLACEHOLDER);
|
||||
expect(prompt).not.toContain(PLATFORM_PLACEHOLDER);
|
||||
expect(prompt).not.toContain(OS_VERSION_PLACEHOLDER);
|
||||
expect(prompt).not.toContain(DATE_PLACEHOLDER);
|
||||
});
|
||||
|
||||
it("replaces AGENTS.md and specific Session environment fields at template locations", () => {
|
||||
const state = {
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: {
|
||||
system_prompt: [
|
||||
"before",
|
||||
`sid=${SESSION_ID_PLACEHOLDER}`,
|
||||
`cwd=${CWD_PLACEHOLDER}`,
|
||||
`aid=${AGENT_ID_PLACEHOLDER}`,
|
||||
`pdir=${PROJECT_DIR_PLACEHOLDER}`,
|
||||
`platform=${PLATFORM_PLACEHOLDER}`,
|
||||
`os=${OS_VERSION_PLACEHOLDER}`,
|
||||
`date=${DATE_PLACEHOLDER}`,
|
||||
"middle",
|
||||
AGENTS_MD_PLACEHOLDER,
|
||||
"after",
|
||||
].join("\n"),
|
||||
},
|
||||
agentsMd: "# Agent Rules\nFollow local rules.",
|
||||
};
|
||||
|
||||
const prompt = assembleSystemPrompt(state, {
|
||||
sessionId: "session-1",
|
||||
cwd: "/tmp/ws",
|
||||
agentId: "agent-x",
|
||||
projectDir: "/tmp/proj",
|
||||
platform: "darwin",
|
||||
osVersion: "Darwin 25.0.0",
|
||||
date: "2026-06-30",
|
||||
});
|
||||
expect(prompt).toBe(
|
||||
[
|
||||
"before",
|
||||
"sid=session-1",
|
||||
"cwd=/tmp/ws",
|
||||
"aid=agent-x",
|
||||
"pdir=/tmp/proj",
|
||||
"platform=darwin",
|
||||
"os=Darwin 25.0.0",
|
||||
"date=2026-06-30",
|
||||
"middle",
|
||||
"# Agent Rules\nFollow local rules.",
|
||||
"after",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces the placeholder with an empty string when AGENTS.md is blank", () => {
|
||||
const state = {
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: {
|
||||
system_prompt: ["before", AGENTS_MD_PLACEHOLDER, "after"].join("\n"),
|
||||
},
|
||||
agentsMd: " \n",
|
||||
};
|
||||
|
||||
const prompt = assembleSystemPrompt(state);
|
||||
expect(prompt).toBe("before\n\nafter");
|
||||
});
|
||||
|
||||
it("does not append AGENTS.md or Session environment without placeholders", () => {
|
||||
const state = {
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: { system_prompt: "base prompt" },
|
||||
agentsMd: "# Agent Rules\nShould not appear.",
|
||||
};
|
||||
|
||||
const prompt = assembleSystemPrompt(state, {
|
||||
sessionId: "session-1",
|
||||
cwd: "/tmp/ws",
|
||||
agentId: "agent-x",
|
||||
projectDir: "/tmp/proj",
|
||||
platform: "darwin",
|
||||
osVersion: "Darwin 25.0.0",
|
||||
date: "2026-06-30",
|
||||
});
|
||||
expect(prompt).toBe("base prompt");
|
||||
});
|
||||
|
||||
it("renders vault key names (never values) via the {{VAULT_KEYS}} placeholder", () => {
|
||||
const state = {
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: {
|
||||
system_prompt: ["before", AGENTS_MD_PLACEHOLDER, VAULT_KEYS_PLACEHOLDER, "after"].join(
|
||||
"\n",
|
||||
),
|
||||
},
|
||||
agentsMd: "# Agent Rules",
|
||||
};
|
||||
|
||||
const prompt = assembleSystemPrompt(state, undefined, ["KEY_A", "KEY_B"]);
|
||||
// The placeholder is replaced with a list of key names (one `- KEY` per line); the vault's
|
||||
// purpose statement is part of the template body, not carried by the replacement value.
|
||||
expect(prompt).toBe(["before", "# Agent Rules", "- KEY_A", "- KEY_B", "after"].join("\n"));
|
||||
});
|
||||
|
||||
it("replaces {{VAULT_KEYS}} with an empty string when there are no keys", () => {
|
||||
const state = {
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: {
|
||||
system_prompt: ["before", AGENTS_MD_PLACEHOLDER, VAULT_KEYS_PLACEHOLDER, "after"].join(
|
||||
"\n",
|
||||
),
|
||||
},
|
||||
agentsMd: "# Agent Rules",
|
||||
};
|
||||
// No keys: the placeholder is replaced with an empty string (the template body's vault
|
||||
// statement is kept, though this test's template does not include one); the placeholder
|
||||
// leaves no residue.
|
||||
const empty = assembleSystemPrompt(state, undefined, []);
|
||||
expect(empty).toBe(["before", "# Agent Rules", "", "after"].join("\n"));
|
||||
expect(assembleSystemPrompt(state)).not.toContain(VAULT_KEYS_PLACEHOLDER);
|
||||
});
|
||||
|
||||
it("does not auto-inject other Agent State files", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), "note.md"),
|
||||
"MEMORY_SHOULD_NOT_BE_IN_PROMPT",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), "SKILL.md"),
|
||||
"SKILL_SHOULD_NOT_BE_IN_PROMPT",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const reloaded = await loadOrInitAgentState();
|
||||
const prompt = assembleSystemPrompt(reloaded);
|
||||
|
||||
expect(prompt).not.toContain("MEMORY_SHOULD_NOT_BE_IN_PROMPT");
|
||||
expect(prompt).not.toContain("SKILL_SHOULD_NOT_BE_IN_PROMPT");
|
||||
});
|
||||
|
||||
it("replaces generated Session environment field placeholders when provided", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
const env = sessionEnvironment(
|
||||
"/tmp/penguin-ws",
|
||||
"session-test-1",
|
||||
{ agentId: "agent-x", projectDir: "/tmp/proj" },
|
||||
new Date("2026-06-30T00:00:00"),
|
||||
);
|
||||
const prompt = assembleSystemPrompt(state, env);
|
||||
|
||||
expect(prompt).toContain("# Environment");
|
||||
expect(prompt).toContain("Session ID: session-test-1");
|
||||
expect(prompt).toContain("CWD: /tmp/penguin-ws");
|
||||
expect(prompt).toContain("Agent ID: agent-x");
|
||||
expect(prompt).toContain("Project Dir: /tmp/proj");
|
||||
expect(prompt).toContain("Platform:");
|
||||
expect(prompt).toContain("OS Version:");
|
||||
expect(prompt).toContain("Date: 2026-06-30");
|
||||
expect(prompt.indexOf("Platform:")).toBeLessThan(prompt.indexOf("OS Version:"));
|
||||
expect(prompt.indexOf("OS Version:")).toBeLessThan(prompt.indexOf("Date:"));
|
||||
expect(prompt.indexOf("Date:")).toBeLessThan(prompt.indexOf("CWD:"));
|
||||
expect(prompt.indexOf("CWD:")).toBeLessThan(prompt.indexOf("Agent ID:"));
|
||||
expect(prompt.indexOf("Agent ID:")).toBeLessThan(prompt.indexOf("Project Dir:"));
|
||||
expect(prompt.indexOf("Project Dir:")).toBeLessThan(prompt.indexOf("Session ID:"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("project-config round trip", () => {
|
||||
it("returns default config when file is absent (without writing)", async () => {
|
||||
const cfg = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
expect(cfg).toEqual(defaultProjectConfig());
|
||||
// loadProjectConfig must not write to disk.
|
||||
expect(await exists(projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID))).toBe(false);
|
||||
});
|
||||
|
||||
it("persists addModel with inline credential and default, then reads back", async () => {
|
||||
const saved = await addModel(
|
||||
tmpRoot,
|
||||
DEFAULT_PROJECT_ID,
|
||||
{
|
||||
provider: "custom",
|
||||
model_id: "gpt-test",
|
||||
context_window: 128000,
|
||||
api_key: "sk-abc",
|
||||
base_url: "https://example.com/v1",
|
||||
},
|
||||
{ setDefault: true },
|
||||
);
|
||||
// default_model is a pair reference (no string concatenation involved).
|
||||
expect(saved.default_model).toEqual({ provider: "custom", model_id: "gpt-test" });
|
||||
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
expect(loaded.default_model).toEqual({ provider: "custom", model_id: "gpt-test" });
|
||||
|
||||
// The credential is inlined in the entry; the two independent fields provider and
|
||||
// model_id together form the unique key.
|
||||
const entry = getModel(loaded, { provider: "custom", model_id: "gpt-test" });
|
||||
expect(entry).toEqual({
|
||||
provider: "custom",
|
||||
model_id: "gpt-test",
|
||||
context_window: 128000,
|
||||
api_key: "sk-abc",
|
||||
base_url: "https://example.com/v1",
|
||||
});
|
||||
|
||||
// getModel matches the exact pair: a different provider means no match.
|
||||
expect(getModel(loaded, { provider: "openai", model_id: "gpt-test" })).toBeUndefined();
|
||||
expect(getModel(loaded, { provider: "custom", model_id: "unknown-model" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("infers the provider from the builtin catalog when addModel omits it", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, { model_id: "claude-sonnet-4-6" });
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, { model_id: "my-own-model" });
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
// A catalog hit gets its provider; otherwise it falls back to custom.
|
||||
expect(
|
||||
getModel(loaded, { provider: "anthropic", model_id: "claude-sonnet-4-6" }),
|
||||
).toBeDefined();
|
||||
expect(getModel(loaded, { provider: "custom", model_id: "my-own-model" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("upserts by the (provider, model_id) pair; same model_id under two providers co-exists", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "pa",
|
||||
model_id: "m1",
|
||||
context_window: 1000,
|
||||
});
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "pa",
|
||||
model_id: "m1",
|
||||
context_window: 2000,
|
||||
});
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "pb",
|
||||
model_id: "m1",
|
||||
context_window: 3000,
|
||||
});
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
const matches = loaded.models.filter((m) => m.model_id === "m1");
|
||||
expect(matches).toHaveLength(2);
|
||||
expect(getModel(loaded, { provider: "pa", model_id: "m1" })?.context_window).toBe(2000);
|
||||
expect(getModel(loaded, { provider: "pb", model_id: "m1" })?.context_window).toBe(3000);
|
||||
});
|
||||
|
||||
it("addModel persists vision flag and upsert preserves it when not re-specified", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "ds",
|
||||
vision: false,
|
||||
});
|
||||
// Only supplements context_window, without vision: the original annotation is kept.
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "ds",
|
||||
context_window: 64000,
|
||||
});
|
||||
const dsRef = { provider: "custom", model_id: "ds" };
|
||||
let m = getModel(await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID), dsRef);
|
||||
expect(m?.vision).toBe(false);
|
||||
expect(m?.context_window).toBe(64000);
|
||||
// Explicitly switches it back to supported.
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "ds",
|
||||
vision: true,
|
||||
});
|
||||
m = getModel(await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID), dsRef);
|
||||
expect(m?.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("setVisionModel persists and validates the target", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "vis",
|
||||
vision: true,
|
||||
});
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "blind",
|
||||
vision: false,
|
||||
});
|
||||
const visRef = { provider: "custom", model_id: "vis" };
|
||||
await setVisionModel(tmpRoot, DEFAULT_PROJECT_ID, visRef);
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
expect(loaded.vision_model).toEqual(visRef);
|
||||
// A subsequent addModel save/reload round trip does not lose vision_model (loadProjectConfig
|
||||
// passes it through explicitly).
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "vis",
|
||||
context_window: 1000,
|
||||
});
|
||||
expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).vision_model).toEqual(visRef);
|
||||
// A target that does not exist or is annotated as not supporting images: throws
|
||||
// (the error includes the pair reference).
|
||||
await expect(
|
||||
setVisionModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "custom", model_id: "nope" }),
|
||||
).rejects.toThrow(/model_id=nope/);
|
||||
await expect(
|
||||
setVisionModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "custom", model_id: "blind" }),
|
||||
).rejects.toThrow(/不支持图片/);
|
||||
});
|
||||
|
||||
it("upsert preserves existing context_window and inline credential when not re-specified", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m1",
|
||||
context_window: 200000,
|
||||
base_url: "https://gw.example",
|
||||
});
|
||||
// Only supplements an api_key, without context_window/base_url.
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m1",
|
||||
api_key: "sk-xyz",
|
||||
});
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
const m = getModel(loaded, { provider: "custom", model_id: "m1" });
|
||||
expect(m?.context_window).toBe(200000); // Not cleared
|
||||
expect(m?.api_key).toBe("sk-xyz");
|
||||
expect(m?.base_url).toBe("https://gw.example"); // The original base_url is kept
|
||||
});
|
||||
|
||||
it("upsert preserves display_name / created_at written by the interface layer", async () => {
|
||||
// The interface layer (server) writes display_name / created_at onto an entry; the CLI-side
|
||||
// addModel must not clear them when supplementing other fields (with a single config file,
|
||||
// these fields now live in the same entry as the credential).
|
||||
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(
|
||||
file,
|
||||
[
|
||||
"[[models]]",
|
||||
'provider = "custom"',
|
||||
'model_id = "m-keep"',
|
||||
'display_name = "My Model"',
|
||||
'api_key = "sk-old"',
|
||||
'created_at = "2026-07-01T00:00:00Z"',
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m-keep",
|
||||
api_key: "sk-new",
|
||||
});
|
||||
const m = getModel(await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID), {
|
||||
provider: "custom",
|
||||
model_id: "m-keep",
|
||||
});
|
||||
expect(m?.api_key).toBe("sk-new");
|
||||
expect(m?.display_name).toBe("My Model");
|
||||
expect(m?.created_at).toBe("2026-07-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("default config carries the anthropic claude-sonnet-4-6 pricing (three buckets)", () => {
|
||||
const entry = getModel(defaultProjectConfig(), {
|
||||
provider: "anthropic",
|
||||
model_id: "claude-sonnet-4-6",
|
||||
});
|
||||
expect(entry?.context_window).toBe(1000000);
|
||||
expect(entry?.pricing).toEqual({
|
||||
unit: "usd_per_mtok",
|
||||
cache_read: 0.3,
|
||||
cache_write: 3.75,
|
||||
output: 15,
|
||||
});
|
||||
// A preset model that supports vision does not persist a vision field (default = supported).
|
||||
expect(entry?.vision).toBeUndefined();
|
||||
});
|
||||
|
||||
it("default config presets the full model catalog (default = deepseek deepseek-v4-pro)", () => {
|
||||
const cfg = defaultProjectConfig();
|
||||
expect(cfg.default_model).toEqual({ provider: "deepseek", model_id: "deepseek-v4-pro" });
|
||||
// The catalog is presented in full: provider and model_id are separate columns, model_id
|
||||
// being the plain upstream id (vision is only persisted as false for models that don't
|
||||
// support images).
|
||||
expect(cfg.models.map((m) => [m.provider, m.model_id])).toEqual(
|
||||
MODEL_CATALOG.map((m) => [m.provider, m.modelId]),
|
||||
);
|
||||
for (const entry of cfg.models) {
|
||||
const cat = MODEL_CATALOG.find(
|
||||
(c) => c.provider === entry.provider && c.modelId === entry.model_id,
|
||||
)!;
|
||||
expect(entry.vision).toBe(cat.supportsVision ? undefined : false);
|
||||
expect(entry.pricing?.unit).toBe("usd_per_mtok");
|
||||
// A model that auto-routes leaves client_type unset; a gateway model (OpenRouter)
|
||||
// explicitly sets it to openai.
|
||||
expect(entry.client_type).toBe(cat.clientType);
|
||||
// A gateway model has its base URL preset inline (no key included); other models have
|
||||
// no credential.
|
||||
expect(entry.base_url).toBe(cat.baseUrl);
|
||||
expect(entry.api_key).toBeUndefined();
|
||||
}
|
||||
expect(getModel(cfg, { provider: "openrouter", model_id: "xiaomi/mimo-v2.5" })?.base_url).toBe(
|
||||
"https://openrouter.ai/api/v1",
|
||||
);
|
||||
expect(
|
||||
getModel(cfg, { provider: "deepseek", model_id: "deepseek-v4-pro" })?.base_url,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists pricing and field-merges buckets on upsert", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "p1",
|
||||
pricing: { cache_read: 0.3, cache_write: 3.75, output: 15 },
|
||||
});
|
||||
// Only output is updated, the other two buckets are kept.
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "p1",
|
||||
pricing: { output: 20 },
|
||||
});
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
expect(getModel(loaded, { provider: "custom", model_id: "p1" })?.pricing).toEqual({
|
||||
unit: "usd_per_mtok",
|
||||
cache_read: 0.3,
|
||||
cache_write: 3.75,
|
||||
output: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("setDefaultModel updates and persists a pair reference", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m2",
|
||||
context_window: 4096,
|
||||
});
|
||||
const m2Ref = { provider: "custom", model_id: "m2" };
|
||||
const updated = await setDefaultModel(tmpRoot, DEFAULT_PROJECT_ID, m2Ref);
|
||||
expect(updated.default_model).toEqual(m2Ref);
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
expect(loaded.default_model).toEqual(m2Ref);
|
||||
// A target not in models: throws (the same validation as setVisionModel, with the error
|
||||
// including the pair reference and a model-list hint), and the original default model is
|
||||
// unaffected. A mismatched provider likewise fails (exact pair match, no fuzzy resolution).
|
||||
await expect(
|
||||
setDefaultModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "custom", model_id: "nope" }),
|
||||
).rejects.toThrow(/\(provider=custom, model_id=nope\).*model list/);
|
||||
await expect(
|
||||
setDefaultModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "openai", model_id: "m2" }),
|
||||
).rejects.toThrow(/不在 models 中/);
|
||||
expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).default_model).toEqual(m2Ref);
|
||||
});
|
||||
|
||||
it("loadProjectConfig tolerates an empty config file (returns defaults, no throw)", async () => {
|
||||
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, "", "utf8"); // Empty file -> parseToml may return null
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
expect(loaded.models).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects old-format config files with a clear error (no migration)", async () => {
|
||||
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
// Old format 1: default_model is a concatenated storage id string.
|
||||
await fs.writeFile(file, 'default_model = "deepseek/deepseek-v4-pro"\n', "utf8");
|
||||
await expect(loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).rejects.toThrow(/旧版本|成对引用/);
|
||||
// Old format 2: a model entry missing provider (from the era of composite model_id +
|
||||
// request_model_id).
|
||||
await fs.writeFile(
|
||||
file,
|
||||
[
|
||||
"[[models]]",
|
||||
'model_id = "anthropic/claude-sonnet-4-6"',
|
||||
'request_model_id = "claude-sonnet-4-6"',
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await expect(loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).rejects.toThrow(/旧版本|独立字段/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelRef(唯一的「省略 provider」解析入口)", () => {
|
||||
const cfg: ProjectConfig = {
|
||||
models: [
|
||||
{ provider: "deepseek", model_id: "deepseek-v4-pro" },
|
||||
{ provider: "siliconflow", model_id: "shared-id" },
|
||||
{ provider: "openrouter", model_id: "shared-id" },
|
||||
],
|
||||
};
|
||||
|
||||
it("resolves a globally unique model_id without provider (exact match only)", () => {
|
||||
expect(resolveModelRef(cfg, "deepseek-v4-pro")).toEqual({
|
||||
provider: "deepseek",
|
||||
model_id: "deepseek-v4-pro",
|
||||
});
|
||||
// Exact-match lookup, no fuzzy/prefix matching.
|
||||
expect(() => resolveModelRef(cfg, "deepseek-v4")).toThrow(/不在 Project 配置中/);
|
||||
});
|
||||
|
||||
it("validates the exact pair when provider is given", () => {
|
||||
expect(resolveModelRef(cfg, "shared-id", "siliconflow")).toEqual({
|
||||
provider: "siliconflow",
|
||||
model_id: "shared-id",
|
||||
});
|
||||
// A wrong provider grouping likewise fails: the error includes the pair reference.
|
||||
expect(() => resolveModelRef(cfg, "shared-id", "openai")).toThrow(
|
||||
/\(provider=openai, model_id=shared-id\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("errors clearly on zero matches", () => {
|
||||
expect(() => resolveModelRef(cfg, "no-such-model")).toThrow(
|
||||
/不在 Project 配置中.*no-such-model/,
|
||||
);
|
||||
});
|
||||
|
||||
it("errors on ambiguity, listing the candidate pair references", () => {
|
||||
expect(() => resolveModelRef(cfg, "shared-id")).toThrow(/歧义/);
|
||||
expect(() => resolveModelRef(cfg, "shared-id")).toThrow(
|
||||
/\(provider=siliconflow, model_id=shared-id\).*\(provider=openrouter, model_id=shared-id\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("单一隐藏配置文件(.project_config.toml,credential 内联)", () => {
|
||||
it("addModel writes one hidden file with 0600 permission; api_key lives inline", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m-split",
|
||||
api_key: "sk-split-1",
|
||||
});
|
||||
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
// The sole config file is hidden (not shown by ls by default) and has 0600 permission
|
||||
// (owner read/write only).
|
||||
expect(path.basename(file)).toBe(".project_config.toml");
|
||||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||||
expect(await fs.readFile(file, "utf8")).toContain("sk-split-1");
|
||||
// The old two-file layout is no longer produced.
|
||||
expect(await exists(path.join(tmpRoot, DEFAULT_PROJECT_ID, "project_config.toml"))).toBe(false);
|
||||
expect(await exists(path.join(tmpRoot, DEFAULT_PROJECT_ID, ".credentials.toml"))).toBe(false);
|
||||
});
|
||||
|
||||
it("chmod converges an existing file back to 0600 on save", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m-perm",
|
||||
api_key: "sk-1",
|
||||
});
|
||||
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
await fs.chmod(file, 0o644);
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m-perm",
|
||||
api_key: "sk-2",
|
||||
});
|
||||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("writes provider and model_id as separate fields; refs are TOML inline tables", async () => {
|
||||
await addModel(
|
||||
tmpRoot,
|
||||
DEFAULT_PROJECT_ID,
|
||||
{ provider: "openrouter", model_id: "xiaomi/mimo-v2.5" },
|
||||
{ setDefault: true },
|
||||
);
|
||||
await setVisionModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "anthropic",
|
||||
model_id: "claude-sonnet-4-6",
|
||||
});
|
||||
const raw = await fs.readFile(projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID), "utf8");
|
||||
// A reference pair is persisted as an inline table; entries have
|
||||
// provider / model_id as separate columns, with no concatenated storage id or
|
||||
// request_model_id appearing anywhere.
|
||||
expect(raw).toContain(
|
||||
'default_model = { provider = "openrouter", model_id = "xiaomi/mimo-v2.5" }',
|
||||
);
|
||||
expect(raw).toContain(
|
||||
'vision_model = { provider = "anthropic", model_id = "claude-sonnet-4-6" }',
|
||||
);
|
||||
expect(raw).toContain('provider = "openrouter"');
|
||||
expect(raw).toContain('model_id = "xiaomi/mimo-v2.5"');
|
||||
expect(raw).not.toContain("request_model_id");
|
||||
expect(raw).not.toContain('"openrouter/xiaomi/mimo-v2.5"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent vault (agent_state/.vault.toml)", () => {
|
||||
it("set/remove roundtrip persists to the agent's .vault.toml", async () => {
|
||||
await setVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "MY_API_KEY", "sk-secret-1");
|
||||
await setVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "OTHER_KEY", "v2");
|
||||
// A same-named key overwrites, producing no duplicate.
|
||||
await setVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "MY_API_KEY", "sk-secret-2");
|
||||
let vault = await loadAgentVault(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
expect(vault).toEqual({ MY_API_KEY: "sk-secret-2", OTHER_KEY: "v2" });
|
||||
// Persisted in plaintext to this Agent's agent_state/.vault.toml (an accepted tradeoff:
|
||||
// masking happens at the interface layer) -- a hidden file (not shown by ls by default)
|
||||
// with 0600 permission (owner read/write only).
|
||||
const file = agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
expect(path.basename(file)).toBe(".vault.toml");
|
||||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||||
const raw = await fs.readFile(file, "utf8");
|
||||
expect(raw).toContain("sk-secret-2");
|
||||
// The Project config no longer carries the vault.
|
||||
expect(JSON.stringify(await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID))).not.toContain(
|
||||
"sk-secret-2",
|
||||
);
|
||||
|
||||
vault = await removeVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "MY_API_KEY");
|
||||
expect(vault).toEqual({ OTHER_KEY: "v2" });
|
||||
// Once emptied, the whole .vault.toml is removed; removing a non-existent key is
|
||||
// idempotent and does not throw.
|
||||
vault = await removeVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "OTHER_KEY");
|
||||
expect(vault).toEqual({});
|
||||
await expect(fs.access(file)).rejects.toThrow();
|
||||
vault = await removeVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "GHOST");
|
||||
expect(vault).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps vaults independent between agents", async () => {
|
||||
await setVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, "agent-a", "KEY_A", "va");
|
||||
await setVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, "agent-b", "KEY_B", "vb");
|
||||
expect(await loadAgentVault(tmpRoot, DEFAULT_PROJECT_ID, "agent-a")).toEqual({ KEY_A: "va" });
|
||||
expect(await loadAgentVault(tmpRoot, DEFAULT_PROJECT_ID, "agent-b")).toEqual({ KEY_B: "vb" });
|
||||
});
|
||||
|
||||
it("rejects invalid keys and keeps shell-safe names only", async () => {
|
||||
const set = (key: string) =>
|
||||
setVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, key, "v");
|
||||
await expect(set("1BAD")).rejects.toThrow(/vault key/);
|
||||
await expect(set("BAD-DASH")).rejects.toThrow();
|
||||
await expect(set("BAD KEY")).rejects.toThrow();
|
||||
await expect(set("")).rejects.toThrow();
|
||||
// Starting with an underscore is valid (shell environment variable naming rule).
|
||||
await set("_OK_1");
|
||||
expect(isValidVaultKey("_OK_1")).toBe(true);
|
||||
expect(isValidVaultKey("9NOPE")).toBe(false);
|
||||
// A value that is too long (>8192) is rejected: since it gets injected into the child
|
||||
// process environment, an oversized value would make exec spawn fail (E2BIG).
|
||||
await expect(
|
||||
setVaultEntry(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "OK_BIG", "x".repeat(8193)),
|
||||
).rejects.toThrow(/too long/);
|
||||
});
|
||||
|
||||
it("ignores non-string values and invalid key names from a hand-edited TOML; missing file is an empty vault", async () => {
|
||||
const file = agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
// Non-string values and invalid key names (starting with a dash / digit) are always
|
||||
// ignored -- the same rule as the write side (review gemini #1: if an invalid key were
|
||||
// loaded, it would get injected into the Prompt/env, and after a GET brought it out, a PUT
|
||||
// of the whole table back would 400, bricking the vault page).
|
||||
await fs.writeFile(file, 'GOOD = "ok"\nBAD = 123\n"BAD-DASH" = "x"\n"9NUM" = "y"\n', "utf8");
|
||||
expect(await loadAgentVault(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID)).toEqual({
|
||||
GOOD: "ok",
|
||||
});
|
||||
expect(await loadAgentVault(tmpRoot, DEFAULT_PROJECT_ID, "no-such-agent")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defensive config parsing", () => {
|
||||
it("throws a clear error when system_config.yaml is empty or corrupt", async () => {
|
||||
// First initialize normally, then empty out system_config.yaml; reloading should throw a
|
||||
// clear error rather than producing an undefined-laden message.
|
||||
await loadOrInitAgentState();
|
||||
const cfgPath = systemConfigPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
await fs.writeFile(cfgPath, "", "utf8");
|
||||
await expect(loadOrInitAgentState()).rejects.toThrow(/system_prompt|非法|损坏/);
|
||||
|
||||
await fs.writeFile(cfgPath, "just a string, not a mapping", "utf8");
|
||||
await expect(loadOrInitAgentState()).rejects.toThrow(/system_prompt|非法|损坏/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* Behavior tests for run_subagent / input_subagent: foreground delegation, backgrounding,
|
||||
* polling, resuming with an appended Prompt, the approval queue, and lifecycle finalization.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createSubagentTool } from "../src/environment/tools/run-subagent.js";
|
||||
import { createInputSubagentTool } from "../src/environment/tools/input-subagent.js";
|
||||
import {
|
||||
ManagedSubagentSession,
|
||||
SubagentSessionManager,
|
||||
} from "../src/environment/tools/subagent/index.js";
|
||||
import { abortEvent, partialText, toolCall, withOrigin } from "../src/omnimessage/index.js";
|
||||
import { collectWindow } from "../src/environment/tools/subagent/collect.js";
|
||||
import type { MessageOrigin, OmniMessage } from "../src/omnimessage/index.js";
|
||||
import type {
|
||||
ApproveFn,
|
||||
EnvironmentServices,
|
||||
SubagentHandle,
|
||||
SubagentRunner,
|
||||
ToolDefinitionConfig,
|
||||
} from "../src/interfaces.js";
|
||||
import type { ToolExecutionContext, ToolResult } from "../src/environment/tools/types.js";
|
||||
|
||||
const DEF: ToolDefinitionConfig = {
|
||||
name: "run_subagent",
|
||||
description: "delegate a subtask",
|
||||
permission: "rw",
|
||||
};
|
||||
|
||||
const INPUT_DEF: ToolDefinitionConfig = {
|
||||
name: "input_subagent",
|
||||
description: "drive a background subagent",
|
||||
permission: "rw",
|
||||
};
|
||||
|
||||
const CTX: ToolExecutionContext = {
|
||||
workspaceDir: "/tmp/ws",
|
||||
toolCallId: "call_1",
|
||||
};
|
||||
|
||||
/** The origin tag the simulated runner stamps on (contract: every message handle.run yields
|
||||
* already carries the child Session id). */
|
||||
const HOP: MessageOrigin = "session-child-12ab34cd";
|
||||
|
||||
interface LoosePayload {
|
||||
type?: string;
|
||||
event_type?: string;
|
||||
output?: string;
|
||||
stop_reason?: string;
|
||||
tool_call_id?: string;
|
||||
}
|
||||
const pl = (m: OmniMessage): LoosePayload => m.payload as LoosePayload;
|
||||
|
||||
type RunInput = { prompt: string; signal?: AbortSignal; approve?: ApproveFn };
|
||||
|
||||
/** Builds a SubagentRunner from a run implementation (spawn arguments observed via a spy). */
|
||||
function runnerOf(
|
||||
run: (input: RunInput) => AsyncGenerator<OmniMessage>,
|
||||
spawnSpy?: (input: { agentId?: string; modelId?: string }) => void,
|
||||
): SubagentRunner {
|
||||
return {
|
||||
async spawn(input) {
|
||||
spawnSpy?.(input);
|
||||
const handle: SubagentHandle = { sessionId: HOP, run, dispose() {} };
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A promise that resolves when the signal aborts (never resolves if there is no signal). */
|
||||
function aborted(signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!signal) return;
|
||||
if (signal.aborted) return resolve();
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Polls until a condition holds (test helper). */
|
||||
async function until(cond: () => boolean, ms = 3000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!cond()) {
|
||||
if (Date.now() - start > ms) throw new Error("condition not met in time");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
}
|
||||
|
||||
/** Collects yielded messages and captures the generator's return value (the tool reports its
|
||||
* finish reason via the return value). */
|
||||
async function collectWithReturn(
|
||||
gen: AsyncGenerator<OmniMessage, ToolResult | void>,
|
||||
): Promise<{ out: OmniMessage[]; result: ToolResult | void }> {
|
||||
const out: OmniMessage[] = [];
|
||||
for (;;) {
|
||||
const res = await gen.next();
|
||||
if (res.done) return { out, result: res.value };
|
||||
out.push(res.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Concatenates the tool's own (origin-free) output deltas. */
|
||||
const ownDeltas = (out: OmniMessage[]): string =>
|
||||
out
|
||||
.filter(
|
||||
(m) =>
|
||||
!m.origin?.length &&
|
||||
pl(m).type === "partial_tool_call_output" &&
|
||||
pl(m).event_type === "delta",
|
||||
)
|
||||
.map((m) => pl(m).output ?? "")
|
||||
.join("");
|
||||
|
||||
/** Extracts the subagent_id from run_subagent's finishing note. */
|
||||
function extractSubagentId(result: ToolResult | void): string {
|
||||
const m = (result?.note ?? "").match(/subagent_id (subagent-[0-9a-f]+)/);
|
||||
expect(m, `expected a subagent_id in: ${JSON.stringify(result?.note)}`).toBeTruthy();
|
||||
return m![1]!;
|
||||
}
|
||||
|
||||
const managers: SubagentSessionManager[] = [];
|
||||
function makeServices(runner?: SubagentRunner): {
|
||||
services: EnvironmentServices;
|
||||
manager: SubagentSessionManager;
|
||||
} {
|
||||
const manager = new SubagentSessionManager();
|
||||
managers.push(manager);
|
||||
return {
|
||||
services: { ...(runner ? { subagentRunner: runner } : {}), subagentSessions: manager },
|
||||
manager,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const m of managers.splice(0)) m.dispose();
|
||||
});
|
||||
|
||||
describe("run_subagent tool (foreground)", () => {
|
||||
it("forwards stamped child messages and mirrors child text as its own output deltas", async () => {
|
||||
const seen: Array<{ prompt?: string; agentId?: string; modelId?: string }> = [];
|
||||
const runner = runnerOf(
|
||||
async function* (input) {
|
||||
seen[0] = { ...seen[0], prompt: input.prompt };
|
||||
yield withOrigin(partialText("delta", "Hello "), HOP);
|
||||
yield withOrigin(partialText("delta", input.prompt), HOP);
|
||||
},
|
||||
(input) => {
|
||||
seen[0] = { ...input };
|
||||
},
|
||||
);
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { out, result } = await collectWithReturn(
|
||||
tool.execute({ prompt: "world", agent_id: "researcher", model_id: "m1" }, CTX),
|
||||
);
|
||||
|
||||
// Child session messages pass through verbatim (with origin).
|
||||
const forwarded = out.filter((m) => m.origin?.length);
|
||||
expect(forwarded).toHaveLength(2);
|
||||
expect(forwarded[0]!.origin![0]).toEqual(HOP);
|
||||
// The child's text deltas are mirrored as this tool's own output (Environment derives the
|
||||
// complete tool_call_output from this).
|
||||
expect(ownDeltas(out)).toBe("Hello world");
|
||||
expect(result?.stopReason).toBe("completed");
|
||||
// The model is free to choose the agent and model (spawn arguments); the prompt is
|
||||
// handed to run.
|
||||
expect(seen[0]).toEqual({ prompt: "world", agentId: "researcher", modelId: "m1" });
|
||||
});
|
||||
|
||||
it("does not mirror deeper-nested (origin.length > 1) text into its own output", async () => {
|
||||
const grandHop: MessageOrigin = "sess_grandchild";
|
||||
const runner = runnerOf(async function* () {
|
||||
// Grandchild-level text (two hops): only forwarded, not counted as the child Agent's reply.
|
||||
yield withOrigin(withOrigin(partialText("delta", "deep"), grandHop), HOP);
|
||||
yield withOrigin(partialText("delta", "answer"), HOP);
|
||||
});
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { out } = await collectWithReturn(tool.execute({ prompt: "x" }, CTX));
|
||||
expect(ownDeltas(out)).toBe("answer");
|
||||
// Grandchild-level messages are still forwarded (origin two hops).
|
||||
expect(out.some((m) => (m.origin?.length ?? 0) === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails gracefully when no runner is injected", async () => {
|
||||
const { services } = makeServices();
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { out, result } = await collectWithReturn(tool.execute({ prompt: "x" }, CTX));
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(ownDeltas(out)).toContain("no subagent runner");
|
||||
});
|
||||
|
||||
it("fails when the required prompt is missing", async () => {
|
||||
const runner = runnerOf(
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* () {
|
||||
/* never invoked */
|
||||
},
|
||||
);
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { out, result } = await collectWithReturn(tool.execute({}, CTX));
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(ownDeltas(out)).toContain("prompt");
|
||||
});
|
||||
|
||||
it("notes when the subagent produces no text", async () => {
|
||||
const runner = runnerOf(
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* () {
|
||||
/* yields no assistant text */
|
||||
},
|
||||
);
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { result } = await collectWithReturn(tool.execute({ prompt: "x" }, CTX));
|
||||
expect(result?.stopReason).toBe("completed");
|
||||
expect(result?.note).toContain("without a text answer");
|
||||
});
|
||||
|
||||
it("reports a failed delegation when the child session aborts", async () => {
|
||||
const runner = runnerOf(async function* () {
|
||||
yield withOrigin(partialText("delta", "partial"), HOP);
|
||||
yield withOrigin(abortEvent("llm error"), HOP);
|
||||
});
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { result } = await collectWithReturn(tool.execute({ prompt: "x" }, CTX));
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(result?.note).toContain("subagent aborted: llm error");
|
||||
});
|
||||
|
||||
it("surfaces child approval requests through the parent approve callback", async () => {
|
||||
const askedFor: string[] = [];
|
||||
const approve: ApproveFn = async (tc) => {
|
||||
askedFor.push(tc.payload.name);
|
||||
expect(tc.origin?.length).toBe(1); // The approval request carries origin, so the approval UI can identify its source
|
||||
return "allow";
|
||||
};
|
||||
const runner = runnerOf(async function* ({ approve: childApprove }) {
|
||||
const decision = childApprove
|
||||
? await childApprove(
|
||||
withOrigin(toolCall({ name: "exec_command", arguments: "{}", toolCallId: "t1" }), HOP),
|
||||
)
|
||||
: "deny";
|
||||
yield withOrigin(partialText("delta", `decision:${decision}`), HOP);
|
||||
});
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { out, result } = await collectWithReturn(
|
||||
tool.execute({ prompt: "x" }, { ...CTX, approve }),
|
||||
);
|
||||
expect(result?.stopReason).toBe("completed");
|
||||
expect(ownDeltas(out)).toContain("decision:allow");
|
||||
expect(askedFor).toEqual(["exec_command"]);
|
||||
});
|
||||
|
||||
it("kills the child and reports aborted when interrupted during the start window", async () => {
|
||||
let sawAbort = false;
|
||||
const runner = runnerOf(async function* ({ signal }) {
|
||||
yield withOrigin(partialText("delta", "working"), HOP);
|
||||
await aborted(signal);
|
||||
sawAbort = true;
|
||||
});
|
||||
const { services } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
const { result } = await collectWithReturn(
|
||||
tool.execute({ prompt: "x", yield_time_ms: 10_000 }, { ...CTX, signal: controller.signal }),
|
||||
);
|
||||
expect(result?.stopReason).toBe("aborted");
|
||||
await until(() => sawAbort);
|
||||
});
|
||||
});
|
||||
|
||||
describe("run_subagent backgrounding + input_subagent", () => {
|
||||
/** A child Agent whose first-turn task is stuck on a gate: after backgrounding, the test
|
||||
* controls when it finishes. */
|
||||
function gatedChild(): {
|
||||
run: (input: RunInput) => AsyncGenerator<OmniMessage>;
|
||||
release: () => void;
|
||||
prompts: string[];
|
||||
} {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const prompts: string[] = [];
|
||||
const run = async function* ({ prompt, signal }: RunInput): AsyncGenerator<OmniMessage> {
|
||||
prompts.push(prompt);
|
||||
if (prompts.length === 1) {
|
||||
yield withOrigin(partialText("delta", `start:${prompt} `), HOP);
|
||||
await Promise.race([gate, aborted(signal)]);
|
||||
if (signal?.aborted) return;
|
||||
yield withOrigin(partialText("delta", `end:${prompt}`), HOP);
|
||||
return;
|
||||
}
|
||||
yield withOrigin(partialText("delta", `ran:${prompt}`), HOP);
|
||||
};
|
||||
return { run, release, prompts };
|
||||
}
|
||||
|
||||
it("yields a subagent_id when the subagent is still working past yield_time_ms", async () => {
|
||||
const child = gatedChild();
|
||||
const { services, manager } = makeServices(runnerOf(child.run));
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { out, result } = await collectWithReturn(
|
||||
tool.execute({ prompt: "task", yield_time_ms: 250 }, CTX),
|
||||
);
|
||||
expect(result?.stopReason).toBe("completed");
|
||||
expect(ownDeltas(out)).toBe("start:task ");
|
||||
const id = extractSubagentId(result);
|
||||
// subagent_id is derived from the tail of the child Session id: it can be correlated with
|
||||
// the message origin / frontend nesting tag.
|
||||
expect(id).toBe(`subagent-${HOP.slice(-8)}`);
|
||||
expect(manager.get(id)).toBeDefined();
|
||||
child.release();
|
||||
});
|
||||
|
||||
it("polls a background subagent and reports the final status when it finishes", async () => {
|
||||
const child = gatedChild();
|
||||
const { services } = makeServices(runnerOf(child.run));
|
||||
const runTool = createSubagentTool(DEF, services);
|
||||
const { result: started } = await collectWithReturn(
|
||||
runTool.execute({ prompt: "task", yield_time_ms: 250 }, CTX),
|
||||
);
|
||||
const id = extractSubagentId(started);
|
||||
|
||||
child.release();
|
||||
const writeTool = createInputSubagentTool(INPUT_DEF, services);
|
||||
const { out, result } = await collectWithReturn(
|
||||
writeTool.execute({ subagent_id: id, yield_time_ms: 3000 }, CTX),
|
||||
);
|
||||
// Output buffered while backgrounded is delivered on the tail via polling; after a turn
|
||||
// ends, the session is kept (resumable).
|
||||
expect(ownDeltas(out)).toContain("end:task");
|
||||
expect(result?.stopReason).toBe("completed");
|
||||
expect(result?.note).toContain(`subagent idle with subagent_id ${id}`);
|
||||
});
|
||||
|
||||
it("continues the same subagent session with a follow-up prompt", async () => {
|
||||
const child = gatedChild();
|
||||
const { services } = makeServices(runnerOf(child.run));
|
||||
const runTool = createSubagentTool(DEF, services);
|
||||
const { result: started } = await collectWithReturn(
|
||||
runTool.execute({ prompt: "one", yield_time_ms: 250 }, CTX),
|
||||
);
|
||||
const id = extractSubagentId(started);
|
||||
child.release();
|
||||
const writeTool = createInputSubagentTool(INPUT_DEF, services);
|
||||
await collectWithReturn(writeTool.execute({ subagent_id: id, yield_time_ms: 3000 }, CTX));
|
||||
|
||||
// Appends a Prompt: resumes for a second turn on the same child Session.
|
||||
const { out, result } = await collectWithReturn(
|
||||
writeTool.execute({ subagent_id: id, prompt: "two", yield_time_ms: 3000 }, CTX),
|
||||
);
|
||||
expect(ownDeltas(out)).toContain("ran:two");
|
||||
expect(result?.stopReason).toBe("completed");
|
||||
expect(child.prompts).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
it("rejects a follow-up prompt while the subagent is still running", async () => {
|
||||
const child = gatedChild();
|
||||
const { services } = makeServices(runnerOf(child.run));
|
||||
const runTool = createSubagentTool(DEF, services);
|
||||
const { result: started } = await collectWithReturn(
|
||||
runTool.execute({ prompt: "task", yield_time_ms: 250 }, CTX),
|
||||
);
|
||||
const id = extractSubagentId(started);
|
||||
|
||||
const writeTool = createInputSubagentTool(INPUT_DEF, services);
|
||||
const { out, result } = await collectWithReturn(
|
||||
writeTool.execute({ subagent_id: id, prompt: "more", yield_time_ms: 250 }, CTX),
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(ownDeltas(out)).toContain("still running");
|
||||
child.release();
|
||||
});
|
||||
|
||||
it("reports an unknown subagent_id without throwing", async () => {
|
||||
const { services } = makeServices();
|
||||
const writeTool = createInputSubagentTool(INPUT_DEF, services);
|
||||
const { out, result } = await collectWithReturn(
|
||||
writeTool.execute({ subagent_id: "subagent-deadbeef" }, CTX),
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(ownDeltas(out)).toContain("unknown subagent_id subagent-deadbeef");
|
||||
});
|
||||
|
||||
it("queues child approvals while backgrounded and surfaces them on the next poll", async () => {
|
||||
const runner = runnerOf(async function* ({ approve }: RunInput) {
|
||||
yield withOrigin(partialText("delta", "working "), HOP);
|
||||
const decision = approve
|
||||
? await approve(
|
||||
withOrigin(toolCall({ name: "exec_command", arguments: "{}", toolCallId: "t1" }), HOP),
|
||||
)
|
||||
: "deny";
|
||||
yield withOrigin(partialText("delta", `approved:${decision}`), HOP);
|
||||
});
|
||||
const { services } = makeServices(runner);
|
||||
// The start call has no approve: once the window ends and it backgrounds, the child
|
||||
// session's approval request queues up waiting.
|
||||
const runTool = createSubagentTool(DEF, services);
|
||||
const { result: started } = await collectWithReturn(
|
||||
runTool.execute({ prompt: "task", yield_time_ms: 250 }, CTX),
|
||||
);
|
||||
const id = extractSubagentId(started);
|
||||
expect(started?.note).toContain("waiting for approval of 1 tool call(s)");
|
||||
|
||||
// Polling hooks up the approval outlet: the queued request is put to ctx.approve, and the
|
||||
// decision is sent back to the child session.
|
||||
const approve: ApproveFn = async () => "allow";
|
||||
const writeTool = createInputSubagentTool(INPUT_DEF, services);
|
||||
const { out, result } = await collectWithReturn(
|
||||
writeTool.execute({ subagent_id: id, yield_time_ms: 3000 }, { ...CTX, approve }),
|
||||
);
|
||||
expect(ownDeltas(out)).toContain("approved:allow");
|
||||
expect(result?.stopReason).toBe("completed");
|
||||
});
|
||||
|
||||
it("refuses to spawn beyond the background subagent capacity", async () => {
|
||||
const { services, manager } = makeServices(
|
||||
runnerOf(async function* ({ signal }) {
|
||||
yield withOrigin(partialText("delta", "x"), HOP);
|
||||
await aborted(signal);
|
||||
}),
|
||||
);
|
||||
// Fills the concurrency limit: 8 running background sessions (running ones cannot be evicted).
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const session = new ManagedSubagentSession({
|
||||
sessionId: `session-occupy-0000000${i}`,
|
||||
// eslint-disable-next-line require-yield
|
||||
run: async function* ({ signal }: RunInput): AsyncGenerator<OmniMessage> {
|
||||
await aborted(signal);
|
||||
},
|
||||
dispose() {},
|
||||
});
|
||||
session.startRun("occupy");
|
||||
manager.register(session);
|
||||
}
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { out, result } = await collectWithReturn(tool.execute({ prompt: "x" }, CTX));
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(ownDeltas(out)).toContain("too many background subagents");
|
||||
});
|
||||
|
||||
it("aborts background subagents and denies pending approvals on dispose", async () => {
|
||||
let sawAbort = false;
|
||||
let decision: string | null = null;
|
||||
const runner = runnerOf(async function* ({ approve, signal }: RunInput) {
|
||||
yield withOrigin(partialText("delta", "working"), HOP);
|
||||
if (approve) {
|
||||
decision = await approve(
|
||||
withOrigin(toolCall({ name: "exec_command", arguments: "{}", toolCallId: "t1" }), HOP),
|
||||
);
|
||||
}
|
||||
await aborted(signal);
|
||||
sawAbort = true;
|
||||
});
|
||||
const { services, manager } = makeServices(runner);
|
||||
const tool = createSubagentTool(DEF, services);
|
||||
const { result } = await collectWithReturn(
|
||||
tool.execute({ prompt: "task", yield_time_ms: 250 }, CTX),
|
||||
);
|
||||
extractSubagentId(result);
|
||||
|
||||
manager.dispose();
|
||||
await until(() => sawAbort);
|
||||
expect(decision).toBe("deny");
|
||||
});
|
||||
|
||||
it("delivers output arriving while the consumer is suspended without waiting out the window", async () => {
|
||||
// Wake-race regression: when output arrives while suspended at `yield`, its wakeup happens
|
||||
// before the next wait begins (so it would be missed). collectWindow must re-check the
|
||||
// buffer right before sleeping, otherwise this batch of output would not be delivered
|
||||
// until the window ends (here, 5s).
|
||||
let emitSecond: (() => void) | null = null;
|
||||
const session = new ManagedSubagentSession({
|
||||
sessionId: HOP,
|
||||
run: async function* ({ signal }: RunInput): AsyncGenerator<OmniMessage> {
|
||||
yield withOrigin(partialText("delta", "first"), HOP);
|
||||
await new Promise<void>((resolve) => {
|
||||
emitSecond = resolve;
|
||||
});
|
||||
yield withOrigin(partialText("delta", "second"), HOP);
|
||||
await aborted(signal);
|
||||
},
|
||||
dispose() {},
|
||||
});
|
||||
try {
|
||||
session.startRun("go");
|
||||
const gen = collectWindow(session, { yieldMs: 5000, toolCallId: "call_race" });
|
||||
const first = await gen.next(); // First: the forwarded "first" child session message
|
||||
expect(first.done).toBe(false);
|
||||
// The generator is still suspended at the yield above: releasing "second" now means both
|
||||
// buffering and the wakeup have already happened.
|
||||
await until(() => emitSecond !== null);
|
||||
emitSecond!();
|
||||
await until(() => session.hasPending);
|
||||
const startedAt = Date.now();
|
||||
let out = "";
|
||||
for (;;) {
|
||||
const res = await gen.next();
|
||||
expect(res.done).toBe(false);
|
||||
const p = pl(res.value as OmniMessage);
|
||||
if (p.type === "partial_text" || p.type === "partial_tool_call_output") {
|
||||
out += (res.value.payload as { text?: string; output?: string }).text ?? p.output ?? "";
|
||||
}
|
||||
if (out.includes("second")) break;
|
||||
}
|
||||
expect(Date.now() - startedAt).toBeLessThan(1500);
|
||||
await gen.return(undefined);
|
||||
} finally {
|
||||
session.kill();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assistantText,
|
||||
partialText,
|
||||
sessionMeta,
|
||||
subagentEvent,
|
||||
tokenUsage,
|
||||
emptyTokenCounts,
|
||||
withOrigin,
|
||||
} from "../src/omnimessage/index.js";
|
||||
import { Writer, readTrace } from "../src/trace/index.js";
|
||||
|
||||
const SESSION_ID = "sess_abc";
|
||||
|
||||
function meta() {
|
||||
return sessionMeta({
|
||||
session_id: SESSION_ID,
|
||||
provider: "custom",
|
||||
model_id: "test-model",
|
||||
model_context_window: 200000,
|
||||
system_prompt: "test system prompt",
|
||||
tools: [{ name: "exec_command", description: "test tool" }],
|
||||
thinking_level: "medium",
|
||||
agent_state: "/tmp/agent_state",
|
||||
workspace: "/tmp/workspace",
|
||||
});
|
||||
}
|
||||
|
||||
describe("Writer", () => {
|
||||
let tracesDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tracesDir = await mkdtemp(join(tmpdir(), "penguin-trace-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tracesDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes only recordable messages and skips partial_*, in order", async () => {
|
||||
// Injects a fixed date, and asserts the directory name.
|
||||
const writer = new Writer({
|
||||
tracesDir,
|
||||
sessionId: SESSION_ID,
|
||||
date: new Date(2026, 0, 9), // Local 2026-01-09 (note the zero padding)
|
||||
});
|
||||
|
||||
await writer.writeAll([
|
||||
meta(),
|
||||
assistantText("hi"),
|
||||
partialText("delta", "x"), // Should be skipped
|
||||
tokenUsage(emptyTokenCounts(), emptyTokenCounts()),
|
||||
]);
|
||||
|
||||
const rows = await readTrace(writer.currentPath());
|
||||
|
||||
// Exactly 3 rows (partial_text is skipped).
|
||||
expect(rows).toHaveLength(3);
|
||||
|
||||
// Every row can be JSON.parse'd (readTrace already parses it) and is in the correct order.
|
||||
expect(rows[0]!.type).toBe("session_meta");
|
||||
expect(rows[1]!.type).toBe("model_msg");
|
||||
expect((rows[1]!.payload as { type: string }).type).toBe("text");
|
||||
expect(rows[2]!.type).toBe("event_msg");
|
||||
expect((rows[2]!.payload as { type: string }).type).toBe("token_usage");
|
||||
|
||||
// Contains no partial_* at all.
|
||||
const innerTypes = rows.map((m) => (m.payload as { type?: string }).type);
|
||||
expect(innerTypes.some((t) => t?.startsWith("partial_"))).toBe(false);
|
||||
});
|
||||
|
||||
it("skips all nested-session messages; the subagent pointer event is recordable", async () => {
|
||||
const writer = new Writer({
|
||||
tracesDir,
|
||||
sessionId: SESSION_ID,
|
||||
date: new Date(2026, 0, 9),
|
||||
});
|
||||
const childMeta = sessionMeta({
|
||||
session_id: "sess_child",
|
||||
provider: "custom",
|
||||
model_id: "test-model",
|
||||
model_context_window: 200000,
|
||||
system_prompt: "child prompt",
|
||||
tools: [],
|
||||
thinking_level: "medium",
|
||||
agent_state: "/tmp/child_agent/agent_state",
|
||||
workspace: "/tmp/workspace",
|
||||
});
|
||||
await writer.writeAll([
|
||||
meta(),
|
||||
// The derived pointer is written by context_engine when the child session_meta arrives
|
||||
// (recording only the child Session id).
|
||||
subagentEvent("sess_child"),
|
||||
withOrigin(childMeta, "sess_child"),
|
||||
withOrigin(assistantText("from child"), "sess_child"),
|
||||
withOrigin(withOrigin(childMeta, "sess_grandchild"), "sess_child"),
|
||||
assistantText("from parent"),
|
||||
]);
|
||||
const rows = await readTrace(writer.currentPath());
|
||||
// meta + the subagent pointer event + the parent's text; origin-tagged child session
|
||||
// messages (including session_meta) are never written.
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.some((m) => (m.payload as { text?: string }).text === "from child")).toBe(false);
|
||||
expect(rows.some((m) => m.origin !== undefined)).toBe(false);
|
||||
const pointer = rows[1]!;
|
||||
expect(pointer.type).toBe("event_msg");
|
||||
expect(pointer.payload).toMatchObject({ type: "subagent", session_id: "sess_child" });
|
||||
});
|
||||
|
||||
it("uses padded date subdir and <sessionId>_001.jsonl path", async () => {
|
||||
const writer = new Writer({
|
||||
tracesDir,
|
||||
sessionId: SESSION_ID,
|
||||
date: new Date(2026, 0, 9),
|
||||
});
|
||||
await writer.write(meta());
|
||||
|
||||
const path = writer.currentPath();
|
||||
// The path matches <sessionId>_001.jsonl and sits under a <yyyy-mm-dd>/ subdirectory.
|
||||
const parts = path.split(sep);
|
||||
const fileName = parts[parts.length - 1]!;
|
||||
const dateSubdir = parts[parts.length - 2]!;
|
||||
expect(fileName).toBe(`${SESSION_ID}_001.jsonl`);
|
||||
expect(dateSubdir).toBe("2026-01-09");
|
||||
|
||||
// The file actually exists.
|
||||
const info = await stat(path);
|
||||
expect(info.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it("rotate() switches to _002.jsonl and leaves the old file append-only", async () => {
|
||||
const writer = new Writer({
|
||||
tracesDir,
|
||||
sessionId: SESSION_ID,
|
||||
date: new Date(2026, 0, 9),
|
||||
});
|
||||
|
||||
await writer.write(meta());
|
||||
await writer.write(assistantText("first context"));
|
||||
const firstPath = writer.currentPath();
|
||||
expect((await readTrace(firstPath)).length).toBe(2);
|
||||
|
||||
await writer.rotate();
|
||||
const secondPath = writer.currentPath();
|
||||
expect(secondPath).not.toBe(firstPath);
|
||||
expect(secondPath.endsWith(`${SESSION_ID}_002.jsonl`)).toBe(true);
|
||||
|
||||
// The write goes into the new file.
|
||||
await writer.write(assistantText("second context"));
|
||||
expect((await readTrace(secondPath)).length).toBe(1);
|
||||
|
||||
// append-only verification: the old file's row count does not increase.
|
||||
expect((await readTrace(firstPath)).length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { assertValidId, isValidId, loadOrInitAgentState } from "../src/state/index.js";
|
||||
|
||||
describe("isValidId / assertValidId", () => {
|
||||
const valid = ["default_project", "default_agent", "my-agent", "agent-1", "ABC_123"];
|
||||
// Only alphanumerics, `_`, and `-` are allowed; dots, spaces/tabs, path separators, and any
|
||||
// other character are all rejected.
|
||||
const invalid = [
|
||||
"",
|
||||
" ",
|
||||
"a b",
|
||||
"a\tb",
|
||||
" lead",
|
||||
"trail ",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
".",
|
||||
"..",
|
||||
"...",
|
||||
"a.b",
|
||||
"v1.0",
|
||||
".hidden",
|
||||
"中文ok",
|
||||
"emoji😀",
|
||||
];
|
||||
|
||||
it.each(valid)("接受合法单段目录名:%j", (id) => {
|
||||
expect(isValidId(id)).toBe(true);
|
||||
expect(() => assertValidId("project_id", id)).not.toThrow();
|
||||
expect(() => assertValidId("agent_id", id)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(invalid)("拒绝非法 id:%j", (id) => {
|
||||
expect(isValidId(id)).toBe(false);
|
||||
expect(() => assertValidId("agent_id", id)).toThrow();
|
||||
});
|
||||
|
||||
it("错误信息点明 kind 与非法 id", () => {
|
||||
expect(() => assertValidId("agent_id", "a/b")).toThrow(/agent_id/);
|
||||
expect(() => assertValidId("agent_id", "a/b")).toThrow(/a\/b/);
|
||||
expect(() => assertValidId("project_id", "..")).toThrow(/project_id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadOrInitAgentState id 校验", () => {
|
||||
let tmpRoot: string;
|
||||
let prevHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevHome = process.env.PENGUIN_HOME;
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-validate-id-"));
|
||||
process.env.PENGUIN_HOME = tmpRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.PENGUIN_HOME;
|
||||
} else {
|
||||
process.env.PENGUIN_HOME = prevHome;
|
||||
}
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("agentId 含路径分隔符时抛错", async () => {
|
||||
await expect(loadOrInitAgentState({ agentId: "a/b" })).rejects.toThrow(/agent_id/);
|
||||
});
|
||||
|
||||
it("projectId 为 .. 时抛错", async () => {
|
||||
await expect(loadOrInitAgentState({ projectId: ".." })).rejects.toThrow(/project_id/);
|
||||
});
|
||||
|
||||
it("agentId 为 Windows 结尾空格变体 '.. ' 时抛错(防路径穿越绕过)", async () => {
|
||||
await expect(loadOrInitAgentState({ agentId: ".. " })).rejects.toThrow(/agent_id/);
|
||||
});
|
||||
|
||||
it("默认 id 合法,可正常初始化", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
expect(state.projectId).toBe("default_project");
|
||||
expect(state.agentId).toBe("default_agent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Process-lifecycle behavior tests for WakeSignal.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("WakeSignal", () => {
|
||||
it("keeps a standalone process alive while wait() is pending without leaving timers behind", () => {
|
||||
const moduleUrl = new URL("../src/environment/tools/background/wake-signal.ts", import.meta.url)
|
||||
.href;
|
||||
const script = `
|
||||
const { WakeSignal } = await import(${JSON.stringify(moduleUrl)});
|
||||
|
||||
await new WakeSignal().wait(50);
|
||||
process.stdout.write("timeout\\n");
|
||||
|
||||
const signal = new WakeSignal();
|
||||
setTimeout(() => signal.notify(), 10);
|
||||
await signal.wait(60_000);
|
||||
process.stdout.write("notified\\n");
|
||||
`;
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--input-type=module", "--eval", script],
|
||||
{ encoding: "utf8", timeout: 3_000 },
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe("timeout\nnotified\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Temporary Workspace creation:
|
||||
*
|
||||
* - The directory name is the workspace_id, shaped like `tmp-<8hex>`, created under
|
||||
* `<agent>/workspaces/`.
|
||||
* - The id is checked for collisions within `workspaces/`: on conflict with an existing
|
||||
* directory (EEXIST), it regenerates rather than reusing the existing directory; once
|
||||
* retries are exhausted, it throws instead of silently falling back to an old temp Workspace.
|
||||
*/
|
||||
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 { createTempWorkspace } from "../src/internal/session-support.js";
|
||||
import { workspacesDir } from "../src/state/paths.js";
|
||||
|
||||
const mocked = vi.hoisted(() => ({ uuids: [] as string[] }));
|
||||
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
return {
|
||||
...actual,
|
||||
// When the queue has values, dequeue from it (tests inject fixed ids), otherwise fall back
|
||||
// to the real implementation.
|
||||
randomUUID: () => mocked.uuids.shift() ?? actual.randomUUID(),
|
||||
};
|
||||
});
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-workspace-"));
|
||||
mocked.uuids.length = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createTempWorkspace", () => {
|
||||
it("在 workspaces/ 下创建 tmp-<8hex> 目录并返回路径", async () => {
|
||||
const dir = await createTempWorkspace(tmpRoot, "proj", "agent");
|
||||
expect(path.dirname(dir)).toBe(workspacesDir(tmpRoot, "proj", "agent"));
|
||||
expect(path.basename(dir)).toMatch(/^tmp-[0-9a-f]{8}$/);
|
||||
expect((await fs.stat(dir)).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("id 与已有目录冲突时重新生成,不复用已有目录", async () => {
|
||||
const base = workspacesDir(tmpRoot, "proj", "agent");
|
||||
await fs.mkdir(path.join(base, "tmp-aaaaaaaa"), { recursive: true });
|
||||
await fs.writeFile(path.join(base, "tmp-aaaaaaaa", "keep.txt"), "old");
|
||||
mocked.uuids.push(
|
||||
"aaaaaaaa-1111-4111-8111-111111111111",
|
||||
"bbbbbbbb-2222-4222-8222-222222222222",
|
||||
);
|
||||
|
||||
const dir = await createTempWorkspace(tmpRoot, "proj", "agent");
|
||||
|
||||
expect(path.basename(dir)).toBe("tmp-bbbbbbbb");
|
||||
expect(await fs.readdir(dir)).toEqual([]);
|
||||
// The existing directory is neither reused nor modified.
|
||||
await expect(fs.readFile(path.join(base, "tmp-aaaaaaaa", "keep.txt"), "utf8")).resolves.toBe(
|
||||
"old",
|
||||
);
|
||||
});
|
||||
|
||||
it("重试耗尽时报错,而非复用已有目录", async () => {
|
||||
const base = workspacesDir(tmpRoot, "proj", "agent");
|
||||
await fs.mkdir(path.join(base, "tmp-cccccccc"), { recursive: true });
|
||||
mocked.uuids.push(...Array.from({ length: 16 }, () => "cccccccc-3333-4333-8333-333333333333"));
|
||||
|
||||
await expect(createTempWorkspace(tmpRoot, "proj", "agent")).rejects.toThrow(
|
||||
/unique temp workspace id/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user