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 Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import { Readable, Writable } from "node:stream";
import { toolCall } from "@prismshadow/penguin-core";
import type { OmniMessage, ToolCallPayload } from "@prismshadow/penguin-core";
import { makeApprove, promptApproval, resolveApprovalMode } from "../src/approval.js";
import { getMessages } from "../src/i18n.js";
const t = getMessages("en");
/** An in-memory writable stream that collects everything written to output. */
function collector(): { stream: Writable; text: () => string } {
let buf = "";
const stream = new Writable({
write(chunk, _enc, cb) {
buf += chunk.toString();
cb();
},
});
return { stream, text: () => buf };
}
// mock_read_only_tool is only used for approval-mode tests, not a real tool; its permission is read-only ("r").
const readTool = (): OmniMessage<ToolCallPayload> =>
toolCall({ name: "mock_read_only_tool", arguments: '{"path":"a"}', toolCallId: "r1" });
const writeTool = (): OmniMessage<ToolCallPayload> =>
toolCall({ name: "exec_command", arguments: '{"cmd":"rm x"}', toolCallId: "w1" });
const perms: Record<string, "r" | "rw"> = {
mock_read_only_tool: "r",
exec_command: "rw",
};
const toolPermission = (name: string): "r" | "rw" | undefined => perms[name];
describe("promptApproval", () => {
it('returns "allow" when the user types "y"', async () => {
const { stream, text } = collector();
const decision = await promptApproval({
input: Readable.from(["y\n"]),
output: stream,
t,
});
expect(decision).toBe("allow");
// Output is exactly the approval prompt itself — no input echo, no repeated tool-call rendering.
expect(text()).toBe("? Approve this tool call? [Y/n] ");
});
it('returns "allow" on empty input (Enter) — tool approval defaults to yes', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from(["\n"]),
output: stream,
t,
});
expect(decision).toBe("allow");
});
it('returns "allow" for "yes" (case-insensitive, trimmed)', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from([" YES \n"]),
output: stream,
t,
});
expect(decision).toBe("allow");
});
it('returns "deny" when the user types "n"', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from(["n\n"]),
output: stream,
t,
});
expect(decision).toBe("deny");
});
it('returns "allow" for unrelated input (tool approval defaults to yes)', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from(["maybe\n"]),
output: stream,
t,
});
expect(decision).toBe("allow");
});
it('returns "deny" when the input stream ends (EOF) instead of hanging', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from([]),
output: stream,
t,
});
expect(decision).toBe("deny");
});
});
describe("resolveApprovalMode", () => {
it("maps --approve values; defaults to allow-all", () => {
expect(resolveApprovalMode("allow-all", t)).toBe("allow-all");
expect(resolveApprovalMode("read-only", t)).toBe("read-only");
expect(resolveApprovalMode("deny-all", t)).toBe("deny-all");
expect(resolveApprovalMode("always-ask", t)).toBe("always-ask");
expect(resolveApprovalMode("READ-ONLY", t)).toBe("read-only");
expect(resolveApprovalMode(undefined, t)).toBe("allow-all");
});
});
describe("makeApprove permission modes", () => {
it("allow-all → allows everything", async () => {
const approve = makeApprove({
mode: "allow-all",
toolPermission,
interactivePrompt: async () => "deny",
});
expect(await approve(readTool())).toBe("allow");
expect(await approve(writeTool())).toBe("allow");
});
it("deny-all → rejects everything", async () => {
const approve = makeApprove({
mode: "deny-all",
toolPermission,
interactivePrompt: async () => "allow",
});
expect(await approve(readTool())).toBe("deny");
expect(await approve(writeTool())).toBe("deny");
});
it("read-only → auto-allows read-only tools, prompts for the rest", async () => {
let prompted = 0;
const approve = makeApprove({
mode: "read-only",
toolPermission,
interactivePrompt: async () => {
prompted += 1;
return "deny";
},
});
// Read-only tools are auto-allowed without prompting.
expect(await approve(readTool())).toBe("allow");
expect(prompted).toBe(0);
// Read-write tools are handed off to the interactive prompt (denied here).
expect(await approve(writeTool())).toBe("deny");
expect(prompted).toBe(1);
});
it("always-ask → always delegates to the interactive prompt", async () => {
let prompted = 0;
const approve = makeApprove({
mode: "always-ask",
toolPermission,
interactivePrompt: async () => {
prompted += 1;
return "allow";
},
});
expect(await approve(readTool())).toBe("allow");
expect(await approve(writeTool())).toBe("allow");
expect(prompted).toBe(2);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { decideSigint } from "../src/commands/chat.js";
import { parseApprovalAnswer } from "../src/approval.js";
describe("decideSigint (Ctrl-C 行为状态机)", () => {
it("approving → deny(无论缓冲区是否有内容)", () => {
expect(decideSigint("approving", false)).toBe("deny");
expect(decideSigint("approving", true)).toBe("deny");
});
it("running → abort(中断当前 Task,不退出)", () => {
expect(decideSigint("running", false)).toBe("abort");
expect(decideSigint("running", true)).toBe("abort");
});
it("idle + 有输入 → clear(清空缓冲区)", () => {
expect(decideSigint("idle", true)).toBe("clear");
});
it("idle + 无输入 → confirm-exit(弹出 y/N 退出确认)", () => {
expect(decideSigint("idle", false)).toBe("confirm-exit");
});
it("confirming-exit → exit(确认中再次 Ctrl-C 直接退出)", () => {
expect(decideSigint("confirming-exit", false)).toBe("exit");
expect(decideSigint("confirming-exit", true)).toBe("exit");
});
});
describe("parseApprovalAnswer", () => {
it("y / yes(trim、不区分大小写)→ allow;n / no → deny", () => {
expect(parseApprovalAnswer("y")).toBe("allow");
expect(parseApprovalAnswer(" YES \n")).toBe("allow");
expect(parseApprovalAnswer("Y")).toBe("allow");
expect(parseApprovalAnswer("n")).toBe("deny");
expect(parseApprovalAnswer("NO")).toBe("deny");
});
it("空/无关输入用 fallback(缺省 deny;工具审批传 allow)", () => {
expect(parseApprovalAnswer("")).toBe("deny"); // default fallback
expect(parseApprovalAnswer("nope")).toBe("deny");
expect(parseApprovalAnswer("", "allow")).toBe("allow"); // tool approval defaults to allow
expect(parseApprovalAnswer("nope", "allow")).toBe("allow");
expect(parseApprovalAnswer("n", "allow")).toBe("deny"); // explicit n still denies
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Unit tests for `config model list` rendering: provider and model_id are separate
* columns (stored fields as-is, with the default model marked `*` before the provider
* column; the request column was removed along with concatenated storage); vision falls
* back to the catalog matched by the (provider, model_id) pair; api_key is masked
* inline; fully empty columns are omitted automatically.
*/
import { describe, expect, it } from "vitest";
import type { ProjectConfig } from "@prismshadow/penguin-core";
import { formatModelRows } from "../src/commands/config.js";
describe("formatModelRows", () => {
const cfg: ProjectConfig = {
default_model: { provider: "anthropic", model_id: "claude-sonnet-4-6" },
models: [
{
provider: "anthropic",
model_id: "claude-sonnet-4-6",
context_window: 1000000,
pricing: { unit: "usd_per_mtok", cache_read: 0.3, cache_write: 3.75, output: 15 },
},
{
provider: "custom",
model_id: "my-proxy-model",
client_type: "openai",
vision: false,
api_key: "sk-test-abcd-1234",
},
],
};
it("provider 与 model_id 双列展示;预置模型 vision 经目录成对匹配,默认模型以 * 标记", () => {
const lines = formatModelRows(cfg);
expect(lines).toHaveLength(2);
expect(lines[0]).toMatch(/^\* anthropic\s+claude-sonnet-4-6\s+vision=Y/);
expect(lines[0]).toContain("price=0.3/3.75/15");
// The request column was removed; no <provider>/<id> concatenation appears anymore.
expect(lines[0]).not.toContain("request=");
expect(lines[0]).not.toContain("anthropic/claude-sonnet-4-6");
});
it("自定义模型 vision 按标注(显式 false 记 -);内联 api_key 掩码显示", () => {
const lines = formatModelRows(cfg);
expect(lines[1]).toMatch(/^ {2}custom\s+my-proxy-model\s+vision=-/);
expect(lines[1]).toContain("client_type=openai");
expect(lines[1]).toContain("api_key=****1234");
expect(lines[1]).not.toContain("sk-test-abcd-1234");
});
it("同名 model_id 双 provider 并存时各占一行,默认标记只落在成对命中的那行", () => {
const lines = formatModelRows({
default_model: { provider: "deepseek", model_id: "m1" },
models: [
{ provider: "deepseek", model_id: "m1" },
{ provider: "siliconflow", model_id: "m1" },
],
});
expect(lines[0]).toMatch(/^\* deepseek\s+m1\s+vision=Y/);
expect(lines[1]).toMatch(/^ {2}siliconflow\s+m1\s+vision=Y/);
});
it("无标注按「缺省=支持」记 Y;全空列省略", () => {
const lines = formatModelRows({
models: [{ provider: "custom", model_id: "m1" }],
});
expect(lines[0]).toBe(" custom m1 vision=Y api_key=-");
});
});
+301
View File
@@ -0,0 +1,301 @@
/**
* Integration tests for `penguin config model add|default|vision|list` (run through
* commander's parseAsync for the full command path): --model-id always takes the
* upstream id, paired with --provider to form a (provider, model_id) reference (add's
* --provider defaults to catalog-based inference, falling back to custom when
* inference fails; default / vision require --provider and raise an error when the
* reference isn't found in models — no string concatenation is ever performed); --root
* specifies the data root directory (takes priority over PENGUIN_HOME); persisted to a
* single hidden .project_config.toml (mode 0600, credentials inline, provider and
* model_id as separate columns); list displays provider and model_id as separate
* columns.
*/
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 { Command } from "commander";
import { parse as parseToml } from "smol-toml";
import { DEFAULT_PROJECT_ID, projectConfigPath } from "@prismshadow/penguin-core";
import { registerConfigCommand } from "../src/commands/config.js";
import { getMessages } from "../src/i18n.js";
let tmpHome: string;
let tmpRoot: string;
let prevHome: string | undefined;
beforeEach(async () => {
prevHome = process.env.PENGUIN_HOME;
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-cli-home-"));
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-cli-root-"));
process.env.PENGUIN_HOME = tmpHome;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.PENGUIN_HOME;
else process.env.PENGUIN_HOME = prevHome;
await fs.rm(tmpHome, { recursive: true, force: true });
await fs.rm(tmpRoot, { recursive: true, force: true });
});
interface TomlModelRef {
provider: string;
model_id: string;
}
/**
* Runs a `penguin config model …` command, capturing stdout / stderr and the exit code
* (without actually exiting the process; under exitOverride, commander usage errors —
* such as a missing required option — are thrown as a CommanderError, which is
* converted to a non-zero exit code).
*/
async function runModel(args: string[]): Promise<{ out: string; err: string; code: number }> {
const program = new Command();
program.exitOverride();
registerConfigCommand(program, getMessages("en"));
const out: string[] = [];
const err: string[] = [];
const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
out.push(String(chunk));
return true;
});
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
err.push(String(chunk));
return true;
});
const prevExitCode = process.exitCode;
process.exitCode = undefined;
try {
await program.parseAsync(["node", "penguin", "config", "model", ...args]);
return { out: out.join(""), err: err.join(""), code: Number(process.exitCode ?? 0) };
} catch (e) {
const exitCode = (e as { exitCode?: number }).exitCode;
return { out: out.join(""), err: err.join(""), code: exitCode || 1 };
} finally {
outSpy.mockRestore();
errSpy.mockRestore();
process.exitCode = prevExitCode;
}
}
describe("penguin config model add/list(--root 与 provider / model_id 分列存储)", () => {
it("--root 优先于 PENGUIN_HOME:落盘到指定根目录的隐藏 .project_config.toml(0600)", async () => {
const add = await runModel([
"add",
"--model-id",
"my-own-model",
"--api-key",
"sk-root-secret-1",
"--root",
tmpRoot,
]);
expect(add.code).toBe(0);
// Catalog inference fails -> falls back to the custom group (provider is a separate field, never concatenated into the id).
expect(add.out).toContain("Added model (provider=custom, model_id=my-own-model).");
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
expect(path.basename(file)).toBe(".project_config.toml");
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
const parsed = parseToml(await fs.readFile(file, "utf8")) as {
models: Array<Record<string, unknown>>;
};
const entry = parsed.models.find(
(m) => m.provider === "custom" && m.model_id === "my-own-model",
);
expect(entry).toBeDefined();
expect(entry?.api_key).toBe("sk-root-secret-1");
// Concatenated storage id and request_model_id have been removed.
expect(entry?.request_model_id).toBeUndefined();
// The root directory pointed to by PENGUIN_HOME is unaffected.
await expect(fs.access(projectConfigPath(tmpHome, DEFAULT_PROJECT_ID))).rejects.toThrow();
// list also reads --root: provider and model_id as separate columns + masked api_key (the request column has been removed).
const list = await runModel(["list", "--root", tmpRoot]);
expect(list.code).toBe(0);
const line = list.out.split("\n").find((l) => l.includes("my-own-model"));
expect(line).toMatch(/custom\s+my-own-model/);
expect(line).toContain("api_key=****et-1");
expect(list.out).not.toContain("request=");
expect(list.out).not.toContain("sk-root-secret-1");
});
it("内置目录推断分组:上游 id 命中目录时条目落该 provider;--set-default 写成对引用", async () => {
const add = await runModel([
"add",
"--model-id",
"claude-sonnet-4-6",
"--set-default",
"--root",
tmpRoot,
]);
expect(add.code).toBe(0);
expect(add.out).toContain("Updated model (provider=anthropic, model_id=claude-sonnet-4-6).");
expect(add.out).toContain("Default model: (provider=anthropic, model_id=claude-sonnet-4-6)");
const parsed = parseToml(
await fs.readFile(projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID), "utf8"),
) as unknown as { default_model: TomlModelRef; models: Array<Record<string, unknown>> };
expect(parsed.default_model).toEqual({
provider: "anthropic",
model_id: "claude-sonnet-4-6",
});
expect(
parsed.models.find((m) => m.provider === "anthropic" && m.model_id === "claude-sonnet-4-6"),
).toBeDefined();
});
it("--provider 显式指定分组:同名上游 id 与预置条目互不冲突(各自独立条目)", async () => {
const add = await runModel([
"add",
"--model-id",
"claude-sonnet-4-6",
"--provider",
"myproxy",
"--base-url",
"https://proxy.example/v1",
"--root",
tmpRoot,
]);
expect(add.code).toBe(0);
expect(add.out).toContain("Added model (provider=myproxy, model_id=claude-sonnet-4-6).");
const list = await runModel(["list", "--root", tmpRoot]);
const line = list.out.split("\n").find((l) => l.includes("myproxy"));
expect(line).toMatch(/myproxy\s+claude-sonnet-4-6/);
expect(line).toContain("base_url=https://proxy.example/v1");
// The pre-existing anthropic entry remains (the (provider, model_id) pair naturally disambiguates).
expect(list.out.split("\n").some((l) => /anthropic\s+claude-sonnet-4-6/.test(l))).toBe(true);
});
it("client_type 缺省按分组语义(PRN-021):custom / 自建 / 网关落 openai,一方厂商不落", async () => {
// custom (catalog inference fails) and self-hosted groups (--provider not a catalog value): default to client_type=openai.
await runModel(["add", "--model-id", "my-openai-proxy", "--root", tmpRoot]);
await runModel(["add", "--model-id", "in-house-1", "--provider", "mylab", "--root", tmpRoot]);
// A non-catalog id under a first-party vendor group: client_type is not set (AgentHub auto-routes by upstream id).
await runModel([
"add",
"--model-id",
"my-fine-tune",
"--provider",
"deepseek",
"--root",
tmpRoot,
]);
// Gateway group: openai + the gateway's endpoint base URL pre-filled.
await runModel([
"add",
"--model-id",
"acme/some-model",
"--provider",
"openrouter",
"--root",
tmpRoot,
]);
// An explicit --client-type is persisted as-is, not overridden by the default rule.
await runModel([
"add",
"--model-id",
"special-1",
"--provider",
"mylab",
"--client-type",
"verbatim-type",
"--root",
tmpRoot,
]);
const parsed = parseToml(
await fs.readFile(projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID), "utf8"),
) as { models: Array<Record<string, unknown>> };
const by = (p: string, id: string) =>
parsed.models.find((m) => m.provider === p && m.model_id === id)!;
expect(by("custom", "my-openai-proxy").client_type).toBe("openai");
expect(by("mylab", "in-house-1").client_type).toBe("openai");
expect(by("deepseek", "my-fine-tune").client_type).toBeUndefined();
expect(by("openrouter", "acme/some-model").client_type).toBe("openai");
expect(by("openrouter", "acme/some-model").base_url).toBe("https://openrouter.ai/api/v1");
expect(by("mylab", "special-1").client_type).toBe("verbatim-type");
});
it("model default 经 --root 指定根目录设置默认模型(--model-id 上游 id + --provider 成对)", async () => {
const set = await runModel([
"default",
"--model-id",
"deepseek-v4-flash",
"--provider",
"deepseek",
"--root",
tmpRoot,
]);
expect(set.code).toBe(0);
expect(set.out).toContain(
"Default model set to (provider=deepseek, model_id=deepseek-v4-flash).",
);
const parsed = parseToml(
await fs.readFile(projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID), "utf8"),
) as unknown as { default_model: TomlModelRef };
expect(parsed.default_model).toEqual({
provider: "deepseek",
model_id: "deepseek-v4-flash",
});
});
});
describe("model default/vision:--provider 必填,(provider, model_id) 成对引用", () => {
it("缺 --provider:commander 用法报错,非零退出码", async () => {
const bad = await runModel(["default", "--model-id", "deepseek-v4-flash", "--root", tmpRoot]);
expect(bad.code).not.toBe(0);
expect(bad.err).toContain("--provider");
});
it("引用落空:成对引用不在 models 中,报错带成对引用与 model list 提示", async () => {
const bad = await runModel([
"default",
"--model-id",
"no-such-model",
"--provider",
"custom",
"--root",
tmpRoot,
]);
expect(bad.code).toBe(1);
expect(bad.err).toContain("(provider=custom, model_id=no-such-model)");
expect(bad.err).toContain("penguin config model list");
// The upstream id matches a pre-existing entry but --provider names the wrong group: also not found (exact pair, no fuzzy matching).
const wrongGroup = await runModel([
"vision",
"--model-id",
"claude-sonnet-4-6",
"--provider",
"openai",
"--root",
tmpRoot,
]);
expect(wrongGroup.code).toBe(1);
expect(wrongGroup.err).toContain("(provider=openai, model_id=claude-sonnet-4-6)");
expect(wrongGroup.err).toContain("penguin config model list");
});
it("model vision 成对引用命中:设置视觉模型(落盘内联表)", async () => {
const ok = await runModel([
"vision",
"--model-id",
"claude-sonnet-4-6",
"--provider",
"anthropic",
"--root",
tmpRoot,
]);
expect(ok.code).toBe(0);
expect(ok.out).toContain(
"Vision model set to (provider=anthropic, model_id=claude-sonnet-4-6).",
);
const parsed = parseToml(
await fs.readFile(projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID), "utf8"),
) as unknown as { vision_model: TomlModelRef };
expect(parsed.vision_model).toEqual({
provider: "anthropic",
model_id: "claude-sonnet-4-6",
});
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* Integration tests for `penguin config vault set|list|remove` (run through commander's
* parseAsync for the full command path, with PENGUIN_HOME pointed at a temp directory):
* writes to a hidden .vault.toml (mode 0600), list masks values without leaking
* plaintext, remove raises an error on a missing key, --agent-id targets a specific
* Agent, and an invalid key name / an overlong value exit with a non-zero code.
*/
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 { Command } from "commander";
import { agentVaultPath, DEFAULT_PROJECT_ID } from "@prismshadow/penguin-core";
import { registerConfigCommand } from "../src/commands/config.js";
import { getMessages } from "../src/i18n.js";
let tmpRoot: string;
let prevHome: string | undefined;
beforeEach(async () => {
prevHome = process.env.PENGUIN_HOME;
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-cli-vault-"));
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 });
});
/** Runs a `penguin config vault …` command, capturing stdout/stderr and the exit code (without actually exiting the process). */
async function runVault(args: string[]): Promise<{ out: string; err: string; code: number }> {
const program = new Command();
program.exitOverride();
registerConfigCommand(program, getMessages("en"));
const out: string[] = [];
const err: string[] = [];
const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
out.push(String(chunk));
return true;
});
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
err.push(String(chunk));
return true;
});
const prevExitCode = process.exitCode;
process.exitCode = undefined;
try {
await program.parseAsync(["node", "penguin", "config", "vault", ...args]);
return { out: out.join(""), err: err.join(""), code: Number(process.exitCode ?? 0) };
} finally {
outSpy.mockRestore();
errSpy.mockRestore();
process.exitCode = prevExitCode;
}
}
describe("penguin config vault", () => {
it("set → list(掩码)→ remove 全链路;落盘为隐藏 .vault.toml 且 0600", async () => {
const set = await runVault(["set", "--key", "MY_KEY", "--value", "vault-secret-9876"]);
expect(set.code).toBe(0);
expect(set.out).toContain("Saved vault entry MY_KEY.");
const file = agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, "default_agent");
expect(path.basename(file)).toBe(".vault.toml");
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
expect(await fs.readFile(file, "utf8")).toContain("vault-secret-9876");
const list = await runVault(["list"]);
expect(list.code).toBe(0);
expect(list.out).toContain("MY_KEY");
expect(list.out).toContain("****9876");
// Plaintext never appears in list output.
expect(list.out).not.toContain("vault-secret-9876");
const removed = await runVault(["remove", "--key", "MY_KEY"]);
expect(removed.code).toBe(0);
expect(removed.out).toContain("Removed vault entry MY_KEY.");
const empty = await runVault(["list"]);
expect(empty.out).toContain("The vault is empty.");
});
it("--agent-id 定向到目标 Agent 的 vault,不影响 default_agent", async () => {
const set = await runVault([
"set",
"--key",
"ONLY_A",
"--value",
"va-secret-value-1",
"--agent-id",
"agent-a",
]);
expect(set.code).toBe(0);
expect(
await fs.readFile(agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, "agent-a"), "utf8"),
).toContain("ONLY_A");
const defaultList = await runVault(["list"]);
expect(defaultList.out).toContain("The vault is empty.");
});
it("--root 指定数据根目录(优先于 PENGUIN_HOME),set/list 均定向到该根目录", async () => {
const otherRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-cli-vault-root-"));
try {
const set = await runVault([
"set",
"--key",
"ROOTED_KEY",
"--value",
"root-secret-value-1",
"--root",
otherRoot,
]);
expect(set.code).toBe(0);
expect(
await fs.readFile(agentVaultPath(otherRoot, DEFAULT_PROJECT_ID, "default_agent"), "utf8"),
).toContain("ROOTED_KEY");
// The root directory pointed to by PENGUIN_HOME is unaffected.
const defaultList = await runVault(["list"]);
expect(defaultList.out).toContain("The vault is empty.");
const rootedList = await runVault(["list", "--root", otherRoot]);
expect(rootedList.out).toContain("ROOTED_KEY");
} finally {
await fs.rm(otherRoot, { recursive: true, force: true });
}
});
it("非法键名 / 超长值以非零码退出并打印原因;remove 不存在的键报错", async () => {
const badKey = await runVault(["set", "--key", "1BAD", "--value", "v"]);
expect(badKey.code).toBe(1);
expect(badKey.err).toContain("Invalid vault key");
const tooLong = await runVault(["set", "--key", "OK_BIG", "--value", "x".repeat(8193)]);
expect(tooLong.code).toBe(1);
expect(tooLong.err).toContain("too long");
const ghost = await runVault(["remove", "--key", "GHOST"]);
expect(ghost.code).toBe(1);
expect(ghost.err).toContain("Vault entry GHOST does not exist.");
});
});
+69
View File
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getMessages, maskApiKey, resolveLanguage } from "../src/i18n.js";
describe("resolveLanguage (env PENGUIN_LANG, default en)", () => {
let prev: string | undefined;
beforeEach(() => {
prev = process.env.PENGUIN_LANG;
});
afterEach(() => {
if (prev === undefined) delete process.env.PENGUIN_LANG;
else process.env.PENGUIN_LANG = prev;
});
it("defaults to en when unset", () => {
delete process.env.PENGUIN_LANG;
expect(resolveLanguage()).toBe("en");
});
it("matches zh exactly (case-insensitive, trimmed)", () => {
process.env.PENGUIN_LANG = "zh";
expect(resolveLanguage()).toBe("zh");
process.env.PENGUIN_LANG = " ZH ";
expect(resolveLanguage()).toBe("zh");
});
it("falls back to en for non-exact zh prefixes and anything else", () => {
process.env.PENGUIN_LANG = "zh-CN"; // no longer prefix-matched -> en
expect(resolveLanguage()).toBe("en");
process.env.PENGUIN_LANG = "fr";
expect(resolveLanguage()).toBe("en");
process.env.PENGUIN_LANG = "en";
expect(resolveLanguage()).toBe("en");
});
});
describe("getMessages", () => {
it("provides zh and en runtime + help strings", () => {
expect(getMessages("zh").modelAdded("m", "m")).toContain("已添加");
expect(getMessages("en").modelAdded("m", "m")).toContain("Added");
expect(getMessages("zh").modelUpdated("m", "m")).toContain("已更新");
expect(getMessages("en").modelUpdated("m", "m")).toContain("Updated");
// Command/option descriptions are also localized.
expect(getMessages("zh").config.addDesc).toContain("模型");
expect(getMessages("en").config.addDesc).toContain("model");
expect(getMessages("en").run.desc).toContain("Task");
// config lang copy.
expect(getMessages("zh").config.langDesc).toContain("语言");
expect(getMessages("en").config.langDesc).toContain("language");
expect(getMessages("en").langSet("zh", "/x/.zshrc")).toContain("/x/.zshrc");
expect(getMessages("zh").langInvalid("fr")).toContain("fr");
});
it("header order is agent → workspace → model", () => {
const h = getMessages("en").header("run", "ag", "/ws", "mod");
expect(h.indexOf("agent=ag")).toBeLessThan(h.indexOf("workspace=/ws"));
expect(h.indexOf("workspace=/ws")).toBeLessThan(h.indexOf("model=mod"));
});
});
describe("maskApiKey", () => {
it("masks all but the last 4 chars", () => {
expect(maskApiKey("sk-1234567890")).toBe("****7890");
});
it("fully masks short keys (≤12 chars would leak most of the secret)", () => {
expect(maskApiKey("sk-test-1234")).toBe("***");
expect(maskApiKey("short")).toBe("***");
});
it("returns - when absent", () => {
expect(maskApiKey(undefined)).toBe("-");
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
LineComposer,
PasteFilter,
endsWithContinuation,
splitTrailingPartial,
} from "../src/input.js";
/** Feeds a series of input chunks into PasteFilter, collecting the forwarded output and paste events. */
async function runFilter(chunks: string[]): Promise<{ forwarded: string; pastes: string[] }> {
const filter = new PasteFilter();
const pastes: string[] = [];
let forwarded = "";
filter.on("data", (d: Buffer) => {
forwarded += d.toString("utf8");
});
filter.on("paste", (t: string) => pastes.push(t));
for (const c of chunks) filter.write(c);
await new Promise<void>((resolve) => {
filter.end(() => resolve());
});
return { forwarded, pastes };
}
describe("splitTrailingPartial", () => {
it("holds a trailing partial-marker prefix", () => {
expect(splitTrailingPartial("abc\x1b[200", "\x1b[200~")).toEqual({
emit: "abc",
hold: "\x1b[200",
});
});
it("holds nothing when no trailing prefix", () => {
expect(splitTrailingPartial("hello", "\x1b[200~")).toEqual({
emit: "hello",
hold: "",
});
});
});
describe("PasteFilter", () => {
it("forwards normal bytes unchanged", async () => {
const { forwarded, pastes } = await runFilter(["hello\r"]);
expect(forwarded).toBe("hello\r");
expect(pastes).toEqual([]);
});
it("strips markers and emits the pasted block (incl. newlines) as one event", async () => {
const { forwarded, pastes } = await runFilter(["\x1b[200~line1\nline2\nline3\x1b[201~"]);
expect(pastes).toEqual(["line1\nline2\nline3"]);
expect(forwarded).toBe(""); // pasted content is not forwarded to readline
});
it("keeps surrounding typed bytes and paste together in order", async () => {
const { forwarded, pastes } = await runFilter(["ab\x1b[200~PASTED\x1b[201~cd\r"]);
expect(forwarded).toBe("abcd\r");
expect(pastes).toEqual(["PASTED"]);
});
it("handles a marker split across chunks", async () => {
const { forwarded, pastes } = await runFilter(["x\x1b[20", "0~mid\x1b[201", "~y\r"]);
expect(forwarded).toBe("xy\r");
expect(pastes).toEqual(["mid"]);
});
});
describe("endsWithContinuation", () => {
it("odd trailing backslashes → continuation", () => {
expect(endsWithContinuation("foo\\")).toBe(true);
expect(endsWithContinuation("foo\\\\\\")).toBe(true);
});
it("even/none → not continuation", () => {
expect(endsWithContinuation("foo")).toBe(false);
expect(endsWithContinuation("foo\\\\")).toBe(false);
});
});
describe("LineComposer", () => {
it("single line → immediate message", () => {
const c = new LineComposer();
expect(c.pushTypedLine("hello")).toEqual({ message: "hello" });
});
it("backslash continuation joins lines with \\n", () => {
const c = new LineComposer();
expect(c.pushTypedLine("a\\")).toEqual({});
expect(c.pushTypedLine("b\\")).toEqual({});
expect(c.pushTypedLine("c")).toEqual({ message: "a\nb\nc" });
});
it("paste buffers a block, Enter on empty line sends it", () => {
const c = new LineComposer();
expect(c.pushPaste("l1\nl2\n")).toEqual({ lineCount: 2, normalized: "l1\nl2" });
expect(c.hasPending()).toBe(true);
expect(c.pushTypedLine("")).toEqual({ message: "l1\nl2" });
expect(c.hasPending()).toBe(false);
});
it("paste then typed text appends the text before sending", () => {
const c = new LineComposer();
c.pushPaste("l1\nl2");
expect(c.pushTypedLine("more")).toEqual({ message: "l1\nl2\nmore" });
});
it("reset clears pending", () => {
const c = new LineComposer();
c.pushPaste("a\nb");
c.reset();
expect(c.hasPending()).toBe(false);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { applyLanguageToRc, resolveShellRc, upsertBlock } from "../src/lang-config.js";
describe("resolveShellRc", () => {
it("maps zsh / bash / fish to their startup files and syntax", () => {
const zsh = resolveShellRc("/bin/zsh", "/home/u");
expect(zsh.kind).toBe("zsh");
expect(zsh.rcPath).toBe("/home/u/.zshrc");
expect(zsh.body("zh")).toBe("export PENGUIN_LANG=zh");
const bash = resolveShellRc("/usr/bin/bash", "/home/u");
expect(bash.kind).toBe("bash");
expect(bash.rcPath).toBe("/home/u/.bashrc");
const fish = resolveShellRc("/usr/local/bin/fish", "/home/u");
expect(fish.kind).toBe("fish");
expect(fish.rcPath).toBe("/home/u/.config/fish/config.fish");
expect(fish.body("en")).toBe("set -gx PENGUIN_LANG en");
});
it("falls back to ~/.profile for an unknown shell", () => {
const rc = resolveShellRc(undefined, "/home/u");
expect(rc.kind).toBe("unknown");
expect(rc.rcPath).toBe("/home/u/.profile");
});
});
describe("upsertBlock", () => {
it("appends a marked block when none exists", () => {
const out = upsertBlock("export PATH=/x\n", "export PENGUIN_LANG=zh");
expect(out).toContain("export PATH=/x");
expect(out).toContain("# >>> PenguinHarness PENGUIN_LANG >>>");
expect(out).toContain("export PENGUIN_LANG=zh");
expect(out).toContain("# <<< PenguinHarness PENGUIN_LANG <<<");
});
it("replaces the block in place and is idempotent", () => {
const first = upsertBlock("", "export PENGUIN_LANG=zh");
const second = upsertBlock(first, "export PENGUIN_LANG=en");
// Only one block remains, with its content replaced by the latest value.
expect(second.match(/PenguinHarness PENGUIN_LANG/g)?.length).toBe(2); // begin + end markers
expect(second).toContain("export PENGUIN_LANG=en");
expect(second).not.toContain("export PENGUIN_LANG=zh");
// Writing the same value again is stable (the block does not keep growing).
const third = upsertBlock(second, "export PENGUIN_LANG=en");
expect(third).toBe(second);
});
it("preserves surrounding content when replacing", () => {
const base = "line1\n" + upsertBlock("", "export PENGUIN_LANG=zh") + "line2\n";
const out = upsertBlock(base, "export PENGUIN_LANG=en");
expect(out.startsWith("line1\n")).toBe(true);
expect(out.endsWith("line2\n")).toBe(true);
expect(out).toContain("export PENGUIN_LANG=en");
});
});
describe("applyLanguageToRc", () => {
let home: string;
afterEach(async () => {
await rm(home, { recursive: true, force: true });
});
it("writes the export line to the resolved startup file", async () => {
home = await mkdtemp(join(tmpdir(), "penguin-lang-"));
const { rcPath, kind } = await applyLanguageToRc("zh", { shell: "/bin/zsh", home });
expect(kind).toBe("zsh");
expect(rcPath).toBe(join(home, ".zshrc"));
const content = await readFile(rcPath, "utf8");
expect(content).toContain("export PENGUIN_LANG=zh");
// Switching the language again updates the file in place instead of appending.
await applyLanguageToRc("en", { shell: "/bin/zsh", home });
const updated = await readFile(rcPath, "utf8");
expect(updated).toContain("export PENGUIN_LANG=en");
expect(updated).not.toContain("export PENGUIN_LANG=zh");
expect(updated.match(/# >>> PenguinHarness/g)?.length).toBe(1);
});
it("creates nested config dir for fish", async () => {
home = await mkdtemp(join(tmpdir(), "penguin-lang-"));
const { rcPath } = await applyLanguageToRc("en", { shell: "/usr/bin/fish", home });
expect(rcPath).toBe(join(home, ".config", "fish", "config.fish"));
const content = await readFile(rcPath, "utf8");
expect(content).toContain("set -gx PENGUIN_LANG en");
});
});
+716
View File
@@ -0,0 +1,716 @@
import { describe, expect, it } from "vitest";
import { Writable } from "node:stream";
import {
approvalDecision,
abortEvent,
assistantText,
compactionBegin,
compactionEnd,
requestBegin,
requestEnd,
thinkingMessage,
toolCall,
toolCallOutput,
tokenUsage,
sessionMeta,
partialText,
partialThinking,
partialToolCall,
partialToolCallOutput,
withOrigin,
} from "@prismshadow/penguin-core";
import type { MessageOrigin } from "@prismshadow/penguin-core";
import { StreamRenderer, formatAbort, humanizeTokens, renderHistory } from "../src/render.js";
import { getMessages } from "../src/i18n.js";
const t = getMessages("en");
function collector(): { stream: Writable; text: () => string } {
let buf = "";
const stream = new Writable({
write(chunk, _enc, cb) {
buf += chunk.toString();
cb();
},
});
return { stream, text: () => buf };
}
function stripAnsi(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
}
/** Overrides a message's timestamp (the constructor defaults to the current time). */
function at<M extends { timestamp: string }>(ts: string, msg: M): M {
return { ...msg, timestamp: ts };
}
/** token_usage shorthand: request.total = req, session.total = sess (all buckets zero, sufficient for this test group). */
function usage(req: number, sess: number) {
return tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: sess },
{ cache_read: 0, cache_write: 0, output: 0, total: req },
);
}
describe("humanizeTokens", () => {
it("abbreviates with k / M and trims .0", () => {
expect(humanizeTokens(0)).toBe("0");
expect(humanizeTokens(999)).toBe("999");
expect(humanizeTokens(1000)).toBe("1k");
expect(humanizeTokens(1234)).toBe("1.2k");
expect(humanizeTokens(32000)).toBe("32k");
expect(humanizeTokens(1_500_000)).toBe("1.5M");
});
});
describe("pure formatters", () => {
it("formatAbort includes the reason", () => {
expect(stripAnsi(formatAbort({ type: "abort", reason: "ctrl-c" }, t))).toContain("ctrl-c");
});
it("renderHistory includes abort events from resumed sessions", () => {
const { stream, text } = collector();
renderHistory([assistantText("partial", "aborted"), abortEvent("aborted by user")], stream, t);
expect(stripAnsi(text())).toBe("partial [aborted]\n[abort]: aborted by user\n");
});
});
describe("StreamRenderer", () => {
it("streams partial_text deltas and does NOT re-render the complete text", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(partialText("start", "Hel"));
r.handle(partialText("delta", "lo "));
r.handle(partialText("delta", "world"));
r.handle(partialText("stop", "", "completed"));
r.handle(assistantText("Hello world")); // complete message: must not be re-rendered
expect(stripAnsi(text())).toBe("Hello world\n");
});
it("streams partial_thinking (dim) and skips the complete thinking", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(partialThinking("start", "think"));
r.handle(partialThinking("delta", "ing"));
r.handle(partialThinking("stop"));
r.handle(thinkingMessage("thinking")); // must not be re-rendered
expect(stripAnsi(text())).toBe("thinking\n");
});
it("does not render a complete tool_call without partials", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c2" }));
expect(text()).toBe("");
});
it("streams partial_tool_call with a pairing tag and skips the complete tool_call", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c4" }));
r.handle(
partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"l', toolCallId: "c4" }),
);
r.handle(partialToolCall({ eventType: "delta", name: "", arguments: 's"}', toolCallId: "c4" }));
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c4" }));
r.handle(toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c4" }));
// The call line carries a [tool-<last-3-chars-of-id>] pairing tag matching the output line.
expect(stripAnsi(text())).toBe("[tool-c4] $ ls\n");
});
it("streams partial_tool_call_output with a tagged gutter and skips the complete tool_call_output", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "c3" }));
r.handle(partialToolCallOutput({ eventType: "delta", output: "line1\n", toolCallId: "c3" }));
r.handle(partialToolCallOutput({ eventType: "delta", output: "line2", toolCallId: "c3" }));
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "c3" }));
r.handle(toolCallOutput({ output: "line1\nline2", toolCallId: "c3" })); // must not be re-rendered
// Each line starts with a tagged gutter (no indent) matching the call line.
expect(stripAnsi(text())).toBe("[tool-c3] >> line1\n[tool-c3] >> line2\n");
});
it("prints the retry line only when the retry request actually begins", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(requestBegin());
r.handle(requestEnd("malformed"));
expect(stripAnsi(text())).toBe(""); // the failure itself prints nothing; only the retry's start does
r.handle(requestBegin()); // retry #1 begins
expect(stripAnsi(text())).toContain("retry #1");
r.handle(requestEnd("timeout"));
r.handle(requestBegin()); // retry #2 begins
expect(stripAnsi(text())).toContain("retry #2");
// Retry #2 fails again and retries are exhausted: no next request_begin, only abort — no retry #3 appears.
r.handle(requestEnd("malformed"));
r.handle(abortEvent("malformed response failed after 2 retries"));
expect(stripAnsi(text())).not.toContain("retry #3");
// The first request of the next run is not a retry, so it prints nothing; a new failure after it counts from 1 again.
r.handle(requestBegin());
r.handle(requestEnd("timeout"));
r.handle(requestBegin());
const lines = stripAnsi(text());
expect(lines.match(/retry #1/g)).toHaveLength(2);
expect(lines).not.toContain("retry #3");
});
it("locks the screen to one streaming tool output; other messages queue until its stop", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "tA" }));
r.handle(partialToolCallOutput({ eventType: "delta", output: "a1\n", toolCallId: "tA" }));
// The screen is locked by tA: other streaming messages queue up.
r.handle(partialText("start", ""));
r.handle(partialText("delta", "hello"));
r.handle(partialToolCallOutput({ eventType: "delta", output: "a2\n", toolCallId: "tA" }));
expect(stripAnsi(text())).toBe("[tool-tA] >> a1\n[tool-tA] >> a2\n"); // hello is still queued
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "tA" }));
r.handle(partialText("stop", "", "completed"));
expect(stripAnsi(text())).toBe("[tool-tA] >> a1\n[tool-tA] >> a2\nhello\n");
});
it("queues everything while a user prompt is active and flushes after it ends", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.beginUserPrompt();
r.handle(partialText("start", ""));
r.handle(partialText("delta", "after prompt"));
r.handle(partialText("stop", "", "completed"));
expect(text()).toBe(""); // the screen is locked while waiting for user input
r.endUserPrompt();
expect(stripAnsi(text())).toBe("after prompt\n");
});
it("does not print token_usage per turn; endTask prints [stats] line with per-task deltas", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(
sessionMeta({
session_id: "s",
provider: "custom",
model_id: "m",
model_context_window: 1,
system_prompt: "sp",
tools: [{ name: "exec_command", description: "test tool" }],
thinking_level: "medium",
agent_state: "/a",
workspace: "/w",
}),
);
// Two turns: request total 1500, 4000. Per-task token delta = 5500; session cumulative = 12000;
// context = the latest request's input+output (= total) = 4000.
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 8000 },
{ cache_read: 0, cache_write: 0, output: 200, total: 1500 },
),
);
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 12000 },
{ cache_read: 0, cache_write: 0, output: 300, total: 4000 },
),
);
expect(stripAnsi(text())).toBe(""); // no stats line is printed mid-turn
r.endTask(2345);
// Exact full-line assertion: context 4k (the latest request's total) and its delta, cumulative tokens 12k,
// per-task delta 5.5k (1500 + 4000), elapsed 2.3s (first task: session equals the delta);
// this also implies session_meta is not rendered (no /w or similar field appears in the output).
expect(stripAnsi(text())).toBe(
"[stats] context 4k (+4k) · tokens 12k (+5.5k) · 2.3s (+2.3s)\n",
);
});
it("accumulates session elapsed across tasks; context delta is vs previous task", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
// task 1: context 4000, elapsed 2000ms.
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 4000 },
{ cache_read: 0, cache_write: 0, output: 0, total: 4000 },
),
);
r.endTask(2000);
// task 2: context 7000 (+3000 vs. the previous task), session elapsed cumulative 5000ms (this task +3000ms).
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 11000 },
{ cache_read: 0, cache_write: 0, output: 0, total: 7000 },
),
);
r.endTask(3000);
const lines = stripAnsi(text()).trim().split("\n");
const last = lines[lines.length - 1]!;
// Exact full-line assertion: context 7k (delta = 7000 - 4000), cumulative session tokens 11k,
// per-task token delta 7k, total session elapsed 5s (this task +3s).
expect(last).toBe("[stats] context 7k (+3k) · tokens 11k (+7k) · 5s (+3s)");
});
it("context delta goes negative after compaction shrinks the context (no clamping)", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
// task 1: context 7000.
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 7000 },
{ cache_read: 0, cache_write: 0, output: 0, total: 7000 },
),
);
r.endTask(1000);
// task 2: context drops to 2000 after compaction -> delta is negative (2000 - 7000 = -5k), not clamped to non-negative.
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 9000 },
{ cache_read: 0, cache_write: 0, output: 0, total: 2000 },
),
);
r.endTask(1000);
const lines = stripAnsi(text()).trim().split("\n");
expect(lines[lines.length - 1]).toBe("[stats] context 2k (-5k) · tokens 9k (+2k) · 2s (+1s)");
});
it("renders mode-specific compaction messages (summarize vs discard)", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(compactionBegin({ reason: "context", mode: "summarize", context: 150, turns: 3 }));
r.handle(compactionEnd({ reason: "context", mode: "summarize", status: "completed" }));
r.handle(compactionBegin({ reason: "manual", mode: "discard", context: 10, turns: 1 }));
r.handle(compactionEnd({ reason: "manual", mode: "discard", status: "completed" }));
r.handle(compactionEnd({ reason: "context", mode: "summarize", status: "failed" }));
expect(stripAnsi(text())).toBe(
[
"[compaction] summarizing context (context)…",
"[compaction] done; continuing with the summarized context",
"[compaction] discarding context (manual)…",
"[compaction] done; old context discarded",
"[compaction] failed; keeping the current context",
"",
].join("\n"),
);
});
it("轮结束后的压缩:压缩完成行展示本次消耗,但不计入本轮统计增量;不更新上下文", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
// Ordinary request: context 5000.
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 8000 },
{ cache_read: 0, cache_write: 0, output: 0, total: 5000 },
),
);
// The compaction request's usage sits between the paired compaction events: no ordinary request_end
// follows it in this turn -> compaction after the turn has ended.
r.handle(compactionBegin({ reason: "context", mode: "summarize", context: 5000, turns: 1 }));
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 14000 },
{ cache_read: 0, cache_write: 0, output: 0, total: 6000 },
),
);
r.handle(compactionEnd({ reason: "context", mode: "summarize", status: "completed" }));
r.endTask(1000);
const s = stripAnsi(text());
// The compaction-done line still shows this call's usage: session cumulative 14k + this compaction's 6k.
expect(s).toContain(
"[compaction] done; continuing with the summarized context · tokens 14k (+6k)",
);
// Stats line: context stays at the ordinary-request figure of 5k; cumulative tokens 14k (includes
// compaction, following the provider), but this turn's **delta** is only the ordinary request's 5k —
// compaction after the turn ends is not attributed to this turn.
expect(s).toContain("context 5k");
expect(s).toContain("tokens 14k (+5k)");
});
it("轮途中的压缩(其后还有普通 request_end):用时含压缩跨度,Token 增量计入压缩", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
// own1: ordinary request, request 5000, 00:00 -> 00:02.
r.handle(at("2026-07-05T00:00:00.000Z", requestBegin()));
r.handle(at("2026-07-05T00:00:01.000Z", usage(5000, 5000)));
r.handle(at("2026-07-05T00:00:02.000Z", requestEnd("completed")));
// Mid-turn compaction: 00:03 -> 00:13, request 6000 (the compaction's own summarization request).
r.handle(
at(
"2026-07-05T00:00:03.000Z",
compactionBegin({ reason: "context", mode: "summarize", context: 5000, turns: 1 }),
),
);
r.handle(at("2026-07-05T00:00:04.000Z", requestBegin()));
r.handle(at("2026-07-05T00:00:10.000Z", usage(6000, 14000)));
r.handle(at("2026-07-05T00:00:12.000Z", requestEnd("completed")));
r.handle(
at(
"2026-07-05T00:00:13.000Z",
compactionEnd({ reason: "context", mode: "summarize", status: "completed" }),
),
);
// The turn continues after compaction (carry-over): own2 request 2000, final request_end at 00:16 -> settles the compaction usage.
r.handle(at("2026-07-05T00:00:14.000Z", requestBegin()));
r.handle(at("2026-07-05T00:00:15.000Z", usage(2000, 16000)));
r.handle(at("2026-07-05T00:00:16.000Z", requestEnd("completed")));
r.endTask(999); // the passed-in wall clock is ignored: with a request_end present, elapsed comes from the timestamp span
const s = stripAnsi(text());
// Elapsed = first event 00:00 -> the last non-compaction request_end 00:16 = 16s (includes the 10s of
// compaction in the middle, which occupied this turn's wall clock).
// Token delta = own1 5000 + own2 2000 + compaction 6000 = 13k; context uses the ordinary-request figure after compaction, 2k.
expect(s).toContain("context 2k");
expect(s).toContain("tokens 16k (+13k)");
expect(s).toContain("16s (+16s)");
});
it("轮结束后的压缩(带 request 事件):用时止于压缩前的最后一个 request_end", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
// own1: 00:00 -> 00:03.
r.handle(at("2026-07-05T00:00:00.000Z", requestBegin()));
r.handle(at("2026-07-05T00:00:01.000Z", usage(5000, 5000)));
r.handle(at("2026-07-05T00:00:03.000Z", requestEnd("completed")));
// Trailing compaction: 00:04 -> 00:24, a full 20s, with no ordinary request_end for this turn after it.
r.handle(
at(
"2026-07-05T00:00:04.000Z",
compactionBegin({ reason: "context", mode: "summarize", context: 5000, turns: 1 }),
),
);
r.handle(at("2026-07-05T00:00:05.000Z", requestBegin()));
r.handle(at("2026-07-05T00:00:20.000Z", usage(6000, 14000)));
r.handle(at("2026-07-05T00:00:23.000Z", requestEnd("completed")));
r.handle(
at(
"2026-07-05T00:00:24.000Z",
compactionEnd({ reason: "context", mode: "summarize", status: "completed" }),
),
);
r.endTask(999);
const s = stripAnsi(text());
// Elapsed = 00:00 -> the last non-compaction request_end before compaction, 00:03 = 3s (the whole 20s
// compaction span comes after it and does not count).
// Token delta is only own1's 5k; compaction's 6k is not attributed to this turn.
expect(s).toContain("tokens 14k (+5k)");
expect(s).toContain("3s (+3s)");
});
it("renders approval_decision events (approved / denied)", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(approvalDecision("allow", "c1"));
r.handle(approvalDecision("deny", "c2"));
const s = stripAnsi(text());
expect(s).toContain("[approved]");
expect(s).toContain("[denied]");
});
it("keeps call → decision contiguous at prompt time and dedupes the late approval_decision event", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
const tc = toolCall({ name: "exec_command", arguments: '{"cmd":"pwd"}', toolCallId: "p8" });
// Interactive approval: while locked, renders "call line -> (prompt, written directly by readline) -> result" as three contiguous lines.
r.beginUserPrompt(tc);
r.noteApprovalDecision(tc, "allow");
r.endUserPrompt();
expect(stripAnsi(text())).toBe("[tool-p8] $ pwd\n✓ [approved]\n");
// A late approval_decision event is deduped by key and not re-rendered.
r.handle(approvalDecision("allow", "p8"));
expect(stripAnsi(text())).toBe("[tool-p8] $ pwd\n✓ [approved]\n");
});
it("re-renders a half-streamed call line at approval and suppresses its late tail deltas", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
const tc = toolCall({
name: "exec_command",
arguments: '{"cmd":"git status"}',
toolCallId: "h7",
});
// The call line is still mid-stream (only half its arguments rendered) when approval begins.
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "h7" }));
r.handle(
partialToolCall({
eventType: "delta",
name: "",
arguments: '{"cmd":"git st',
toolCallId: "h7",
}),
);
r.beginUserPrompt(tc);
// The trailing delta / stop arrive queued while the screen is locked.
r.handle(
partialToolCall({ eventType: "delta", name: "", arguments: 'atus"}', toolCallId: "h7" }),
);
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "h7" }));
r.noteApprovalDecision(tc, "allow");
r.endUserPrompt();
const s = stripAnsi(text());
// At approval time, the full call line is re-rendered in place from the complete message, right next to
// the result; after unlocking, the late tail is deduped and must not start a duplicate call line after
// the result line.
expect(s).toContain("[tool-h7] $ git status\n✓ [approved]\n");
expect(s.slice(s.indexOf("[approved]"))).not.toContain("[tool-h7]");
});
it("defers another call's auto-approval rendering while an interactive prompt is active", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
const parent = toolCall({
name: "exec_command",
arguments: '{"cmd":"pwd"}',
toolCallId: "pa1",
});
const child = withOrigin(
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "ch2" }),
"sess_kid",
);
r.beginUserPrompt(parent); // parent call's interactive prompt: locks the screen
r.noteApprovalDecision(child, "allow"); // concurrent subagent auto-approval: deferred, not inserted mid-prompt
expect(stripAnsi(text())).not.toContain("ch2");
r.noteApprovalDecision(parent, "allow"); // the prompt owner's result renders in place as usual
r.endUserPrompt();
const s = stripAnsi(text());
// Order: parent call line -> parent result -> child call line -> child result.
const iParentOk = s.indexOf("[approved]");
const iChildCall = s.indexOf("[agent-kid-tool-ch2]");
expect(s.indexOf("[tool-pa1]")).toBeGreaterThanOrEqual(0);
expect(iChildCall).toBeGreaterThan(iParentOk);
expect(s.indexOf("[approved]", iChildCall)).toBeGreaterThan(iChildCall);
});
it("endCompact settles manual /compact usage so the next task's delta excludes it", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 8000 },
{ cache_read: 0, cache_write: 0, output: 0, total: 5000 },
),
);
r.endTask(1000);
// Manual /compact: the compaction request consumes 6000 (already shown on the compaction-done line), endCompact settles it.
r.handle(compactionBegin({ reason: "manual", mode: "summarize", context: 5000, turns: 1 }));
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 14000 },
{ cache_read: 0, cache_write: 0, output: 0, total: 6000 },
),
);
r.handle(compactionEnd({ reason: "manual", mode: "summarize", status: "completed" }));
r.endCompact(500);
// The next task consumes only 1000: its delta must not include compaction's 6000.
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 15000 },
{ cache_read: 0, cache_write: 0, output: 0, total: 1000 },
),
);
r.endTask(1000);
const lines = stripAnsi(text()).trim().split("\n");
expect(lines[lines.length - 1]).toContain("tokens 15k (+1k)");
});
it("re-renders the call line next to the decision when other output separated them (auto-approve)", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
// The call line is first rendered while streaming, then separated from the decision by other output.
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c5" }));
r.handle(
partialToolCall({
eventType: "delta",
name: "",
arguments: '{"cmd":"ls"}',
toolCallId: "c5",
}),
);
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c5" }));
r.handle(partialText("start", ""));
r.handle(partialText("delta", "hi"));
r.handle(partialText("stop", "", "completed"));
// Auto-approval: the call line is no longer adjacent -> it is re-rendered in place, with the result immediately following it as a pair.
r.noteApprovalDecision(
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c5" }),
"allow",
);
expect(stripAnsi(text())).toBe("[tool-c5] $ ls\nhi\n[tool-c5] $ ls\n✓ [approved]\n");
});
it("does not re-render the call line when it is already adjacent to the decision", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c6" }));
r.handle(
partialToolCall({
eventType: "delta",
name: "",
arguments: '{"cmd":"ls"}',
toolCallId: "c6",
}),
);
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c6" }));
r.noteApprovalDecision(
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c6" }),
"deny",
);
expect(stripAnsi(text())).toBe("[tool-c6] $ ls\n× [denied]\n");
});
});
describe("StreamRenderer — nested (origin-tagged) subagent messages", () => {
const hop: MessageOrigin = "sess_child";
it("renders nested tool calls with an agent-tool tag; skips nested text/thinking partials", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
// Nested text/thinking is not rendered (the child's reply is shown via the parent tool's output gutter).
r.handle(withOrigin(partialText("delta", "child text"), hop));
r.handle(withOrigin(partialThinking("delta", "child think"), hop));
// A nested complete tool_call renders one line (so the user can see what tool the subagent is calling
// before approval); the tag is agent-<last-3-chars-of-child-session>-tool-<last-3-chars-of-id>; the
// approval line carries no tag.
r.handle(
withOrigin(
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "cc1" }),
hop,
),
);
r.handle(withOrigin(approvalDecision("allow", "cc1"), hop));
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc1] $ ls\n✓ [approved]\n");
});
it("renders the pending nested tool call at approval time when its stream copy has not arrived; dedupes the late copy", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
const tc = withOrigin(
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "cc9" }),
hop,
);
// The approval callback arrives before the forwarded message: beginUserPrompt renders the call line directly from the complete message.
r.beginUserPrompt(tc);
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] $ ls\n");
r.endUserPrompt();
// The late forwarded copy is deduped by key and not re-rendered.
r.handle(tc);
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] $ ls\n");
});
it("renders the pending parent tool call at approval time and suppresses its late partial stream", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.beginUserPrompt(
toolCall({ name: "exec_command", arguments: '{"cmd":"pwd"}', toolCallId: "p7" }),
);
r.endUserPrompt();
// The whole late streaming copy is deduped and skipped.
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "p7" }));
r.handle(
partialToolCall({
eventType: "delta",
name: "",
arguments: '{"cmd":"pwd"}',
toolCallId: "p7",
}),
);
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "p7" }));
expect(stripAnsi(text())).toBe("[tool-p7] $ pwd\n");
});
it("adds nested token_usage request totals to the task delta and the session total", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
// One parent-session request: 1500; one child-session request: 2000 -> per-task delta 3.5k;
// session cumulative = parent 8000 + child 2000 = 10k (delta and cumulative use the same basis: parent + child).
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 8000 },
{ cache_read: 0, cache_write: 0, output: 200, total: 1500 },
),
);
r.handle(
withOrigin(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 2000 },
{ cache_read: 0, cache_write: 0, output: 100, total: 2000 },
),
hop,
),
);
r.endTask(1000);
const s1 = stripAnsi(text());
expect(s1).toContain("3.5k"); // the per-task delta includes child-session usage
expect(s1).toContain("10k"); // the session cumulative includes child-session usage
// The child session's cumulative persists across tasks: the next task consumes only from the parent session, cumulative = 9000 + 2000 = 11k (+1k).
r.handle(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 9000 },
{ cache_read: 0, cache_write: 0, output: 100, total: 1000 },
),
);
r.endTask(1000);
const lines = stripAnsi(text()).trim().split("\n");
const last = lines[lines.length - 1]!;
expect(last).toContain("11k");
expect(last).toContain("+1k");
});
it("prints stats when a task only has nested (subagent) token usage", () => {
const { stream, text } = collector();
const r = new StreamRenderer(stream, t);
r.handle(
withOrigin(
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 2000 },
{ cache_read: 0, cache_write: 0, output: 100, total: 2000 },
),
hop,
),
);
r.endTask(1000);
const s = stripAnsi(text());
expect(s).toContain("[stats]");
expect(s).toContain("2k (+2k)");
});
});
describe("renderHistory (resume)", () => {
it("renders complete messages statically with interruption markers", async () => {
const { renderHistory } = await import("../src/render.js");
const { userText } = await import("@prismshadow/penguin-core");
const { stream, text } = collector();
renderHistory(
[
userText("hello"),
thinkingMessage("pondering"),
assistantText("hi there"),
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "call_653" }),
toolCallOutput({ output: "a.txt\nb.txt", toolCallId: "call_653" }),
assistantText("half answer", "aborted"),
],
stream,
);
const s = stripAnsi(text());
expect(s).toContain("> hello");
expect(s).toContain("pondering");
expect(s).toContain("hi there");
expect(s).toContain("[tool-653] $ ls");
expect(s).toContain("[tool-653] >> a.txt");
expect(s).toContain("[tool-653] >> b.txt");
// An interrupted message carries a marker (rendering includes the interrupted turn).
expect(s).toContain("half answer [aborted]");
});
it("skips events and renders nothing for empty history", async () => {
const { renderHistory } = await import("../src/render.js");
const { stream, text } = collector();
renderHistory(
[
tokenUsage(
{ cache_read: 0, cache_write: 0, output: 0, total: 1 },
{ cache_read: 0, cache_write: 0, output: 0, total: 1 },
),
],
stream,
);
expect(text()).toBe("");
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { Command } from "commander";
import {
DEFAULT_HOST,
DEFAULT_PORT,
browserCommand,
browserUrl,
registerServeCommands,
resolvePort,
} from "../src/commands/serve.js";
import { getMessages } from "../src/i18n.js";
describe("resolvePort(选项 > 环境变量 > 缺省 7364)", () => {
it("都未给时用缺省 7364", () => {
expect(DEFAULT_PORT).toBe(7364);
expect(resolvePort(undefined, undefined)).toBe(7364);
expect(resolvePort(undefined, "")).toBe(7364); // an empty string counts as unset
});
it("只有环境变量时取环境变量", () => {
expect(resolvePort(undefined, "8080")).toBe(8080);
});
it("选项优先于环境变量", () => {
expect(resolvePort("9000", "8080")).toBe(9000);
});
it("非法值(非整数 / 越界)抛错", () => {
expect(() => resolvePort("abc", undefined)).toThrow(/abc/);
expect(() => resolvePort("3.14", undefined)).toThrow();
expect(() => resolvePort("-1", undefined)).toThrow();
expect(() => resolvePort("65536", undefined)).toThrow();
expect(() => resolvePort(undefined, "not-a-port")).toThrow(/not-a-port/);
});
});
describe("browserCommand(按平台选择打开命令)", () => {
const url = "http://127.0.0.1:7364/";
it("darwin → open", () => {
expect(browserCommand("darwin", url)).toEqual({ command: "open", args: [url] });
});
it("win32 → cmd /c start(空标题占位在 URL 前)", () => {
expect(browserCommand("win32", url)).toEqual({
command: "cmd",
args: ["/c", "start", "", url],
});
});
it("其他平台(linux 等)→ xdg-open", () => {
expect(browserCommand("linux", url)).toEqual({ command: "xdg-open", args: [url] });
expect(browserCommand("freebsd", url)).toEqual({ command: "xdg-open", args: [url] });
});
});
describe("browserUrl(通配监听地址转 127.0.0.1)", () => {
it("常规 host 原样拼接", () => {
expect(browserUrl(DEFAULT_HOST, 7364)).toBe("http://127.0.0.1:7364/");
expect(browserUrl("192.168.1.2", 8080)).toBe("http://192.168.1.2:8080/");
});
it("0.0.0.0 / :: 时浏览器 URL 用 127.0.0.1", () => {
expect(browserUrl("0.0.0.0", 7364)).toBe("http://127.0.0.1:7364/");
expect(browserUrl("::", 7364)).toBe("http://127.0.0.1:7364/");
});
});
describe("registerServeCommands(命令注册)", () => {
it("注册 server 与 web 两个顶层命令,web 缺省 open=true(--no-open 可关)", () => {
const program = new Command();
registerServeCommands(program, getMessages("en"));
const names = program.commands.map((c) => c.name());
expect(names).toContain("server");
expect(names).toContain("web");
const web = program.commands.find((c) => c.name() === "web")!;
expect(web.opts().open).toBe(true);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* runTask's result reporting: when a Task ends with a main-session abort event (LLM
* failure / reconnect exhausted / user interrupt), it reports aborted=true, which
* `penguin run` maps to a non-zero exit code; a sub-session abort does not count.
*/
import { describe, expect, it } from "vitest";
import { Writable } from "node:stream";
import { abortEvent, assistantText, withOrigin } from "@prismshadow/penguin-core";
import type { OmniMessage, Session } from "@prismshadow/penguin-core";
import { StreamRenderer } from "../src/render.js";
import { runTask } from "../src/task-loop.js";
import { getMessages } from "../src/i18n.js";
const t = getMessages("en");
function fakeSession(messages: OmniMessage[]): Session {
return {
async *run() {
for (const m of messages) yield m;
},
toolPermission: () => "rw",
} as unknown as Session;
}
function silentRenderer(): StreamRenderer {
const stream = new Writable({
write(_chunk, _enc, cb) {
cb();
},
});
return new StreamRenderer(stream, t);
}
describe("runTask abort reporting", () => {
it("reports aborted=true when the task ends with a main-session abort event", async () => {
const result = await runTask(fakeSession([abortEvent("llm request error: 401")]), [], {
renderer: silentRenderer(),
t,
});
expect(result.aborted).toBe(true);
});
it("reports aborted=false on normal completion; child-session aborts do not count", async () => {
const result = await runTask(
fakeSession([withOrigin(abortEvent("child aborted"), "sess_child"), assistantText("done")]),
[],
{ renderer: silentRenderer(), t },
);
expect(result.aborted).toBe(false);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { renderPartialToolCall } from "../src/tool-render.js";
describe("renderPartialToolCall", () => {
it("renders partial exec_command args as $ <cmd-so-far>", () => {
expect(renderPartialToolCall("exec_command", '{"cmd":')).toBeNull();
expect(renderPartialToolCall("exec_command", '{"cmd":"l')).toBe("$ l");
expect(renderPartialToolCall("exec_command", '{"cmd":"ls"}')).toBe("$ ls");
expect(renderPartialToolCall("exec_command", '{"cmd":"echo \\"hi\\"')).toBe('$ echo "hi"');
});
it("renders run_subagent as run_subagent << <prompt>, folded to one line", () => {
expect(renderPartialToolCall("run_subagent", '{"prompt":')).toBeNull();
expect(renderPartialToolCall("run_subagent", '{"prompt":"analy')).toBe("run_subagent << analy");
expect(renderPartialToolCall("run_subagent", '{"prompt":"line1\\nline2"}')).toBe(
"run_subagent << line1 line2",
);
});
it("renders input_command polls (empty chars) without a payload", () => {
expect(renderPartialToolCall("input_command", '{"process_id":')).toBeNull();
expect(renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d"}')).toBe(
"⌨ input_command → proc-1a2b3c4d",
);
expect(
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":""}'),
).toBe("⌨ input_command → proc-1a2b3c4d");
});
it("renders non-empty input_command chars with visible control characters", () => {
expect(
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"y\\n"}'),
).toBe("⌨ input_command → proc-1a2b3c4d << y\\n");
// U+0003 (Ctrl-C) is rendered in caret notation.
expect(
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"\\u0003"}'),
).toBe("⌨ input_command → proc-1a2b3c4d << ^C");
// Disambiguates literal backslash escapes: chars "a", "\", "n" render as a\\n, distinct from a real newline \n.
expect(
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"a\\\\n"}'),
).toBe("⌨ input_command → proc-1a2b3c4d << a\\\\n");
});
it("keeps input_command previews append-only across \\uXXXX delta boundaries", () => {
const stages = [
'{"process_id":"proc-1a2b3c4d","chars":"y',
'{"process_id":"proc-1a2b3c4d","chars":"y\\u0',
'{"process_id":"proc-1a2b3c4d","chars":"y\\u0003',
];
const previews = stages.map((s) => renderPartialToolCall("input_command", s)!);
expect(previews[0]).toBe("⌨ input_command → proc-1a2b3c4d << y");
// An incomplete \u escape is treated as "stop here" rather than emitting the raw hex as literal text.
expect(previews[1]).toBe("⌨ input_command → proc-1a2b3c4d << y");
expect(previews[2]).toBe("⌨ input_command → proc-1a2b3c4d << y^C");
for (let i = 1; i < previews.length; i++) {
expect(previews[i]!.startsWith(previews[i - 1]!)).toBe(true);
}
});
it("renders input_subagent polls without a payload and follow-up prompts with one", () => {
expect(
renderPartialToolCall("input_subagent", '{"subagent_id":"subagent-9f8e7d6c","prompt":""}'),
).toBe("⌨ input_subagent → subagent-9f8e7d6c");
expect(
renderPartialToolCall(
"input_subagent",
'{"subagent_id":"subagent-9f8e7d6c","prompt":"continue with the tests"}',
),
).toBe("⌨ input_subagent → subagent-9f8e7d6c << continue with the tests");
});
it("truncates long payload previews and stops growing afterwards", () => {
const long = "x".repeat(130);
const capped = renderPartialToolCall(
"input_subagent",
`{"subagent_id":"subagent-9f8e7d6c","prompt":"${long}"}`,
);
expect(capped).toBe(`⌨ input_subagent → subagent-9f8e7d6c << ${"x".repeat(120)}…`);
const longer = renderPartialToolCall(
"input_subagent",
`{"subagent_id":"subagent-9f8e7d6c","prompt":"${long}yyy"}`,
);
expect(longer).toBe(capped);
});
it("falls back to name(args-prefix) for unknown tools", () => {
expect(renderPartialToolCall("search", '{"q":"hi')).toBe('search({"q":"hi');
});
});