Files
gstack/design/test/models.test.ts
T
ac20ef1e13 v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807)

* ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997)

* fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805)

* fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847)

* feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096)

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>

* fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851)

* fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906)

* test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910)

* chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes

* v1.91.14.0: release metadata

* test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group

* ci: Codex login steps run under bash (the CI container's default sh rejects pipefail)

* fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt

* fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts

* test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call

* fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier

* test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers)

* test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts)

* test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep)

* fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit

* test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently

* test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census

* fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic

* test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output

* fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens

* test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design)

* test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept)

* fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files

---------

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
2026-10-03 07:54:09 -07:00

272 lines
13 KiB
TypeScript

/**
* #2807: the design binary's OpenAI model choice lives in design/src/models.ts.
*
* Every call site is driven through its exported entry point with a stubbed
* fetch, so these tests prove REQUEST SHAPE only: which endpoint, which model
* (the GSTACK_DESIGN_MODEL override reaches all ten sites), the pinned
* image tool model, the token parameter the gpt-5 family accepts with
* reasoning headroom, and an error that names
* GSTACK_DESIGN_MODEL when OpenAI rejects the model. They do not prove that
* any account is entitled to any model; that needs a live keyed call.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fs from "fs";
import os from "os";
import path from "path";
import {
DEFAULT_DESIGN_MODELS,
DESIGN_MODEL_ENV,
IMAGE_TOOL_MODEL,
REASONING_HEADROOM_TOKENS,
designModel,
modelRejectionHint,
} from "../src/models";
import { checkMockup } from "../src/check";
import { diffMockups } from "../src/diff";
import { extractDesignLanguage } from "../src/memory";
import { generateDesignToCodePrompt } from "../src/design-to-code";
import { evolve } from "../src/evolve";
import { generate } from "../src/generate";
import { iterate } from "../src/iterate";
import { generateVariant } from "../src/variants";
const TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
const OVERRIDE = "gpt-test-override";
const REJECTED = JSON.stringify({ error: { message: `The model \`${OVERRIDE}\` does not exist or you do not have access to it.`, type: "invalid_request_error" } });
interface Call { url: string; body: any }
let calls: Call[];
let errors: string[];
let dir: string;
const saved: Record<string, string | undefined> = {};
const savedFetch = globalThis.fetch;
const savedConsoleError = console.error;
const savedConsoleLog = console.log;
/** Stub fetch: `reply(url, n)` returns the Response for the n-th call. */
function stubFetch(reply: (url: string, n: number) => Response) {
globalThis.fetch = (async (input: any, init?: any) => {
const url = String(input);
calls.push({ url, body: JSON.parse(String(init?.body)) });
return reply(url, calls.length);
}) as typeof globalThis.fetch;
}
function ok(url: string, chatContent = "{}"): Response {
const body = url.endsWith("/v1/responses")
? { id: "resp_test", output: [{ type: "image_generation_call", result: TINY_PNG }] }
: { choices: [{ message: { content: chatContent } }] };
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
}
const rejected = () => new Response(REJECTED, { status: 400 });
beforeEach(() => {
calls = [];
errors = [];
dir = fs.mkdtempSync(path.join(os.tmpdir(), "design-models-"));
for (const k of ["HOME", "GSTACK_HOME", "OPENAI_API_KEY", DESIGN_MODEL_ENV]) saved[k] = process.env[k];
process.env.HOME = dir;
process.env.GSTACK_HOME = dir;
process.env.OPENAI_API_KEY = "test-openai-key";
process.env[DESIGN_MODEL_ENV] = OVERRIDE;
console.error = (...a: unknown[]) => { errors.push(a.map(String).join(" ")); };
console.log = () => {};
fs.writeFileSync(path.join(dir, "shot.png"), Buffer.from(TINY_PNG, "base64"));
});
afterEach(() => {
globalThis.fetch = savedFetch;
console.error = savedConsoleError;
console.log = savedConsoleLog;
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
fs.rmSync(dir, { recursive: true, force: true });
});
function expectImageShape(c: Call) {
expect(c.url).toBe("https://api.openai.com/v1/responses");
expect(c.body.model).toBe(OVERRIDE);
expect(c.body.tools).toHaveLength(1);
expect(c.body.tools[0].type).toBe("image_generation");
// Never omitted: the API's fallback, gpt-image-1, shuts down 2026-10-23.
expect(c.body.tools[0].model).toBe(IMAGE_TOOL_MODEL);
}
function expectVisionShape(c: Call) {
expect(c.url).toBe("https://api.openai.com/v1/chat/completions");
expect(c.body.model).toBe(OVERRIDE);
expect(c.body.max_tokens).toBeUndefined();
expect(c.body.max_completion_tokens).toBeGreaterThan(REASONING_HEADROOM_TOKENS);
expect(c.body.messages[0].content.some((p: any) => p.type === "image_url")).toBe(true);
}
const names = (s: string) => expect(s).toContain(`${DESIGN_MODEL_ENV}=${OVERRIDE}`);
describe("designModel / modelRejectionHint", () => {
test("defaults per call type; GSTACK_DESIGN_MODEL overrides both; blank is unset", () => {
expect(designModel("image", {})).toBe("gpt-5.5");
expect(designModel("vision", {})).toBe("gpt-5.5");
expect(designModel("image", {})).toBe(DEFAULT_DESIGN_MODELS.image);
expect(designModel("vision", {})).toBe(DEFAULT_DESIGN_MODELS.vision);
expect(designModel("image", { [DESIGN_MODEL_ENV]: " gpt-x " })).toBe("gpt-x");
expect(designModel("vision", { [DESIGN_MODEL_ENV]: " " })).toBe(DEFAULT_DESIGN_MODELS.vision);
});
test("the hint fires for model/parameter rejections only", () => {
names(modelRejectionHint(400, "Unsupported parameter: 'max_tokens' is not supported with this model.", "vision"));
names(modelRejectionHint(404, REJECTED, "image"));
expect(modelRejectionHint(400, "Invalid image: could not decode", "vision")).toBe("");
expect(modelRejectionHint(429, "Rate limit for model gpt-4o", "image")).toBe("");
expect(modelRejectionHint(500, "model overloaded", "image")).toBe("");
delete process.env[DESIGN_MODEL_ENV];
expect(modelRejectionHint(400, REJECTED, "image")).toContain(`the default model ${DEFAULT_DESIGN_MODELS.image}`);
expect(modelRejectionHint(400, REJECTED, "image")).toContain(`set ${DESIGN_MODEL_ENV}`);
});
test("no design/src module hardcodes a model or the max_tokens parameter outside models.ts", () => {
const src = path.join(import.meta.dir, "..", "src");
for (const f of fs.readdirSync(src).filter((n) => n.endsWith(".ts") && n !== "models.ts")) {
const body = fs.readFileSync(path.join(src, f), "utf-8");
expect({ f, literal: /\bmodel:\s*["'`]/.test(body), maxTokens: /\bmax_tokens\b/.test(body) }).toEqual({ f, literal: false, maxTokens: false });
}
});
});
describe("image-generation call sites (Responses API)", () => {
test("generate", async () => {
stubFetch((u) => ok(u));
const r = await generate({ brief: "a login page", output: path.join(dir, "g.png") });
fs.rmSync(r.sessionFile, { force: true });
expect(calls).toHaveLength(1);
expectImageShape(calls[0]);
stubFetch(() => rejected());
await expect(generate({ brief: "x", output: path.join(dir, "g2.png") })).rejects.toThrow(`${DESIGN_MODEL_ENV}=${OVERRIDE}`);
// An override that cannot drive gpt-image-2 fails before any request.
calls = [];
process.env[DESIGN_MODEL_ENV] = "gpt-4o";
await expect(generate({ brief: "x", output: path.join(dir, "g3.png") })).rejects.toThrow(DESIGN_MODEL_ENV);
expect(calls).toHaveLength(0);
});
test("variants", async () => {
stubFetch((u) => ok(u));
expect((await generateVariant("sk", "p", path.join(dir, "v.png"), "1536x1024", "high")).success).toBe(true);
expectImageShape(calls[0]);
stubFetch(() => rejected());
const r = await generateVariant("sk", "p", path.join(dir, "v2.png"), "1536x1024", "high");
expect(r.success).toBe(false);
names(r.error!);
});
test("iterate: threaded and fresh fallback", async () => {
const id = `models-test-${process.pid}-${Date.now()}`;
const session = path.join("/tmp", `design-session-${id}.json`);
const write = () => fs.writeFileSync(session, JSON.stringify({ id, lastResponseId: "resp_prev", originalBrief: "b", feedbackHistory: [], outputPaths: [], createdAt: "", updatedAt: "" }));
try {
write();
stubFetch((u, n) => (n === 1 ? new Response("server error", { status: 500 }) : ok(u)));
await iterate({ session, feedback: "bigger", output: path.join(dir, "i.png") });
expect(calls).toHaveLength(2);
expectImageShape(calls[0]);
expect(calls[0].body.previous_response_id).toBe("resp_prev");
expectImageShape(calls[1]);
expect(calls[1].body.previous_response_id).toBeUndefined();
write();
errors = [];
stubFetch(() => rejected());
await expect(iterate({ session, feedback: "x", output: path.join(dir, "i2.png") })).rejects.toThrow(`${DESIGN_MODEL_ENV}=${OVERRIDE}`);
names(errors.find((e) => e.includes("Threading failed"))!);
} finally {
fs.rmSync(session, { force: true });
}
});
test("evolve: vision analysis, then image generation", async () => {
stubFetch((u) => ok(u, "a centered card"));
await evolve({ screenshot: path.join(dir, "shot.png"), brief: "darker", output: path.join(dir, "e.png") });
expect(calls).toHaveLength(2);
expectVisionShape(calls[0]);
expectImageShape(calls[1]);
expect(calls[1].body.input).toContain("a centered card");
stubFetch(() => rejected());
await expect(evolve({ screenshot: path.join(dir, "shot.png"), brief: "x", output: path.join(dir, "e2.png") })).rejects.toThrow(`${DESIGN_MODEL_ENV}=${OVERRIDE}`);
names(errors.find((e) => e.includes("Screenshot analysis failed"))!);
});
});
describe("vision call sites (Chat Completions)", () => {
test("check", async () => {
stubFetch((u) => ok(u, "PASS"));
expect(await checkMockup(path.join(dir, "shot.png"), "brief")).toEqual({ pass: true, issues: "" });
expectVisionShape(calls[0]);
stubFetch(() => rejected());
await checkMockup(path.join(dir, "shot.png"), "brief");
names(errors.find((e) => e.includes("Vision check API error"))!);
});
test("diff", async () => {
stubFetch((u) => ok(u, JSON.stringify({ differences: [], summary: "same", matchScore: 100 })));
expect((await diffMockups(path.join(dir, "shot.png"), path.join(dir, "shot.png"))).matchScore).toBe(100);
expectVisionShape(calls[0]);
expect(calls[0].body.response_format).toEqual({ type: "json_object" });
stubFetch(() => rejected());
expect((await diffMockups(path.join(dir, "shot.png"), path.join(dir, "shot.png"))).matchScore).toBe(-1);
names(errors.find((e) => e.includes("Diff API error"))!);
});
test("memory", async () => {
stubFetch((u) => ok(u, JSON.stringify({ colors: [], typography: [], spacing: [], layout: [], mood: "calm" })));
expect((await extractDesignLanguage(path.join(dir, "shot.png"))).mood).toBe("calm");
expectVisionShape(calls[0]);
expect(calls[0].body.response_format).toEqual({ type: "json_object" });
stubFetch(() => rejected());
await extractDesignLanguage(path.join(dir, "shot.png"));
names(errors.find((e) => e.includes("Vision extraction failed"))!);
});
test("design-to-code", async () => {
const result = { implementationPrompt: "build it", colors: [], typography: [], layout: [], components: [] };
stubFetch((u) => ok(u, JSON.stringify(result)));
expect(await generateDesignToCodePrompt(path.join(dir, "shot.png"))).toEqual(result);
expectVisionShape(calls[0]);
expect(calls[0].body.response_format).toEqual({ type: "json_object" });
stubFetch(() => rejected());
await expect(generateDesignToCodePrompt(path.join(dir, "shot.png"))).rejects.toThrow(`${DESIGN_MODEL_ENV}=${OVERRIDE}`);
});
});
/**
* A reasoning model bills its hidden reasoning against max_completion_tokens.
* This stub spends REASONING_SPEND tokens thinking first (gpt-5.5 defaults to
* medium effort) and returns the answer only if the limit leaves room for it,
* else an empty message with finish_reason "length" — what OpenAI returns
* when reasoning exhausts the limit. Every vision site must still get its answer.
*/
describe("vision budgets leave room for reasoning", () => {
const REASONING_SPEND = 6000;
const reasoningStub = (answer: string) => (url: string) => {
const limit = calls[calls.length - 1].body.max_completion_tokens as number;
const starved = limit < REASONING_SPEND + 50;
const message = { content: starved ? "" : answer };
return new Response(JSON.stringify({ choices: [{ message, finish_reason: starved ? "length" : "stop" }] }), { status: 200 });
};
test("check, diff, memory, design-to-code and evolve analysis all answer", async () => {
stubFetch(reasoningStub("PASS"));
expect(await checkMockup(path.join(dir, "shot.png"), "brief")).toEqual({ pass: true, issues: "" });
stubFetch(reasoningStub(JSON.stringify({ differences: [], summary: "same", matchScore: 100 })));
expect((await diffMockups(path.join(dir, "shot.png"), path.join(dir, "shot.png"))).matchScore).toBe(100);
stubFetch(reasoningStub(JSON.stringify({ colors: [], typography: [], spacing: [], layout: [], mood: "calm" })));
expect((await extractDesignLanguage(path.join(dir, "shot.png"))).mood).toBe("calm");
const d2c = { implementationPrompt: "build it", colors: [], typography: [], layout: [], components: [] };
stubFetch(reasoningStub(JSON.stringify(d2c)));
expect(await generateDesignToCodePrompt(path.join(dir, "shot.png"))).toEqual(d2c);
stubFetch((u) => (u.endsWith("/v1/responses") ? ok(u) : reasoningStub("a centered card")(u)));
await evolve({ screenshot: path.join(dir, "shot.png"), brief: "darker", output: path.join(dir, "e.png") });
expect(calls[calls.length - 1].body.input).toContain("a centered card");
});
});