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