Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)

Branch-length batch covering tooling, the model layer, the Web App and the public
surfaces. Highlights:

- Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each
  change touches, with a root CHANGELOG.md holding one line per release.
- Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a
  lock and keeps `pnpm install` current; `pnpm dev` runs server+web together.
- AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity`
  object in place of item-level `signature`/`phase`, threaded verbatim through Trace,
  replay and resume; malformed classification adapted to the new error types.
- Model layer: a model is always referenced by an explicit `(provider, model_id)` pair.
  The provider is never inferred, guessed or defaulted -- both the catalog inference and
  the unique-match config resolution are gone, and CLI, SDK, server routes and
  run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen
  Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group.
- Web App: catalog preset sync and per-group speed test on the Models page, positional
  slash commands, a markdown renderer, skill-library update reminders, and a vertically
  centred draft page whose upward menus size themselves to the room available.
- Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed
  benchmark results for both suites, and the demo videos playing on the landing page.

Includes the fixes from a full review of the branch: 23 confirmed findings, among them a
provider-inference bug that could send one vendor's API key to another vendor's endpoint,
and an Escape handler that destroyed the composer's contents unrecoverably.

Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and
pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
Yaowei Zheng
2026-07-21 17:43:31 +08:00
committed by GitHub
parent abf0a2f248
commit d4faee3a1e
214 changed files with 9406 additions and 1688 deletions
+99
View File
@@ -0,0 +1,99 @@
/**
* Model connectivity-probe tests (testModel's pure parts): streamed-content detection, the
* outcome verdict — in particular the reasoning-heavy-model case where the probe's tiny
* max_tokens is burned entirely on thinking (finish_reason=length -> AgentHub
* EmptyResponseError -> malformed outcome) yet the endpoint demonstrably works — and the
* output-rate guard that keeps a too-small sample from being reported as throughput.
*/
import { describe, expect, it } from "vitest";
import {
assistantText,
partialText,
partialThinking,
thinkingMessage,
tokenUsage,
toolCall,
emptyTokenCounts,
} from "@prismshadow/penguin-core";
import { isProbeContent, probeTps, probeVerdict } from "../src/services/project-config-service.js";
describe("isProbeContent", () => {
it("counts thinking and text content, partial or complete", () => {
expect(isProbeContent(partialThinking("delta", "let me think"))).toBe(true);
expect(isProbeContent(partialText("delta", "pong"))).toBe(true);
expect(isProbeContent(thinkingMessage("burned the whole budget", "malformed"))).toBe(true);
expect(isProbeContent(assistantText("pong"))).toBe(true);
});
it("ignores empty segments, events, and tool calls", () => {
expect(isProbeContent(partialThinking("start"))).toBe(false);
expect(isProbeContent(partialThinking("stop", "", "malformed"))).toBe(false);
expect(isProbeContent(thinkingMessage("", "malformed", { id: "rs_1" }))).toBe(false);
expect(isProbeContent(tokenUsage(emptyTokenCounts(), emptyTokenCounts()))).toBe(false);
expect(isProbeContent(toolCall({ name: "t", arguments: "{}", toolCallId: "tc1" }))).toBe(false);
});
});
describe("probeVerdict", () => {
it("passes a completed probe", () => {
expect(probeVerdict({ status: "completed" }, false)).toEqual({ ok: true });
});
it("passes a thinking-only malformed ending when content was streamed (reasoning model hit the tiny cap)", () => {
const outcome = {
status: "malformed" as const,
message: 'OpenaiClient returned no content other than thinking (finish_reason="length").',
};
expect(probeVerdict(outcome, true)).toEqual({ ok: true });
});
it("fails a malformed ending with nothing received (broken response, not a working endpoint)", () => {
const verdict = probeVerdict({ status: "malformed", message: "unexpected EOF" }, false);
expect(verdict).toEqual({ ok: false, message: "unexpected EOF" });
});
it("fails timeouts and errors even when content was streamed (flaky is not ok)", () => {
expect(probeVerdict({ status: "timeout" }, true)).toEqual({ ok: false, message: "timeout" });
expect(probeVerdict({ status: "failed", message: "401 unauthorized" }, true)).toEqual({
ok: false,
message: "401 unauthorized",
});
expect(probeVerdict({ status: "aborted" }, true)).toEqual({ ok: false, message: "aborted" });
});
it("truncates long failure messages to 300 characters", () => {
const verdict = probeVerdict({ status: "failed", message: "x".repeat(500) }, false);
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.message).toHaveLength(300);
});
});
describe("probeTps", () => {
it("reports the rate for a sample that spans a real streaming window", () => {
// The speed probe's 64-token cap: a full window is what the badge thresholds grade.
expect(probeTps(64, 1000)).toBe(64);
expect(probeTps(48, 900)).toBe(53.3);
});
it("reports at the sample floors", () => {
expect(probeTps(16, 400)).toBe(40);
expect(probeTps(16, 101)).toBe(158.4);
});
it("omits the rate for a one-word answer (jitter, not throughput)", () => {
// 2 tokens in 30ms would read as 66.7 tok/s; 30ms of jitter later, as 33.3 — the caller
// still reports ttftMs, just no rate.
expect(probeTps(2, 30)).toBeUndefined();
expect(probeTps(2, 60)).toBeUndefined();
expect(probeTps(15, 300)).toBeUndefined();
});
it("omits the rate for a window too short to time, however many tokens arrived", () => {
expect(probeTps(64, 100)).toBeUndefined();
expect(probeTps(64, 0)).toBeUndefined();
});
it("omits the rate when no usage was reported (malformed thinking-only ending)", () => {
expect(probeTps(0, 5000)).toBeUndefined();
});
});
Binary file not shown.
@@ -49,6 +49,11 @@ describe("parseScheduleFile", () => {
});
});
it("keeps a complete model reference pair", () => {
const def = defOf(`${BASE}provider = "custom"\nmodel_id = "m1"\n`);
expect(def).toMatchObject({ provider: "custom", modelId: "m1" });
});
it("enabled defaults to off; omitting period means one-shot", () => {
const def = defOf(`prompt = "p"\nstart_at = "2026-07-16T09:00:00Z"\n`);
expect(def.enabled).toBe(false);
@@ -67,6 +72,10 @@ describe("parseScheduleFile", () => {
[`${BASE}session_id = "s"\nworkspace = "/tmp/w"\n`, "new-Session mode"],
[`${BASE}session_id = "s"\nmodel_id = "m1"\n`, "new-Session mode"],
[`${BASE}model_id = ""\n`, "model_id"],
// A model reference is always a pair: half of one is invalid in either direction
// (model_id alone is what a file written before this rule looks like).
[`${BASE}model_id = "m1"\n`, "given together"],
[`${BASE}provider = "custom"\n`, "given together"],
];
for (const [raw, hint] of cases) {
const r = parseScheduleFile("x", raw);
+32 -12
View File
@@ -47,9 +47,9 @@ describe("scheduler", () => {
beforeEach(async () => {
root = await makeTempRoot();
// A schedule's model reference is resolved against the Project config during
// reconciliation, so provide a minimal config here:
// m-bench is globally unique, so provider can be omitted and still resolve uniquely.
// A schedule's model reference is checked against the Project config during
// reconciliation, so provide a minimal config here: the file must name the whole
// (provider, model_id) pair for it to match.
await saveProjectConfig(root, P, {
default_model: { provider: "custom", model_id: "m-bench" },
models: [{ provider: "custom", model_id: "m-bench" }],
@@ -279,6 +279,7 @@ describe("scheduler", () => {
`start_at = "${iso(T0 + MIN)}"`,
`period = "5m"`,
`workspace = "/tmp/ws"`,
`provider = "custom"`,
`model_id = "m-bench"`,
]);
await scheduler.tickOnce();
@@ -287,31 +288,50 @@ describe("scheduler", () => {
nowMs = T0 + 6 * MIN;
await scheduler.tickOnce();
expect(created).toHaveLength(2);
// The file only supplies model_id (no provider): passed through as-is, resolved
// into a paired reference downstream via a unique match.
// The file's model reference is passed straight through as a pair.
expect(created[0]).toMatchObject({
projectId: P,
agentId: A,
workspace: "/tmp/ws",
provider: "custom",
modelId: "m-bench",
});
expect("provider" in created[0]!).toBe(false);
expect(started.map((s) => s.sessionId)).toEqual(["session-new-1", "session-new-2"]);
});
it("new-Session mode: when the file gives provider, it is passed through as a pair", async () => {
await writeFile("paired", [
`prompt = "paired"`,
it("new-Session mode: omitting the model reference entirely leaves the Project default to the session creator", async () => {
await writeFile("default-model", [
`prompt = "default"`,
`enabled = true`,
`start_at = "${iso(T0 + MIN)}"`,
`provider = "custom"`,
`model_id = "m-bench"`,
]);
await scheduler.tickOnce();
nowMs = T0 + MIN;
await scheduler.tickOnce();
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({ provider: "custom", modelId: "m-bench" });
expect("provider" in created[0]!).toBe(false);
expect("modelId" in created[0]!).toBe(false);
});
it("a file carrying model_id without provider is invalid: never scheduled, error recorded", async () => {
// A schedule file persisted before the pairing rule can hold model_id alone. It must
// fail exactly like any other invalid file — skipped with an error recorded — rather
// than resolving the provider or throwing out of the tick loop.
await writeFile("legacy", [
`prompt = "legacy"`,
`enabled = true`,
`start_at = "${iso(T0 + MIN)}"`,
`model_id = "m-bench"`,
]);
await scheduler.tickOnce();
nowMs = T0 + 2 * MIN;
await scheduler.tickOnce();
expect(created).toHaveLength(0);
expect(started).toHaveLength(0);
expect(repo.find(P, A, "legacy")).toBeNull();
const recorded = errors.filter((e) => e.code === "schedule_invalid_file");
expect(recorded.length).toBeGreaterThan(0);
expect((recorded[0]?.err as Error).message).toContain("given together");
});
it("invalid files are skipped with an error recorded, without affecting other tasks", async () => {
+26 -1
View File
@@ -146,17 +146,24 @@ describe("schedules api", () => {
).toBe(404);
});
it("validation 400: period below the minimum, invalid instant, pick-one target, invalid task name", async () => {
it("validation 400: period below the minimum, invalid instant, pick-one target, half a model reference, invalid task name", async () => {
const ok = { prompt: "p", enabled: false, startAt: FUTURE };
const cases = [
{ name: "j1", ...ok, period: "4m" },
{ name: "j2", ...ok, startAt: "someday" },
{ name: "j3", ...ok, sessionId: "s", workspace: "/w" },
// A model reference is a pair: neither half alone is accepted (nothing is inferred).
{ name: "j4", ...ok, modelId: "deepseek-v4-pro" },
{ name: "j5", ...ok, provider: "deepseek" },
{ name: "bad.name", ...ok },
];
for (const body of cases) {
expect((await owner.post(base, body)).status, JSON.stringify(body)).toBe(400);
}
// Nothing was persisted for any of them.
const list = (await (await owner.get(base)).json()) as SchedulesResponse;
expect(list.schedules).toEqual([]);
expect(list.invalidFiles).toEqual([]);
});
it("hand-edited files: invalid ones land in invalidFiles; expired one-shots are marked missed during reconciliation", async () => {
@@ -175,4 +182,22 @@ describe("schedules api", () => {
// For a hand-edited file, the creator falls back to the Project owner.
expect(stale?.creatorUserId).toBe("owner_a");
});
it("a file holding model_id without provider lands in invalidFiles instead of being scheduled", async () => {
// What a schedule file persisted before the pairing rule looks like: the provider is
// never filled in for it, the file is simply reported invalid and skipped.
const dir = scheduleDir(t.root, projectId, "default_agent");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "legacy.toml"),
`prompt = "p"\nenabled = true\nstart_at = "${FUTURE}"\nmodel_id = "deepseek-v4-pro"\n`,
"utf8",
);
const list = (await (await owner.get(base)).json()) as SchedulesResponse;
expect(list.schedules).toEqual([]);
expect(list.invalidFiles.map((f) => f.name)).toEqual(["legacy"]);
expect(list.invalidFiles[0]?.error).toContain("given together");
// Reading it by name reports the same error rather than 404 or a 500.
expect((await owner.get(`${base}/legacy`)).status).toBe(400);
});
});
@@ -103,6 +103,21 @@ describe("session-index", () => {
expect(list.sessions.map((s) => s.sessionId)).toContain(session.sessionId);
});
it("half a model reference is 400: the missing half is never inferred", async () => {
await configureModels();
// Only modelId: even though it names the one configured model, the provider is never
// filled in for the caller — a reference is submitted as a pair or not at all.
const onlyModel = await api.post(base(), { modelId: "claude-sonnet-4-6" });
expect(onlyModel.status).toBe(400);
const onlyProvider = await api.post(base(), { provider: "anthropic" });
expect(onlyProvider.status).toBe(400);
// The complete pair works, and so does omitting both (Project default).
expect(
(await api.post(base(), { provider: "anthropic", modelId: "claude-sonnet-4-6" })).status,
).toBe(201);
expect((await api.post(base(), {})).status).toBe(201);
});
it("an explicit Workspace only needs to exist; it may live outside the Project directory", async () => {
await configureModels();
const inside = path.join(t.root, projectId, "my-workdir");
+176 -2
View File
@@ -2,8 +2,9 @@
* Unit tests for the Session runtime (a fake Session / Loader is injected; no
* real LLM requests are made): driving and state transitions, 409 mutual
* exclusion, the four approval modes and taking effect immediately on change,
* abort collapsing to deny, self-healing id swaps, and LLM / tool errors in the
* message stream being persisted (core doesn't throw, so try/catch can't catch them).
* abort collapsing to deny, self-healing id swaps, vault invalidation re-resuming
* stale runtimes, and LLM / tool errors in the message stream being persisted
* (core doesn't throw, so try/catch can't catch them).
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { DatabaseSync } from "node:sqlite";
@@ -475,6 +476,62 @@ describe("session-manager", () => {
expect(loads).toBe(1);
});
it("invalidateAgentRuntimes: an idle entry is discarded and re-resumed on next access; other Agents unaffected", async () => {
let loads = 0;
const loader: SessionLoader = {
load: async () => {
loads++;
return approvalFakeSession("session-1");
},
};
const manager = makeManager(loader);
sessions.updateApprovalMode("session-1", "allow-all");
// Adopted after creation: records the current generation, so Tasks reuse it without loading.
manager.adopt(ROW, approvalFakeSession("session-1"));
// Another Agent's vault update leaves this entry alone.
manager.invalidateAgentRuntimes("p1", "other_agent");
await manager.startTask("session-1", [userText("a")]);
await waitFor(() => manager.statusOf("session-1") === "idle");
expect(loads).toBe(0);
// This Agent's vault update: the next Task rebuilds the runtime via the loader.
manager.invalidateAgentRuntimes("p1", "a1");
await manager.startTask("session-1", [userText("b")]);
await waitFor(() => manager.statusOf("session-1") === "idle");
expect(loads).toBe(1);
// Rebuilt once only: the fresh entry is current again.
await manager.startTask("session-1", [userText("c")]);
await waitFor(() => manager.statusOf("session-1") === "idle");
expect(loads).toBe(1);
});
it("invalidateAgentRuntimes mid-run: the in-flight Task keeps its runtime; the first Task after it finishes re-resumes", async () => {
let loads = 0;
const loader: SessionLoader = {
load: async () => {
loads++;
return approvalFakeSession("session-1");
},
};
const manager = makeManager(loader);
await manager.startTask("session-1", [userText("go")]); // built here: load #1
await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
manager.invalidateAgentRuntimes("p1", "a1"); // vault updated while the Task waits on approval
// The pending approval still targets the live entry: the run completes on the old runtime.
expect(manager.decideApproval("session-1", "tc-1", "allow")).toBe(true);
await waitFor(() => manager.statusOf("session-1") === "idle");
expect(loads).toBe(1);
// First Task after it finished: the stale entry is discarded and re-resumed with current values.
sessions.updateApprovalMode("session-1", "allow-all");
await manager.startTask("session-1", [userText("next")]);
await waitFor(() => manager.statusOf("session-1") === "idle");
expect(loads).toBe(2);
});
it("sweepIdle: entries that are running / have pending approvals are not evicted", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
await manager.startTask("session-1", [userText("go")]);
@@ -547,4 +604,121 @@ describe("session-manager", () => {
await new Promise((r) => setTimeout(r, 10));
expect(notified.length).toBe(1);
});
it("fires title generation early once 1000 chars of body text have streamed (mid-run, before the Task finishes)", async () => {
const notified: { ctx: UsageContext; req: TitleRequest }[] = [];
// Gate the run after the big body text so the early trigger provably happens mid-run.
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const longSession: RuntimeSession = {
sessionId: "session-1",
toolPermission: () => "rw",
generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const,
async *run() {
yield assistantText("x".repeat(600));
// Sub-session text doesn't count toward the main-session body threshold
// (asserted on its own in the next test).
yield withOrigin(assistantText("z".repeat(2000)), "session-sub");
yield assistantText("y".repeat(600)); // crosses the 1000-char threshold
await gate;
yield assistantText("tail");
},
async *compact() {},
};
const manager = new SessionManager({
sessions,
channels,
loader: loaderOf(longSession),
recorder: { record: async () => {} },
titles: {
maybeGenerate: (ctx, _session, req) => notified.push({ ctx, req }),
},
log: () => {},
});
await manager.startTask("session-1", [userText("long question")]);
// The early trigger fires while the run is still in flight (gated before completion).
await waitFor(() => notified.length === 1);
expect(manager.statusOf("session-1")).toBe("running");
expect(notified[0]!.req.fallbackText).toBe("long question");
expect(notified[0]!.req.material).toBeUndefined();
// Completion still notifies as the short-answer fallback path (the generator itself dedups).
release();
await waitFor(() => manager.statusOf("session-1") === "idle");
await waitFor(() => notified.length === 2);
});
it("a subagent's output never fires the early title: only main-session body text counts toward the 1000 chars", async () => {
const notified: { ctx: UsageContext; req: TitleRequest }[] = [];
const driven: OmniMessage[] = [];
// Gate the run right after the sub-session's long output: while the parent is parked
// here the completion trigger hasn't run yet, so an empty `notified` proves the child's
// text alone never crossed the threshold (without the gate an early fire would be
// indistinguishable from the completion one).
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const delegating: RuntimeSession = {
sessionId: "session-1",
toolPermission: () => "rw",
generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const,
async *run() {
yield toolCall({
name: "run_subagent",
arguments: JSON.stringify({ prompt: "Research the background of this question" }),
toolCallId: "sub-1",
});
const hop = "child-1";
yield withOrigin(
sessionMeta({
session_id: "child-1",
model_id: "m-child",
provider: "custom",
model_context_window: 1000,
system_prompt: "sys",
tools: [],
thinking_level: "default",
agent_state: "/root/p1/child_agent/agent_state",
workspace: "/tmp/w-child",
}),
hop,
);
// Far past the threshold, but it belongs to the sub-session's own conversation.
yield withOrigin(assistantText("z".repeat(3000)), hop);
await gate;
yield assistantText("short answer"); // the parent's whole body, well under 1000 chars
},
async *compact() {},
};
const manager = new SessionManager({
sessions,
channels,
loader: loaderOf(delegating),
recorder: {
record: async (_ctx, msg) => {
driven.push(msg);
},
},
titles: {
maybeGenerate: (ctx, _session, req) => notified.push({ ctx, req }),
},
log: () => {},
});
await manager.startTask("session-1", [userText("delegate this")]);
// Recording happens after the early-title check, so once the sub-session's 3000 chars
// have been driven through the parent's counter has already seen everything it will
// ever see from the child — and it must still be at zero.
await waitFor(() => driven.length === 3);
expect(manager.statusOf("session-1")).toBe("running");
expect(notified.length).toBe(0);
// Only the completion path notifies: once for the parent, once for the sub-session's own title.
release();
await waitFor(() => manager.statusOf("session-1") === "idle");
await waitFor(() => notified.length === 2);
expect(notified.map((n) => n.ctx.sessionId)).toEqual(["session-1", "child-1"]);
});
});
+14 -13
View File
@@ -57,11 +57,10 @@ describe("skills api", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as SkillLibraryResponse;
expect(body.groups.map((g) => g.id)).toEqual([
"agent-development",
"data-analysis",
"penguin-development",
"web-development",
"software-engineering",
"office-productivity",
"software-development",
"ai-app-development",
"agent-tuning",
]);
for (const group of body.groups) {
expect(group.title.length).toBeGreaterThan(0);
@@ -71,20 +70,22 @@ describe("skills api", () => {
expect("description" in group).toBe(false);
}
// Members within a group follow the SKILL_GROUPS list order (as ungrouped by loadSkillGroups).
expect(body.groups[0]!.skills.map((s) => s.name)).toEqual([
"agent-creation",
"benchmark-design",
"agent-evaluation",
"agent-optimization",
expect(body.groups[0]!.skills.map((s) => s.name)).toEqual(["data-analysis", "firecrawl"]);
expect(body.groups[1]!.skills.map((s) => s.name)).toEqual([
"web-design",
"software-engineering",
]);
expect(body.groups[1]!.skills.map((s) => s.name)).toEqual(["data-analysis"]);
expect(body.groups[2]!.skills.map((s) => s.name)).toEqual([
"penguin-sdk",
"penguin-cli",
"agenthub-models",
]);
expect(body.groups[3]!.skills.map((s) => s.name)).toEqual(["web-design"]);
expect(body.groups[4]!.skills.map((s) => s.name)).toEqual(["software-engineering"]);
expect(body.groups[3]!.skills.map((s) => s.name)).toEqual([
"agent-creation",
"benchmark-design",
"agent-evaluation",
"agent-optimization",
]);
const skills = body.groups.flatMap((g) => g.skills);
for (const skill of skills) {
expect(skill.name.length).toBeGreaterThan(0);
@@ -164,6 +164,17 @@ describe("title-generator", () => {
await waitFor(() => recorded.length > 0);
});
it("fallback strips a leading <use_skills> block so the marker never becomes the title", async () => {
const calls = { count: 0, args: [] as unknown[] };
const gen = makeGenerator();
gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), {
fallbackText: "<use_skills>\nskills: web-design\n</use_skills>\n做一个落地页",
});
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
// Not "<use_skills>" — the marker block is removed before the first line is taken.
expect(sessions.findById(ROW.sessionId)?.title).toBe("做一个落地页");
});
it("LLM returns null and the fallback material is blank → the title stays NULL (retryable next time)", async () => {
const calls = { count: 0, args: [] as unknown[] };
const gen = makeGenerator();
+65 -4
View File
@@ -3,14 +3,29 @@
* agent_state/.vault.toml): GET masks values (plaintext
* is never sent), PUT is owner-only, whole-table replace semantics (omitting
* value keeps the original, an absent key is deleted, a new key must supply a
* value), 400 on key/shape validation, 404 for a nonexistent Agent, and vaults
* of different Agents are independent of each other.
* value), 400 on key/shape validation, 404 for a nonexistent Agent, vaults of
* different Agents are independent of each other, and PUT invalidates the Agent's
* cached Session runtimes (the next Task re-resumes and sees the new values).
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { userText } from "@prismshadow/penguin-core";
import type { ProjectCreateResponse, VaultResponse } from "../src/api/types.js";
import { apiClient, createTestApp, provisionUser } from "./helpers.js";
import type { RuntimeSession } from "../src/runtime/session-manager.js";
import { apiClient, createTestApp, provisionUser, waitFor } from "./helpers.js";
import type { TestApp } from "./helpers.js";
/** Minimal fake runtime Session: one assistant reply, no approvals (keeps the loader-count test free of LLM calls). */
function fakeRuntimeSession(sessionId: string): RuntimeSession {
return {
sessionId,
toolPermission: () => "rw",
generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const,
async *run() {},
async *compact() {},
};
}
describe("vault api", () => {
let t: TestApp;
let owner: ReturnType<typeof apiClient>;
@@ -18,9 +33,19 @@ describe("vault api", () => {
let outsider: ReturnType<typeof apiClient>;
let projectId: string;
let vaultPath: string;
/** Loader call count: how many times the manager (re)built a runtime from the index. */
let loads: number;
beforeEach(async () => {
t = await createTestApp();
loads = 0;
t = await createTestApp({
loader: {
load: async (row) => {
loads++;
return fakeRuntimeSession(row.sessionId);
},
},
});
const a = await provisionUser(t.app, "owner_a");
const b = await provisionUser(t.app, "member_b");
const c = await provisionUser(t.app, "outsider_c");
@@ -112,6 +137,42 @@ describe("vault api", () => {
expect(modelsBody.models[0]!.credential).toBeTruthy();
});
it("PUT invalidates the Agent's cached Session runtimes: the next Task re-resumes and picks up the new vault", async () => {
t.deps.sessionsRepo.insert({
sessionId: "vault-sess-1",
projectId,
agentId: "default_agent",
modelId: "m1",
provider: "custom",
workspace: t.root,
approvalMode: "allow-all",
title: null,
createdAt: new Date().toISOString(),
});
const idle = () => t.deps.manager.statusOf("vault-sess-1") === "idle";
// First Task builds the runtime (load #1); the second reuses the active-table entry.
await t.deps.manager.startTask("vault-sess-1", [userText("a")]);
await waitFor(idle);
await t.deps.manager.startTask("vault-sess-1", [userText("b")]);
await waitFor(idle);
expect(loads).toBe(1);
// Vault update via the API: the cached runtime is stale, so the next Task re-resumes
// (the loader re-reads .vault.toml — the new value reaches the next Task's environment).
const put = await owner.put(vaultPath, { entries: [{ key: "NEW_KEY", value: "v-secret-1" }] });
expect(put.status).toBe(200);
await t.deps.manager.startTask("vault-sess-1", [userText("c")]);
await waitFor(idle);
expect(loads).toBe(2);
// Reads don't invalidate: the rebuilt runtime is reused.
expect((await owner.get(vaultPath)).status).toBe(200);
await t.deps.manager.startTask("vault-sess-1", [userText("d")]);
await waitFor(idle);
expect(loads).toBe(2);
});
it("Agent-level isolation: different Agents' vaults are independent; a nonexistent Agent 404", async () => {
await owner.put(vaultPath, { entries: [{ key: "ONLY_DEFAULT", value: "v-default-1" }] });
@@ -122,6 +122,7 @@ describe("files/stat route (batch existence check)", () => {
let owner: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>;
let sessionId: string;
let workspace: string;
beforeEach(async () => {
t = await createTestApp();
@@ -141,6 +142,7 @@ describe("files/stat route (batch existence check)", () => {
await owner.post(`/api/projects/${projectId}/agents/default_agent/sessions`, {})
).json()) as SessionCreateResponse;
sessionId = sess.session.sessionId;
workspace = sess.session.workspace;
await fs.mkdir(path.join(sess.session.workspace, "sub"));
await fs.writeFile(path.join(sess.session.workspace, "a.txt"), "A");
await fs.writeFile(path.join(sess.session.workspace, "sub", "b.md"), "B");
@@ -149,6 +151,35 @@ describe("files/stat route (batch existence check)", () => {
await t.cleanup();
});
it("files/content on html: inline stays text/plain; preview=1 keeps text/html under a CSP sandbox; download keeps the real type with no CSP", async () => {
await fs.writeFile(path.join(workspace, "page.html"), "<!doctype html><script>1</script>");
const url = `/api/sessions/${sessionId}/files/content?path=page.html`;
const inline = await owner.get(url);
expect(inline.status).toBe(200);
expect(inline.headers.get("content-type")).toContain("text/plain");
expect(inline.headers.get("content-security-policy")).toBeNull();
const preview = await owner.get(`${url}&preview=1`);
expect(preview.status).toBe(200);
expect(preview.headers.get("content-type")).toContain("text/html");
const csp = preview.headers.get("content-security-policy") ?? "";
expect(csp).toContain("sandbox");
expect(csp).not.toContain("allow-same-origin");
expect(preview.headers.get("content-disposition")).toContain("inline");
// download wins over preview: attachment + real type, no CSP needed.
const download = await owner.get(`${url}&download=1&preview=1`);
expect(download.headers.get("content-type")).toContain("text/html");
expect(download.headers.get("content-disposition")).toContain("attachment");
expect(download.headers.get("content-security-policy")).toBeNull();
// Non-scriptable files are unaffected by preview.
const txt = await owner.get(`/api/sessions/${sessionId}/files/content?path=a.txt&preview=1`);
expect(txt.headers.get("content-type")).toContain("text/plain");
expect(txt.headers.get("content-security-policy")).toBeNull();
});
it("existing files return in order, deduplicated; missing / directory / out-of-bounds all count as nonexistent, always 200", async () => {
const res = await owner.post(`/api/sessions/${sessionId}/files/stat`, {
paths: ["sub/b.md", "a.txt", "sub/b.md", "nope.txt", "sub", "../escape.txt", "/etc/passwd"],