45bfae6e94
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
257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
/**
|
||
* Agent.createSession's Workspace handling and vault injection (no network needed; only
|
||
* constructs the Session, never sends a request).
|
||
*
|
||
* Regression: an explicitly given Workspace must be an existing directory. When it
|
||
* does not exist, a clear error must be thrown rather than auto-creating it, and bash must not
|
||
* be started with an invalid cwd after Session creation, which would throw a misleading
|
||
* `spawn bash ENOENT`. A temp directory is only created when no Workspace is specified.
|
||
*
|
||
* vault: the Agent vault's (agent_state/.vault.toml) **key names** are
|
||
* injected into the assembled system prompt; values are never injected.
|
||
*/
|
||
import fs from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { afterEach, beforeEach, describe, expect, it } 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();
|
||
}
|
||
});
|
||
});
|