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:
Yaowei ZhengandClaude Opus 4.8 authored and GitHub committed 2026-07-19 14:06:53 +08:00
1 parent 056bed7aeb
commit 45bfae6e94
543 files changed
+92949

No files matched your search

+105
View File
@@ -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");
});
});
+201
View File
@@ -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);
});
});
+256
View File
@@ -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();
});
});
+141
View File
@@ -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>/");
});
});
+683
View File
@@ -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");
});
});
+241
View File
@@ -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([]);
});
});
+292
View File
@@ -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();
}
});
});
+82
View File
@@ -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");
});
});
+150
View File
@@ -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
+212
View File
@@ -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");
}
}
});
});
+198
View File
@@ -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");
});
});
+24
View File
@@ -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;
}
};
}
+147
View File
@@ -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");
});
});
+469
View File
@@ -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");
});
});
+288
View File
@@ -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);
});
});
+175
View File
@@ -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 });
});
});
+988
View File
@@ -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|非法|损坏/);
});
});
+515
View File
@@ -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();
}
});
});
+160
View File
@@ -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);
});
});
+84
View File
@@ -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");
});
});
+33
View File
@@ -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");
});
});
+76
View File
@@ -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/,
);
});
});