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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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=-");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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("-");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user