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
+78 -31
View File
@@ -41,6 +41,7 @@ import {
skillsDir,
systemConfigPath,
toolsDir,
type ModelRef,
type ProjectConfig,
} from "../src/state/index.js";
import { sessionEnvironment } from "../src/internal/session-support.js";
@@ -288,6 +289,8 @@ describe("assembleSystemPrompt", () => {
sessionEnvironment("/tmp/penguin-ws", "session-test-1", {
agentId: DEFAULT_AGENT_ID,
projectDir: "/tmp/proj",
provider: "deepseek",
modelId: "deepseek-v4-pro",
}),
);
expect(prompt).toContain("AGENTS.md");
@@ -341,6 +344,8 @@ describe("assembleSystemPrompt", () => {
cwd: "/tmp/ws",
agentId: "agent-x",
projectDir: "/tmp/proj",
provider: "deepseek",
modelId: "deepseek-v4-pro",
platform: "darwin",
osVersion: "Darwin 25.0.0",
date: "2026-06-30",
@@ -393,6 +398,8 @@ describe("assembleSystemPrompt", () => {
cwd: "/tmp/ws",
agentId: "agent-x",
projectDir: "/tmp/proj",
provider: "deepseek",
modelId: "deepseek-v4-pro",
platform: "darwin",
osVersion: "Darwin 25.0.0",
date: "2026-06-30",
@@ -466,7 +473,7 @@ describe("assembleSystemPrompt", () => {
const env = sessionEnvironment(
"/tmp/penguin-ws",
"session-test-1",
{ agentId: "agent-x", projectDir: "/tmp/proj" },
{ agentId: "agent-x", projectDir: "/tmp/proj", provider: "openai", modelId: "gpt-5.5" },
new Date("2026-06-30T00:00:00"),
);
const prompt = assembleSystemPrompt(state, env);
@@ -476,15 +483,19 @@ describe("assembleSystemPrompt", () => {
expect(prompt).toContain("CWD: /tmp/penguin-ws");
expect(prompt).toContain("Agent ID: agent-x");
expect(prompt).toContain("Project Dir: /tmp/proj");
expect(prompt).toContain("Provider: openai");
expect(prompt).toContain("Model ID: gpt-5.5");
expect(prompt).toContain("Platform:");
expect(prompt).toContain("OS Version:");
expect(prompt).toContain("Date: 2026-06-30");
expect(prompt.indexOf("Platform:")).toBeLessThan(prompt.indexOf("OS Version:"));
expect(prompt.indexOf("OS Version:")).toBeLessThan(prompt.indexOf("Date:"));
expect(prompt.indexOf("Date:")).toBeLessThan(prompt.indexOf("CWD:"));
expect(prompt.indexOf("CWD:")).toBeLessThan(prompt.indexOf("Agent ID:"));
expect(prompt.indexOf("Agent ID:")).toBeLessThan(prompt.indexOf("Project Dir:"));
expect(prompt.indexOf("Project Dir:")).toBeLessThan(prompt.indexOf("Session ID:"));
expect(prompt.indexOf("Date:")).toBeLessThan(prompt.indexOf("Project Dir:"));
expect(prompt.indexOf("Project Dir:")).toBeLessThan(prompt.indexOf("Agent ID:"));
expect(prompt.indexOf("Agent ID:")).toBeLessThan(prompt.indexOf("CWD:"));
expect(prompt.indexOf("CWD:")).toBeLessThan(prompt.indexOf("Provider:"));
expect(prompt.indexOf("Provider:")).toBeLessThan(prompt.indexOf("Model ID:"));
expect(prompt.indexOf("Model ID:")).toBeLessThan(prompt.indexOf("Session ID:"));
});
});
@@ -531,15 +542,41 @@ describe("project-config round trip", () => {
expect(getModel(loaded, { provider: "custom", model_id: "unknown-model" })).toBeUndefined();
});
it("infers the provider from the builtin catalog when addModel omits it", async () => {
await addModel(tmpRoot, DEFAULT_PROJECT_ID, { model_id: "claude-sonnet-4-6" });
await addModel(tmpRoot, DEFAULT_PROJECT_ID, { model_id: "my-own-model" });
it("addModel files the entry under the provider it was given, never one of its own choosing", async () => {
// provider is a required field: nothing is inferred from the builtin catalog, so a model
// outside the known groups is filed under custom only because the caller said so. glm-5.2
// is sold by both the Qwen Token Plan gateway and Zhipu — the caller names which one, and
// the entry (with its api_key) lands in exactly that group.
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "anthropic",
model_id: "claude-sonnet-4-6",
});
await addModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "custom", model_id: "my-own-model" });
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "zhipu",
model_id: "glm-5.2",
api_key: "sk-zhipu",
});
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
// A catalog hit gets its provider; otherwise it falls back to custom.
expect(
getModel(loaded, { provider: "anthropic", model_id: "claude-sonnet-4-6" }),
).toBeDefined();
expect(getModel(loaded, { provider: "custom", model_id: "my-own-model" })).toBeDefined();
expect(getModel(loaded, { provider: "zhipu", model_id: "glm-5.2" })?.api_key).toBe("sk-zhipu");
// The key never leaks into the other group that resells the same bare id.
expect(
getModel(loaded, { provider: "qwen-token-plan", model_id: "glm-5.2" })?.api_key,
).toBeUndefined();
});
it("addModel requires an explicit provider: a bare model_id does not type-check", () => {
// Compile-time contract (asserted by `pnpm typecheck`, which includes this file): with the
// catalog inference gone there is nothing to fall back to, so the entry's provider field is
// required rather than optional. vitest only checks that the call expression exists.
const bare = { model_id: "glm-5.2" };
// @ts-expect-error provider is required: a model reference is always a (provider, model_id) pair.
const call = (): Promise<ProjectConfig> => addModel(tmpRoot, DEFAULT_PROJECT_ID, bare);
expect(call).toBeTypeOf("function");
});
it("upserts by the (provider, model_id) pair; same model_id under two providers co-exists", async () => {
@@ -706,7 +743,10 @@ describe("project-config round trip", () => {
(c) => c.provider === entry.provider && c.modelId === entry.model_id,
)!;
expect(entry.vision).toBe(cat.supportsVision ? undefined : false);
expect(entry.pricing?.unit).toBe("usd_per_mtok");
// A catalog entry without a list price (the Token Plan preview model) presets no
// pricing; every other catalog entry stores USD pricing.
if (cat.pricing === undefined) expect(entry.pricing).toBeUndefined();
else expect(entry.pricing?.unit).toBe("usd_per_mtok");
// A model that auto-routes leaves client_type unset; a gateway model (OpenRouter)
// explicitly sets it to openai.
expect(entry.client_type).toBe(cat.clientType);
@@ -800,7 +840,7 @@ describe("project-config round trip", () => {
});
});
describe('resolveModelRef (the single "provider omitted" resolution entry point)', () => {
describe("resolveModelRef (validates a (provider, model_id) pair against the config)", () => {
const cfg: ProjectConfig = {
models: [
{ provider: "deepseek", model_id: "deepseek-v4-pro" },
@@ -809,37 +849,44 @@ describe('resolveModelRef (the single "provider omitted" resolution entry point)
],
};
it("resolves a globally unique model_id without provider (exact match only)", () => {
expect(resolveModelRef(cfg, "deepseek-v4-pro")).toEqual({
it("returns the pair when it names a configured entry", () => {
expect(resolveModelRef(cfg, "deepseek-v4-pro", "deepseek")).toEqual({
provider: "deepseek",
model_id: "deepseek-v4-pro",
});
// Exact-match lookup, no fuzzy/prefix matching.
expect(() => resolveModelRef(cfg, "deepseek-v4")).toThrow(/is not in the Project config/);
});
it("validates the exact pair when provider is given", () => {
// The same bare model_id under two groups is never ambiguous: each pair names its own entry.
expect(resolveModelRef(cfg, "shared-id", "siliconflow")).toEqual({
provider: "siliconflow",
model_id: "shared-id",
});
// A wrong provider grouping likewise fails: the error includes the pair reference.
expect(resolveModelRef(cfg, "shared-id", "openrouter")).toEqual({
provider: "openrouter",
model_id: "shared-id",
});
});
it("throws when the pair is not configured; the error carries the pair reference", () => {
// Wrong group for a configured model_id: no fallback to "the entry that happens to have
// this id" — a pair the config doesn't have simply isn't a model.
expect(() => resolveModelRef(cfg, "shared-id", "openai")).toThrow(
/\(provider=openai, model_id=shared-id\)/,
/is not in the Project config.*\(provider=openai, model_id=shared-id\)/,
);
// Unknown model_id, and exact matching only (no fuzzy/prefix matching).
expect(() => resolveModelRef(cfg, "no-such-model", "deepseek")).toThrow(
/\(provider=deepseek, model_id=no-such-model\)/,
);
expect(() => resolveModelRef(cfg, "deepseek-v4", "deepseek")).toThrow(
/is not in the Project config/,
);
});
it("errors clearly on zero matches", () => {
expect(() => resolveModelRef(cfg, "no-such-model")).toThrow(
/is not in the Project config.*no-such-model/,
);
});
it("errors on ambiguity, listing the candidate pair references", () => {
expect(() => resolveModelRef(cfg, "shared-id")).toThrow(/Ambiguous/);
expect(() => resolveModelRef(cfg, "shared-id")).toThrow(
/\(provider=siliconflow, model_id=shared-id\).*\(provider=openrouter, model_id=shared-id\)/,
);
it("requires provider: a bare model_id does not type-check (no resolution path left)", () => {
// The pair is enforced by the type checker — asserted by `pnpm typecheck`, which includes
// this file; the unused-directive error is the failure mode if the parameter ever goes
// optional again. vitest only checks that the call expression exists.
// @ts-expect-error provider is required: a model reference is always a (provider, model_id) pair.
const call = (): ModelRef => resolveModelRef(cfg, "deepseek-v4-pro");
expect(call).toBeTypeOf("function");
});
});