Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
114 commits
Select commit Hold shift + click to select a range
52273b7
fix(desktop): browser panel can approve local HTTPS certificates (#24…
juliusmarminge Nov 12, 2026
a4d0a06
perf(server): sending to a very long thread reads about 5x less of it…
t3dotgg Nov 12, 2026
56c4e20
fix(cursor): user-invoked skills hidden from the model reach the Curs…
juliusmarminge Nov 12, 2026
9f4b42e
fix(server): thread find skips empty replies hidden beside an HTML re…
juliusmarminge Nov 12, 2026
09e1751
fix(server): Codex sign-in hint names the instance's CODEX_HOME (#35004)
juliusmarminge Nov 12, 2026
3126806
fix(server): project identity follows gh repo set-default (#35005)
juliusmarminge Nov 12, 2026
25123c2
fix(shared): html_render pages from OpenCode 2 Code Mode show and are…
juliusmarminge Nov 12, 2026
c17ec82
fix(web): overlong content search queries explain the 256-character l…
juliusmarminge Nov 12, 2026
1a5d373
fix(server): Claude replies stream paragraph by paragraph again (#35037)
juliusmarminge Nov 12, 2026
37e0fd3
fix(server): a run whose recovery state can't be read fails instead o…
juliusmarminge Nov 12, 2026
84e69da
fix(web): No project threads offer no Initialize Git, and their linke…
shivamhwp Nov 12, 2026
805251b
fix(server): thread setting changes no longer end a Claude session's …
juliusmarminge Nov 12, 2026
becc7d3
fix(server): Tailscale Serve no longer takes a live server's route an…
juliusmarminge Nov 12, 2026
ac781dc
fix(server): delegate_task wait returns its task handle before Node f…
juliusmarminge Nov 12, 2026
7a39060
fix(server): a damaged state database fails startup with its path and…
juliusmarminge Nov 12, 2026
e1fad19
fix(desktop): a login shell slower than 5s at launch still yields its…
juliusmarminge Nov 12, 2026
3ec8347
fix(opencode): a slow OpenCode 2 server start no longer leaves the mo…
juliusmarminge Nov 12, 2026
cbc38a1
fix(server): one unresponsive provider no longer stalls every thread'…
juliusmarminge Nov 12, 2026
68f1bc5
fix(desktop): SnapShot shortcut rebinds after the desktop portal rest…
juliusmarminge Nov 12, 2026
5bb4452
fix(server): desktop browser tabs on System appearance follow the sys…
juliusmarminge Nov 12, 2026
d6ac53e
fix(server): a project whose origin URL contains a space gets a repos…
juliusmarminge Nov 12, 2026
dd9c2f8
fix(desktop): installing an update closes SSH port forwards before re…
juliusmarminge Nov 12, 2026
f10a502
fix(web): Files tree keeps expanded folders across remounts (#35067)
juliusmarminge Nov 12, 2026
22910ff
fix(ssh): an ssh config alias keeps following its Port after it chang…
juliusmarminge Nov 12, 2026
4ff329d
fix(server): fork PRs into gh's upstream base use an owner-qualified …
juliusmarminge Nov 12, 2026
002dc9b
fix(web): file names with a backslash no longer split into folders in…
juliusmarminge Nov 12, 2026
6da3ff2
fix(server): automatic pulls and pull request refresh let slow checko…
juliusmarminge Nov 12, 2026
12b29eb
fix(server): Codex turns use the configured reasoning summary instead…
juliusmarminge Nov 12, 2026
beaf284
fix(provider-core): Vite+ system_first shims update the install they …
juliusmarminge Nov 12, 2026
f39c440
fix(web): diff tab keeps its scroll position across thread switches (…
juliusmarminge Nov 12, 2026
5fae2b5
test(web): command palette tests clear a leaked last-used project (#3…
juliusmarminge Nov 12, 2026
bf13d60
fix(opencode): OpenCode 2 threads see T3 Code's MCP tools on their fi…
juliusmarminge Nov 12, 2026
40526f9
fix(server): a finished turn refreshes only its own project's PR list…
juliusmarminge Nov 12, 2026
8f54d4b
fix(desktop): exits that skip quit are recorded in the trace and logs…
juliusmarminge Nov 12, 2026
2b7e975
fix(clients): say "Starting Claude" until the provider actually start…
juliusmarminge Nov 12, 2026
01200aa
fix(mobile): long thread titles in the @ picker keep their Thread mar…
juliusmarminge Nov 12, 2026
12d4c62
fix(web): every settings nav group has a name for screen readers (#35…
juliusmarminge Nov 12, 2026
b2e86d7
fix(web): an invalid provider setting is refused at its field instead…
juliusmarminge Nov 12, 2026
abbd967
fix(web): PR comment hover excerpts no longer end in a replacement ch…
juliusmarminge Nov 12, 2026
9c5b6f3
fix(mobile): Android composer keeps its cursor after a drag-scroll (#…
juliusmarminge Nov 12, 2026
32f3d8b
fix(client-runtime): Diagnostics Resource History keeps updating whil…
juliusmarminge Nov 12, 2026
efd683c
fix(web): a failed send leaves the timeline when a new draft was type…
juliusmarminge Nov 12, 2026
cb931fc
fix(web): a save in one tab no longer erases what another tab saved (…
juliusmarminge Nov 12, 2026
80cccdc
fix(server): projects read their own Git repository when the server i…
juliusmarminge Nov 12, 2026
3d6ece2
fix(web): file links the agent cited from another project open that p…
juliusmarminge Nov 12, 2026
ced85c6
fix(client): a message the host never answers fails after 60 seconds …
juliusmarminge Nov 12, 2026
e6a8b48
fix(server): a new worktree no longer fails when its generated folder…
juliusmarminge Nov 12, 2026
e89c8ce
fix(server): a file ticked Viewed before loading a push shows Changed…
juliusmarminge Nov 12, 2026
0627489
fix(server): selected-file commits during a cherry-pick or revert are…
juliusmarminge Nov 12, 2026
83ee96f
fix(web): chat text selection stays continuous while scrolling (#35074)
juliusmarminge Nov 12, 2026
a33d50a
fix(server): committing a large selection of files no longer fails wi…
juliusmarminge Nov 12, 2026
86ae174
fix(server): Claude threads resume the session /clear started (#35054)
juliusmarminge Nov 12, 2026
8b2763f
perf(mobile): each environment sends about 85% less data at launch (#…
t3dotgg Nov 12, 2026
7d3635e
perf(server): oversized transcript exports fail fast and thread opens…
t3dotgg Nov 12, 2026
d2b8f29
fix(web): icon-only chat header controls show their name on hover (#3…
juliusmarminge Nov 12, 2026
5574cca
fix(web): panel resize handle no longer covers the chat scrollbar (#3…
juliusmarminge Nov 12, 2026
e6119fd
fix(web): browser settings changed in project scope no longer overwri…
juliusmarminge Nov 12, 2026
8ef649f
fix(server): Commit refuses while a merge still has conflict markers …
juliusmarminge Nov 12, 2026
1de3c3c
fix(server): a pass that changes several watched pull requests wakes …
juliusmarminge Nov 12, 2026
51b1858
fix(server): a slow file read in the search watcher no longer freezes…
juliusmarminge Nov 12, 2026
1c9d32f
fix(shared): composer # suggestions sort by update instant, not times…
juliusmarminge Nov 12, 2026
3a815e9
fix(client-runtime): relative Markdown images in a remote thread no l…
juliusmarminge Nov 12, 2026
26e769d
docs: the README now shows the app and gets you installed fast (#35170)
t3dotgg Nov 12, 2026
a30cb32
fix(web): Esc in the keybindings search clears it without closing Set…
juliusmarminge Nov 12, 2026
a94846d
fix(web): file editor carets and selections follow code font changes …
juliusmarminge Nov 12, 2026
e93f2a5
fix(server): schedule_task no longer tells agents bindToCurrentThread…
phon-t Nov 12, 2026
71dfe27
fix(web): an untouched sidebar thread rename keeps a title that arriv…
juliusmarminge Nov 12, 2026
fc6c7c8
fix(server): usage model details count the real unpriced tokens (#35179)
juliusmarminge Nov 12, 2026
3e03072
perf(server): thread list reads no longer scan a long thread's runs a…
t3dotgg Nov 12, 2026
9771c5f
fix(server): Kiro, fx and Antigravity find a ~/ Binary path (#35171)
juliusmarminge Nov 12, 2026
f419698
feat(web): drop folders on the sidebar to add them as projects (#35202)
t3dotgg Nov 12, 2026
1032135
fix(server): Edit from here recreates a worktree folder that lost its…
juliusmarminge Nov 12, 2026
d412871
fix(server): a task on a linked environment stays under its parent in…
juliusmarminge Nov 12, 2026
64959eb
fix(web): terminal Ctrl+Shift+C copies on non-Latin keyboard layouts …
juliusmarminge Nov 12, 2026
5e2438c
perf(server): sending to a thread with thousands of finished subagent…
t3dotgg Nov 12, 2026
65c0195
fix(mobile): the thread list no longer reshuffles once per environmen…
t3dotgg Nov 12, 2026
bf8e5e6
fix(web): a failed page in the floating browser preview shows the err…
juliusmarminge Nov 12, 2026
1d218d1
feat(web): screen reader window title leads with the thread's status …
shivamhwp Nov 12, 2026
476fc9b
test(server): wire budget measures what clients fetch today, and the …
t3dotgg Nov 12, 2026
4947db0
fix(github): PR templates in a lowercase pull_request_template/ direc…
juliusmarminge Nov 12, 2026
5911884
perf(server): sending to a long compacted thread waits about 3x less …
t3dotgg Nov 12, 2026
e9e934b
fix(acp): new Copilot threads run the model the composer shows (#35199)
juliusmarminge Nov 12, 2026
156dcd8
perf(server): long threads open 2 to 6x faster from a cold cache (#35…
t3dotgg Nov 12, 2026
3ffd38e
ci(release): a repo variable pauses scheduled nightly releases (#35287)
t3dotgg Nov 12, 2026
6807c3f
fix(server): file search no longer crashes the server, and a failed E…
t3dotgg Nov 12, 2026
2482b03
perf(client): threads load while the socket connects, with fewer roun…
t3dotgg Nov 12, 2026
4fd40e1
perf(server): Load earlier turns takes about half the time on a runni…
t3dotgg Nov 12, 2026
fd8e386
fix(mobile): the thread list shows server changes after a tab round t…
t3dotgg Nov 12, 2026
2b4a9c7
test(web): inline confirm tests no longer race the 3s disarm timer (#…
t3dotgg Nov 12, 2026
69dd4d0
test(web): Add Environment dialog test fills fields instead of typing…
t3dotgg Nov 12, 2026
4e48d9e
test(web): HTML preview accordion test waits for the frame's script b…
t3dotgg Nov 12, 2026
7c22095
fix(client-runtime): a stalled sign-in token or dead route no longer …
juliusmarminge Nov 12, 2026
34c5faa
fix(desktop): release-note entities named like Object.prototype keys …
juliusmarminge Nov 12, 2026
371963c
fix(desktop): startup log prune keeps files that rotation never names…
juliusmarminge Nov 12, 2026
f99b283
fix(provider-core): a timed-out status probe stops a CLI that ignores…
juliusmarminge Nov 12, 2026
49b858b
fix(git): selected-file commits work through symlinked subdirectories…
Kirat0201 Nov 12, 2026
833a15b
fix(opencode): a project opened mid-scan lists its OpenCode skills (#…
juliusmarminge Nov 12, 2026
a5bd0ba
fix(ssh): SSH setup on a host without gzip says gzip is missing (#35389)
juliusmarminge Nov 12, 2026
f03db4f
fix(pi): blank extension dialog titles no longer fail the turn (#35271)
Kirat0201 Nov 12, 2026
c5f5efd
fix(server): text-generation fallback keeps the thread default's cust…
juliusmarminge Nov 12, 2026
e96c96e
fix(server): storage cleanup stops holding usage-limited threads too …
juliusmarminge Nov 12, 2026
167d41c
fix(web): a numeric author or labels in the URL no longer crashes Pul…
juliusmarminge Nov 12, 2026
4d64163
fix(web): bare URLs end at Chinese and Japanese punctuation (#35267)
sanjaibalajee Nov 12, 2026
88bf393
fix(server): @ file search finds filenames containing literal braces …
sidhartha1s Nov 12, 2026
2a1e43e
fix(server): t3_thread_search says which thread is the caller's (#35377)
josephv123 Nov 12, 2026
a909ea4
perf(server): older history pages load without indexing the whole thr…
t3dotgg Nov 12, 2026
f4cf69f
fix(server): selecting a remote branch lands on its local branch (#35…
juliusmarminge Nov 12, 2026
a2685e6
fix(web): SCP-style Git remotes no longer become terminal file links …
juliusmarminge Nov 12, 2026
d0f9d4d
fix(web): sidebar draft previews show cited assistant text, not the r…
juliusmarminge Nov 12, 2026
0cd6a73
fix(web): pasting an image the draft already has no longer flashes a …
Srijan-D Nov 12, 2026
0b21a82
fix(shared): pooled limits weight each account by its plan size (#35427)
juliusmarminge Nov 12, 2026
60c3b22
fix(mobile): browser and MCP-app links with blank IDs no longer crash…
juliusmarminge Nov 12, 2026
55081a1
fix(shared): thread search ignores the t3 prefix of flat temporary br…
juliusmarminge Nov 12, 2026
55ec01e
Merge remote-tracking branch 'upstream/main' into sync/upstream-20261…
onmax Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Binary file added .github/readme/hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
159 changes: 150 additions & 9 deletions .github/scripts/thread-transfer-report.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const path = require("node:path");

const ARTIFACT_NAME = "thread-transfer-results";
const RESULT_FILE = "thread-transfer-result.json";
const SCENARIOS_FILE = "thread-transfer-scenarios.json";
const COMMENT_MARKER = "<!-- t3-thread-transfer-report -->";
const PROVIDERS = ["codex", "claudeAgent"];
const OBSERVED_KEYS = [
Expand All @@ -29,6 +30,12 @@ const SCENARIO_KEYS = [
"measuredMcpResultBytes",
];

const SCENARIO_ROW_KEYS = ["phase", "metric", "unit", "observed", "ceiling"];
// These labels come from the PR and reach a comment the bot posts: no markup, links or mentions.
// GitHub still links plain text such as `GH-26`, so they are rendered as code spans.
const SCENARIO_LABEL = /^[A-Za-z0-9][A-Za-z0-9 ,-]{0,79}$/;
const MAX_SCENARIO_ROWS = 100;

function resultShaMarker(sha) {
return `<!-- t3-thread-transfer-result-sha:${sha} -->`;
}
Expand Down Expand Up @@ -85,15 +92,63 @@ function validateResult(value) {
return value;
}

function readResult(directory) {
function validateScenarios(value) {
assertExactKeys(value, ["schemaVersion", "providers"], "scenarios");
if (value.schemaVersion !== 1) {
throw new Error("scenarios.schemaVersion must be 1");
}
assertExactKeys(value.providers, PROVIDERS, "scenarios.providers");
for (const provider of PROVIDERS) {
const rows = value.providers[provider];
if (!Array.isArray(rows) || rows.length > MAX_SCENARIO_ROWS) {
throw new Error(
`scenarios.providers.${provider} must list at most ${MAX_SCENARIO_ROWS} rows`,
);
}
const seen = new Set();
for (const [index, row] of rows.entries()) {
const label = `scenarios.providers.${provider}[${index}]`;
assertExactKeys(row, SCENARIO_ROW_KEYS, label);
for (const key of ["phase", "metric"]) {
if (typeof row[key] !== "string" || !SCENARIO_LABEL.test(row[key])) {
throw new Error(`${label}.${key} must be a short plain label`);
}
}
if (row.unit !== "bytes" && row.unit !== "count") {
throw new Error(`${label}.unit must be bytes or count`);
}
assertMetric(row.observed, `${label}.observed`);
assertMetric(row.ceiling, `${label}.ceiling`);
const key = scenarioKey(row);
if (seen.has(key)) throw new Error(`${label} repeats an earlier row`);
seen.add(key);
}
}
return value;
}

function scenarioKey(row) {
return `${row.phase}|${row.metric}`;
}

function readArtifactFile(directory, name, validate) {
if (!directory) return undefined;
const file = path.join(directory, RESULT_FILE);
const file = path.join(directory, name);
if (!fs.existsSync(file)) return undefined;
const stat = fs.lstatSync(file);
if (!stat.isFile() || stat.size > 64 * 1_024) {
throw new Error("thread transfer result must be a regular file smaller than 64 KiB");
throw new Error(`${name} must be a regular file smaller than 64 KiB`);
}
return validateResult(JSON.parse(fs.readFileSync(file, "utf8")));
return validate(JSON.parse(fs.readFileSync(file, "utf8")));
}

function readResult(directory) {
return readArtifactFile(directory, RESULT_FILE, validateResult);
}

// Older runs upload only the result file, so a run without scenarios is still a valid run.
function readScenarios(directory) {
return readArtifactFile(directory, SCENARIOS_FILE, validateScenarios);
}

function formatBytes(bytes) {
Expand All @@ -103,7 +158,7 @@ function formatBytes(bytes) {
}

function formatValue(value, kind) {
return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value);
return kind === "bytes" ? formatBytes(value) : value.toLocaleString("en-US");
}

function formatImpact(current, baseline, kind) {
Expand Down Expand Up @@ -136,6 +191,77 @@ const METRICS = [
{ key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" },
];

function providerLabel(provider) {
return provider === "codex" ? "Codex" : "Claude";
}

/**
* The scenario rows of a run against the same rows on `main`. Rows that fail or differ from
* `main` are listed in the open; the full table is collapsed below them.
*/
function renderScenarios(current, baseline, ceilingChanges) {
if (!current) return { lines: [], failed: false };
const header = [
"| Provider | Scenario | Metric | Main baseline | This PR | Impact | PR ceiling | |",
"| --- | --- | --- | ---: | ---: | ---: | ---: | --- |",
];
const all = [];
const notable = [];
let failed = false;
let withoutBaseline = 0;

for (const provider of PROVIDERS) {
const baselineRows = new Map(
(baseline?.providers[provider] ?? []).map((row) => [scenarioKey(row), row]),
);
for (const row of current.providers[provider]) {
const before = baselineRows.get(scenarioKey(row));
const beforeObserved = before?.unit === row.unit ? before.observed : undefined;
const pass = row.observed <= row.ceiling;
failed ||= !pass;
if (beforeObserved === undefined) withoutBaseline += 1;
const line = `| ${providerLabel(provider)} | \`${row.phase}\` | \`${row.metric}\` | ${beforeObserved === undefined ? "—" : formatValue(beforeObserved, row.unit)} | ${formatValue(row.observed, row.unit)} | ${formatImpact(row.observed, beforeObserved, row.unit)} | ${formatValue(row.ceiling, row.unit)} | ${pass ? "✅" : "❌"} |`;
all.push(line);
if (!pass || (beforeObserved !== undefined && beforeObserved !== row.observed)) {
notable.push(line);
}
if (beforeObserved !== undefined && before.ceiling !== row.ceiling) {
ceilingChanges.push(
`- ${providerLabel(provider)} \`${row.phase}\` \`${row.metric}\`: ${formatValue(before.ceiling, row.unit)} → ${formatValue(row.ceiling, row.unit)}`,
);
}
}
}

const compared = all.length - withoutBaseline;
const summary = !baseline
? `No \`main\` baseline exists yet for these ${all.length} scenario measurements.`
: [
notable.length > 0
? `${notable.length} of ${all.length} scenario measurements changed or failed.`
: `${compared === all.length ? "All " : ""}${compared} scenario measurements match \`main\`.`,
...(withoutBaseline > 0 ? [`${withoutBaseline} have no \`main\` baseline.`] : []),
].join(" ");
return {
failed,
lines: [
"### Other scenarios",
"",
summary,
...(notable.length > 0 ? ["", ...header, ...notable] : []),
"",
"<details>",
`<summary>All ${all.length} scenario measurements</summary>`,
"",
...header,
...all,
"",
"</details>",
"",
],
};
}

function renderComment(input) {
const current = input.current;
const baseline = input.baseline;
Expand All @@ -154,16 +280,22 @@ function renderComment(input) {
const pass = observed <= ceiling;
failed ||= !pass;
rows.push(
`| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`,
`| ${providerLabel(provider)} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`,
);

if (comparable && baseline.providers[provider].ceiling[metric.key] !== ceiling) {
ceilingChanges.push(
`- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`,
`- ${providerLabel(provider)} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`,
);
}
}
}
const scenarios = renderScenarios(
input.currentScenarios,
input.baselineScenarios,
ceilingChanges,
);
failed ||= scenarios.failed;

const baselineLink = input.baselineRun
? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})`
Expand Down Expand Up @@ -203,6 +335,7 @@ function renderComment(input) {
"| --- | --- | ---: | ---: | ---: | ---: | --- |",
...rows,
"",
...scenarios.lines,
`Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`,
"",
"<details>",
Expand All @@ -212,7 +345,7 @@ function renderComment(input) {
"",
...PROVIDERS.map(
(provider) =>
`- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`,
`- ${providerLabel(provider)} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`,
),
"",
"</details>",
Expand Down Expand Up @@ -437,7 +570,14 @@ async function publish({ github, context, core }) {
url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`,
}
: undefined;
const body = renderComment({ current, baseline, currentRun, baselineRun });
const body = renderComment({
current,
baseline,
currentScenarios: readScenarios(process.env.PR_RESULT_DIR),
baselineScenarios: readScenarios(process.env.BASELINE_RESULT_DIR),
currentRun,
baselineRun,
});
const published = await upsertCommentForCurrentHead(
github,
context,
Expand All @@ -458,4 +598,5 @@ module.exports = {
resolve,
upsertCommentForCurrentHead,
validateResult,
validateScenarios,
};
79 changes: 79 additions & 0 deletions .github/scripts/thread-transfer-report.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ const assert = require("node:assert/strict");
const test = require("node:test");

const {
renderComment,
resolve,
upsertCommentForCurrentHead,
validateResult,
validateScenarios,
} = require("./thread-transfer-report.cjs");

function result(overrides = {}) {
Expand Down Expand Up @@ -40,6 +42,29 @@ function result(overrides = {}) {
};
}

function scenarios(overrides = {}) {
const rows = [
{
phase: "long turn finished, cold open",
metric: "HTTP decoded",
unit: "bytes",
observed: 390_000,
ceiling: 420_000,
},
{
phase: "idle thread resume",
metric: "WebSocket messages",
unit: "count",
observed: 1,
ceiling: 1,
},
];
return {
schemaVersion: 1,
providers: { codex: [{ ...rows[0], ...overrides }, rows[1]], claudeAgent: rows },
};
}

test("validates the fixed artifact schema", () => {
assert.equal(validateResult(result()).schemaVersion, 1);
assert.throws(
Expand All @@ -50,6 +75,60 @@ test("validates the fixed artifact schema", () => {
() => validateResult(result({ totalWireBytes: "lots" })),
/non-negative safe integer/,
);

assert.equal(validateScenarios(scenarios()).schemaVersion, 1);
for (const phase of ["@everyone", "[link](https://example.com)", "a | b", "<img>", ""]) {
assert.throws(() => validateScenarios(scenarios({ phase })), /short plain label/);
}
assert.throws(() => validateScenarios(scenarios({ note: "extra" })), /unexpected fields/);
assert.throws(() => validateScenarios(scenarios({ ceiling: null })), /non-negative safe integer/);
assert.throws(
() =>
validateScenarios(scenarios({ phase: "idle thread resume", metric: "WebSocket messages" })),
/repeats an earlier row/,
);
});

test("lists scenario rows that changed or failed above the collapsed table", () => {
const run = { sha: "abcdef1234", url: "https://example.com/run", conclusion: "success" };
const render = (currentScenarios, baselineScenarios) =>
renderComment({
current: result(),
baseline: result(),
currentScenarios,
baselineScenarios,
currentRun: run,
baselineRun: { ...run, matchesBase: true },
});
const open = (body) => body.slice(0, body.indexOf("<details>"));

const unchanged = render(scenarios(), scenarios());
assert.match(unchanged, /✅ Thread transfer remains within every enforced ceiling/);
assert.match(open(unchanged), /All 4 scenario measurements match `main`/);
assert.doesNotMatch(open(unchanged), /long turn finished, cold open/);

const grown = render(scenarios({ observed: 400_000 }), scenarios());
assert.match(open(grown), /1 of 4 scenario measurements changed or failed/);
assert.match(
open(grown),
/\| Codex \| `long turn finished, cold open` \| `HTTP decoded` \| 380\.9 KiB \| 390\.6 KiB \| \+9\.8 KiB \(\+2\.6%\) \| 410\.2 KiB \| ✅ \|/,
);

const over = render(scenarios({ observed: 430_000, ceiling: 425_000 }), scenarios());
assert.match(over, /❌ One or more thread transfer ceilings were exceeded/);
assert.match(
over,
/Codex `long turn finished, cold open` `HTTP decoded`: 410\.2 KiB → 415\.0 KiB/,
);

// A row `main` does not have is not reported as matching it.
const added = render(scenarios({ phase: "GH-26" }), scenarios());
assert.match(open(added), /3 scenario measurements match `main`\. 1 have no `main` baseline\./);
assert.match(added, /\| Codex \| `GH-26` \|/);

// A main run from before the scenario file has nothing to compare; a PR run from before it has no section.
assert.match(open(render(scenarios(), undefined)), /No `main` baseline exists yet/);
assert.doesNotMatch(render(undefined, scenarios()), /Other scenarios/);
});

test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => {
Expand Down
Loading
Loading