mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-20 22:25:30 +00:00
Merge branch 'main' into fix/vp-bottom-align-9300
This commit is contained in:
commit
c68e20b2f4
179 changed files with 21282 additions and 3152 deletions
2
.github/scripts/fixtures/serve-ab-session.jsonl
vendored
Normal file
2
.github/scripts/fixtures/serve-ab-session.jsonl
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"uuid":"10000000-0000-4000-8000-000000000001","parentUuid":null,"sessionId":"00000000-0000-4000-8000-000000000000","timestamp":"2026-01-01T00:00:00.000Z","type":"user","provenance":"real_user","cwd":"/workspace","version":"0.21.11","message":{"role":"user","parts":[{"text":"serve A/B fixture turn"}]}}
|
||||
{"uuid":"10000000-0000-4000-8000-000000000002","parentUuid":"10000000-0000-4000-8000-000000000001","sessionId":"00000000-0000-4000-8000-000000000000","timestamp":"2026-01-01T00:00:00.000Z","type":"assistant","provenance":"assistant_output","cwd":"/workspace","version":"0.21.11","model":"fixture-model","message":{"role":"model","parts":[{"text":"serve A/B fixture reply"}]},"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":3,"thoughtsTokenCount":0,"totalTokenCount":8,"cachedContentTokenCount":0}}
|
||||
54
.github/scripts/serve-ab-diff.mjs
vendored
54
.github/scripts/serve-ab-diff.mjs
vendored
|
|
@ -44,6 +44,15 @@ export function maskPath(path, patterns = DEFAULT_VOLATILE) {
|
|||
return patterns.some((re) => re.test(path));
|
||||
}
|
||||
|
||||
// The completion marker is OWNED by the drive script (the writer) and imported
|
||||
// here rather than re-declared: two copies drift silently — each suite would
|
||||
// keep testing against its own — and a drifted reader either flags every
|
||||
// complete baseline as truncated or stops noticing truncated ones at all.
|
||||
// Importing is side-effect-free; the drive's CLI body sits behind an
|
||||
// `import.meta.url` guard.
|
||||
export { DRIVE_COMPLETE_MARKER } from './serve-ab-drive.mjs';
|
||||
import { DRIVE_COMPLETE_MARKER } from './serve-ab-drive.mjs';
|
||||
|
||||
export function typeOf(v) {
|
||||
if (v === null) return 'null';
|
||||
if (Array.isArray(v)) return 'array';
|
||||
|
|
@ -165,6 +174,17 @@ export function buildComment(sections, ctx = {}) {
|
|||
out.push('— _Qwen Code · serve A/B_');
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
// Partial run: the base produced SOME captures and then stopped (a canary
|
||||
// deviation, a daemon crash). The scenarios it never reached have no
|
||||
// baseline, so they would render as "this PR adds these responses" — the same
|
||||
// shape a genuinely new scenario produces. Disclose it rather than let the
|
||||
// reader mistake a truncated baseline for a complete one.
|
||||
if (ctx.baselineIncomplete) {
|
||||
out.push(
|
||||
'⚠️ _The PR-base drive did not finish, so its capture set is partial. Scenarios it never reached appear below as additions rather than as a before/after — treat those tables as unverified._',
|
||||
);
|
||||
out.push('');
|
||||
}
|
||||
if (ctx.removed?.length) {
|
||||
out.push(
|
||||
`⚠️ _Present in the base but absent from this PR: ${ctx.removed
|
||||
|
|
@ -187,11 +207,16 @@ export function buildComment(sections, ctx = {}) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Read a capture dir's `<scenario>.json` files → `{ sections, baselineMissing }`.
|
||||
* Each section diffs an after-capture against the same-named base file. When the
|
||||
* base captures are ENTIRELY absent (a failed base build/drive) but head
|
||||
* captures exist, `baselineMissing` is set so the caller reports "diff skipped"
|
||||
* rather than misreporting every field as added. This is the function the CI
|
||||
* Read a capture dir's `<scenario>.json` files →
|
||||
* `{ sections, baselineMissing, baselineIncomplete, removed }`. Each section
|
||||
* diffs an after-capture against the same-named base file.
|
||||
*
|
||||
* Two degraded baselines are distinguished, because both would otherwise read
|
||||
* as an ordinary diff. `baselineMissing`: the base produced NO captures (a
|
||||
* failed base build/drive), so nothing was compared. `baselineIncomplete`: the
|
||||
* base drive started and stopped part-way, so the scenarios it never reached
|
||||
* have no baseline and render as pure additions — indistinguishable, on the
|
||||
* page, from a scenario this PR genuinely adds. This is the function the CI
|
||||
* `comment` subcommand actually invokes, so it is exported + covered.
|
||||
*/
|
||||
export function diffCaptureDirs(beforeDir, afterDir) {
|
||||
|
|
@ -205,6 +230,10 @@ export function diffCaptureDirs(beforeDir, afterDir) {
|
|||
const afterFiles = jsonFiles(afterDir).sort();
|
||||
const beforeFiles = jsonFiles(beforeDir);
|
||||
const baselineMissing = afterFiles.length > 0 && beforeFiles.length === 0;
|
||||
const baselineIncomplete =
|
||||
!baselineMissing &&
|
||||
beforeFiles.length > 0 &&
|
||||
!existsSync(join(beforeDir, DRIVE_COMPLETE_MARKER));
|
||||
const afterSet = new Set(afterFiles);
|
||||
// Scenarios present in the base but gone from the head — a removed or broken
|
||||
// scenario would otherwise vanish silently and lower the "across N" count,
|
||||
|
|
@ -223,20 +252,23 @@ export function diffCaptureDirs(beforeDir, afterDir) {
|
|||
const before = existsSync(beforePath) ? readJson(beforePath) : {};
|
||||
return { scenario, changes: diffJson(before, after) };
|
||||
});
|
||||
return { sections, baselineMissing, removed };
|
||||
return { sections, baselineMissing, baselineIncomplete, removed };
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
if (cmd === 'comment') {
|
||||
const [beforeDir, afterDir, shortSha, bodyFile] = rest;
|
||||
const { sections, baselineMissing, removed } = diffCaptureDirs(
|
||||
beforeDir,
|
||||
afterDir,
|
||||
);
|
||||
const { sections, baselineMissing, baselineIncomplete, removed } =
|
||||
diffCaptureDirs(beforeDir, afterDir);
|
||||
writeFileSync(
|
||||
bodyFile,
|
||||
buildComment(sections, { shortSha, baselineMissing, removed }),
|
||||
buildComment(sections, {
|
||||
shortSha,
|
||||
baselineMissing,
|
||||
baselineIncomplete,
|
||||
removed,
|
||||
}),
|
||||
);
|
||||
const total = baselineMissing
|
||||
? 0
|
||||
|
|
|
|||
142
.github/scripts/serve-ab-diff.test.mjs
vendored
142
.github/scripts/serve-ab-diff.test.mjs
vendored
|
|
@ -5,12 +5,15 @@
|
|||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DRIVE_COMPLETE_MARKER,
|
||||
buildComment,
|
||||
diffCaptureDirs,
|
||||
diffJson,
|
||||
|
|
@ -230,3 +233,138 @@ test('diffCaptureDirs: a base-only (removed) scenario is surfaced, not dropped',
|
|||
/Present in the base but absent from this PR: `capabilities`/,
|
||||
);
|
||||
});
|
||||
|
||||
test('diffCaptureDirs: status-only change is a reported diff', () => {
|
||||
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
||||
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
||||
// Same body, different status — invisible before `_status` was captured.
|
||||
writeFileSync(
|
||||
join(before, 'restore.json'),
|
||||
JSON.stringify({ _status: 200, code: undefined }),
|
||||
);
|
||||
writeFileSync(join(after, 'restore.json'), JSON.stringify({ _status: 409 }));
|
||||
const { sections } = diffCaptureDirs(before, after);
|
||||
assert.deepEqual(sections[0].changes, [
|
||||
{ path: '_status', kind: 'changed', before: 200, after: 409 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('diffCaptureDirs: a scenario absent from the base reports as an addition', () => {
|
||||
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
||||
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
||||
// New scenario: no base capture at all — every field reads as added.
|
||||
writeFileSync(
|
||||
join(after, 'new-scenario.json'),
|
||||
JSON.stringify({ _status: 400, code: 'reserved_session_source' }),
|
||||
);
|
||||
writeFileSync(join(before, 'health.json'), JSON.stringify({ status: 'ok' }));
|
||||
writeFileSync(join(after, 'health.json'), JSON.stringify({ status: 'ok' }));
|
||||
const { sections } = diffCaptureDirs(before, after);
|
||||
const added = sections.find((s) => s.scenario === 'new-scenario');
|
||||
assert.ok(added.changes.some((c) => c.path === '_status'));
|
||||
});
|
||||
|
||||
test('diffCaptureDirs: a base that never finished is reported as incomplete', () => {
|
||||
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
||||
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
||||
// The base drive stopped after one scenario; the head captured two. Without
|
||||
// the completion marker the second reads as "this PR adds this response".
|
||||
writeFileSync(join(before, 'health.json'), JSON.stringify({ _status: 200 }));
|
||||
writeFileSync(join(after, 'health.json'), JSON.stringify({ _status: 200 }));
|
||||
writeFileSync(join(after, 'restore.json'), JSON.stringify({ _status: 409 }));
|
||||
const partial = diffCaptureDirs(before, after);
|
||||
assert.equal(partial.baselineIncomplete, true);
|
||||
assert.equal(partial.baselineMissing, false);
|
||||
assert.match(
|
||||
buildComment(partial.sections, {
|
||||
shortSha: 'x',
|
||||
baselineIncomplete: partial.baselineIncomplete,
|
||||
}),
|
||||
/PR-base drive did not finish/,
|
||||
);
|
||||
|
||||
// With the marker the same dirs are a complete baseline and say nothing.
|
||||
writeFileSync(join(before, DRIVE_COMPLETE_MARKER), '');
|
||||
const complete = diffCaptureDirs(before, after);
|
||||
assert.equal(complete.baselineIncomplete, false);
|
||||
assert.doesNotMatch(
|
||||
buildComment(complete.sections, {
|
||||
shortSha: 'x',
|
||||
baselineIncomplete: complete.baselineIncomplete,
|
||||
}),
|
||||
/PR-base drive did not finish/,
|
||||
);
|
||||
});
|
||||
|
||||
test('diffCaptureDirs: an empty base stays "missing", not "incomplete"', () => {
|
||||
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
||||
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
||||
writeFileSync(join(after, 'health.json'), JSON.stringify({ _status: 200 }));
|
||||
const r = diffCaptureDirs(before, after);
|
||||
assert.equal(r.baselineMissing, true);
|
||||
assert.equal(r.baselineIncomplete, false);
|
||||
});
|
||||
|
||||
test('the marker is not itself enumerated as a scenario', () => {
|
||||
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
||||
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
||||
for (const d of [before, after]) {
|
||||
writeFileSync(join(d, 'health.json'), JSON.stringify({ _status: 200 }));
|
||||
writeFileSync(join(d, DRIVE_COMPLETE_MARKER), '');
|
||||
}
|
||||
const { sections } = diffCaptureDirs(before, after);
|
||||
assert.deepEqual(
|
||||
sections.map((s) => s.scenario),
|
||||
['health'],
|
||||
);
|
||||
});
|
||||
|
||||
// The `comment` subcommand is the ONLY invocation path in CI, and every test
|
||||
// above builds the buildComment ctx by hand — so the glue between
|
||||
// diffCaptureDirs and buildComment (destructure → pass-through) is exercised by
|
||||
// nothing. A dropped or misspelled flag there loses a degraded-baseline warning
|
||||
// while the whole suite stays green.
|
||||
const CLI = join(dirname(fileURLToPath(import.meta.url)), 'serve-ab-diff.mjs');
|
||||
const runComment = (before, after) => {
|
||||
const bodyFile = join(mkdtempSync(join(tmpdir(), 'sa-body-')), 'body.md');
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[CLI, 'comment', before, after, 'abc1234', bodyFile],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
},
|
||||
);
|
||||
return readFileSync(bodyFile, 'utf8');
|
||||
};
|
||||
|
||||
test('comment CLI: a marker-less baseline carries the truncation warning', () => {
|
||||
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
||||
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
||||
writeFileSync(join(before, 'health.json'), JSON.stringify({ _status: 200 }));
|
||||
writeFileSync(join(after, 'health.json'), JSON.stringify({ _status: 200 }));
|
||||
writeFileSync(join(after, 'restore.json'), JSON.stringify({ _status: 409 }));
|
||||
assert.match(runComment(before, after), /PR-base drive did not finish/);
|
||||
});
|
||||
|
||||
test('comment CLI: a complete baseline carries no degraded-baseline warning', () => {
|
||||
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
||||
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
||||
for (const d of [before, after]) {
|
||||
writeFileSync(join(d, 'health.json'), JSON.stringify({ _status: 200 }));
|
||||
writeFileSync(join(d, DRIVE_COMPLETE_MARKER), '');
|
||||
}
|
||||
const body = runComment(before, after);
|
||||
assert.doesNotMatch(body, /PR-base drive did not finish/);
|
||||
assert.doesNotMatch(body, /could not be built this run/);
|
||||
assert.match(
|
||||
body,
|
||||
/No response changes against the PR base across 1 scenario/,
|
||||
);
|
||||
});
|
||||
|
||||
test('comment CLI: an empty baseline reports the diff as skipped', () => {
|
||||
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
||||
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
||||
writeFileSync(join(after, 'health.json'), JSON.stringify({ _status: 200 }));
|
||||
assert.match(runComment(before, after), /could not be built this run/);
|
||||
});
|
||||
|
|
|
|||
445
.github/scripts/serve-ab-drive.mjs
vendored
445
.github/scripts/serve-ab-drive.mjs
vendored
|
|
@ -12,19 +12,275 @@
|
|||
*
|
||||
* Deterministic + credential-free: `/health` needs no auth; `/capabilities`
|
||||
* uses the local `--token`. No model is contacted (dummy OpenAI creds), so the
|
||||
* responses are stable and safe to diff. Scenarios that mutate state (create a
|
||||
* session, etc.) can be added here later — mask their volatile fields in
|
||||
* serve-ab-diff.mjs.
|
||||
* responses are stable and safe to diff.
|
||||
*
|
||||
* A scenario may also stage ON-DISK state before its request (`fixtures`) and
|
||||
* capture a reduced projection of the response (`project`). Without staging,
|
||||
* every probe hits an empty daemon and the whole session-admission surface —
|
||||
* case resolution, transcript integrity, archive conflicts, reserved sources —
|
||||
* is unreachable, so a PR that rewrites it diffs as "no response changes".
|
||||
*
|
||||
* node serve-ab-drive.mjs <cliEntry> <outDir>
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Written into a capture dir once every scenario has been captured. Its absence
|
||||
* means the drive aborted part-way and the dir is only a partial baseline.
|
||||
*/
|
||||
export const DRIVE_COMPLETE_MARKER = '.drive-complete';
|
||||
|
||||
export function isPlainObject(v) {
|
||||
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the capture for one scenario.
|
||||
*
|
||||
* `_status` is always recorded, and always the HTTP status the harness saw: a
|
||||
* status-only change (404 → 409, say) under an otherwise similar body is
|
||||
* exactly the admission difference these scenarios exist to catch, so neither
|
||||
* a body nor a scenario projection can overwrite it with its own `_status`
|
||||
* key. A non-object body (scalar, null, array) is nested rather than spread —
|
||||
* spreading would drop a scalar and re-key an array — because the capture has
|
||||
* to survive whatever a future scenario probes.
|
||||
*/
|
||||
export function composeCapture(scenario, json, res) {
|
||||
if (scenario.project) {
|
||||
return { ...scenario.project(json, res), _status: res.status };
|
||||
}
|
||||
return isPlainObject(json)
|
||||
? { ...json, _status: res.status }
|
||||
: { _status: res.status, _body: json };
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty a capture directory before a drive writes into it, so a re-run cannot
|
||||
* let an earlier run's files stand in for scenarios this run never captured.
|
||||
*
|
||||
* Guarded, because `outDir` comes straight off the command line and the
|
||||
* documented local usage invites a mistyped or reused path: only a directory
|
||||
* that already looks like a capture dir is deleted. In CI the capture dirs are
|
||||
* also cleared by an unconditional workflow step, which covers the runs where
|
||||
* an arm is skipped entirely and this function never executes at all.
|
||||
*/
|
||||
export function clearCaptureDir(outDir) {
|
||||
if (!existsSync(outDir)) return;
|
||||
const entries = readdirSync(outDir);
|
||||
const looksLikeCaptures = entries.every(
|
||||
(f) => f.endsWith('.json') || f === DRIVE_COMPLETE_MARKER,
|
||||
);
|
||||
if (!looksLikeCaptures) {
|
||||
throw new Error(
|
||||
`refusing to clear ${outDir}: it holds files that are not serve-ab captures (${entries
|
||||
.slice(0, 5)
|
||||
.join(', ')})`,
|
||||
);
|
||||
}
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every scenario against one daemon and write its capture.
|
||||
*
|
||||
* Extracted from {@link driveCli} so the ordering that matters can be tested
|
||||
* without a daemon: the completion marker is written only after the LAST
|
||||
* capture, so an abort part-way through leaves a capture dir the diff can
|
||||
* recognise as truncated. Moving that write into a `finally` — a plausible
|
||||
* "make sure the marker is always there" edit — would silently re-introduce the
|
||||
* misreport the marker exists to prevent.
|
||||
*/
|
||||
export async function captureScenarios(scenarios, { request, ctx, outDir }) {
|
||||
for (const s of scenarios) {
|
||||
// Stage on-disk state (transcripts) before anything is requested.
|
||||
s.fixtures?.(ctx);
|
||||
// Run any setup requests (e.g. create a session) before the capture.
|
||||
for (const step of s.setup ?? []) {
|
||||
const r = await request(step);
|
||||
// A failed setup (e.g. POST /session non-2xx) would let the capture
|
||||
// reflect wrong state (0 sessions) and silently mask or fake a diff —
|
||||
// fail loudly instead.
|
||||
if (!r.ok) {
|
||||
const body = await r.text().catch(() => '');
|
||||
throw new Error(
|
||||
`setup ${step.method} ${step.path} failed (HTTP ${r.status}) for "${s.name}": ${body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const res = await request(s);
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = { _nonJson: text.slice(0, 500) };
|
||||
}
|
||||
const captured = composeCapture(s, json, res);
|
||||
writeFileSync(
|
||||
join(outDir, `${s.name}.json`),
|
||||
JSON.stringify(captured, null, 2) + '\n',
|
||||
);
|
||||
process.stderr.write(` captured ${s.name} (HTTP ${res.status})\n`);
|
||||
// Checked AFTER the capture is written, so a deviating response is on
|
||||
// disk and in the log rather than lost to the abort.
|
||||
assertCanaryStatus(s, res.status, text, captured);
|
||||
}
|
||||
// Completion marker, written only once every scenario is captured. An abort
|
||||
// part-way through (a canary, a daemon crash) leaves a capture dir that LOOKS
|
||||
// like a full baseline, and the scenarios it never reached would render as
|
||||
// "this PR adds these responses". The diff treats a marker-less baseline as
|
||||
// degraded and says so. Not a `.json` file: the diff enumerates those as
|
||||
// scenarios.
|
||||
writeFileSync(join(outDir, DRIVE_COMPLETE_MARKER), '');
|
||||
}
|
||||
|
||||
/**
|
||||
* A canary scenario asserts its own precondition and aborts the drive when it
|
||||
* fails — publishing "no response changes" from a scenario set that never
|
||||
* created the state it believed it was probing is the failure this whole
|
||||
* harness exists to prevent.
|
||||
*
|
||||
* Two shapes, because the two canaries guard different things:
|
||||
*
|
||||
* - `expectStatus` — the answer must be exactly this. For a precondition every
|
||||
* later scenario shares (the project directory, the `chats` leaf, the fixture
|
||||
* loading at all): if it moved, nothing below it means anything.
|
||||
* - `rejectStatus` — only this answer is a failure, anything else is data. For
|
||||
* a precondition that just asks "did the daemon see the file I staged?": a
|
||||
* 404 says it did not, while any other answer proves it did and is a product
|
||||
* decision worth capturing rather than a reason to suppress the whole report.
|
||||
* - `expectReplay` — the restore must carry at least one replay entry. A status
|
||||
* check alone cannot see fixture rot: the product validates transcripts
|
||||
* record by record and fails OPEN (an unrecognised record is skipped), so a
|
||||
* fixture whose records stop validating restores as an EMPTY session and
|
||||
* still answers 200. Every staged scenario would then probe an empty daemon
|
||||
* identically on both arms and the A/B would report no changes.
|
||||
*/
|
||||
export function assertCanaryStatus(scenario, status, bodyText = '', captured) {
|
||||
const fail = (expectation) => {
|
||||
throw new Error(
|
||||
`scenario "${scenario.name}" ${expectation} but got ${status}: ${String(
|
||||
bodyText,
|
||||
).slice(0, 300)}`,
|
||||
);
|
||||
};
|
||||
if (scenario.expectStatus !== undefined && status !== scenario.expectStatus) {
|
||||
fail(`expected HTTP ${scenario.expectStatus}`);
|
||||
}
|
||||
if (scenario.rejectStatus !== undefined && status === scenario.rejectStatus) {
|
||||
fail(`must not answer HTTP ${scenario.rejectStatus}`);
|
||||
}
|
||||
if (scenario.expectReplay && !(captured?._replayItems > 0)) {
|
||||
throw new Error(
|
||||
`scenario "${scenario.name}" restored an EMPTY transcript (_replayItems=${
|
||||
captured?._replayItems
|
||||
}). The staged fixture no longer validates against this build — record ` +
|
||||
`validation fails open, so every staged scenario below is probing an ` +
|
||||
`empty daemon and would diff clean.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the daemon persists a workspace's transcripts: `Storage.getProjectDir()`
|
||||
* (`<runtimeBaseDir>/projects/<sanitized cwd>`) plus SessionService's `chats`
|
||||
* leaf, with `archive/` under it. Kept in lockstep with `sanitizeCwd()` in
|
||||
* packages/core/src/utils/paths.ts. The daemon canonicalizes its workspace
|
||||
* path, so realpath first (`/tmp` is a symlink on some runners).
|
||||
*
|
||||
* If this ever drifts from the product code the staged fixtures land nowhere
|
||||
* and every staged scenario would quietly answer 404 on BOTH arms — which is
|
||||
* why `session-restore-healthy` below is a hard-failing canary.
|
||||
*/
|
||||
export function chatsDirFor(home, workspaceCwd) {
|
||||
// sanitizeCwd lowercases on Windows only; the mirror must take the same
|
||||
// branch, or fixtures staged on one platform land where the daemon built
|
||||
// for the other one will never read them.
|
||||
const normalized =
|
||||
process.platform === 'win32' ? workspaceCwd.toLowerCase() : workspaceCwd;
|
||||
const projectId = normalized.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
return join(home, '.qwen', 'projects', projectId, 'chats');
|
||||
}
|
||||
|
||||
/**
|
||||
* The committed transcript fixture, recorded from a real CLI turn (a genuine
|
||||
* `user` + `assistant` record pair) rather than hand-written: the loader
|
||||
* rejects synthesized records that get details like `message.role` wrong, and a
|
||||
* fixture that fails to load would silently neuter every scenario below.
|
||||
*/
|
||||
export function readTranscriptFixture() {
|
||||
const raw = readFileSync(
|
||||
join(HERE, 'fixtures', 'serve-ab-session.jsonl'),
|
||||
'utf8',
|
||||
);
|
||||
return raw
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
/** Re-point the fixture records at one session id + workspace. */
|
||||
export function retargetTranscript(records, sessionId, cwd) {
|
||||
return (
|
||||
records.map((r) => JSON.stringify({ ...r, sessionId, cwd })).join('\n') +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
|
||||
// Session ids are hardcoded per scenario, never random: the base and head
|
||||
// daemons run as separate processes, so a random id would differ between the
|
||||
// two captures and diff as noise. Distinct ids also keep each scenario from
|
||||
// attaching to a live entry a previous scenario left behind — an attach also
|
||||
// answers 200 and would mask a restore-path difference.
|
||||
export const SID = {
|
||||
healthy: 'a0000000-0000-4000-8000-00000000da01',
|
||||
mixedCase: 'A0000000-0000-4000-8000-00000000DA02',
|
||||
twins: 'A0000000-0000-4000-8000-00000000DA03',
|
||||
unreadable: 'a0000000-0000-4000-8000-00000000da04',
|
||||
archived: 'a0000000-0000-4000-8000-00000000da05',
|
||||
archivedOnly: 'a0000000-0000-4000-8000-00000000da06',
|
||||
};
|
||||
|
||||
/** Stage transcripts for a scenario; returns nothing, throws on IO failure. */
|
||||
function stageTranscripts(ctx, entries) {
|
||||
const chats = chatsDirFor(ctx.home, ctx.workspace);
|
||||
mkdirSync(join(chats, 'archive'), { recursive: true });
|
||||
const records = readTranscriptFixture();
|
||||
for (const e of entries) {
|
||||
const dir = e.archived ? join(chats, 'archive') : chats;
|
||||
const body =
|
||||
e.raw !== undefined
|
||||
? e.raw
|
||||
: retargetTranscript(records, e.sessionId, ctx.workspace);
|
||||
writeFileSync(join(dir, `${e.sessionId}.jsonl`), body);
|
||||
}
|
||||
}
|
||||
|
||||
// A restore answer is a decision, not a payload: keep the status and the error
|
||||
// discriminator and drop the session snapshot, whose replay ids, epochs and
|
||||
// per-record timestamps churn on every run and would bury the signal.
|
||||
export const admissionOnly = (json, res) => ({
|
||||
_status: res.status,
|
||||
...(json?.code === undefined ? {} : { code: json.code }),
|
||||
...(json?.error === undefined ? {} : { error: json.error }),
|
||||
});
|
||||
|
||||
// The fixed scenarios. `auth` sends the bearer token; anything mutating the
|
||||
// daemon would push requests here in order.
|
||||
|
|
@ -45,13 +301,150 @@ export const SCENARIOS = [
|
|||
method: 'POST',
|
||||
path: '/session',
|
||||
auth: true,
|
||||
body: ({ home }) => ({ clientId: 'serve-ab', workspaceCwd: home }),
|
||||
// Empty on purpose. `cwd` is omitted so the route falls back to the
|
||||
// daemon's bound workspace, which is already canonicalized; the
|
||||
// previous `workspaceCwd` and `clientId` keys were both inert (the
|
||||
// route reads `cwd`, and the client id only from `X-Qwen-Client-Id`),
|
||||
// and an inert key reads like a probe that identifies itself.
|
||||
body: () => ({}),
|
||||
},
|
||||
],
|
||||
method: 'GET',
|
||||
path: '/health?deep=1',
|
||||
auth: true,
|
||||
},
|
||||
|
||||
// --- session admission -----------------------------------------------
|
||||
// These run last so the probes above still see the daemon they saw before.
|
||||
// Each stages transcripts on disk first; without that the restore path only
|
||||
// ever answers "no such session" and its guards are unreachable.
|
||||
{
|
||||
// Canary. A healthy transcript under its exact spelling must restore. If
|
||||
// this stops answering 200 the fixture or the on-disk layout has drifted
|
||||
// and every scenario below is meaningless — so the drive fails loudly
|
||||
// instead of publishing a reassuring all-clear.
|
||||
name: 'session-restore-healthy',
|
||||
fixtures: (ctx) => stageTranscripts(ctx, [{ sessionId: SID.healthy }]),
|
||||
method: 'POST',
|
||||
path: `/session/${SID.healthy}/load`,
|
||||
auth: true,
|
||||
body: () => ({}),
|
||||
// Keeps a replay-size witness on top of the admission decision. The count
|
||||
// is stable (it is derived from the committed fixture), and it is the only
|
||||
// field in any capture that would move if the fixture stopped validating.
|
||||
project: (json, res) => ({
|
||||
...admissionOnly(json, res),
|
||||
_replayItems: Array.isArray(json?.compactedReplay)
|
||||
? json.compactedReplay.length
|
||||
: 0,
|
||||
}),
|
||||
expectStatus: 200,
|
||||
expectReplay: true,
|
||||
},
|
||||
{
|
||||
// Second canary, for the archive leaf. The healthy restore above certifies
|
||||
// the sanitized project directory, the `chats` leaf and the fixture; only
|
||||
// this one certifies that the daemon reads the `chats/archive` leaf the
|
||||
// harness writes to. Without it, a drifted archive name would leave the
|
||||
// active/archived conflict scenario below loading from active on both arms
|
||||
// — identical captures, and a conflict-admission regression diffing clean.
|
||||
//
|
||||
// `rejectStatus`, not `expectStatus`: the only answer that means "the
|
||||
// staged file was never seen" is 404. Today the daemon refuses an
|
||||
// archived-only load with 409, but if that ever becomes loadable the
|
||||
// precondition still held, and pinning the exact status would abort the
|
||||
// drive and suppress the very `409 → 200` row the captures already hold.
|
||||
name: 'session-restore-archived-only',
|
||||
fixtures: (ctx) =>
|
||||
stageTranscripts(ctx, [{ sessionId: SID.archivedOnly, archived: true }]),
|
||||
method: 'POST',
|
||||
path: `/session/${SID.archivedOnly}/load`,
|
||||
auth: true,
|
||||
body: () => ({}),
|
||||
project: admissionOnly,
|
||||
rejectStatus: 404,
|
||||
},
|
||||
{
|
||||
// Legacy `uuidgen` spelling: only the uppercase file exists, the caller
|
||||
// asks in lowercase.
|
||||
name: 'session-restore-mixed-case',
|
||||
fixtures: (ctx) => stageTranscripts(ctx, [{ sessionId: SID.mixedCase }]),
|
||||
method: 'POST',
|
||||
path: `/session/${SID.mixedCase.toLowerCase()}/load`,
|
||||
auth: true,
|
||||
body: () => ({}),
|
||||
project: admissionOnly,
|
||||
},
|
||||
{
|
||||
// Two persisted spellings of one id — possible on any case-sensitive
|
||||
// filesystem, which is what CI runs on.
|
||||
name: 'session-restore-case-twins',
|
||||
fixtures: (ctx) =>
|
||||
stageTranscripts(ctx, [
|
||||
{ sessionId: SID.twins },
|
||||
{ sessionId: SID.twins.toLowerCase() },
|
||||
]),
|
||||
method: 'POST',
|
||||
path: `/session/${SID.twins.toLowerCase()}/load`,
|
||||
auth: true,
|
||||
body: () => ({}),
|
||||
project: admissionOnly,
|
||||
},
|
||||
{
|
||||
// Crash-shaped damage: nothing in the head of the file parses.
|
||||
name: 'session-restore-unreadable',
|
||||
fixtures: (ctx) =>
|
||||
stageTranscripts(ctx, [
|
||||
{ sessionId: SID.unreadable, raw: 'not json at all\n{"broken":\n' },
|
||||
]),
|
||||
method: 'POST',
|
||||
path: `/session/${SID.unreadable}/load`,
|
||||
auth: true,
|
||||
body: () => ({}),
|
||||
project: admissionOnly,
|
||||
},
|
||||
{
|
||||
// The same id persisted in both the active and the archive directory.
|
||||
name: 'session-restore-active-and-archived',
|
||||
fixtures: (ctx) =>
|
||||
stageTranscripts(ctx, [
|
||||
{ sessionId: SID.archived },
|
||||
{ sessionId: SID.archived, archived: true },
|
||||
]),
|
||||
method: 'POST',
|
||||
path: `/session/${SID.archived}/load`,
|
||||
auth: true,
|
||||
body: () => ({}),
|
||||
project: admissionOnly,
|
||||
},
|
||||
{
|
||||
// The source today's daemon actually reserves: `default` +
|
||||
// `realtime_voice:`, refused with 400 reserved_session_source. This is the
|
||||
// scenario that pins the existing refusal — rewrite the predicate or the
|
||||
// response and it moves.
|
||||
name: 'session-create-reserved-source',
|
||||
method: 'POST',
|
||||
path: '/session',
|
||||
auth: true,
|
||||
body: () => ({
|
||||
sourceType: 'default',
|
||||
sourceId: 'realtime_voice:serve-ab',
|
||||
}),
|
||||
project: admissionOnly,
|
||||
},
|
||||
{
|
||||
// An ordinary, currently-unreserved source type — a real one, not an
|
||||
// invented string: the daemon's own scheduler creates sessions under it.
|
||||
// Admitted today; the point is that a PR which starts reserving it shows
|
||||
// up here as 200 → 400 instead of diffing clean, which is how the harness
|
||||
// missed exactly that change once already.
|
||||
name: 'session-create-unreserved-source',
|
||||
method: 'POST',
|
||||
path: '/session',
|
||||
auth: true,
|
||||
body: () => ({ sourceType: 'scheduled_task', sourceId: 'serve-ab' }),
|
||||
project: admissionOnly,
|
||||
},
|
||||
];
|
||||
|
||||
function freePort() {
|
||||
|
|
@ -80,6 +473,7 @@ async function waitForHealth(base, timeoutMs = 30000) {
|
|||
}
|
||||
|
||||
export async function driveCli(cliEntry, outDir) {
|
||||
clearCaptureDir(outDir);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpdir(), 'serve-ab-home-'));
|
||||
const token = 'serve-ab-token';
|
||||
|
|
@ -114,6 +508,11 @@ export async function driveCli(cliEntry, outDir) {
|
|||
},
|
||||
);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
// The daemon canonicalizes `--workspace`, and the on-disk project directory
|
||||
// is derived from that canonical path — so fixtures must be staged under the
|
||||
// realpath, not the (possibly symlinked) mkdtemp path.
|
||||
const workspace = realpathSync(home);
|
||||
const ctx = { home, workspace };
|
||||
try {
|
||||
await waitForHealth(base);
|
||||
const doRequest = (spec) => {
|
||||
|
|
@ -121,8 +520,7 @@ export async function driveCli(cliEntry, outDir) {
|
|||
let body;
|
||||
if (spec.body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
const b =
|
||||
typeof spec.body === 'function' ? spec.body({ home }) : spec.body;
|
||||
const b = typeof spec.body === 'function' ? spec.body(ctx) : spec.body;
|
||||
body = JSON.stringify(b);
|
||||
}
|
||||
return fetch(`${base}${spec.path}`, {
|
||||
|
|
@ -131,34 +529,7 @@ export async function driveCli(cliEntry, outDir) {
|
|||
body,
|
||||
});
|
||||
};
|
||||
for (const s of SCENARIOS) {
|
||||
// Run any setup requests (e.g. create a session) before the capture.
|
||||
for (const step of s.setup ?? []) {
|
||||
const r = await doRequest(step);
|
||||
// A failed setup (e.g. POST /session non-2xx) would let the capture
|
||||
// reflect wrong state (0 sessions) and silently mask or fake a diff —
|
||||
// fail loudly instead.
|
||||
if (!r.ok) {
|
||||
const body = await r.text().catch(() => '');
|
||||
throw new Error(
|
||||
`setup ${step.method} ${step.path} failed (HTTP ${r.status}) for "${s.name}": ${body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const res = await doRequest(s);
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = { _status: res.status, _nonJson: text.slice(0, 500) };
|
||||
}
|
||||
writeFileSync(
|
||||
join(outDir, `${s.name}.json`),
|
||||
JSON.stringify(json, null, 2) + '\n',
|
||||
);
|
||||
process.stderr.write(` captured ${s.name} (HTTP ${res.status})\n`);
|
||||
}
|
||||
await captureScenarios(SCENARIOS, { request: doRequest, ctx, outDir });
|
||||
} finally {
|
||||
daemon.kill('SIGTERM');
|
||||
// Await exit so a hung daemon (pending async / open WebSockets) can't
|
||||
|
|
|
|||
563
.github/scripts/serve-ab-drive.test.mjs
vendored
Normal file
563
.github/scripts/serve-ab-drive.test.mjs
vendored
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, sep } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DRIVE_COMPLETE_MARKER,
|
||||
SCENARIOS,
|
||||
SID,
|
||||
admissionOnly,
|
||||
assertCanaryStatus,
|
||||
captureScenarios,
|
||||
chatsDirFor,
|
||||
clearCaptureDir,
|
||||
composeCapture,
|
||||
isPlainObject,
|
||||
readTranscriptFixture,
|
||||
retargetTranscript,
|
||||
} from './serve-ab-drive.mjs';
|
||||
|
||||
test('chatsDirFor mirrors the daemon project-dir layout', () => {
|
||||
// The expectation is built with join(), like the function and the daemon's
|
||||
// Storage.getProjectDir — a hardcoded separator only passes on POSIX.
|
||||
assert.equal(
|
||||
chatsDirFor('/home/runner/work/tmp', '/srv/my project'),
|
||||
join(
|
||||
'/home/runner/work/tmp',
|
||||
'.qwen',
|
||||
'projects',
|
||||
'-srv-my-project',
|
||||
'chats',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('chatsDirFor sanitizes every non-alphanumeric character, like sanitizeCwd', () => {
|
||||
const dir = chatsDirFor('/h', '/a_b.c/d-e');
|
||||
assert.equal(dir, join('/h', '.qwen', 'projects', '-a-b-c-d-e', 'chats'));
|
||||
// No path separators survive from the workspace path: the whole workspace
|
||||
// collapses into ONE directory name.
|
||||
assert.equal(dir.split(sep).filter(Boolean).length, 5);
|
||||
// sanitizeCwd lowercases on Windows ONLY; the mirror must take the same
|
||||
// branch — an unconditional lowercase would strand fixtures on the Linux
|
||||
// runners this harness actually drives.
|
||||
const casedProjectId = process.platform === 'win32' ? '-abc' : '-AbC';
|
||||
assert.equal(
|
||||
chatsDirFor('/h', '/AbC'),
|
||||
join('/h', '.qwen', 'projects', casedProjectId, 'chats'),
|
||||
);
|
||||
});
|
||||
|
||||
test('the transcript fixture is a genuine user + assistant record pair', () => {
|
||||
const records = readTranscriptFixture();
|
||||
assert.equal(records.length, 2);
|
||||
assert.equal(records[0].type, 'user');
|
||||
assert.equal(records[0].message.role, 'user');
|
||||
assert.equal(records[1].type, 'assistant');
|
||||
// The loader rejects `role: "assistant"` here — it wants `model`. Pinning it
|
||||
// keeps a well-meaning edit from turning every staged scenario into a 404.
|
||||
assert.equal(records[1].message.role, 'model');
|
||||
assert.equal(records[1].parentUuid, records[0].uuid);
|
||||
});
|
||||
|
||||
test('retargetTranscript rewrites sessionId + cwd on every record', () => {
|
||||
const out = retargetTranscript(
|
||||
readTranscriptFixture(),
|
||||
'a0000000-0000-4000-8000-00000000da01',
|
||||
'/srv/ws',
|
||||
);
|
||||
const lines = out.split('\n').filter(Boolean);
|
||||
assert.equal(lines.length, 2);
|
||||
for (const line of lines) {
|
||||
const rec = JSON.parse(line);
|
||||
assert.equal(rec.sessionId, 'a0000000-0000-4000-8000-00000000da01');
|
||||
assert.equal(rec.cwd, '/srv/ws');
|
||||
}
|
||||
assert.ok(out.endsWith('\n'), 'JSONL must end with a newline');
|
||||
});
|
||||
|
||||
test('every scenario has a unique name, and every id in a path is a SID constant', () => {
|
||||
const names = SCENARIOS.map((s) => s.name);
|
||||
assert.equal(new Set(names).size, names.length);
|
||||
// The two arms run as separate processes, so an id computed at request time
|
||||
// would differ between the captures and diff as pure noise. Asserting the
|
||||
// paths embed the exported constants is what actually pins that — a check for
|
||||
// the substrings "random"/"Date.now" passes for `crypto.randomUUID()` too.
|
||||
const known = new Set(Object.values(SID).map((id) => id.toLowerCase()));
|
||||
for (const s of SCENARIOS) {
|
||||
const id = /\/session\/([^/]+)\//.exec(s.path)?.[1];
|
||||
if (!id) continue;
|
||||
assert.ok(
|
||||
known.has(id.toLowerCase()),
|
||||
`scenario ${s.name} uses an id that is not a SID constant: ${id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the restore scenario set is pinned by name, not by a slack count', () => {
|
||||
// A lower bound lets a scenario be deleted or "consolidated" silently, and
|
||||
// the surface it probed then drops out of the A/B with every test green.
|
||||
// Adding one here is deliberate friction: say which surface it covers.
|
||||
assert.deepEqual(
|
||||
SCENARIOS.filter((s) => s.name.startsWith('session-restore-')).map(
|
||||
(s) => s.name,
|
||||
),
|
||||
[
|
||||
'session-restore-healthy',
|
||||
'session-restore-archived-only',
|
||||
'session-restore-mixed-case',
|
||||
'session-restore-case-twins',
|
||||
'session-restore-unreadable',
|
||||
'session-restore-active-and-archived',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('restore scenarios never share a session id (an attach also answers 200)', () => {
|
||||
const ids = SCENARIOS.filter((s) => s.name.startsWith('session-restore-'))
|
||||
.map((s) => /\/session\/([^/]+)\/load/.exec(s.path)?.[1])
|
||||
.map((id) => id?.toLowerCase());
|
||||
assert.ok(ids.every(Boolean));
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
});
|
||||
|
||||
test('both canaries assert their own precondition', () => {
|
||||
const canaries = SCENARIOS.filter(
|
||||
(s) => s.expectStatus !== undefined || s.rejectStatus !== undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
canaries.map((c) => [c.name, c.expectStatus, c.rejectStatus]),
|
||||
[
|
||||
// Shared by every scenario below it → must be exactly 200.
|
||||
['session-restore-healthy', 200, undefined],
|
||||
// Only 404 proves the staged file was never seen; any other answer is a
|
||||
// product decision the captures should carry, not a reason to abort.
|
||||
['session-restore-archived-only', undefined, 404],
|
||||
],
|
||||
);
|
||||
// One certifies the active `chats` leaf and the fixture, the other the
|
||||
// `chats/archive` leaf — every layout fact the harness encodes is covered.
|
||||
for (const c of canaries) assert.equal(typeof c.fixtures, 'function');
|
||||
});
|
||||
|
||||
test('the unreserved-source witness sends a source the daemon admits today', () => {
|
||||
// The witness only diffs 200 → 400 when the daemon admits its body today:
|
||||
// a body the daemon ALREADY refuses captures the same 400 on both arms,
|
||||
// diffs clean, and silently covers nothing — that is how the `standalone`
|
||||
// reservation went unseen. Mirrors the route's two reserved shapes:
|
||||
// `standalone` (daemon-owned standalone sessions) and `default` +
|
||||
// `realtime_voice:` (daemon-owned Live Voice sessions).
|
||||
const witness = SCENARIOS.find(
|
||||
(s) => s.name === 'session-create-unreserved-source',
|
||||
);
|
||||
assert.ok(witness, 'the admitted-source witness is missing');
|
||||
const body = witness.body();
|
||||
// A named source: an empty body would ride the legacy path instead.
|
||||
assert.equal(typeof body.sourceType, 'string');
|
||||
assert.notEqual(
|
||||
body.sourceType,
|
||||
'standalone',
|
||||
'the daemon reserves `standalone` for its own sessions',
|
||||
);
|
||||
assert.ok(
|
||||
!(
|
||||
body.sourceType === 'default' &&
|
||||
typeof body.sourceId === 'string' &&
|
||||
body.sourceId.startsWith('realtime_voice:')
|
||||
),
|
||||
'the daemon reserves the `default` + `realtime_voice:` source',
|
||||
);
|
||||
});
|
||||
|
||||
test('assertCanaryStatus enforces both canary shapes and leaves others alone', () => {
|
||||
const exact = { name: 'exact', expectStatus: 200 };
|
||||
assert.doesNotThrow(() => assertCanaryStatus(exact, 200, 'body'));
|
||||
assert.throws(
|
||||
() => assertCanaryStatus(exact, 404, 'body'),
|
||||
/scenario "exact" expected HTTP 200 but got 404/,
|
||||
);
|
||||
|
||||
const reject = { name: 'reject', rejectStatus: 404 };
|
||||
assert.doesNotThrow(() => assertCanaryStatus(reject, 409));
|
||||
assert.doesNotThrow(
|
||||
() => assertCanaryStatus(reject, 200),
|
||||
'a changed admission answer is data, not a drift alarm',
|
||||
);
|
||||
assert.throws(
|
||||
() => assertCanaryStatus(reject, 404),
|
||||
/scenario "reject" must not answer HTTP 404/,
|
||||
);
|
||||
|
||||
// A scenario with neither field is never a canary, whatever it answers.
|
||||
for (const status of [200, 404, 409, 500]) {
|
||||
assert.doesNotThrow(() => assertCanaryStatus({ name: 'plain' }, status));
|
||||
}
|
||||
});
|
||||
|
||||
test('every staged scenario probes an id it actually staged', () => {
|
||||
for (const s of SCENARIOS) {
|
||||
if (!s.fixtures) continue;
|
||||
const probed = /\/session\/([^/]+)\//.exec(s.path)?.[1];
|
||||
if (!probed) continue;
|
||||
// A FRESH home per scenario: sharing one would assert against the union of
|
||||
// everything staged so far, so a scenario staging the wrong id would pass
|
||||
// on an earlier scenario's file.
|
||||
const home = mkdtempSync(join(tmpdir(), 'sad-one-'));
|
||||
const workspace = join(home, 'ws');
|
||||
const chats = chatsDirFor(home, workspace);
|
||||
s.fixtures({ home, workspace });
|
||||
const staged = [
|
||||
...readdirSync(chats),
|
||||
...readdirSync(join(chats, 'archive')),
|
||||
]
|
||||
.filter((f) => f.endsWith('.jsonl'))
|
||||
.map((f) => f.slice(0, -'.jsonl'.length).toLowerCase());
|
||||
// Case-insensitively: mixed-case and case-twins deliberately probe a
|
||||
// spelling other than the one on disk. A scenario probing an id it never
|
||||
// staged answers 404 on BOTH arms — identical captures, "no response
|
||||
// changes", and that branch silently drops out of A/B coverage.
|
||||
assert.ok(
|
||||
staged.includes(probed.toLowerCase()),
|
||||
`${s.name} probes ${probed} but stages no spelling of it`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('staged scenarios stage fixtures before they probe', () => {
|
||||
for (const s of SCENARIOS) {
|
||||
if (!s.name.startsWith('session-restore-')) continue;
|
||||
assert.equal(
|
||||
typeof s.fixtures,
|
||||
'function',
|
||||
`${s.name} probes restore state but stages nothing`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fixtures land in the chats leaf, and archived ones under archive/', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'sad-'));
|
||||
const workspace = join(home, 'ws');
|
||||
const chats = chatsDirFor(home, workspace);
|
||||
for (const s of SCENARIOS) s.fixtures?.({ home, workspace });
|
||||
|
||||
const active = (id) => join(chats, `${id}.jsonl`);
|
||||
const archived = (id) => join(chats, 'archive', `${id}.jsonl`);
|
||||
|
||||
assert.ok(existsSync(active(SID.healthy)), 'healthy canary → chats/');
|
||||
assert.ok(
|
||||
existsSync(archived(SID.archivedOnly)),
|
||||
'archive canary → chats/archive/',
|
||||
);
|
||||
assert.ok(
|
||||
!existsSync(active(SID.archivedOnly)),
|
||||
'the archive-only canary must NOT also have an active copy',
|
||||
);
|
||||
// The conflict scenario needs BOTH copies; with only the active one it loads
|
||||
// normally on either arm and silently stops covering the conflict path.
|
||||
assert.ok(existsSync(active(SID.archived)));
|
||||
assert.ok(existsSync(archived(SID.archived)));
|
||||
// Case twins: two spellings, both in the active leaf.
|
||||
assert.ok(existsSync(active(SID.twins)));
|
||||
assert.ok(existsSync(active(SID.twins.toLowerCase())));
|
||||
});
|
||||
|
||||
test('a raw fixture body is written verbatim, a retargeted one is valid JSONL', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'sad-'));
|
||||
const workspace = join(home, 'ws');
|
||||
const chats = chatsDirFor(home, workspace);
|
||||
for (const s of SCENARIOS) s.fixtures?.({ home, workspace });
|
||||
|
||||
const damaged = readFileSync(join(chats, `${SID.unreadable}.jsonl`), 'utf8');
|
||||
assert.equal(damaged, 'not json at all\n{"broken":\n');
|
||||
assert.throws(() => JSON.parse(damaged.split('\n')[0]));
|
||||
|
||||
const healthy = readFileSync(join(chats, `${SID.healthy}.jsonl`), 'utf8');
|
||||
for (const line of healthy.split('\n').filter(Boolean)) {
|
||||
const rec = JSON.parse(line);
|
||||
assert.equal(rec.sessionId, SID.healthy);
|
||||
assert.equal(rec.cwd, workspace);
|
||||
}
|
||||
});
|
||||
|
||||
test('admissionOnly keeps the decision and drops the session snapshot', () => {
|
||||
const res = { status: 409 };
|
||||
assert.deepEqual(
|
||||
admissionOnly(
|
||||
{ code: 'session_conflict', error: 'two spellings', compactedReplay: [] },
|
||||
res,
|
||||
),
|
||||
{ _status: 409, code: 'session_conflict', error: 'two spellings' },
|
||||
);
|
||||
// A success carries neither discriminator — status alone, not `code: null`,
|
||||
// which would diff against a refusal's string as a type change.
|
||||
assert.deepEqual(admissionOnly({ sessionId: 'x' }, { status: 200 }), {
|
||||
_status: 200,
|
||||
});
|
||||
// Each discriminator is kept independently of the other.
|
||||
assert.deepEqual(admissionOnly({ code: 'c' }, res), {
|
||||
_status: 409,
|
||||
code: 'c',
|
||||
});
|
||||
assert.deepEqual(admissionOnly({ error: 'e' }, res), {
|
||||
_status: 409,
|
||||
error: 'e',
|
||||
});
|
||||
});
|
||||
|
||||
test('isPlainObject decides which bodies may be spread into a capture', () => {
|
||||
assert.equal(isPlainObject({ a: 1 }), true);
|
||||
// Spreading these would drop a scalar/null body and re-key an array into an
|
||||
// indexed object, so a future scenario probing such an endpoint would diff
|
||||
// clean no matter what changed.
|
||||
assert.equal(isPlainObject([1, 2]), false);
|
||||
assert.equal(isPlainObject(null), false);
|
||||
assert.equal(isPlainObject(123), false);
|
||||
assert.equal(isPlainObject('x'), false);
|
||||
});
|
||||
|
||||
test('composeCapture always records the status the harness saw', () => {
|
||||
// A body key of the same name must not win: that would turn a status-only
|
||||
// regression into "body unchanged", the masking this harness exists to stop.
|
||||
assert.deepEqual(
|
||||
composeCapture({}, { _status: 400, ok: true }, { status: 200 }),
|
||||
{
|
||||
ok: true,
|
||||
_status: 200,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(composeCapture({}, { a: 1 }, { status: 409 }), {
|
||||
a: 1,
|
||||
_status: 409,
|
||||
});
|
||||
});
|
||||
|
||||
test('composeCapture nests non-object bodies instead of spreading them', () => {
|
||||
assert.deepEqual(composeCapture({}, 123, { status: 200 }), {
|
||||
_status: 200,
|
||||
_body: 123,
|
||||
});
|
||||
assert.deepEqual(composeCapture({}, null, { status: 204 }), {
|
||||
_status: 204,
|
||||
_body: null,
|
||||
});
|
||||
assert.deepEqual(composeCapture({}, ['a', 'b'], { status: 200 }), {
|
||||
_status: 200,
|
||||
_body: ['a', 'b'],
|
||||
});
|
||||
});
|
||||
|
||||
test('composeCapture defers to a scenario projection when one is declared', () => {
|
||||
const scenario = {
|
||||
project: (json, res) => ({ _status: res.status, code: json.code }),
|
||||
};
|
||||
assert.deepEqual(
|
||||
composeCapture(
|
||||
scenario,
|
||||
{ code: 'x', compactedReplay: [] },
|
||||
{ status: 409 },
|
||||
),
|
||||
{ _status: 409, code: 'x' },
|
||||
);
|
||||
});
|
||||
|
||||
test('composeCapture keeps the harness status when a projection supplies its own', () => {
|
||||
// The invariant is about the capture, not about who composes it: a
|
||||
// projection-supplied `_status` must not win over the one the harness saw,
|
||||
// or a status-only regression can hide behind it.
|
||||
const scenario = { project: () => ({ _status: 999, code: 'x' }) };
|
||||
assert.deepEqual(composeCapture(scenario, {}, { status: 409 }), {
|
||||
_status: 409,
|
||||
code: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
test('clearCaptureDir empties a capture dir and refuses anything else', () => {
|
||||
const dir = join(mkdtempSync(join(tmpdir(), 'sad-clear-')), 'captures');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'health.json'), '{}');
|
||||
writeFileSync(join(dir, DRIVE_COMPLETE_MARKER), '');
|
||||
clearCaptureDir(dir);
|
||||
assert.equal(existsSync(dir), false);
|
||||
|
||||
// `outDir` comes straight off the command line, so a mistyped or reused path
|
||||
// must not be recursively deleted just because it exists.
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'notes.txt'), 'precious');
|
||||
assert.throws(() => clearCaptureDir(dir), /refusing to clear/);
|
||||
assert.equal(existsSync(join(dir, 'notes.txt')), true);
|
||||
|
||||
// A path that does not exist is simply nothing to do.
|
||||
assert.doesNotThrow(() => clearCaptureDir(join(dir, 'nope')));
|
||||
});
|
||||
|
||||
test('assertCanaryStatus fails a canary whose transcript restored empty', () => {
|
||||
// Record validation fails OPEN in the product, so a rotted fixture answers
|
||||
// 200 over an empty session. Status alone cannot see that.
|
||||
const canary = { name: 'healthy', expectStatus: 200, expectReplay: true };
|
||||
assert.doesNotThrow(() =>
|
||||
assertCanaryStatus(canary, 200, '', { _status: 200, _replayItems: 3 }),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertCanaryStatus(canary, 200, '', { _status: 200, _replayItems: 0 }),
|
||||
/restored an EMPTY transcript/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertCanaryStatus(canary, 200, '', { _status: 200 }),
|
||||
/restored an EMPTY transcript/,
|
||||
);
|
||||
// Scenarios that do not ask for the witness are unaffected.
|
||||
assert.doesNotThrow(() =>
|
||||
assertCanaryStatus({ name: 'plain' }, 200, '', { _status: 200 }),
|
||||
);
|
||||
});
|
||||
|
||||
test('the healthy canary keeps a replay witness in its capture', () => {
|
||||
const canary = SCENARIOS.find((s) => s.name === 'session-restore-healthy');
|
||||
assert.equal(canary.expectReplay, true);
|
||||
assert.deepEqual(
|
||||
canary.project(
|
||||
{ code: undefined, compactedReplay: [{ id: 1 }, { id: 2 }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
{ _status: 200, _replayItems: 2 },
|
||||
);
|
||||
// A body without the field reads as zero, not as "unknown".
|
||||
assert.deepEqual(canary.project({}, { status: 200 }), {
|
||||
_status: 200,
|
||||
_replayItems: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('captureScenarios writes the completion marker only after the last capture', async () => {
|
||||
const outDir = mkdtempSync(join(tmpdir(), 'sad-cap-'));
|
||||
const reply = (status, body) => ({
|
||||
ok: status < 400,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
});
|
||||
const scenarios = [
|
||||
{ name: 'first', path: '/a', method: 'GET' },
|
||||
{ name: 'second', path: '/b', method: 'GET' },
|
||||
];
|
||||
await captureScenarios(scenarios, {
|
||||
request: () => reply(200, { ok: true }),
|
||||
ctx: {},
|
||||
outDir,
|
||||
});
|
||||
assert.deepEqual(readdirSync(outDir).sort(), [
|
||||
DRIVE_COMPLETE_MARKER,
|
||||
'first.json',
|
||||
'second.json',
|
||||
]);
|
||||
|
||||
// An abort part-way must leave the dir recognisably truncated: with a marker
|
||||
// present the diff would report a partial baseline as a complete one.
|
||||
const aborted = mkdtempSync(join(tmpdir(), 'sad-cap-'));
|
||||
const canaryScenarios = [
|
||||
{ name: 'first', path: '/a', method: 'GET' },
|
||||
{ name: 'canary', path: '/b', method: 'GET', expectStatus: 200 },
|
||||
{ name: 'third', path: '/c', method: 'GET' },
|
||||
];
|
||||
await assert.rejects(
|
||||
captureScenarios(canaryScenarios, {
|
||||
request: (s) => reply(s.name === 'canary' ? 500 : 200, { ok: true }),
|
||||
ctx: {},
|
||||
outDir: aborted,
|
||||
}),
|
||||
/scenario "canary" expected HTTP 200/,
|
||||
);
|
||||
const left = readdirSync(aborted).sort();
|
||||
assert.deepEqual(left, ['canary.json', 'first.json']);
|
||||
assert.ok(
|
||||
!left.includes(DRIVE_COMPLETE_MARKER),
|
||||
'a truncated run must not look complete',
|
||||
);
|
||||
});
|
||||
|
||||
test('captureScenarios stages fixtures before it requests', async () => {
|
||||
const outDir = mkdtempSync(join(tmpdir(), 'sad-order-'));
|
||||
const order = [];
|
||||
await captureScenarios(
|
||||
[
|
||||
{
|
||||
name: 'staged',
|
||||
path: '/x',
|
||||
method: 'GET',
|
||||
fixtures: () => order.push('fixtures'),
|
||||
},
|
||||
],
|
||||
{
|
||||
request: () => {
|
||||
order.push('request');
|
||||
return { ok: true, status: 200, text: async () => '{}' };
|
||||
},
|
||||
ctx: {},
|
||||
outDir,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(order, ['fixtures', 'request']);
|
||||
});
|
||||
|
||||
test('captureScenarios aborts when a setup request fails', async () => {
|
||||
const outDir = mkdtempSync(join(tmpdir(), 'sad-setup-'));
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'health-deep-with-session',
|
||||
path: '/health?deep=1',
|
||||
method: 'GET',
|
||||
setup: [{ method: 'POST', path: '/session', body: () => ({}) }],
|
||||
},
|
||||
];
|
||||
// A setup that quietly fails leaves the capture describing a daemon where the
|
||||
// session was never created — a masked or faked diff, which is worse than no
|
||||
// diff at all.
|
||||
await assert.rejects(
|
||||
captureScenarios(scenarios, {
|
||||
request: (spec) =>
|
||||
spec.path === '/session'
|
||||
? { ok: false, status: 400, text: async () => 'workspace_mismatch' }
|
||||
: { ok: true, status: 200, text: async () => '{}' },
|
||||
ctx: {},
|
||||
outDir,
|
||||
}),
|
||||
/setup POST \/session failed \(HTTP 400\) for "health-deep-with-session"/,
|
||||
);
|
||||
// Neither the capture nor the completion marker may exist: the scenario never
|
||||
// ran, and a marker here would certify a truncated baseline as complete.
|
||||
assert.deepEqual(readdirSync(outDir), []);
|
||||
|
||||
// A setup that succeeds runs the scenario as normal — and strictly BEFORE
|
||||
// the probe: the probe must capture the daemon the setup created state on,
|
||||
// so the two requests cannot trade places without the test noticing.
|
||||
const okDir = mkdtempSync(join(tmpdir(), 'sad-setup-'));
|
||||
const order = [];
|
||||
await captureScenarios(scenarios, {
|
||||
request: (spec) => {
|
||||
order.push(spec.path);
|
||||
return { ok: true, status: 200, text: async () => '{"ok":true}' };
|
||||
},
|
||||
ctx: {},
|
||||
outDir: okDir,
|
||||
});
|
||||
assert.deepEqual(order, ['/session', '/health?deep=1']);
|
||||
assert.deepEqual(readdirSync(okDir).sort(), [
|
||||
DRIVE_COMPLETE_MARKER,
|
||||
'health-deep-with-session.json',
|
||||
]);
|
||||
});
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -50,7 +50,7 @@ env:
|
|||
# BOTH the github_ci_only helper step and the full-profile Test step, so a
|
||||
# new helper test can't be added to one path and silently dropped from the
|
||||
# other.
|
||||
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs'
|
||||
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs'
|
||||
|
||||
jobs:
|
||||
classify_pr:
|
||||
|
|
|
|||
13
.github/workflows/finalize-release.yml
vendored
13
.github/workflows/finalize-release.yml
vendored
|
|
@ -201,12 +201,13 @@ jobs:
|
|||
${{ steps.meta.outputs.is_stable == 'true' }}
|
||||
id: 'pr'
|
||||
env:
|
||||
# Author the PR as github-actions[bot] (installation token) so that
|
||||
# NEITHER bot PAT is the author: GitHub forbids self-approval, and
|
||||
# the two approve steps below need both PAT identities free to
|
||||
# supply the branch protection's two required approvals without a
|
||||
# human.
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
# Author the PR as the review bot (a third identity) so that NEITHER
|
||||
# approve PAT (ci-bot / dev-bot) is the author: GitHub forbids
|
||||
# self-approval, and the two approve steps below need both PAT
|
||||
# identities free to supply the branch protection's two required
|
||||
# approvals without a human. GITHUB_TOKEN cannot be used here
|
||||
# because the org disables GitHub Actions from creating PRs.
|
||||
GITHUB_TOKEN: '${{ secrets.CI_REVIEW_BOT_PAT }}'
|
||||
RELEASE_BRANCH: '${{ steps.meta.outputs.release_branch }}'
|
||||
RELEASE_TAG: '${{ env.RELEASE_TAG }}'
|
||||
run: |-
|
||||
|
|
|
|||
23
.github/workflows/qwen-autofix.yml
vendored
23
.github/workflows/qwen-autofix.yml
vendored
|
|
@ -5356,7 +5356,7 @@ jobs:
|
|||
repository(owner:$owner,name:$name){
|
||||
pullRequest(number:$pr){
|
||||
reviewThreads(first:100, after:$endCursor){
|
||||
nodes{id isResolved comments(first:100){nodes{databaseId} pageInfo{hasNextPage}}}
|
||||
nodes{id isResolved comments(first:100){nodes{databaseId author{login} body} pageInfo{hasNextPage}}}
|
||||
pageInfo{hasNextPage endCursor}
|
||||
}
|
||||
}
|
||||
|
|
@ -5471,6 +5471,27 @@ jobs:
|
|||
root_id="$(jq -r --argjson id "${rc_id}" \
|
||||
'map(select(any(.comments.nodes[]; .databaseId == $id)))
|
||||
| .[0].comments.nodes[0].databaseId // $id' <<< "${THREADS_JSON}")"
|
||||
# Idempotence gate: a crash-and-rerun of this round, a
|
||||
# same-run repair that regenerates the dispositions, or a
|
||||
# later round whose agent rewrites an unchanged declination
|
||||
# must not post the same bot reply twice on one thread
|
||||
# (observed 2026-08-16: an identical reply posted three
|
||||
# times, #9296). Skip when the thread already carries a
|
||||
# comment by the bot whose body EQUALS the neutralised body
|
||||
# about to be posted; a changed body — a new reason in a
|
||||
# later round — still posts. Best-effort like the rest: with
|
||||
# a stale or empty threads view this degrades to the old
|
||||
# post-always behavior.
|
||||
if jq -e --argjson id "${root_id}" --arg bot "${AUTOFIX_BOT}" \
|
||||
--arg body "${REPLY_BODY}" '
|
||||
map(select(any(.comments.nodes[]; .databaseId == $id)))
|
||||
| .[0].comments.nodes // []
|
||||
| any(.[]; (.author.login // "") == $bot
|
||||
and (.body // "") == $body)' \
|
||||
<<< "${THREADS_JSON}" > /dev/null 2>&1; then
|
||||
echo "⏭️ reply to review comment ${rc_id} skipped — identical bot reply already on the thread"
|
||||
continue
|
||||
fi
|
||||
if gh api "repos/${REPO}/pulls/${PR}/comments/${root_id}/replies" \
|
||||
-f body="${REPLY_BODY}" > /dev/null 2>&1; then
|
||||
REPLIED_N=$(( REPLIED_N + 1 ))
|
||||
|
|
|
|||
82
.github/workflows/qwen-fleet-shepherd.yml
vendored
82
.github/workflows/qwen-fleet-shepherd.yml
vendored
|
|
@ -11,7 +11,11 @@ name: 'Fleet Shepherd'
|
|||
# propagates workflow/skill fixes; self-limiting since
|
||||
# behind_by resets to 0 after the sync)
|
||||
# • scan liveness → if no autofix full scan (schedule/dispatch) ran
|
||||
# recently, dispatch one (GitHub cron is unreliable)
|
||||
# recently, dispatch one (GitHub cron is unreliable).
|
||||
# A run wedged in `queued` past ZOMBIE_QUEUED_MINUTES
|
||||
# never counts as in-flight: GitHub never started it,
|
||||
# so deferring to it starves the watchdog forever
|
||||
# (2026-08-19, an oversized workflow file)
|
||||
#
|
||||
# It also maintains a single "Fleet Shepherd Dashboard" issue (edited in
|
||||
# place, never comment spam) so fleet state is observable at a glance. The
|
||||
|
|
@ -70,6 +74,15 @@ env:
|
|||
AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}"
|
||||
BEHIND_SYNC_THRESHOLD: '25'
|
||||
SCAN_LIVENESS_MINUTES: '60'
|
||||
# A dispatched or scheduled run claims a runner within seconds; one still
|
||||
# 'queued' this long was never STARTED by GitHub at all. That run is wedged,
|
||||
# not in flight, and counting it as in-flight starves every lever that
|
||||
# defers to a live run — permanently, since nothing will ever complete it.
|
||||
# 2026-08-19: a workflow file over GitHub's 500 KB limit produced exactly
|
||||
# this (runs created, zero jobs, uncancellable through the API) and the
|
||||
# liveness watchdog sat at in-flight=1 for 18 hours while the loop was dark.
|
||||
# Generous by design: it must never fire on an ordinary runner queue.
|
||||
ZOMBIE_QUEUED_MINUTES: "${{ vars.QWEN_SHEPHERD_ZOMBIE_QUEUED_MINUTES || '30' }}"
|
||||
MAX_SYNCS_PER_TICK: '3'
|
||||
MAX_CONFLICT_DISPATCHES_PER_TICK: '2'
|
||||
DASHBOARD_TITLE: 'Fleet Shepherd Dashboard'
|
||||
|
|
@ -218,6 +231,36 @@ jobs:
|
|||
SCAN_RUNS_OK=false
|
||||
echo "::warning::autofix run-list read failed; liveness lever and conflict dispatches skipped this tick"
|
||||
fi
|
||||
# The variable is operator-tunable, so it is also operator-
|
||||
# breakable: a non-numeric value makes every jq consumer carrying
|
||||
# $zmin exit 5 into its benign fallback — in-flight reads 0 on top
|
||||
# of live runs and the census reads 0, re-hiding exactly the wedge
|
||||
# this lever exists to surface. Fall back to the default, mirroring
|
||||
# AUTO_RELEASE_DAYS. The digit-only regex still admits values large
|
||||
# enough to wedge every queued run at once — re-creating the
|
||||
# starvation — so bound by string LENGTH too, and reject zero: it
|
||||
# wedges every queued run at birth, the exact opposite of the
|
||||
# generous-by-design invariant the env block declares.
|
||||
if [[ ! "${ZOMBIE_QUEUED_MINUTES}" =~ ^[0-9]+$ ]] || [[ ${#ZOMBIE_QUEUED_MINUTES} -gt 3 ]] || [[ "${ZOMBIE_QUEUED_MINUTES}" =~ ^0+$ ]]; then
|
||||
echo "::warning::ZOMBIE_QUEUED_MINUTES '${ZOMBIE_QUEUED_MINUTES}' is not a positive integer or is too large; using 30"
|
||||
ZOMBIE_QUEUED_MINUTES=30
|
||||
fi
|
||||
# One definition of "wedged", shared by every reader of the run
|
||||
# snapshot below, so the in-flight count, the census, and the
|
||||
# liveness re-dispatch guard can never disagree about what counts
|
||||
# as a live run. A missing createdAt reads as brand new (never
|
||||
# wedged): unknown age must not license a duplicate dispatch.
|
||||
ZOMBIE_JQ='def wedged($now; $mins): .status == "queued" and (((.createdAt // "") | if . == "" then 9999999999 else fromdateiso8601 end) <= (($now | tonumber) - ($mins | tonumber) * 60));'
|
||||
SCAN_ZOMBIES="$(jq -r --arg now "${NOW_EPOCH}" --arg zmin "${ZOMBIE_QUEUED_MINUTES}" "${ZOMBIE_JQ}"'
|
||||
[ .[] | select(wedged($now; $zmin)) ] | length' /tmp/scan-runs.json 2> /dev/null || echo 0)"
|
||||
SCAN_ZOMBIE_OLDEST="$(jq -r --arg now "${NOW_EPOCH}" --arg zmin "${ZOMBIE_QUEUED_MINUTES}" "${ZOMBIE_JQ}"'
|
||||
[ .[] | select(wedged($now; $zmin)) | .createdAt ] | sort | first // ""' /tmp/scan-runs.json 2> /dev/null || echo '')"
|
||||
# Loud, because invisibility is what made this expensive: the loop
|
||||
# looked half-alive for a day (PR-event runs kept succeeding) while
|
||||
# every dispatch queued forever.
|
||||
if [[ "${SCAN_ZOMBIES}" -gt 0 ]]; then
|
||||
echo "::warning::${SCAN_ZOMBIES} autofix run(s) stuck 'queued' for over ${ZOMBIE_QUEUED_MINUTES}m (oldest ${SCAN_ZOMBIE_OLDEST:-unknown}) — GitHub is not starting them; a workflow file over the 500 KB limit does exactly this. They are excluded from the in-flight count so the liveness lever keeps working."
|
||||
fi
|
||||
LAST_SCHEDULE="$(jq -r '[.[] | select(.event == "schedule")] | first | .createdAt // ""' /tmp/scan-runs.json 2> /dev/null || echo '')"
|
||||
# In-flight counts SCHEDULE runs plus OUR OWN liveness dispatch,
|
||||
# attributed by recorded run id — never by timestamp proximity: a
|
||||
|
|
@ -228,13 +271,29 @@ jobs:
|
|||
# marker): the dispatch is simply not counted, so the failure mode
|
||||
# is one absorbed duplicate scan — never starvation. Same for the
|
||||
# first tick: no watermark, nothing attributed.
|
||||
SCAN_INFLIGHT="$(jq -r --arg lvrun "${PREV_LIVENESS_RUN}" '
|
||||
SCAN_INFLIGHT="$(jq -r --arg lvrun "${PREV_LIVENESS_RUN}" --arg now "${NOW_EPOCH}" --arg zmin "${ZOMBIE_QUEUED_MINUTES}" "${ZOMBIE_JQ}"'
|
||||
[ .[] | select(.status != "completed")
|
||||
| select(wedged($now; $zmin) | not)
|
||||
| select(
|
||||
(.event == "schedule")
|
||||
or (.event == "workflow_dispatch" and $lvrun != ""
|
||||
and ((.databaseId | tostring) == $lvrun)) ) ]
|
||||
| length' /tmp/scan-runs.json 2> /dev/null || echo 0)"
|
||||
# During a PERSISTENT wedge the watermark cycle would reopen this
|
||||
# gate every 60 minutes and plant a fresh uncancellable queued
|
||||
# dispatch per hour, each refreshing the very liveness watermark
|
||||
# whose growing age exposed the incident. If the run recorded from
|
||||
# the last dispatch is ITSELF still wedged in the snapshot, another
|
||||
# dispatch would wedge too — keep the gate closed. This lengthens
|
||||
# the interval, it does not block hard: once that run starts,
|
||||
# completes, or leaves the snapshot window, the gate reopens on its
|
||||
# own, so the residual stays the documented single absorbed
|
||||
# duplicate scan instead of one corpse per hour. When attribution
|
||||
# falls back to run=none the guard cannot see the dispatch — no id
|
||||
# was recorded — and the watermark-cycle corpse planting resumes;
|
||||
# the wedge banner remains the exposure signal for that path.
|
||||
PREV_LIVENESS_WEDGED="$(jq -r --arg lvrun "${PREV_LIVENESS_RUN}" --arg now "${NOW_EPOCH}" --arg zmin "${ZOMBIE_QUEUED_MINUTES}" "${ZOMBIE_JQ}"'
|
||||
[ .[] | select($lvrun != "" and ((.databaseId | tostring) == $lvrun) and wedged($now; $zmin)) ] | length' /tmp/scan-runs.json 2> /dev/null || echo 0)"
|
||||
LAST_SIGNAL="${LAST_SCHEDULE}"
|
||||
if [[ -n "${PREV_LIVENESS}" && "${PREV_LIVENESS}" > "${LAST_SIGNAL}" ]]; then
|
||||
LAST_SIGNAL="${PREV_LIVENESS}"
|
||||
|
|
@ -245,8 +304,8 @@ jobs:
|
|||
fi
|
||||
LIVENESS_OUT="${PREV_LIVENESS}"
|
||||
LIVENESS_RUN_OUT="${PREV_LIVENESS_RUN}"
|
||||
echo "🫀 last scan signal: ${LAST_SIGNAL:-never} (${SCAN_AGE_MIN}m ago), liveness-relevant in-flight: ${SCAN_INFLIGHT}, snapshot ok: ${SCAN_RUNS_OK}, watermark state known: ${DASH_LOOKUP_OK}"
|
||||
if [[ "${DASH_LOOKUP_OK}" == "true" && "${SCAN_RUNS_OK}" == "true" && "${SCAN_AGE_MIN}" -ge "${SCAN_LIVENESS_MINUTES}" && "${SCAN_INFLIGHT}" == "0" ]]; then
|
||||
echo "🫀 last scan signal: ${LAST_SIGNAL:-never} (${SCAN_AGE_MIN}m ago), liveness-relevant in-flight: ${SCAN_INFLIGHT}, wedged-queued: ${SCAN_ZOMBIES}, prev-liveness wedged: ${PREV_LIVENESS_WEDGED}, snapshot ok: ${SCAN_RUNS_OK}, watermark state known: ${DASH_LOOKUP_OK}"
|
||||
if [[ "${DASH_LOOKUP_OK}" == "true" && "${SCAN_RUNS_OK}" == "true" && "${SCAN_AGE_MIN}" -ge "${SCAN_LIVENESS_MINUTES}" && "${SCAN_INFLIGHT}" == "0" && "${PREV_LIVENESS_WEDGED}" == "0" ]]; then
|
||||
DISPATCH_T0="$(date -u -d '5 seconds ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
if act "scan liveness: dispatch unforced review scan" \
|
||||
env GITHUB_TOKEN="${ACTIONS_TOKEN}" gh workflow run qwen-autofix.yml --repo "${REPO}" -f phase=review; then
|
||||
|
|
@ -305,6 +364,11 @@ jobs:
|
|||
# enumeration is not a busy-set, it is unknown busy-state, and
|
||||
# BUSY_OK=false defers every conflict dispatch below (it inherits
|
||||
# SCAN_RUNS_OK so a failed run-list read defers the same way).
|
||||
# Wedged runs are NOT skipped here (unlike the in-flight count):
|
||||
# age alone proves jobless only for the wedge class that defined
|
||||
# the threshold — when the runner pool is offline, queued runs hold
|
||||
# live review-address jobs indefinitely, and dropping them by age
|
||||
# would silently re-dispatch their PRs. The jobs read settles it.
|
||||
SHEP_BUSY=' '
|
||||
BUSY_OK="${SCAN_RUNS_OK}"
|
||||
while IFS= read -r LIVE_RUN; do
|
||||
|
|
@ -1130,6 +1194,16 @@ jobs:
|
|||
echo
|
||||
echo "Last tick: $(date -u +%Y-%m-%dT%H:%M:%SZ) · scan-signal age: ${SCAN_AGE_MIN}m · syncs: ${SYNCS} · dispatches: ${DISPATCHES} · releases: ${RELEASES} · cleanups: ${CLEANUPS}"
|
||||
echo
|
||||
# Surfaced on the dashboard, not just in a log nobody opens: a
|
||||
# wedged queue is the shape of a dead loop that still reports
|
||||
# green from PR-event runs.
|
||||
if [[ "${SCAN_ZOMBIES}" -gt 0 ]]; then
|
||||
echo "> ⚠️ **${SCAN_ZOMBIES} autofix run(s) wedged in \`queued\`** (oldest ${SCAN_ZOMBIE_OLDEST:-unknown}) — excluded from the in-flight count. The list is status+age only and cannot see jobs: a workflow file over GitHub's 500 KB limit wedges zero-job runs, and an offline runner pool keeps live jobs queued just as long. Check \`gh run view <id> --json jobs\` before deleting any of them."
|
||||
if [[ "${PREV_LIVENESS_WEDGED}" -gt 0 ]]; then
|
||||
echo "> 🚧 The shepherd's liveness re-dispatch stays paused while the recorded liveness run (id ${PREV_LIVENESS_RUN}) is among them — a fresh dispatch would only wedge again. Deleting THAT run (\`gh run delete ${PREV_LIVENESS_RUN}\`) reopens it immediately; it also reopens once it leaves the 50-run snapshot window. When several runs are listed, check \`gh run view <id> --json jobs\` before deleting — an offline runner pool keeps live jobs queued."
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
echo '## Bot fleet'
|
||||
echo
|
||||
echo '| PR | Head | State | Action this tick |'
|
||||
|
|
|
|||
167
.github/workflows/qwen-triage.yml
vendored
167
.github/workflows/qwen-triage.yml
vendored
|
|
@ -2599,21 +2599,68 @@ jobs:
|
|||
run: |-
|
||||
set -uo pipefail
|
||||
WS="${GITHUB_WORKSPACE:?}"
|
||||
# Refuse to run anywhere unexpected: a wipe pointed at the wrong
|
||||
# path by a mangled env is far worse than a skipped wipe, and
|
||||
# this job cannot proceed safely without it either way. This is
|
||||
# the guard from qwen-code-pr-review.yml's checkout heal (#9220),
|
||||
# backported per #9265: measured on main, the bare denylist let
|
||||
# non-canonical spellings of the guarded roots through (/home/,
|
||||
# /home/., //usr, /root/, /var/ all reached the rm).
|
||||
# Strip trailing slashes on the RAW path, before anything reads it:
|
||||
# `[ -L "$WS/" ]` and `[ ! -d "$WS/" ]` both resolve THROUGH a link
|
||||
# and report its target, so one trailing slash hides the corruption
|
||||
# the heal below exists to clear.
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
# The allowlist root is prepared BEFORE the heal, because it bounds
|
||||
# what the heal may touch: canonical, slash-free and non-degenerate.
|
||||
# An empty $RUNNER_WORKSPACE would turn every containment pattern
|
||||
# below into the match-all "/*".
|
||||
RWS="${RUNNER_WORKSPACE:?}"
|
||||
RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi
|
||||
case "$RWS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;;
|
||||
esac
|
||||
# Heal a workspace a previous job replaced with a symlink (or any
|
||||
# non-directory) BEFORE canonicalizing it. Afterwards the path
|
||||
# resolves to the link's target, the allowlist refuses that, and the
|
||||
# refusal removes nothing — so every later job on this runner dies
|
||||
# here, permanently, on corruption that is itself inside the runner
|
||||
# workspace and safe to unlink.
|
||||
if [ -L "$WS" ] || [ ! -d "$WS" ]; then
|
||||
# Judge the PARENT, canonicalized. The heal necessarily acts on a
|
||||
# raw path, and a raw containment match is not enough: the kernel
|
||||
# resolves intermediate components too, so `$RWS/link/sub` matches
|
||||
# "$RWS"/* as a string while naming a file outside it. Resolving
|
||||
# the parent — never $WS itself, which would resolve through the
|
||||
# very link being removed — is what makes the unlink containable.
|
||||
HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; }
|
||||
case "$HEAL_PARENT" in
|
||||
"$RWS"|"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;;
|
||||
esac
|
||||
# The incident this heal exists for leaves no other trace: say what
|
||||
# was found, and where it pointed, before it is gone.
|
||||
if [ -L "$WS" ]; then
|
||||
# The target is bytes a PREVIOUS job chose — on this pool that
|
||||
# job may have run a contributor's code — and the runner parses
|
||||
# `::` at the start of any stdout line as a workflow command. A
|
||||
# target of $'x\n::error::forged' would therefore forge an
|
||||
# annotation. Keep untrusted bytes off the command line itself,
|
||||
# strip the line breaks that could start a new one, and cap the
|
||||
# length.
|
||||
heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '<unreadable>')"
|
||||
heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)"
|
||||
echo "::warning::healing workspace ${WS}: it was a symlink"
|
||||
printf 'heal: %s pointed at %s\n' "$WS" "$heal_target"
|
||||
else
|
||||
echo "::warning::healing workspace ${WS}: it was not a directory"
|
||||
fi
|
||||
# `rm -f` on the RAW path removes the link itself and never
|
||||
# follows it. Both legs fail closed: under `-e` a failure that is
|
||||
# not the last command of an && list is swallowed, and a swallowed
|
||||
# one here would leave the wipe running against a corrupt path.
|
||||
rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; }
|
||||
mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; }
|
||||
fi
|
||||
# Canonicalize before matching: the kernel resolves non-canonical
|
||||
# spellings to the guarded roots (`/home/.` -> /home, `//usr` ->
|
||||
# /usr), so a raw string match lets them slip past the case arms.
|
||||
WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; }
|
||||
# Trailing slashes slip past the exact-match case arms below
|
||||
# (`/home/` would pass the guard and reach the rm); realpath strips
|
||||
# them too; keep the guard whole if the path reaches this point with
|
||||
# trailing slashes.
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
case "$WS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;;
|
||||
|
|
@ -2623,19 +2670,7 @@ jobs:
|
|||
esac
|
||||
# A denylist can only enumerate known roots — the allowlist closes
|
||||
# every other one (/tmp, /opt, ...): only a directory inside the
|
||||
# runner workspace may be wiped. RUNNER_WORKSPACE is set in every
|
||||
# step env, and for container steps the runner translates it —
|
||||
# together with GITHUB_WORKSPACE — to the container path, so the
|
||||
# allowlist holds inside this job's container as well.
|
||||
RWS="${RUNNER_WORKSPACE:?}"
|
||||
RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
# Mirror the WS strip before building the allowlist pattern; "/"
|
||||
# stripped empty would match every path instead.
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi
|
||||
case "$RWS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;;
|
||||
esac
|
||||
# runner workspace may be wiped.
|
||||
case "$WS" in
|
||||
"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;;
|
||||
|
|
@ -4811,12 +4846,76 @@ jobs:
|
|||
if: "always() && needs.authorize.outputs.verify_trust == 'external'"
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
# Same guard as the pre-run wipe above (canonicalize, strip
|
||||
# trailing slashes, denylist, RUNNER_WORKSPACE allowlist) — this
|
||||
# copy predates the checkout-heal hardening and never received it
|
||||
# (#9265). See that step's comments for what each layer catches;
|
||||
# the suite pins this copy's behavior on its own.
|
||||
# Same guard as the pre-run wipe above, layer for layer: raw
|
||||
# trailing-slash strip, RUNNER_WORKSPACE allowlist root, symlink
|
||||
# heal, canonicalize, strip, denylist, allowlist. Both copies now
|
||||
# carry the checkout-heal hardening (#9277) and its heal (#9480);
|
||||
# this header is the in-code inventory a future convergence of the
|
||||
# wipe copies will read, so it must not understate what is here.
|
||||
# See that step's comments for what each layer catches; the suite
|
||||
# pins this copy's behavior on its own.
|
||||
WS="${GITHUB_WORKSPACE:?}"
|
||||
# Strip trailing slashes on the RAW path, before anything reads it:
|
||||
# `[ -L "$WS/" ]` and `[ ! -d "$WS/" ]` both resolve THROUGH a link
|
||||
# and report its target, so one trailing slash hides the corruption
|
||||
# the heal below exists to clear.
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
# The allowlist root is prepared BEFORE the heal, because it bounds
|
||||
# what the heal may touch: canonical, slash-free and non-degenerate.
|
||||
# An empty $RUNNER_WORKSPACE would turn every containment pattern
|
||||
# below into the match-all "/*".
|
||||
RWS="${RUNNER_WORKSPACE:?}"
|
||||
RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi
|
||||
case "$RWS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;;
|
||||
esac
|
||||
# Heal a workspace a previous job replaced with a symlink (or any
|
||||
# non-directory) BEFORE canonicalizing it. Afterwards the path
|
||||
# resolves to the link's target, the allowlist refuses that, and the
|
||||
# refusal removes nothing — so every later job on this runner dies
|
||||
# here, permanently, on corruption that is itself inside the runner
|
||||
# workspace and safe to unlink.
|
||||
if [ -L "$WS" ] || [ ! -d "$WS" ]; then
|
||||
# Judge the PARENT, canonicalized. The heal necessarily acts on a
|
||||
# raw path, and a raw containment match is not enough: the kernel
|
||||
# resolves intermediate components too, so `$RWS/link/sub` matches
|
||||
# "$RWS"/* as a string while naming a file outside it. Resolving
|
||||
# the parent — never $WS itself, which would resolve through the
|
||||
# very link being removed — is what makes the unlink containable.
|
||||
HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; }
|
||||
case "$HEAL_PARENT" in
|
||||
"$RWS"|"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;;
|
||||
esac
|
||||
# The incident this heal exists for leaves no other trace: say what
|
||||
# was found, and where it pointed, before it is gone.
|
||||
if [ -L "$WS" ]; then
|
||||
# The target is bytes a PREVIOUS job chose — on this pool that
|
||||
# job may have run a contributor's code — and the runner parses
|
||||
# `::` at the start of any stdout line as a workflow command. A
|
||||
# target of $'x\n::error::forged' would therefore forge an
|
||||
# annotation. Keep untrusted bytes off the command line itself,
|
||||
# strip the line breaks that could start a new one, and cap the
|
||||
# length.
|
||||
heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '<unreadable>')"
|
||||
heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)"
|
||||
echo "::warning::healing workspace ${WS}: it was a symlink"
|
||||
printf 'heal: %s pointed at %s\n' "$WS" "$heal_target"
|
||||
else
|
||||
echo "::warning::healing workspace ${WS}: it was not a directory"
|
||||
fi
|
||||
# `rm -f` on the RAW path removes the link itself and never
|
||||
# follows it. Both legs fail closed: under `-e` a failure that is
|
||||
# not the last command of an && list is swallowed, and a swallowed
|
||||
# one here would leave the wipe running against a corrupt path.
|
||||
rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; }
|
||||
mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; }
|
||||
fi
|
||||
# Canonicalize before matching: the kernel resolves non-canonical
|
||||
# spellings to the guarded roots (`/home/.` -> /home, `//usr` ->
|
||||
# /usr), so a raw string match lets them slip past the case arms.
|
||||
WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; }
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
case "$WS" in
|
||||
|
|
@ -4825,13 +4924,9 @@ jobs:
|
|||
case "$WS" in
|
||||
/|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;;
|
||||
esac
|
||||
RWS="${RUNNER_WORKSPACE:?}"
|
||||
RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi
|
||||
case "$RWS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;;
|
||||
esac
|
||||
# A denylist can only enumerate known roots — the allowlist closes
|
||||
# every other one (/tmp, /opt, ...): only a directory inside the
|
||||
# runner workspace may be wiped.
|
||||
case "$WS" in
|
||||
"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;;
|
||||
|
|
|
|||
103
.github/workflows/serve-ab.yml
vendored
103
.github/workflows/serve-ab.yml
vendored
|
|
@ -34,9 +34,23 @@ on:
|
|||
# acceptable best-effort miss. Doc/UI-only PRs don't need an A/B either.
|
||||
paths:
|
||||
- 'packages/cli/src/serve/**'
|
||||
# Core files the daemon's session-admission answers are decided by:
|
||||
# transcript lookup and creation-metadata reads, the JSONL recovery they
|
||||
# sit on, and the two the harness MIRRORS to place its fixtures (a rename
|
||||
# there strands every staged transcript). Named individually rather than
|
||||
# globbing `packages/core/**`, which would fire this 2x-build A/B on most
|
||||
# core PRs for nothing. This list is deliberately not the transitive
|
||||
# closure of the admission path — that has no principled stop — so a PR
|
||||
# further out lands unprobed; the drive's canaries are what keep a stale
|
||||
# scheme loud rather than silent.
|
||||
- 'packages/core/src/services/sessionService.ts'
|
||||
- 'packages/core/src/utils/jsonl-utils.ts'
|
||||
- 'packages/core/src/utils/paths.ts'
|
||||
- 'packages/core/src/config/storage.ts'
|
||||
- '.github/workflows/serve-ab.yml'
|
||||
- '.github/scripts/serve-ab-diff.mjs'
|
||||
- '.github/scripts/serve-ab-drive.mjs'
|
||||
- '.github/scripts/fixtures/serve-ab-session.jsonl'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
|
@ -58,7 +72,10 @@ jobs:
|
|||
# other fork PRs stay on ephemeral hosted runners. Keep in sync with
|
||||
# ci.yml's classify_pr routing. Kill-switch: MAINTAINER_ECS_RUNNER_DISABLED.
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
timeout-minutes: 30
|
||||
# Two full checkouts, each npm-ci + build + drive: a healthy run lands
|
||||
# near twenty minutes, and a slow runner pushed a run past the old
|
||||
# 30-minute bound, cancelling it.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: 'Restore workspace ownership'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
|
|
@ -88,14 +105,68 @@ jobs:
|
|||
# strip trailing slashes, denylist the known roots, and require
|
||||
# the target to sit inside the runner workspace before any rm.
|
||||
WS="${GITHUB_WORKSPACE:?}"
|
||||
# Strip trailing slashes on the RAW path, before anything reads it:
|
||||
# `[ -L "$WS/" ]` and `[ ! -d "$WS/" ]` both resolve THROUGH a link
|
||||
# and report its target, so one trailing slash hides the corruption
|
||||
# the heal below exists to clear.
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
# The allowlist root is prepared BEFORE the heal, because it bounds
|
||||
# what the heal may touch: canonical, slash-free and non-degenerate.
|
||||
# An empty $RUNNER_WORKSPACE would turn every containment pattern
|
||||
# below into the match-all "/*".
|
||||
RWS="${RUNNER_WORKSPACE:?}"
|
||||
RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi
|
||||
case "$RWS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;;
|
||||
esac
|
||||
# Heal a workspace a previous job replaced with a symlink (or any
|
||||
# non-directory) BEFORE canonicalizing it. Afterwards the path
|
||||
# resolves to the link's target, the allowlist refuses that, and the
|
||||
# refusal removes nothing — so every later job on this runner dies
|
||||
# here, permanently, on corruption that is itself inside the runner
|
||||
# workspace and safe to unlink.
|
||||
if [ -L "$WS" ] || [ ! -d "$WS" ]; then
|
||||
# Judge the PARENT, canonicalized. The heal necessarily acts on a
|
||||
# raw path, and a raw containment match is not enough: the kernel
|
||||
# resolves intermediate components too, so `$RWS/link/sub` matches
|
||||
# "$RWS"/* as a string while naming a file outside it. Resolving
|
||||
# the parent — never $WS itself, which would resolve through the
|
||||
# very link being removed — is what makes the unlink containable.
|
||||
HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; }
|
||||
case "$HEAL_PARENT" in
|
||||
"$RWS"|"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;;
|
||||
esac
|
||||
# The incident this heal exists for leaves no other trace: say what
|
||||
# was found, and where it pointed, before it is gone.
|
||||
if [ -L "$WS" ]; then
|
||||
# The target is bytes a PREVIOUS job chose — on this pool that
|
||||
# job may have run a contributor's code — and the runner parses
|
||||
# `::` at the start of any stdout line as a workflow command. A
|
||||
# target of $'x\n::error::forged' would therefore forge an
|
||||
# annotation. Keep untrusted bytes off the command line itself,
|
||||
# strip the line breaks that could start a new one, and cap the
|
||||
# length.
|
||||
heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '<unreadable>')"
|
||||
heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)"
|
||||
echo "::warning::healing workspace ${WS}: it was a symlink"
|
||||
printf 'heal: %s pointed at %s\n' "$WS" "$heal_target"
|
||||
else
|
||||
echo "::warning::healing workspace ${WS}: it was not a directory"
|
||||
fi
|
||||
# `rm -f` on the RAW path removes the link itself and never
|
||||
# follows it. Both legs fail closed: under `-e` a failure that is
|
||||
# not the last command of an && list is swallowed, and a swallowed
|
||||
# one here would leave the wipe running against a corrupt path.
|
||||
rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; }
|
||||
mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; }
|
||||
fi
|
||||
# Canonicalize before matching: the kernel resolves non-canonical
|
||||
# spellings to the guarded roots (`/home/.` -> /home, `//usr` ->
|
||||
# /usr), so a raw string match lets them slip past the case arms.
|
||||
WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; }
|
||||
# Trailing slashes slip past the exact-match case arms below
|
||||
# (`/home/` would pass the guard and reach the rm); realpath strips
|
||||
# them too; keep the guard whole if the path reaches this point with
|
||||
# trailing slashes.
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
case "$WS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;;
|
||||
|
|
@ -106,15 +177,6 @@ jobs:
|
|||
# A denylist can only enumerate known roots — the allowlist closes
|
||||
# every other one (/tmp, /opt, ...): only a directory inside the
|
||||
# runner workspace may be wiped.
|
||||
RWS="${RUNNER_WORKSPACE:?}"
|
||||
RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; }
|
||||
# Mirror the WS strip before building the allowlist pattern; "/"
|
||||
# stripped empty would match every path instead.
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi
|
||||
case "$RWS" in
|
||||
..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;;
|
||||
esac
|
||||
case "$WS" in
|
||||
"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;;
|
||||
|
|
@ -178,6 +240,19 @@ jobs:
|
|||
cache: 'npm'
|
||||
cache-dependency-path: 'head/package-lock.json'
|
||||
|
||||
# Unconditional, and BEFORE either drive: on the persistent pool
|
||||
# `${RUNNER_TEMP}` outlives a run, and the drive's own reset lives inside
|
||||
# the script — which never executes when an arm is skipped by its `if:`
|
||||
# (no merge-base resolved, base checkout failed) or dies during
|
||||
# `npm ci`/`npm run build`. An inherited capture set from an earlier run
|
||||
# carries its completion marker too, so neither degraded-baseline warning
|
||||
# would fire and the comment would diff this head against another run's
|
||||
# base.
|
||||
- name: 'Clear stale capture dirs'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
rm -rf "${RUNNER_TEMP}/before" "${RUNNER_TEMP}/after"
|
||||
|
||||
- name: 'Build + drive the PR head'
|
||||
working-directory: 'head'
|
||||
run: |-
|
||||
|
|
|
|||
|
|
@ -138,6 +138,16 @@ dispositions, changed files, checks actually run, and remaining blocker.
|
|||
just moves the rejection later and wastes the round. Record the exact
|
||||
commands you ran and their results in your summary (see the per-mode
|
||||
outcomes); a bare "verified" without them is not acceptable.
|
||||
- Every guard, branch, or behavior a round's commits add needs its OWN witness
|
||||
in the tests the round commits. Verify with a mutation probe before
|
||||
committing: temporarily remove or negate the new guard or branch, re-run the
|
||||
focused tests that should catch it, and confirm they FAIL; then restore it
|
||||
and re-run to green. If the suite stays green with your guard deleted, the
|
||||
guard has no coverage — write a test that pins it (or drop the guard)
|
||||
instead of shipping it: the deterministic gate re-runs only the tests that
|
||||
exist, so an unwitnessed guard passes every gate and its hole resurfaces as
|
||||
a new finding in a later round. Record each probe and its result in your
|
||||
summary alongside the verification commands.
|
||||
- Regenerate committed generated artifacts when you change their source. If you
|
||||
edit `packages/cli/src/config/settingsSchema.ts` (or `settings.ts`), run
|
||||
`npm run generate:settings-schema` and commit the regenerated
|
||||
|
|
|
|||
|
|
@ -448,13 +448,31 @@ finding, not a pass.
|
|||
Report the mutation matrix **including the mutations that changed nothing**:
|
||||
one row per guard the PR introduces, the suite that should catch it, and
|
||||
pinned / not-pinned. Survivors are not noise — classify each as an ordinary
|
||||
**coverage gap** (the behaviour is right, nothing asserts it) or as **dead
|
||||
code** (the clause cannot decide any outcome), and say which. A guard whose
|
||||
deletion leaves every test green is one of those two things, and the
|
||||
difference matters to the author. Where a survivor mirrors a pre-existing gap
|
||||
**coverage gap** (the behaviour is right, nothing asserts it), as **dead
|
||||
code** (the clause cannot decide any outcome), or as **redundant defence** (a
|
||||
sibling hunk in this same PR closes the same hazard, so nothing can observe
|
||||
this one alone), and say which. A guard whose deletion leaves every test
|
||||
green is one of those three, and the difference matters to the author: the
|
||||
first is a test to write, the second is code to delete, and the third is
|
||||
correct exactly as it stands. Where a survivor mirrors a pre-existing gap
|
||||
rather than something the PR introduced, say so — and label the whole set as
|
||||
completeness reporting, not merge conditions, unless one of them is load-bearing.
|
||||
|
||||
**Layered guards hide each other — revert the set, not only the hunk.** A
|
||||
one-row-per-guard matrix is blind to defence in depth, which is exactly the
|
||||
shape a careful author ships: two hunks closing one hazard from different
|
||||
directions. Revert either alone and the other still holds the line, so both
|
||||
rows read "survived" and the matrix reports two coverage gaps that do not
|
||||
exist. When two or more hunks in the PR defend the same hazard, add a
|
||||
**combination row** that reverts the set together. A hazard that appears only
|
||||
in the combination row is the proof the set is load-bearing, and it
|
||||
reclassifies every single-hunk survivor in that set as redundant defence.
|
||||
Measured example: on a session-list change, reverting the every-page live
|
||||
merge alone changed nothing and reverting the emitted-identity cursor alone
|
||||
changed nothing, while reverting both returned one session twice across a
|
||||
paginated walk — a duplicate neither single-hunk row could see, on a PR whose
|
||||
two guards were both correct.
|
||||
|
||||
**A surviving mutation needs a positive control before it becomes a
|
||||
finding.** An unmutated green run proves the suite passes; it does not prove
|
||||
your harness can make it fail. Land one mutation you expect to be caught and
|
||||
|
|
@ -465,6 +483,18 @@ test pins, turned exactly one test red. Without that row, "your suite does
|
|||
not cover this" and "my harness never ran your suite" are the same
|
||||
observation.
|
||||
|
||||
**Land that control in the same file as the mutant.** A control that turns a
|
||||
test red somewhere else proves the runner runs; it does not prove the command
|
||||
you chose collects anything that exercises the file you mutated. Measured
|
||||
example: deleting a route's entire response projection left all 1021 tests of
|
||||
its package's main server suite green, and the survivor was on its way into
|
||||
the report as a coverage gap — the coverage lived in a second test file the
|
||||
chosen command never collected, and running that one turned three tests red.
|
||||
Six other mutations in the same round were all caught, so the harness-level
|
||||
control was green the whole time and said nothing about this one. Either land
|
||||
the control in the mutated file, or show that the chosen command collects at
|
||||
least one test that imports it.
|
||||
|
||||
The mutation runs in reverse too: when the round produces a **candidate
|
||||
further fix** (a sibling shape closed, a guard tightened), apply it in a
|
||||
scratch copy and rerun the suite. Green on both sides is not reassurance —
|
||||
|
|
|
|||
627
CHANGELOG.md
627
CHANGELOG.md
|
|
@ -12,6 +12,633 @@ are listed; nightly and preview pre-releases are intentionally omitted.
|
|||
> [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it
|
||||
> by hand — run `npm run changelog` to regenerate.
|
||||
|
||||
## [0.21.14](https://github.com/QwenLM/qwen-code/releases/tag/v0.21.14) - 2026-08-19
|
||||
|
||||
### Highlights
|
||||
|
||||
- Added qwen sessions ps command and live-state API to list and monitor running interactive sessions with JSON output. ([#8969](https://github.com/QwenLM/qwen-code/pull/8969), [#9261](https://github.com/QwenLM/qwen-code/pull/9261), [#9366](https://github.com/QwenLM/qwen-code/pull/9366))
|
||||
- Introduced /advisor slash command for independent read-only opinions and enhanced review skills for GitLab and CI script analysis. ([#7567](https://github.com/QwenLM/qwen-code/pull/7567), [#9226](https://github.com/QwenLM/qwen-code/pull/9226), [#9263](https://github.com/QwenLM/qwen-code/pull/9263))
|
||||
- Improved Web Shell resilience by allowing prompt submission during disconnection and preventing session crashes on render errors. ([#9323](https://github.com/QwenLM/qwen-code/pull/9323), [#9292](https://github.com/QwenLM/qwen-code/pull/9292))
|
||||
- Minimized spam visibility gaps by checking new comments against blocklists immediately upon creation. ([#9266](https://github.com/QwenLM/qwen-code/pull/9266))
|
||||
- Added end-to-end support for session-scoped media references ensuring image previews persist across refreshes. ([#9310](https://github.com/QwenLM/qwen-code/pull/9310))
|
||||
- Enabled workflow agents to pin to specific working directories using the workingDir parameter to extend their lifecycle. ([#8972](https://github.com/QwenLM/qwen-code/pull/8972))
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
No known breaking changes.
|
||||
|
||||
### Session Management & Web Shell
|
||||
|
||||
Enhanced live session tracking, media persistence, and file handling in Web Shell with improved state synchronization and UI controls.
|
||||
|
||||
- Adds a live-session registry and the qwen sessions ps command to list running interactive sessions with optional JSON output. ([#8969](https://github.com/QwenLM/qwen-code/pull/8969))
|
||||
- Improves Web Shell sidebar session management with consistent hover details, compact status indicators, and persistent workspace expansion. ([#9311](https://github.com/QwenLM/qwen-code/pull/9311))
|
||||
- Added end-to-end support for session-scoped media references, ensuring image previews persist across refreshes and reconcile consistently. ([#9310](https://github.com/QwenLM/qwen-code/pull/9310))
|
||||
- Adds a trusted-only GET /workspaces/:workspace/sessions/live-state endpoint returning volatile session snapshots and a catalog version token to reduce polling. ([#9261](https://github.com/QwenLM/qwen-code/pull/9261))
|
||||
- WebShell now consumes workspace-scoped session live-state to reduce polling overhead and only refresh the session catalog when necessary. ([#9366](https://github.com/QwenLM/qwen-code/pull/9366))
|
||||
- Web Shell now fully disables file drag-and-drop when fileUploadEnabled is false and adds support for uploading directories via fileUploadDirectory. ([#9382](https://github.com/QwenLM/qwen-code/pull/9382))
|
||||
- The exported HTML viewer now includes a global Expand all/Collapse all toolbar to simultaneously toggle thinking blocks, tool outputs, and file references. ([#9367](https://github.com/QwenLM/qwen-code/pull/9367))
|
||||
- revert(web-shell): restore pre-#8098 composer animations at 50% opacity ([#9349](https://github.com/QwenLM/qwen-code/pull/9349))
|
||||
|
||||
### Review Pipeline & Automation
|
||||
|
||||
Improved review accuracy with better anchor handling, multi-model support, and automated workflows for SWE-bench and Terminal-Bench.
|
||||
|
||||
- Chains Terminal-Bench release evaluation by submitting SWE-bench runs first and dispatching TB runs only after SWE results are published. ([#9120](https://github.com/QwenLM/qwen-code/pull/9120))
|
||||
- Repairs seven review pipeline defects found in live runs, including fixing incremental anchor withholding and enabling multi-call build-and-test dimensions. ([#9175](https://github.com/QwenLM/qwen-code/pull/9175))
|
||||
- Enhanced the review skill to analyze shell and CI scripts against the specific lanes and environments that execute them. ([#9263](https://github.com/QwenLM/qwen-code/pull/9263))
|
||||
- Updated review skill documentation and tests to reflect the settled 3-round cap state and improve coverage for chunk gating logic. ([#9258](https://github.com/QwenLM/qwen-code/pull/9258))
|
||||
- Incremental review anchors now record the certifying model, preventing incorrect skip behavior when re-running with a different model. ([#9184](https://github.com/QwenLM/qwen-code/pull/9184))
|
||||
- Added Aone Code read path support to /review, enabling meta, issue-context, and fetch-pr commands for GitLab-based repositories. ([#9226](https://github.com/QwenLM/qwen-code/pull/9226))
|
||||
- The compose-review command now enforces GitHub's 65,536-character review limit by trimming Chinese translations and deferral notes before truncating essential blockers. ([#9247](https://github.com/QwenLM/qwen-code/pull/9247))
|
||||
- Review comments posted via --comment now use plain reviewer prose instead of templated scaffolding, while severity markers continue to follow review.attribution rules. ([#9027](https://github.com/QwenLM/qwen-code/pull/9027))
|
||||
- Sandboxed verification now includes a deterministic flakiness gate that re-runs modified unit tests multiple times to detect and report non-deterministic failures. ([#9130](https://github.com/QwenLM/qwen-code/pull/9130))
|
||||
- Added --resume flag to fetch-pr to resume interrupted reviews by validating on-disk state and reusing the worktree. ([#9092](https://github.com/QwenLM/qwen-code/pull/9092))
|
||||
- Enabled pagination for review thread fetching to ensure all threads are resolved instead of only the oldest 100. ([#9390](https://github.com/QwenLM/qwen-code/pull/9390))
|
||||
- Simplified the review checkout self-heal logic by removing complex guard layers and retaining the core wipe-and-retry mechanism. ([#9327](https://github.com/QwenLM/qwen-code/pull/9327))
|
||||
|
||||
### Agent Capabilities & Orchestration
|
||||
|
||||
Expanded agent functionality with directory pinning, team task routing, and robust error handling for foreground processes.
|
||||
|
||||
- daemon: attach skill-toggle mutation metadata to settings_changed ([#9051](https://github.com/QwenLM/qwen-code/pull/9051))
|
||||
- Enabled workflow agents to pin to a specific working directory using the workingDir parameter, allowing them to outlive default bounds. ([#8972](https://github.com/QwenLM/qwen-code/pull/8972))
|
||||
- Fixed an issue where foreground agents were incorrectly marked as failed due to missing routing fields in SSE events. ([#9330](https://github.com/QwenLM/qwen-code/pull/9330))
|
||||
- Updated agent-team prompts and TeamCreate descriptions to accurately reflect automatic final answer delivery when teammates go idle. ([#9284](https://github.com/QwenLM/qwen-code/pull/9284))
|
||||
- core: dispatch manually assigned team tasks to their owner ([#9289](https://github.com/QwenLM/qwen-code/pull/9289))
|
||||
- autofix: seed the takeover round counter with /takeover from N ([#9321](https://github.com/QwenLM/qwen-code/pull/9321))
|
||||
- The autofix convergence brake now correctly instructs the agent to write handoff details to failure.md instead of restricted wrapper files. ([#9371](https://github.com/QwenLM/qwen-code/pull/9371))
|
||||
- The autofix fleet scan now fails closed on API enumeration errors to prevent dispatching jobs to busy PRs and marks dispatched PRs clearly. ([#9329](https://github.com/QwenLM/qwen-code/pull/9329))
|
||||
- Certification bars in the reverse-audit path now report specific failure names like 'receipt lead contradicts the phrase' to improve diagnostic clarity for retirement causes. ([#9272](https://github.com/QwenLM/qwen-code/pull/9272))
|
||||
|
||||
### System Reliability & Performance
|
||||
|
||||
Strengthened system stability with retry logic for I/O errors, memory cache bounds, and graceful degradation for render failures.
|
||||
|
||||
- Makes transient resource-exhaustion and read I/O errors retryable while keeping malformed record errors terminal to prevent false corruption flags. ([#9362](https://github.com/QwenLM/qwen-code/pull/9362))
|
||||
- Bounds text utility caches to 500 entries with oldest-entry eviction to prevent unbounded memory growth in long sessions. ([#9185](https://github.com/QwenLM/qwen-code/pull/9185))
|
||||
- Clamps compression output budget to the remaining context window size to ensure valid requests when prompt estimates exhaust available tokens. ([#9109](https://github.com/QwenLM/qwen-code/pull/9109))
|
||||
- Wraps the agent-tab view in a non-fatal ErrorBoundary so render errors degrade gracefully instead of exiting the entire session. ([#9292](https://github.com/QwenLM/qwen-code/pull/9292))
|
||||
- Images with unsupported MIME types or decoding errors are now omitted with a text notice instead of causing the entire session to abort. ([#9295](https://github.com/QwenLM/qwen-code/pull/9295))
|
||||
- Memory recall now waits up to 100ms for results before injecting deterministic candidates, improving reliability and non-ASCII coverage. ([#8716](https://github.com/QwenLM/qwen-code/pull/8716))
|
||||
- The web-shell now uses backend-authoritative queue states to prevent duplicate messages and ensure draft payloads are only restored after proven delivery failures. ([#9407](https://github.com/QwenLM/qwen-code/pull/9407))
|
||||
- Cleared a backlog of nineteen deferred suggestions and fixed behavior issues including persistRecoveredLedger flag handling. ([#9342](https://github.com/QwenLM/qwen-code/pull/9342))
|
||||
|
||||
### Daemon & Local Control
|
||||
|
||||
Consolidated Local Control architecture and added configurable modes and pollable routes for daemon status and answers.
|
||||
|
||||
- Consolidates Local Control into a single daemon-owned implementation with a secondary listener, unified security model, and revocable pairing credentials. ([#9106](https://github.com/QwenLM/qwen-code/pull/9106))
|
||||
- daemon: make serve new-file mode configurable (QWEN_SERVE_NEW_FILE_MODE) ([#9364](https://github.com/QwenLM/qwen-code/pull/9364))
|
||||
- Added pollable HTTP routes to check daemon turn status and retrieve final model answers without requiring an SSE subscription. ([#9080](https://github.com/QwenLM/qwen-code/pull/9080))
|
||||
|
||||
### Security & Spam Prevention
|
||||
|
||||
Reduced spam visibility gaps with instant blocklist checks and added workspace wipe guards to prevent unsafe directory removal.
|
||||
|
||||
- Minimizes new spam comments immediately upon creation by checking against the blocklist, reducing visibility gaps from over an hour to near zero. ([#9266](https://github.com/QwenLM/qwen-code/pull/9266))
|
||||
- Added workspace wipe guards to triage and Serve A/B workflows to prevent unsafe directory removal in non-canonical runner environments. ([#9277](https://github.com/QwenLM/qwen-code/pull/9277))
|
||||
|
||||
### User Commands & Interfaces
|
||||
|
||||
Introduced the /advisor command for second opinions and improved CLI behavior for severity floors and resume flags.
|
||||
|
||||
- Added the /advisor slash command to request an independent, read-only second opinion on the current conversation without mutating history. ([#7567](https://github.com/QwenLM/qwen-code/pull/7567))
|
||||
- When the severity floor resolves to Critical-only, the CLI automatically moves inline Suggestion comments to the review body deferral list instead of posting them to GitHub. ([#9279](https://github.com/QwenLM/qwen-code/pull/9279))
|
||||
- Autofix failure comments now include bilingual content with Chinese analysis in a collapsed details block. ([#9386](https://github.com/QwenLM/qwen-code/pull/9386))
|
||||
|
||||
### Diagnostics & Observability
|
||||
|
||||
Added privacy-safe diagnostic events and fixed context reporting to exclude disabled skills for accurate consumption metrics.
|
||||
|
||||
- Introduced privacy-safe diagnostic events to correlate oversized or mutated tool-result representations without exposing sensitive content. ([#9039](https://github.com/QwenLM/qwen-code/pull/9039))
|
||||
- Fixed context usage details to exclude disabled skills, ensuring accurate reporting of context consumption. ([#9346](https://github.com/QwenLM/qwen-code/pull/9346))
|
||||
- Unit tests in packages/cli now fail immediately with an actionable error message and the exact npm run build command if required dist/ outputs or generated files are missing. ([#9171](https://github.com/QwenLM/qwen-code/pull/9171))
|
||||
- artifacts: verify and canonicalize record_artifact workspace paths ([#9142](https://github.com/QwenLM/qwen-code/pull/9142))
|
||||
|
||||
### Other Changes
|
||||
|
||||
- ci: drop pull_request_review events on closed PRs at the route gate ([#9299](https://github.com/QwenLM/qwen-code/pull/9299))
|
||||
- Decouples the composer from SSE catch-up to keep input enabled during reconnection and allows prompt submission even when disconnected. ([#9323](https://github.com/QwenLM/qwen-code/pull/9323))
|
||||
- The Weixin channel now refreshes the typing indicator every 4 seconds during long turns to prevent it from expiring prematurely. ([#9358](https://github.com/QwenLM/qwen-code/pull/9358))
|
||||
- Fixed a bug where the enableCacheSharing setting default was ignored, ensuring cache-aware suggestions work without explicit user configuration. ([#9233](https://github.com/QwenLM/qwen-code/pull/9233))
|
||||
- Removed thirteen unused internal helpers from the settings utility module to clean up legacy code. ([#9379](https://github.com/QwenLM/qwen-code/pull/9379))
|
||||
|
||||
### 中文摘要
|
||||
|
||||
#### 亮点
|
||||
|
||||
- 新增 qwen sessions ps 命令和 live-state API,支持以 JSON 格式列出和监控运行中的交互式会话。 ([#8969](https://github.com/QwenLM/qwen-code/pull/8969), [#9261](https://github.com/QwenLM/qwen-code/pull/9261), [#9366](https://github.com/QwenLM/qwen-code/pull/9366))
|
||||
- 新增 /advisor 斜杠命令获取独立只读意见,并增强 review 技能以分析 GitLab 仓库和 CI 脚本。 ([#7567](https://github.com/QwenLM/qwen-code/pull/7567), [#9226](https://github.com/QwenLM/qwen-code/pull/9226), [#9263](https://github.com/QwenLM/qwen-code/pull/9263))
|
||||
- 提升 Web Shell 韧性,支持断开连接时提交提示,并防止因渲染错误导致会话崩溃。 ([#9323](https://github.com/QwenLM/qwen-code/pull/9323), [#9292](https://github.com/QwenLM/qwen-code/pull/9292))
|
||||
- 通过在创建时即时检查黑名单,将新垃圾评论的可见间隔缩短至接近零。 ([#9266](https://github.com/QwenLM/qwen-code/pull/9266))
|
||||
- 新增会话级媒体引用端到端支持,确保图片预览在刷新后持久保留。 ([#9310](https://github.com/QwenLM/qwen-code/pull/9310))
|
||||
- 允许工作流代理通过 workingDir 参数锁定特定目录,从而延长其生存周期。 ([#8972](https://github.com/QwenLM/qwen-code/pull/8972))
|
||||
|
||||
#### 会话管理与 Web Shell
|
||||
|
||||
增强了 Web Shell 中的实时会话跟踪、媒体持久化和文件处理,改进了状态同步和 UI 控制。
|
||||
|
||||
- 新增实时会话注册表和 qwen sessions ps 命令,用于列出正在运行的交互式会话并支持 JSON 输出。 ([#8969](https://github.com/QwenLM/qwen-code/pull/8969))
|
||||
- 改进了 Web Shell 侧边栏会话管理,提供一致的海悬停详情、紧凑状态指示器和持久的工作区展开功能。 ([#9311](https://github.com/QwenLM/qwen-code/pull/9311))
|
||||
- 新增会话级媒体引用端到端支持,确保图片预览在刷新后保留并一致协调。 ([#9310](https://github.com/QwenLM/qwen-code/pull/9310))
|
||||
- 新增受信任的 GET /workspaces/:workspace/sessions/live-state 端点,返回实时会话快照和目录版本令牌以减少轮询。 ([#9261](https://github.com/QwenLM/qwen-code/pull/9261))
|
||||
- WebShell 现使用工作区会话实时状态以减少轮询开销,仅在必要时刷新会话目录。 ([#9366](https://github.com/QwenLM/qwen-code/pull/9366))
|
||||
- Web Shell 现在在 fileUploadEnabled 为 false 时完全禁用文件拖放,并通过 fileUploadDirectory 支持目录上传。 ([#9382](https://github.com/QwenLM/qwen-code/pull/9382))
|
||||
- 导出的 HTML 查看器现在包含一个全局的 Expand all/Collapse all 工具栏,可同时切换思考块、工具输出和文件引用。 ([#9367](https://github.com/QwenLM/qwen-code/pull/9367))
|
||||
- revert(web-shell): restore pre-#8098 composer animations at 50% opacity ([#9349](https://github.com/QwenLM/qwen-code/pull/9349))
|
||||
|
||||
#### 审查管道与自动化
|
||||
|
||||
通过更好的锚点处理、多模型支持以及 SWE-bench 和 Terminal-Bench 的自动化工作流,提高了审查准确性。
|
||||
|
||||
- 通过先提交 SWE-bench 运行并在发布结果后分派 TB 运行,实现了 Terminal-Bench 发布评估的链式处理。 ([#9120](https://github.com/QwenLM/qwen-code/pull/9120))
|
||||
- 修复了实时运行中发现的七个审查管道缺陷,包括解决增量锚点扣留问题并启用多调用构建测试维度。 ([#9175](https://github.com/QwenLM/qwen-code/pull/9175))
|
||||
- 增强了 review 技能,使其能针对执行 shell 和 CI 脚本的具体 lane 和环境进行分析。 ([#9263](https://github.com/QwenLM/qwen-code/pull/9263))
|
||||
- 更新了 review 技能文档和测试以反映确定的 3 轮上限状态,并改进 chunk gating 逻辑的覆盖率。 ([#9258](https://github.com/QwenLM/qwen-code/pull/9258))
|
||||
- 增量审查锚点现在记录认证模型,防止在不同模型下重运行时出现错误的跳过行为。 ([#9184](https://github.com/QwenLM/qwen-code/pull/9184))
|
||||
- 为 /review 添加 Aone Code 读取路径支持,使基于 GitLab 的仓库可使用 meta、issue-context 和 fetch-pr 命令。 ([#9226](https://github.com/QwenLM/qwen-code/pull/9226))
|
||||
- compose-review 现在强制执行 GitHub 65,536 字符限制,优先裁剪中文翻译和延期注释,最后才截断关键的 blockers。 ([#9247](https://github.com/QwenLM/qwen-code/pull/9247))
|
||||
- 通过 --comment 发布的审查注释现在使用纯审查员散文而非模板脚手架,而严重性标记继续遵循 review.attribution 规则。 ([#9027](https://github.com/QwenLM/qwen-code/pull/9027))
|
||||
- 沙盒验证现在包含一个确定性 flakiness 门控,可多次重新运行修改后的单元测试以检测并报告非确定性失败。 ([#9130](https://github.com/QwenLM/qwen-code/pull/9130))
|
||||
- 为 fetch-pr 添加 --resume 标志,通过验证磁盘状态和重用工作树来恢复中断的审查。 ([#9092](https://github.com/QwenLM/qwen-code/pull/9092))
|
||||
- 启用了审查线程获取的分页功能,确保解析所有线程而不仅是最旧的 100 个。 ([#9390](https://github.com/QwenLM/qwen-code/pull/9390))
|
||||
- 通过移除复杂的防护层并保留核心的 wipe-and-retry 机制简化了审查检出自愈逻辑。 ([#9327](https://github.com/QwenLM/qwen-code/pull/9327))
|
||||
|
||||
#### 代理能力与编排
|
||||
|
||||
通过目录锁定、团队任务路由和前台进程的健壮错误处理,扩展了代理功能。
|
||||
|
||||
- daemon: attach skill-toggle mutation metadata to settings_changed ([#9051](https://github.com/QwenLM/qwen-code/pull/9051))
|
||||
- 允许工作流代理通过 workingDir 参数锁定特定目录,使其生存期超出默认限制。 ([#8972](https://github.com/QwenLM/qwen-code/pull/8972))
|
||||
- 修复了因 SSE 事件中缺少路由字段导致前台代理被错误标记为失败的问题。 ([#9330](https://github.com/QwenLM/qwen-code/pull/9330))
|
||||
- 更新了 agent-team 提示和 TeamCreate 描述,以准确反映队友空闲时自动交付最终答案的行为。 ([#9284](https://github.com/QwenLM/qwen-code/pull/9284))
|
||||
- core: dispatch manually assigned team tasks to their owner ([#9289](https://github.com/QwenLM/qwen-code/pull/9289))
|
||||
- autofix: seed the takeover round counter with /takeover from N ([#9321](https://github.com/QwenLM/qwen-code/pull/9321))
|
||||
- autofix 收敛制动现在正确指示代理将交接详情写入 failure.md 而非受限的包装文件。 ([#9371](https://github.com/QwenLM/qwen-code/pull/9371))
|
||||
- autofix 集群扫描现在在 API 枚举错误时失败关闭,防止向忙碌的 PR 分发任务,并清晰标记已分发的 PR。 ([#9329](https://github.com/QwenLM/qwen-code/pull/9329))
|
||||
- 逆向审计路径中的认证条现在报告具体的失败名称(如'receipt lead contradicts the phrase'),以提高退休原因的诊断清晰度。 ([#9272](https://github.com/QwenLM/qwen-code/pull/9272))
|
||||
|
||||
#### 系统可靠性与性能
|
||||
|
||||
通过 I/O 错误的重试逻辑、内存缓存限制和渲染失败的优雅降级,加强了系统稳定性。
|
||||
|
||||
- 使临时资源耗尽和读取 I/O 错误可重试,同时保持格式错误为终止状态,以防止误报损坏。 ([#9362](https://github.com/QwenLM/qwen-code/pull/9362))
|
||||
- 将文本工具缓存限制为 500 个条目并淘汰最旧 entry,以防止长会话中内存无限增长。 ([#9185](https://github.com/QwenLM/qwen-code/pull/9185))
|
||||
- 将压缩输出预算限制在剩余上下文窗口大小内,确保提示估算耗尽可用 token 时请求仍有效。 ([#9109](https://github.com/QwenLM/qwen-code/pull/9109))
|
||||
- 将 agent-tab 视图包裹在非致命 ErrorBoundary 中,使渲染错误优雅降级而非退出整个会话。 ([#9292](https://github.com/QwenLM/qwen-code/pull/9292))
|
||||
- 不支持的 MIME 类型或解码错误的图像现在会被省略并提示文本,避免导致整个会话中断。 ([#9295](https://github.com/QwenLM/qwen-code/pull/9295))
|
||||
- 内存召回现在最多等待 100ms 再注入确定性候选项,提升了可靠性和非 ASCII 内容的覆盖范围。 ([#8716](https://github.com/QwenLM/qwen-code/pull/8716))
|
||||
- web-shell 现在使用后端权威的队列状态以防止消息重复,并确保仅在确认交付失败后恢复草稿负载。 ([#9407](https://github.com/QwenLM/qwen-code/pull/9407))
|
||||
- 清除了十九个延迟建议的积压并修复了包括 persistRecoveredLedger 标志处理在内的行为问题。 ([#9342](https://github.com/QwenLM/qwen-code/pull/9342))
|
||||
|
||||
#### 守护进程与本地控制
|
||||
|
||||
整合了本地控制架构,并为守护进程状态和答案添加了可配置模式和可轮询路由。
|
||||
|
||||
- 将 Local Control 整合为单一的 daemon 实现,包含辅助监听器、统一安全模型和可撤销的配对凭证。 ([#9106](https://github.com/QwenLM/qwen-code/pull/9106))
|
||||
- daemon: make serve new-file mode configurable (QWEN_SERVE_NEW_FILE_MODE) ([#9364](https://github.com/QwenLM/qwen-code/pull/9364))
|
||||
- 添加了可轮询的 HTTP 路由以检查 daemon 轮次状态并获取最终模型答案,无需 SSE 订阅。 ([#9080](https://github.com/QwenLM/qwen-code/pull/9080))
|
||||
|
||||
#### 安全与垃圾信息防护
|
||||
|
||||
通过即时黑名单检查减少了垃圾信息可见性差距,并添加了工作区清理保护以防止不安全的目录删除。
|
||||
|
||||
- 通过创建时即时检查黑名单来最小化新垃圾评论,将可见间隔从一小时以上缩短至接近零。 ([#9266](https://github.com/QwenLM/qwen-code/pull/9266))
|
||||
- 为 triage 和 Serve A/B 流程添加工作区清理保护,防止在非规范运行环境中发生不安全的目录删除。 ([#9277](https://github.com/QwenLM/qwen-code/pull/9277))
|
||||
|
||||
#### 用户命令与界面
|
||||
|
||||
引入了用于获取第二意见的 /advisor 命令,并改进了 CLI 在严重性下限和恢复标志方面的行为。
|
||||
|
||||
- 新增 /advisor 斜杠命令,可在不修改历史记录的情况下获取对当前对话的独立只读评审意见。 ([#7567](https://github.com/QwenLM/qwen-code/pull/7567))
|
||||
- 当严重性下限解析为 Critical-only 时,CLI 会自动将内联 Suggestion 注释移至审查正文的延期列表,而不是发布到 GitHub。 ([#9279](https://github.com/QwenLM/qwen-code/pull/9279))
|
||||
- Autofix 失败评论现在包含双语内容,在折叠的详情块中提供中文分析。 ([#9386](https://github.com/QwenLM/qwen-code/pull/9386))
|
||||
|
||||
#### 诊断与可观测性
|
||||
|
||||
添加了隐私安全的诊断事件,并修复了上下文报告以排除已禁用的 skills,从而获得准确的消耗指标。
|
||||
|
||||
- 引入了隐私安全的诊断事件,用于关联过大或变异的工具结果表示,同时不暴露敏感内容。 ([#9039](https://github.com/QwenLM/qwen-code/pull/9039))
|
||||
- 修复了上下文使用详情,排除已禁用的 skills,确保上下文消耗报告准确。 ([#9346](https://github.com/QwenLM/qwen-code/pull/9346))
|
||||
- 如果缺少必需的 dist/输出或生成文件,packages/cli 中的单元测试现在会立即失败,并提供可操作的错误消息及确切的 npm run build 命令。 ([#9171](https://github.com/QwenLM/qwen-code/pull/9171))
|
||||
- artifacts: verify and canonicalize record_artifact workspace paths ([#9142](https://github.com/QwenLM/qwen-code/pull/9142))
|
||||
|
||||
#### 其他变更
|
||||
|
||||
- ci: drop pull_request_review events on closed PRs at the route gate ([#9299](https://github.com/QwenLM/qwen-code/pull/9299))
|
||||
- 将 composer 与 SSE 追赶解耦,确保重连期间输入可用,并允许在断开连接时继续提交提示。 ([#9323](https://github.com/QwenLM/qwen-code/pull/9323))
|
||||
- Weixin 通道现在每 4 秒刷新一次 typing 指示器,防止其在长轮次中过早消失。 ([#9358](https://github.com/QwenLM/qwen-code/pull/9358))
|
||||
- 修复了 enableCacheSharing 设置默认值被忽略的问题,确保无需用户显式配置即可使用缓存感知建议。 ([#9233](https://github.com/QwenLM/qwen-code/pull/9233))
|
||||
- 移除了设置工具模块中十三个未使用的内部辅助函数以清理遗留代码。 ([#9379](https://github.com/QwenLM/qwen-code/pull/9379))
|
||||
|
||||
### Complete Change List (54 pull requests)
|
||||
|
||||
#### Features
|
||||
|
||||
- core: add a live-session registry and qwen sessions ps ([#8969](https://github.com/QwenLM/qwen-code/pull/8969)) by @qqqys
|
||||
- daemon: attach skill-toggle mutation metadata to settings_changed ([#9051](https://github.com/QwenLM/qwen-code/pull/9051)) by @samuelhsin
|
||||
- chain Terminal-Bench release evaluation ([#9120](https://github.com/QwenLM/qwen-code/pull/9120)) by @DennisYu07
|
||||
- web-shell: improve sidebar session management ([#9311](https://github.com/QwenLM/qwen-code/pull/9311)) by @ytahdn
|
||||
- web-shell: decouple composer from catch-up and rebuild SSE on disconnected submit ([#9323](https://github.com/QwenLM/qwen-code/pull/9323)) by @ytahdn
|
||||
- support session media references end-to-end ([#9310](https://github.com/QwenLM/qwen-code/pull/9310)) by @ytahdn
|
||||
- core: let a workflow agent pin a directory and outlive the default bounds ([#8972](https://github.com/QwenLM/qwen-code/pull/8972)) by @qqqys
|
||||
- review: review shell and CI scripts against the lanes that run them ([#9263](https://github.com/QwenLM/qwen-code/pull/9263)) by @wenshao
|
||||
- cli: add /advisor command for second-opinion conversation review ([#7567](https://github.com/QwenLM/qwen-code/pull/7567)) by @yiliang114
|
||||
- core: Add privacy-safe tool-result boundary diagnostics ([#9039](https://github.com/QwenLM/qwen-code/pull/9039)) by @doudouOUC
|
||||
- serve: Add workspace session live-state endpoint and catalog version ([#9261](https://github.com/QwenLM/qwen-code/pull/9261)) by @doudouOUC
|
||||
- consolidate Local Control into one daemon-owned implementation ([#9106](https://github.com/QwenLM/qwen-code/pull/9106)) by @yiliang114
|
||||
- autofix: seed the takeover round counter with /takeover from N ([#9321](https://github.com/QwenLM/qwen-code/pull/9321)) by @wenshao
|
||||
- daemon: make serve new-file mode configurable (QWEN_SERVE_NEW_FILE_MODE) ([#9364](https://github.com/QwenLM/qwen-code/pull/9364)) by @yiliang114
|
||||
- web-shell: support upload directory and hard-disable drag-in when fileUploadEnabled=false ([#9382](https://github.com/QwenLM/qwen-code/pull/9382)) by @ytahdn
|
||||
- serve: add pollable daemon turn status ([#9080](https://github.com/QwenLM/qwen-code/pull/9080)) by @BenGuanRan
|
||||
- web-shell: Consume workspace session live-state ([#9366](https://github.com/QwenLM/qwen-code/pull/9366)) by @doudouOUC
|
||||
- review: Aone Code read path (second review-platform provider) ([#9226](https://github.com/QwenLM/qwen-code/pull/9226)) by @wenshao
|
||||
- webui: add global expand/collapse control to exported HTML viewer ([#9367](https://github.com/QwenLM/qwen-code/pull/9367)) by @yiliang114
|
||||
- review: enforce the resolved severity floor at the posting boundary ([#9279](https://github.com/QwenLM/qwen-code/pull/9279)) by @wenshao
|
||||
- cli: plain-prose /review comments; severity markers follow review.attribution ([#9027](https://github.com/QwenLM/qwen-code/pull/9027)) by @wenshao
|
||||
- triage: add a deterministic flakiness gate to sandboxed verification ([#9130](https://github.com/QwenLM/qwen-code/pull/9130)) by @wenshao
|
||||
- review: resume an interrupted PR review from its on-disk state ([#9092](https://github.com/QwenLM/qwen-code/pull/9092)) by @wenshao
|
||||
- ci: post autofix failure-path handoff comments bilingually ([#9386](https://github.com/QwenLM/qwen-code/pull/9386)) by @wenshao
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- ci: minimize new spam comments on creation ([#9266](https://github.com/QwenLM/qwen-code/pull/9266)) by @yiliang114
|
||||
- ci: drop pull_request_review events on closed PRs at the route gate ([#9299](https://github.com/QwenLM/qwen-code/pull/9299)) by @wenshao
|
||||
- review: repair seven pipeline defects found by live runs ([#9175](https://github.com/QwenLM/qwen-code/pull/9175)) by @wenshao
|
||||
- web-shell: keep foreground agent status on SSE ([#9330](https://github.com/QwenLM/qwen-code/pull/9330)) by @ytahdn
|
||||
- cli: exclude disabled skills from context usage details ([#9346](https://github.com/QwenLM/qwen-code/pull/9346)) by @callmeYe
|
||||
- core: align agent-team prompts and TeamCreate description with actual delivery ([#9284](https://github.com/QwenLM/qwen-code/pull/9284)) by @yiliang114
|
||||
- cli: Keep transient runtime record I/O retryable ([#9362](https://github.com/QwenLM/qwen-code/pull/9362)) by @doudouOUC
|
||||
- cli: contain agent-tab render errors instead of exiting the session ([#9292](https://github.com/QwenLM/qwen-code/pull/9292)) by @yiliang114
|
||||
- cli: bound string width and code point caches (#2128) ([#9185](https://github.com/QwenLM/qwen-code/pull/9185)) by @yiliang114
|
||||
- core: Clamp compression output budget to remaining context window ([#9109](https://github.com/QwenLM/qwen-code/pull/9109)) by @ZijianZhang989
|
||||
- weixin: keep typing indicator alive during long turns ([#9358](https://github.com/QwenLM/qwen-code/pull/9358)) by @yiliang114
|
||||
- ci: route the autofix convergence-brake handoff through failure.md ([#9371](https://github.com/QwenLM/qwen-code/pull/9371)) by @wenshao
|
||||
- ci: make autofix busy detection fail closed and mark dispatched PRs ([#9329](https://github.com/QwenLM/qwen-code/pull/9329)) by @wenshao
|
||||
- cli: honour the declared enableCacheSharing default in both suggestion gates ([#9233](https://github.com/QwenLM/qwen-code/pull/9233)) by @yiliang114
|
||||
- core: dispatch manually assigned team tasks to their owner ([#9289](https://github.com/QwenLM/qwen-code/pull/9289)) by @yiliang114
|
||||
- ci: back-port the checkout-heal wipe guard to the triage and serve-ab wipes ([#9277](https://github.com/QwenLM/qwen-code/pull/9277)) by @yiliang114
|
||||
- review: gate the recovered incremental anchor on the model that certified it ([#9184](https://github.com/QwenLM/qwen-code/pull/9184)) by @wenshao
|
||||
- artifacts: verify and canonicalize record_artifact workspace paths ([#9142](https://github.com/QwenLM/qwen-code/pull/9142)) by @zjgzx1988
|
||||
- core: omit image media the model endpoint cannot safely consume (#9291) ([#9295](https://github.com/QwenLM/qwen-code/pull/9295)) by @yiliang114
|
||||
- memory: improve recall reliability and candidate coverage ([#8716](https://github.com/QwenLM/qwen-code/pull/8716)) by @yiliang114
|
||||
- review: budget the composed body against GitHub's review limit ([#9247](https://github.com/QwenLM/qwen-code/pull/9247)) by @wenshao
|
||||
- web-shell: use backend-authoritative queue state ([#9407](https://github.com/QwenLM/qwen-code/pull/9407)) by @ytahdn
|
||||
- review: name each certification bar and defer degrade notes past admission (#9259) ([#9272](https://github.com/QwenLM/qwen-code/pull/9272)) by @wenshao
|
||||
- devx: fail with actionable message when unit-test build prerequisites are missing (#9149) ([#9171](https://github.com/QwenLM/qwen-code/pull/9171)) by @yiliang114
|
||||
- review: clear the deferred-suggestion backlog from #9175's review rounds ([#9342](https://github.com/QwenLM/qwen-code/pull/9342)) by @wenshao
|
||||
- autofix: paginate review threads instead of reaching the oldest 100 ([#9390](https://github.com/QwenLM/qwen-code/pull/9390)) by @qqqys
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- revert(web-shell): restore pre-#8098 composer animations at 50% opacity ([#9349](https://github.com/QwenLM/qwen-code/pull/9349)) by @ytahdn
|
||||
- test(review): sync round-cap prose and pin the deferred coverage gaps (#9256) ([#9258](https://github.com/QwenLM/qwen-code/pull/9258)) by @yiliang114
|
||||
- refactor(cli): remove superseded settings dialog helpers ([#9379](https://github.com/QwenLM/qwen-code/pull/9379)) by @qqqys
|
||||
- refactor(ci): simplify the review checkout self-heal back to wipe-and-retry ([#9327](https://github.com/QwenLM/qwen-code/pull/9327)) by @wenshao
|
||||
|
||||
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.21.13...v0.21.14
|
||||
|
||||
## [0.21.13](https://github.com/QwenLM/qwen-code/releases/tag/v0.21.13) - 2026-08-17
|
||||
|
||||
### Highlights
|
||||
|
||||
- Web Shell composer now supports dragging, dropping, and pasting text files as named attachments alongside images. ([#9180](https://github.com/QwenLM/qwen-code/pull/9180))
|
||||
- Users can now fork conversations from any specific Assistant response using durable checkpoints to ensure branch accuracy. ([#8817](https://github.com/QwenLM/qwen-code/pull/8817))
|
||||
- Split-view panes now inherit the host's @ mention configuration, ensuring custom categories and exclusions apply consistently. ([#9052](https://github.com/QwenLM/qwen-code/pull/9052))
|
||||
- The /review skill now uses dedicated platform subcommands like meta and fetch-diff instead of executing raw gh commands. ([#9096](https://github.com/QwenLM/qwen-code/pull/9096))
|
||||
- Stopped takeover PRs now receive an autofix/needs-human label and appear in a new Takeover pool table on the dashboard. ([#8960](https://github.com/QwenLM/qwen-code/pull/8960))
|
||||
- Review sessions now record session IDs and diff hashes to enable crediting agent work across interrupted runs. ([#9091](https://github.com/QwenLM/qwen-code/pull/9091))
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
No known breaking changes.
|
||||
|
||||
### Review Workflow & /review Skill
|
||||
|
||||
Enhanced the /review skill with dedicated subcommands, dynamic finding limits, and improved concurrency handling to prevent loops and ensure accurate reporting.
|
||||
|
||||
- Split-view panes now inherit the host's @ mention configuration, ensuring custom categories appear and builtinAtProviders exclusions apply consistently. ([#9052](https://github.com/QwenLM/qwen-code/pull/9052))
|
||||
- The /review skill now uses dedicated platform subcommands like meta and fetch-diff instead of executing raw gh commands via prompt prose. ([#9096](https://github.com/QwenLM/qwen-code/pull/9096))
|
||||
- The /review command now limits posted suggestions to Critical findings after round 5 to prevent review loops, while deferring other findings to the final report. ([#9118](https://github.com/QwenLM/qwen-code/pull/9118))
|
||||
- Review workflow concurrency now isolates no-op human review requests to prevent them from blocking active PR review jobs. ([#9210](https://github.com/QwenLM/qwen-code/pull/9210))
|
||||
- Duplicate suggestions in /review are now listed in a dedicated paragraph with links to existing comments instead of being counted as anchor failures. ([#9215](https://github.com/QwenLM/qwen-code/pull/9215))
|
||||
- The /review presubmit gate now correctly handles carried-id re-posts to prevent dropping valid findings that match existing comment IDs. ([#9212](https://github.com/QwenLM/qwen-code/pull/9212))
|
||||
- Fixed the /review pipeline to accept bracketed source tags and added a --to-anchors option to normalize inputs for final gates. ([#9222](https://github.com/QwenLM/qwen-code/pull/9222))
|
||||
- The PR review workflow now posts a fallback comment with retry guidance if the main review job dies abnormally. ([#9255](https://github.com/QwenLM/qwen-code/pull/9255))
|
||||
- Review sessions now record session IDs and diff hashes to enable crediting agent work across interrupted runs. ([#9091](https://github.com/QwenLM/qwen-code/pull/9091))
|
||||
- Review runners now automatically wipe and retry failed checkouts to self-heal from persistent workspace corruption. ([#9220](https://github.com/QwenLM/qwen-code/pull/9220))
|
||||
- PR review worktree leases now act as locks to prevent concurrent sessions from destroying active review states during setup or cleanup. ([#9211](https://github.com/QwenLM/qwen-code/pull/9211))
|
||||
|
||||
### Autofix & Takeover Management
|
||||
|
||||
Improved Autofix visibility with new dashboard pools and labels, while refining footprint gates and growth tracking for better takeover handling.
|
||||
|
||||
- autofix: deny-by-default footprint gate and positional window censuses ([#9156](https://github.com/QwenLM/qwen-code/pull/9156))
|
||||
- Stopped takeover PRs now receive an autofix/needs-human label and appear in a new Takeover pool table on the dashboard for better visibility. ([#8960](https://github.com/QwenLM/qwen-code/pull/8960))
|
||||
- The autofix-growth-now marker now uses the prepare-time measurement instant to ensure growth divergence reports accurately reflect the correct base version. ([#9192](https://github.com/QwenLM/qwen-code/pull/9192))
|
||||
- Prevented the triage agent from processing tracking issues created by the autofix bot by adding a user login guard to the workflow trigger. ([#9271](https://github.com/QwenLM/qwen-code/pull/9271))
|
||||
|
||||
### Reverse-Audit & Findings Tracking
|
||||
|
||||
Optimized reverse-audit rounds based on diff size and ensured verified findings outside PR footprints are tracked in dedicated issues.
|
||||
|
||||
- The reverse-audit round cap now scales dynamically based on diff topology, allowing more rounds for small diffs and fewer for huge ones. ([#9183](https://github.com/QwenLM/qwen-code/pull/9183))
|
||||
- The reverse-audit round cap for huge diffs is now reduced to three only when the run has a clock; otherwise, it remains at five. ([#9203](https://github.com/QwenLM/qwen-code/pull/9203))
|
||||
- Verified findings outside the PR footprint are now deferred to a follow-up queue and tracked in a dedicated issue instead of being lost. ([#9189](https://github.com/QwenLM/qwen-code/pull/9189))
|
||||
- Reverse-audit now reports retirement failures with specific diagnostics and accepts additional punctuation separators in dry-receipt parsing. ([#9213](https://github.com/QwenLM/qwen-code/pull/9213))
|
||||
- Presubmit overlap lists are now written to a separate file to prevent overwriting the canonical findings artifact. ([#9268](https://github.com/QwenLM/qwen-code/pull/9268))
|
||||
|
||||
### Web Shell & User Interface
|
||||
|
||||
Added support for dragging and pasting text files in Web Shell and improved release note presentation with bilingual digests.
|
||||
|
||||
- The Web Shell composer now supports dragging, dropping, and pasting text files as named attachments alongside images. ([#9180](https://github.com/QwenLM/qwen-code/pull/9180))
|
||||
- Release notes are now presented as a user-friendly digest grouped by capability themes with bilingual English and Chinese summaries and attached screenshots. ([#9216](https://github.com/QwenLM/qwen-code/pull/9216))
|
||||
- Skill bodies are now redacted from Web Shell event surfaces to reduce payload size while remaining available for native ACP clients. ([#9235](https://github.com/QwenLM/qwen-code/pull/9235))
|
||||
|
||||
### Conversation & Session Control
|
||||
|
||||
Enabled forking conversations from specific responses and improved session resilience to preserve active work during shutdowns.
|
||||
|
||||
- Users can now fork conversations from any specific Assistant response using durable checkpoints to ensure branch accuracy. ([#8817](https://github.com/QwenLM/qwen-code/pull/8817))
|
||||
- Established a hidden runtime boundary for Conversations to isolate ownership and lifecycle while preserving existing owner-routed compatibility paths. ([#9181](https://github.com/QwenLM/qwen-code/pull/9181))
|
||||
- Sessions now preserve active work when close is refused by draining queued tasks within an 8-second budget before tearing down the session. ([#9134](https://github.com/QwenLM/qwen-code/pull/9134))
|
||||
|
||||
### CLI Tools & Extensions
|
||||
|
||||
Introduced External Context Provider profiles and added flags to qwen review commands for incremental validation and scoping.
|
||||
|
||||
- Introduced the External Context Provider Extension Profile v1 to enable provider-owned retrieval integrations via Qwen Extension and MCP boundaries. ([#9068](https://github.com/QwenLM/qwen-code/pull/9068))
|
||||
- Added --since flag to qwen review fetch-pr for validating incremental review anchors and scoping diffs based on local cache or ledger markers. ([#9100](https://github.com/QwenLM/qwen-code/pull/9100))
|
||||
- The runAllChunks command now outputs a diagnostic message on stderr when --all-chunks fans out a plan identified as Step 3A. ([#9249](https://github.com/QwenLM/qwen-code/pull/9249))
|
||||
- Fixed a bug where the findings command could silently overwrite input files when using --to-anchors and added validation to prevent flag wiring mismatches. ([#9270](https://github.com/QwenLM/qwen-code/pull/9270))
|
||||
|
||||
### Infrastructure & Reliability
|
||||
|
||||
Strengthened CI pipelines, resource limits, and automation workflows to prevent build failures and ensure consistent agent settings.
|
||||
|
||||
- ci: skip non-bot review_requested siblings before jobs spend compute ([#9204](https://github.com/QwenLM/qwen-code/pull/9204))
|
||||
- The hourly spam-minimization sweep now includes inline pull request review comments to block unwanted content effectively. ([#9229](https://github.com/QwenLM/qwen-code/pull/9229))
|
||||
- Increased the browser daemon SDK bundle size budget to 191 KiB to accommodate new attachment metadata and fix build failures. ([#9238](https://github.com/QwenLM/qwen-code/pull/9238))
|
||||
- ACP HTTP pre-attach buffers are now strictly bounded by byte count and frame limits to prevent resource exhaustion. ([#9007](https://github.com/QwenLM/qwen-code/pull/9007))
|
||||
- Release jobs now force-push release branches to prevent non-fast-forward errors during retries after previous failed publication attempts. ([#9082](https://github.com/QwenLM/qwen-code/pull/9082))
|
||||
- Fixed automation workflows to correctly pass agent settings like turn caps and tool allowlists that were previously silently dropped. ([#9252](https://github.com/QwenLM/qwen-code/pull/9252))
|
||||
|
||||
### Other Fixes & Improvements
|
||||
|
||||
Addressed various edge cases in tracing, mocking, and goal summarization to ensure system stability.
|
||||
|
||||
- goal: summarise the last Goal when a turn holds no permit ([#9164](https://github.com/QwenLM/qwen-code/pull/9164))
|
||||
- Fixed main-agent tracing edge cases regarding budget-triggered aborts, non-streaming calls, and deferred TUI tool batch ownership. ([#9121](https://github.com/QwenLM/qwen-code/pull/9121))
|
||||
- Fixed mock ACP child fixtures in integration tests to correctly handle the tool-guard handshake required by qwen serve. ([#9161](https://github.com/QwenLM/qwen-code/pull/9161))
|
||||
|
||||
### Other Changes
|
||||
|
||||
- Added an internal limitKind field to GoalRecord to improve how stopped Goals are typed, with no change to user-facing resume behavior. ([#9165](https://github.com/QwenLM/qwen-code/pull/9165))
|
||||
- Added test-only pins to verify ordering invariants and write-target paths without changing production behavior. ([#9225](https://github.com/QwenLM/qwen-code/pull/9225))
|
||||
- Tests now verify that background artifact refresh failures silently preserve the last successful state without showing errors or clearing the panel. ([#9227](https://github.com/QwenLM/qwen-code/pull/9227))
|
||||
- Fixed skill-parity tests on macOS by resolving the fixture root with realpath to ensure path consistency when tmpdir is a symlink. ([#9269](https://github.com/QwenLM/qwen-code/pull/9269))
|
||||
|
||||
### 中文摘要
|
||||
|
||||
#### 亮点
|
||||
|
||||
- Web Shell 编辑器现在支持拖放和粘贴文本文件作为命名附件,与图片并列显示。 ([#9180](https://github.com/QwenLM/qwen-code/pull/9180))
|
||||
- 用户现在可以使用持久化检查点从任意 Assistant 回复分叉对话,以确保分支准确性。 ([#8817](https://github.com/QwenLM/qwen-code/pull/8817))
|
||||
- 分屏视图现在继承主机的 @ 提及配置,确保自定义类别显示且 builtinAtProviders 排除项一致生效。 ([#9052](https://github.com/QwenLM/qwen-code/pull/9052))
|
||||
- /review 技能现在使用 meta 和 fetch-diff 等专用平台子命令,不再通过提示文本执行原始 gh 命令。 ([#9096](https://github.com/QwenLM/qwen-code/pull/9096))
|
||||
- 停止的接管 PR 现在获得 autofix/needs-human 标签,并在仪表板的 Takeover pool 表中显示。 ([#8960](https://github.com/QwenLM/qwen-code/pull/8960))
|
||||
- 审查会话现在记录会话 ID 和差异哈希,以便在中断的运行中确认代理工作。 ([#9091](https://github.com/QwenLM/qwen-code/pull/9091))
|
||||
|
||||
#### 审查工作流与 /review 技能
|
||||
|
||||
增强了 /review 技能,引入专用子命令、动态发现限制及改进的并发处理,以防止循环并确保报告准确。
|
||||
|
||||
- 分屏视图现在继承主机的 @ 提及配置,确保自定义类别显示且 builtinAtProviders 排除项一致生效。 ([#9052](https://github.com/QwenLM/qwen-code/pull/9052))
|
||||
- /review 技能现在使用 meta 和 fetch-diff 等专用平台子命令,不再通过提示文本执行原始 gh 命令。 ([#9096](https://github.com/QwenLM/qwen-code/pull/9096))
|
||||
- /review 命令在第 5 轮后仅发布 Critical 建议以避免循环,其他发现将延迟至最终报告。 ([#9118](https://github.com/QwenLM/qwen-code/pull/9118))
|
||||
- 审查工作流并发现在隔离无效的人类审查请求,防止其阻塞活跃的 PR 审查作业。 ([#9210](https://github.com/QwenLM/qwen-code/pull/9210))
|
||||
- /review 中的重复建议现在列在带有现有评论链接的专用段落中,不再计为锚点失败。 ([#9215](https://github.com/QwenLM/qwen-code/pull/9215))
|
||||
- /review 预提交网关现在正确处理携带 ID 的重新发布,防止丢弃匹配现有评论 ID 的有效发现。 ([#9212](https://github.com/QwenLM/qwen-code/pull/9212))
|
||||
- 修复了 /review 管道以接受带括号来源标签,并添加了 --to-anchors 选项以规范化最终网关的输入。 ([#9222](https://github.com/QwenLM/qwen-code/pull/9222))
|
||||
- 如果主审查作业异常终止,PR 审查工作流现在会发布包含重试指南的备用评论。 ([#9255](https://github.com/QwenLM/qwen-code/pull/9255))
|
||||
- 审查会话现在记录会话 ID 和差异哈希,以便在中断的运行中确认代理工作。 ([#9091](https://github.com/QwenLM/qwen-code/pull/9091))
|
||||
- 审查运行器现在会自动清除并重试失败的检出,以从持久性工作区损坏中自我修复。 ([#9220](https://github.com/QwenLM/qwen-code/pull/9220))
|
||||
- PR 审查工作树租约现在充当锁,防止并发会话破坏正在进行的审查状态。 ([#9211](https://github.com/QwenLM/qwen-code/pull/9211))
|
||||
|
||||
#### Autofix 与接管管理
|
||||
|
||||
通过新仪表板池和标签提升 Autofix 可见性,同时优化足迹门控和增长追踪以更好地处理接管任务。
|
||||
|
||||
- autofix: deny-by-default footprint gate and positional window censuses ([#9156](https://github.com/QwenLM/qwen-code/pull/9156))
|
||||
- 停止的接管 PR 现在获得 autofix/needs-human 标签,并在仪表板的 Takeover pool 表中显示。 ([#8960](https://github.com/QwenLM/qwen-code/pull/8960))
|
||||
- autofix-growth-now 标记现在使用准备时的测量时刻,确保增长差异报告准确反映正确的基准版本。 ([#9192](https://github.com/QwenLM/qwen-code/pull/9192))
|
||||
- 通过在工作流触发器中添加用户登录守卫,防止审查代理处理 autofix bot 创建的跟踪问题。 ([#9271](https://github.com/QwenLM/qwen-code/pull/9271))
|
||||
|
||||
#### 逆向审计与发现追踪
|
||||
|
||||
根据差异大小优化逆向审计轮次,并确保 PR 范围外的已验证发现在专用 issue 中追踪。
|
||||
|
||||
- reverse-audit 轮次上限现在根据 diff 拓扑动态调整,小 diff 允许更多轮次,大 diff 则减少。 ([#9183](https://github.com/QwenLM/qwen-code/pull/9183))
|
||||
- 只有当运行有时钟时,巨大差异的反向审计轮次上限才会降至三,否则保持为五。 ([#9203](https://github.com/QwenLM/qwen-code/pull/9203))
|
||||
- PR 范围外已验证的发现现在推迟到后续队列,并在专用 issue 中跟踪,避免丢失。 ([#9189](https://github.com/QwenLM/qwen-code/pull/9189))
|
||||
- 逆向审计现在报告具体的退休失败诊断信息,并在干接收解析中接受更多标点分隔符。 ([#9213](https://github.com/QwenLM/qwen-code/pull/9213))
|
||||
- 预提交重叠列表现在写入单独的文件,以防止覆盖规范 findings 工件。 ([#9268](https://github.com/QwenLM/qwen-code/pull/9268))
|
||||
|
||||
#### Web Shell 与用户界面
|
||||
|
||||
在 Web Shell 中支持拖放和粘贴文本文件,并通过双语摘要改进发布说明展示。
|
||||
|
||||
- Web Shell 编辑器现在支持拖放和粘贴文本文件作为命名附件,与图片并列显示。 ([#9180](https://github.com/QwenLM/qwen-code/pull/9180))
|
||||
- 发布说明现在按功能主题分组,提供双语摘要和截图,更易于用户阅读。 ([#9216](https://github.com/QwenLM/qwen-code/pull/9216))
|
||||
- 已从 Web Shell 事件表面中剔除技能正文以减少负载大小,同时保留对原生 ACP 客户端的可用性。 ([#9235](https://github.com/QwenLM/qwen-code/pull/9235))
|
||||
|
||||
#### 对话与会话控制
|
||||
|
||||
支持从特定回复分叉对话,并增强会话弹性以在关闭时保留活跃工作。
|
||||
|
||||
- 用户现在可以使用持久化检查点从任意 Assistant 回复分叉对话,以确保分支准确性。 ([#8817](https://github.com/QwenLM/qwen-code/pull/8817))
|
||||
- 为 Conversations 建立隐藏运行时边界以隔离所有权和生命周期,同时保留现有的所有者路由兼容路径。 ([#9181](https://github.com/QwenLM/qwen-code/pull/9181))
|
||||
- 当关闭被拒绝时,会话现在会在 8 秒内排空排队任务再销毁会话,从而保留正在进行的活跃工作。 ([#9134](https://github.com/QwenLM/qwen-code/pull/9134))
|
||||
|
||||
#### CLI 工具与扩展
|
||||
|
||||
引入外部上下文提供者配置,并为 qwen review 命令添加标志以支持增量验证和范围限定。
|
||||
|
||||
- 引入 External Context Provider Extension Profile v1,支持通过 Qwen Extension 和 MCP 边界提供独立的检索集成。 ([#9068](https://github.com/QwenLM/qwen-code/pull/9068))
|
||||
- 为 qwen review fetch-pr 添加 --since 标志,用于验证增量审查锚点并基于本地缓存或账本标记限定差异范围。 ([#9100](https://github.com/QwenLM/qwen-code/pull/9100))
|
||||
- 当 --all-chunks 分发被识别为 Step 3A 的计划时,runAllChunks 命令现在会在 stderr 输出诊断信息。 ([#9249](https://github.com/QwenLM/qwen-code/pull/9249))
|
||||
- 修复了 findings 命令在使用 --to-anchors 时可能静默覆盖输入文件的问题,并增加了标志验证。 ([#9270](https://github.com/QwenLM/qwen-code/pull/9270))
|
||||
|
||||
#### 基础设施与可靠性
|
||||
|
||||
强化 CI 流水线、资源限制及自动化工作流,以防止构建失败并确保代理设置一致。
|
||||
|
||||
- ci: skip non-bot review_requested siblings before jobs spend compute ([#9204](https://github.com/QwenLM/qwen-code/pull/9204))
|
||||
- 每小时垃圾邮件最小化扫描现在包含内联 PR 审查评论,以有效阻止不需要的内容。 ([#9229](https://github.com/QwenLM/qwen-code/pull/9229))
|
||||
- 将 browser daemon SDK 包大小预算提升至 191 KiB 以容纳新的附件元数据并修复构建失败。 ([#9238](https://github.com/QwenLM/qwen-code/pull/9238))
|
||||
- ACP HTTP 预附加缓冲区现在严格按字节数和帧数限制,以防止资源耗尽。 ([#9007](https://github.com/QwenLM/qwen-code/pull/9007))
|
||||
- 发布任务现在强制推送 release 分支,防止因之前失败尝试导致的非快进错误在重试时阻塞发布。 ([#9082](https://github.com/QwenLM/qwen-code/pull/9082))
|
||||
- 修复了自动化工作流,正确传递之前被静默丢弃的代理设置(如轮次上限和工具允许列表)。 ([#9252](https://github.com/QwenLM/qwen-code/pull/9252))
|
||||
|
||||
#### 其他修复与改进
|
||||
|
||||
解决了追踪、模拟和目标摘要中的各种边缘情况,以确保系统稳定性。
|
||||
|
||||
- goal: summarise the last Goal when a turn holds no permit ([#9164](https://github.com/QwenLM/qwen-code/pull/9164))
|
||||
- 修复了 main-agent 追踪中关于预算触发中止、非流式调用和延迟 TUI 工具批处理所有权的边缘情况。 ([#9121](https://github.com/QwenLM/qwen-code/pull/9121))
|
||||
- 修复了集成测试中的 mock ACP child 以正确处理 qwen serve 所需的 tool-guard 握手。 ([#9161](https://github.com/QwenLM/qwen-code/pull/9161))
|
||||
|
||||
#### 其他变更
|
||||
|
||||
- 在 GoalRecord 中添加了内部 limitKind 字段以改进已停止 Goal 的类型定义,不影响用户可见的恢复行为。 ([#9165](https://github.com/QwenLM/qwen-code/pull/9165))
|
||||
- 添加了仅用于测试的固定项以验证顺序不变量和写入目标路径,不改变生产行为。 ([#9225](https://github.com/QwenLM/qwen-code/pull/9225))
|
||||
- 测试验证后台构件刷新失败时静默保留上次成功状态,不显示错误也不清空面板。 ([#9227](https://github.com/QwenLM/qwen-code/pull/9227))
|
||||
- 通过在 macOS 上对 fixture 根目录使用 realpath 修复技能一致性测试,确保 tmpdir 为符号链接时的路径一致性。 ([#9269](https://github.com/QwenLM/qwen-code/pull/9269))
|
||||
|
||||
### Complete Change List (43 pull requests)
|
||||
|
||||
#### Features
|
||||
|
||||
- autofix: deny-by-default footprint gate and positional window censuses ([#9156](https://github.com/QwenLM/qwen-code/pull/9156)) by @wenshao
|
||||
- review: absorb prose gh commands into platform-backed subcommands ([#9096](https://github.com/QwenLM/qwen-code/pull/9096)) by @wenshao
|
||||
- support fork from any conversation ([#8817](https://github.com/QwenLM/qwen-code/pull/8817)) by @water-in-stone
|
||||
- web-shell: support text file attachments in the composer ([#9180](https://github.com/QwenLM/qwen-code/pull/9180)) by @doudouOUC
|
||||
- review: adopt a round-aware convergence posture for posted findings ([#9118](https://github.com/QwenLM/qwen-code/pull/9118)) by @wenshao
|
||||
- autofix: escalate stopped takeover PRs and age out unanswered pauses ([#8960](https://github.com/QwenLM/qwen-code/pull/8960)) by @wenshao
|
||||
- review: scale the reverse-audit round cap to the diff topology ([#9183](https://github.com/QwenLM/qwen-code/pull/9183)) by @wenshao
|
||||
- review: apply the huge round reduction only when the run has a clock ([#9203](https://github.com/QwenLM/qwen-code/pull/9203)) by @wenshao
|
||||
- autofix: defer verified out-of-footprint findings to a surviving follow-up queue ([#9189](https://github.com/QwenLM/qwen-code/pull/9189)) by @wenshao
|
||||
- review: run-session ledger and cross-session agent evidence ([#9091](https://github.com/QwenLM/qwen-code/pull/9091)) by @wenshao
|
||||
- external-context: Add provider extension profile ([#9068](https://github.com/QwenLM/qwen-code/pull/9068)) by @doudouOUC
|
||||
- review: validate and scope the incremental anchor inside fetch-pr ([#9100](https://github.com/QwenLM/qwen-code/pull/9100)) by @wenshao
|
||||
- daemon: Isolate the Conversations runtime boundary ([#9181](https://github.com/QwenLM/qwen-code/pull/9181)) by @doudouOUC
|
||||
- release: user-facing bilingual digest for release notes ([#9216](https://github.com/QwenLM/qwen-code/pull/9216)) by @wenshao
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- web-shell: share at mention providers with split-view panes ([#9052](https://github.com/QwenLM/qwen-code/pull/9052)) by @samuelhsin
|
||||
- ci: skip non-bot review_requested siblings before jobs spend compute ([#9204](https://github.com/QwenLM/qwen-code/pull/9204)) by @yiliang114
|
||||
- ci: minimize spam inline review comments ([#9229](https://github.com/QwenLM/qwen-code/pull/9229)) by @yiliang114
|
||||
- ci: keep no-op review requests out of the PR review concurrency group ([#9210](https://github.com/QwenLM/qwen-code/pull/9210)) by @wenshao
|
||||
- integration-tests: ack daemon tool-guard handshake in the mock ACP child (#9159) ([#9161](https://github.com/QwenLM/qwen-code/pull/9161)) by @qwen-code-dev-bot
|
||||
- sdk: raise daemon browser bundle budget to 191KB ([#9238](https://github.com/QwenLM/qwen-code/pull/9238)) by @wenshao
|
||||
- review: note when --all-chunks fans out a plan whose numbers say 3A ([#9249](https://github.com/QwenLM/qwen-code/pull/9249)) by @yiliang114
|
||||
- telemetry: Address main agent tracing edge cases ([#9121](https://github.com/QwenLM/qwen-code/pull/9121)) by @doudouOUC
|
||||
- review: give duplicate-dropped Suggestions their own compose state and body sentence ([#9215](https://github.com/QwenLM/qwen-code/pull/9215)) by @wenshao
|
||||
- autofix: re-anchor growth divergence on measurement time and external head moves ([#9192](https://github.com/QwenLM/qwen-code/pull/9192)) by @wenshao
|
||||
- ci: stop dropping agent settings in resolve and follow-up workflows ([#9252](https://github.com/QwenLM/qwen-code/pull/9252)) by @wenshao
|
||||
- goal: summarise the last Goal when a turn holds no permit ([#9164](https://github.com/QwenLM/qwen-code/pull/9164)) by @qqqys
|
||||
- serve: redact skill bodies from the Web Shell event surface ([#9235](https://github.com/QwenLM/qwen-code/pull/9235)) by @wenshao
|
||||
- review: exempt carried-id re-posts from the presubmit overlap drop ([#9212](https://github.com/QwenLM/qwen-code/pull/9212)) by @yiliang114
|
||||
- review: normalize last-gate inputs and anchor mid-line fragments ([#9222](https://github.com/QwenLM/qwen-code/pull/9222)) by @wenshao
|
||||
- review: fix silent reverse-audit retirement failures and keep non-converged evidence ([#9213](https://github.com/QwenLM/qwen-code/pull/9213)) by @wenshao
|
||||
- ci: keep a fallback comment when the PR review runner dies ([#9255](https://github.com/QwenLM/qwen-code/pull/9255)) by @wenshao
|
||||
- serve: Bound ACP HTTP pre-attach buffers by bytes ([#9007](https://github.com/QwenLM/qwen-code/pull/9007)) by @doudouOUC
|
||||
- ci: self-heal failed checkouts on the reused review runners ([#9220](https://github.com/QwenLM/qwen-code/pull/9220)) by @wenshao
|
||||
- review: keep the presubmit overlap list out of the canonical findings artifact ([#9268](https://github.com/QwenLM/qwen-code/pull/9268)) by @wenshao
|
||||
- ci: force-push release branch so retries replace failed attempts (#9076) ([#9082](https://github.com/QwenLM/qwen-code/pull/9082)) by @qwen-code-dev-bot
|
||||
- ci: stop triaging the autofix bot's own deferred-finding tracking issues (#9264) ([#9271](https://github.com/QwenLM/qwen-code/pull/9271)) by @yiliang114
|
||||
- daemon: Preserve sessions when active-work close is refused ([#9134](https://github.com/QwenLM/qwen-code/pull/9134)) by @doudouOUC
|
||||
- review: close out the four leftover findings from the #9222 review ([#9270](https://github.com/QwenLM/qwen-code/pull/9270)) by @wenshao
|
||||
- review: lock the PR review worktree lease against concurrent sessions ([#9211](https://github.com/QwenLM/qwen-code/pull/9211)) by @wenshao
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- refactor(goal): type the limit that stopped a Goal ([#9165](https://github.com/QwenLM/qwen-code/pull/9165)) by @qqqys
|
||||
- test(review): close confirmed pin gaps from #9194 (batch 1) ([#9225](https://github.com/QwenLM/qwen-code/pull/9225)) by @yiliang114
|
||||
- test(web-shell): pin silent failure of background artifact refreshes (#7427) ([#9227](https://github.com/QwenLM/qwen-code/pull/9227)) by @yiliang114
|
||||
- test(review): realpath the skill-parity fixture root ([#9269](https://github.com/QwenLM/qwen-code/pull/9269)) by @wenshao
|
||||
|
||||
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.21.12...v0.21.13
|
||||
|
||||
## [0.21.12](https://github.com/QwenLM/qwen-code/releases/tag/v0.21.12) - 2026-08-14
|
||||
|
||||
### Highlights
|
||||
|
||||
- Added support for uploading workspace files to the Web Shell composer via drag-and-drop or the @ file panel with progress tracking. ([#8874](https://github.com/QwenLM/qwen-code/pull/8874))
|
||||
- Implemented a diff growth brake in autofix reviews to limit source and test line increases per window using configurable budgets. ([#8981](https://github.com/QwenLM/qwen-code/pull/8981))
|
||||
- Confirmed Critical findings now require an executed witness with observed output, automatically demoting unverified claims to low confidence. ([#9065](https://github.com/QwenLM/qwen-code/pull/9065))
|
||||
- The daemon now adaptively grows live-journal caps up to 256 MiB per session to prevent data loss during long turns. ([#8905](https://github.com/QwenLM/qwen-code/pull/8905))
|
||||
- Fixed visual jitter in the desktop app's sidebar and ensured external URLs open reliably through the system browser. ([#9073](https://github.com/QwenLM/qwen-code/pull/9073), [#9069](https://github.com/QwenLM/qwen-code/pull/9069), [#9111](https://github.com/QwenLM/qwen-code/pull/9111))
|
||||
- Compact mode now displays model reasoning as a foldable summary, and background shell activity is tracked to prevent premature cleanup. ([#9148](https://github.com/QwenLM/qwen-code/pull/9148), [#9042](https://github.com/QwenLM/qwen-code/pull/9042))
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
No known breaking changes.
|
||||
|
||||
### Complete Change List
|
||||
|
||||
#### Features
|
||||
|
||||
- Added support for uploading workspace files directly to the Web Shell composer via drag-and-drop or the @ file panel with progress tracking. ([#8874](https://github.com/QwenLM/qwen-code/pull/8874)) by @ytahdn
|
||||
- Implemented a diff growth brake in autofix reviews to limit source and test line increases per window using configurable budgets. ([#8981](https://github.com/QwenLM/qwen-code/pull/8981)) by @wenshao
|
||||
- The daemon now adaptively grows live-journal caps up to 256 MiB per session before truncating replay entries, using a shared memory pool to prevent data loss during long turns. ([#8905](https://github.com/QwenLM/qwen-code/pull/8905)) by @wenshao
|
||||
- Confirmed Critical findings now require an executed witness with observed output, automatically demoting unverified claims to low confidence and hiding them from PR posts. ([#9065](https://github.com/QwenLM/qwen-code/pull/9065)) by @wenshao
|
||||
- Review ledger markers now include the anchor commit SHA to ensure accurate incremental diff scoping across environments and prevent certification of unreviewed ranges. ([#9067](https://github.com/QwenLM/qwen-code/pull/9067)) by @wenshao
|
||||
- Added user settings to control review attribution footers, default effort levels, and default comments, preventing repository files from overriding these policies. ([#8994](https://github.com/QwenLM/qwen-code/pull/8994)) by @wenshao
|
||||
- Requests routed through Alibaba Cloud API Gateway domains now correctly include metadata fields for session tracing and log correlation. ([#9103](https://github.com/QwenLM/qwen-code/pull/9103)) by @yiliang114
|
||||
- Added optional OpenTelemetry trace and span IDs to daemon logs to improve correlation and debugging capabilities for sampled requests. ([#9084](https://github.com/QwenLM/qwen-code/pull/9084)) by @doudouOUC
|
||||
- Enabled per-agent JSONL transcripts for all workflow agent dispatches to record prompts, tool calls, and results consistently. ([#8971](https://github.com/QwenLM/qwen-code/pull/8971)) by @qqqys
|
||||
- The /review command now identifies and reports entire classes of unbounded defects prospectively instead of listing individual instances one by one. ([#9095](https://github.com/QwenLM/qwen-code/pull/9095)) by @wenshao
|
||||
- Main agent invocations now generate OpenTelemetry-compliant traces with stable identities and correct status semantics for success, cancellation, and errors. ([#9107](https://github.com/QwenLM/qwen-code/pull/9107)) by @doudouOUC
|
||||
- Background shells are now tracked in activeWork, enabling explicit negotiation of shell categories and preventing premature automatic cleanup during shell activity. ([#9042](https://github.com/QwenLM/qwen-code/pull/9042)) by @doudouOUC
|
||||
- Web Shell Channel management now supports full policy configuration, workspace binding, and a redesigned interface consistent with other management surfaces. ([#8848](https://github.com/QwenLM/qwen-code/pull/8848)) by @qqqys
|
||||
- Added automatic escalation to maintainers when autofix diffs exceed growth budgets across multiple rounds instead of patching indefinitely. ([#9104](https://github.com/QwenLM/qwen-code/pull/9104)) by @wenshao
|
||||
- Updated the review loop to validate feedback based on content accuracy rather than relying solely on the author's identity. ([#8996](https://github.com/QwenLM/qwen-code/pull/8996)) by @wenshao
|
||||
- Added a guard to block cross-worktree Git mutations from model-issued commands that target directories outside the current session. ([#8687](https://github.com/QwenLM/qwen-code/pull/8687)) by @wenshao
|
||||
- Compact mode (Ctrl+O) now displays model reasoning as a foldable "Thinking…" summary instead of hiding it entirely. ([#9148](https://github.com/QwenLM/qwen-code/pull/9148)) by @ytahdn
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- Fixed an issue in the standalone Web Shell where the first prompt could fail to submit due to session target synchronization errors. ([#9038](https://github.com/QwenLM/qwen-code/pull/9038)) by @ytahdn
|
||||
- Updated the release workflow to allow automated bots to fully approve release pull requests without requiring manual human intervention. ([#9056](https://github.com/QwenLM/qwen-code/pull/9056)) by @yiliang114
|
||||
- Ensured OpenTelemetry session ownership is correctly preserved across model requests and asynchronous streams in daemon modes. ([#9077](https://github.com/QwenLM/qwen-code/pull/9077)) by @doudouOUC
|
||||
- Fixed visual jitter in the desktop app's sidebar project list by reserving space for the scrollbar gutter. ([#9073](https://github.com/QwenLM/qwen-code/pull/9073)) by @yiliang114
|
||||
- Fixed a security gap where inline-level quotations could bypass layer gates, ensuring that only properly walked block-level quotes can trigger approval caps. ([#9020](https://github.com/QwenLM/qwen-code/pull/9020)) by @wenshao
|
||||
- Autofix verification gates are now hermetic to runner git configurations, preventing host-level settings from poisoning subsequent test runs. ([#8961](https://github.com/QwenLM/qwen-code/pull/8961)) by @wenshao
|
||||
- Fixed an issue where restricted secondary workspace rows in the web-shell displayed duplicate archive buttons, ensuring only one accessible action remains visible. ([#9066](https://github.com/QwenLM/qwen-code/pull/9066)) by @yiliang114
|
||||
- Enabled opening external URLs from Markdown links and artifacts in the desktop app by routing them through the system browser with improved error handling. ([#9069](https://github.com/QwenLM/qwen-code/pull/9069)) by @yiliang114
|
||||
- Prevented the Windows runtime terminal window from appearing during startup and aligned the reduced-motion bootstrap view for consistent visual centering. ([#9064](https://github.com/QwenLM/qwen-code/pull/9064)) by @yiliang114
|
||||
- Extended the one-time migration bridge from Electron to Tauri to support Windows and Linux, ensuring seamless updates while preserving user data. ([#9079](https://github.com/QwenLM/qwen-code/pull/9079)) by @yiliang114
|
||||
- Reduced CI test flakes caused by disk space and system load by optimizing fixture cleanup and using disk-backed temporary directories on Linux. ([#8982](https://github.com/QwenLM/qwen-code/pull/8982)) by @yiliang114
|
||||
- Improved error messages for review comments when pull request binding is missing and added tests to enforce operator-scope invariants. ([#9102](https://github.com/QwenLM/qwen-code/pull/9102)) by @wenshao
|
||||
- The Windows standalone installer now uses built-in .NET hashing instead of PowerShell commands to prevent failures when verifying checksums. ([#9112](https://github.com/QwenLM/qwen-code/pull/9112)) by @MichaelYochpaz
|
||||
- Automatic fixes now wait for in-flight code reviews to complete before updating branches, ensuring human feedback is incorporated without losing context. ([#8899](https://github.com/QwenLM/qwen-code/pull/8899)) by @yiliang114
|
||||
- The daemon now offers a compact conversation summary for web clients that excludes detailed subagent events to improve load times and reduce data usage. ([#9057](https://github.com/QwenLM/qwen-code/pull/9057)) by @ytahdn
|
||||
- The desktop app now reliably opens all external links, including OAuth and documentation URLs, through the system browser instead of silently dropping them. ([#9111](https://github.com/QwenLM/qwen-code/pull/9111)) by @yiliang114
|
||||
- Tool execution failures in the web shell no longer display prominent text labels in collapsed summaries, showing only a subtle icon count instead. ([#9053](https://github.com/QwenLM/qwen-code/pull/9053)) by @ytahdn
|
||||
- The review pipeline now isolates concurrent runs to prevent verdict overwrites and includes regression tests for four previously observed live-run failures. ([#9086](https://github.com/QwenLM/qwen-code/pull/9086)) by @wenshao
|
||||
- Tool-loop protection stops now surface as structured turn errors with localized guidance, ensuring errors persist across page reloads without offering invalid retry actions. ([#8853](https://github.com/QwenLM/qwen-code/pull/8853)) by @ytahdn
|
||||
- Assistant footer actions like Copy and Branch now remain hidden until background agents complete and the main agent provides a final summarized response. ([#8787](https://github.com/QwenLM/qwen-code/pull/8787)) by @carffuca
|
||||
- The review run command now rejects targets consisting only of path separators and fixes the composed-name oracle anchoring to prevent slow failure paths. ([#9128](https://github.com/QwenLM/qwen-code/pull/9128)) by @wenshao
|
||||
- Reverted transactional session switching to restore the loading-skeleton model, ensuring transcripts clear and skeletons display during session loads. ([#9129](https://github.com/QwenLM/qwen-code/pull/9129)) by @ytahdn
|
||||
- Fixed spam minimization workflows by using the repository-scoped GITHUB_TOKEN to prevent permission errors when minimizing comments. ([#9140](https://github.com/QwenLM/qwen-code/pull/9140)) by @yiliang114
|
||||
- Fixed Shell to correctly honor the tools.truncateToolOutputThreshold setting instead of hardcoding a 30,000-character limit. ([#9014](https://github.com/QwenLM/qwen-code/pull/9014)) by @cxruan
|
||||
- Enabled workspace batch Skill toggles to accept uninstalled Skill names, allowing users to declare disabled states before installation. ([#9139](https://github.com/QwenLM/qwen-code/pull/9139)) by @callmeYe
|
||||
- fix(core): detect line-continuation and @P shell substitutions (#8582) ([#8590](https://github.com/QwenLM/qwen-code/pull/8590)) by @yiliang114
|
||||
- Fixed E2E test failures by updating the mock ACP child to correctly acknowledge the daemon tool guard handshake. ([#9162](https://github.com/QwenLM/qwen-code/pull/9162)) by @qwen-code-dev-bot
|
||||
|
||||
#### Performance
|
||||
|
||||
- Daemon session restore is now selective, reading only necessary records to reconstruct state and significantly improving performance for large sessions. ([#9055](https://github.com/QwenLM/qwen-code/pull/9055)) by @doudouOUC
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- Upgraded the sharp image library to version 0.35.0 to resolve a known security vulnerability flagged by npm audit. ([#8952](https://github.com/QwenLM/qwen-code/pull/8952)) by @yiliang114
|
||||
- Fixed a flaky test in the web UI by improving the timing logic for draining batched transcript dispatches. ([#9058](https://github.com/QwenLM/qwen-code/pull/9058)) by @wenshao
|
||||
- Release workflows now require designated approvers, use least-privilege permissions, and include automated security scans for dependencies and secrets. ([#9008](https://github.com/QwenLM/qwen-code/pull/9008)) by @yiliang114
|
||||
- The Conversations runtime foundation has been generalized to support both the standalone daemon and Live Voice sessions within a unified manager. ([#8890](https://github.com/QwenLM/qwen-code/pull/8890)) by @doudouOUC
|
||||
- VSCode companion sync publishing can now be paused by setting the RELEASE_VSCODE_SYNC_PUBLISH repository variable to false without affecting manual releases. ([#9132](https://github.com/QwenLM/qwen-code/pull/9132)) by @yiliang114
|
||||
- Refactored internal CLI dependencies to remove circular imports between utils, serve, and UI layers for better module isolation. ([#9147](https://github.com/QwenLM/qwen-code/pull/9147)) by @yiliang114
|
||||
|
||||
### New Contributors
|
||||
|
||||
- @MichaelYochpaz made their first contribution in [#9112](https://github.com/QwenLM/qwen-code/pull/9112)
|
||||
|
||||
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.21.11...v0.21.12
|
||||
|
||||
## [0.21.11](https://github.com/QwenLM/qwen-code/releases/tag/v0.21.11) - 2026-08-13
|
||||
|
||||
### Highlights
|
||||
|
|
|
|||
|
|
@ -272,6 +272,91 @@ Enterprise paragraph.
|
|||
exported-GH_HOST only, and "unavailable otherwise"), or gate it off
|
||||
explicitly on non-github.com runs. E2E: `--comment` against a
|
||||
scratch/test CR.
|
||||
- **Landed (2026-08-19):** the `submit` slice. `submitAoneReview` in
|
||||
`lib/platform/aone.ts` posts the review as N+1 calls — one
|
||||
`a1 repo mr comment create` per inline finding, the summary comment
|
||||
last (Q5 order), `a1 repo mr approve` on APPROVE (D6); writes ride a
|
||||
no-retry transport (`a1Once`) so a transient retry can never
|
||||
double-post. The commit_id gate GitHub enforces server-side lives in
|
||||
the provider as a pre-write head-drift refusal; a mid-batch failure
|
||||
throws `AonePartialPostError` naming exactly what landed, and
|
||||
`submit` reports it exit-3 with do-not-re-run advice (a retry would
|
||||
duplicate). REQUEST_CHANGES posts the blocking summary header (D6);
|
||||
the recorded-but-hostless refusal stays fail-closed, now between two
|
||||
WRITABLE platforms. The created-comment read-back is tolerant: an
|
||||
exec failure still propagates, but an ACCEPTED write whose answer
|
||||
fails to parse degrades to "landed, id unknown" — counting it as
|
||||
unposted would re-post it on a retry. Two deliberate trade-offs to
|
||||
revisit when the Q4-era response changes land: the head-drift gate is
|
||||
fail-OPEN on an empty `sourceBranch` (a `mr view` shape regression
|
||||
must not brick posting), and the id read-back parses a set of
|
||||
tolerated shapes best-effort. Still open: `composeUrl`, cleanup
|
||||
audit, AI-comment marking (Q4), the render-adjudication carve-out.
|
||||
- **Hardened (2026-08-19, review round 2):** five write-safety fixes
|
||||
from the maintainer review of #9491. (1) The `target-platform-unbound`
|
||||
refusal now HONOURS its own remedy — an explicit `--host` on the
|
||||
re-run is platform proof and lifts it, instead of refusing again.
|
||||
(2) The write gate binds hosts through `hostsEquivalent`, not raw
|
||||
equality — Aone's web/git host pair is one platform. (3) Write
|
||||
routing keys on the CANONICAL Aone pair (`isAoneCanonicalHost`),
|
||||
never the family wildcard (a `*.alibaba-inc.com` GHE host is not
|
||||
Aone), never the ambient GH_HOST (reads never detect from it), and
|
||||
an explicit `--host` outranks the recorded binding in both
|
||||
directions. (4) A size gate refuses any message over the
|
||||
131072-byte single-argv-element limit a1 must pass it as, BEFORE
|
||||
any write lands (a long CJK summary is inside compose-review's
|
||||
char cap and outside the OS byte limit). (5) An exec failure counts
|
||||
as possibly-landed (`ambiguous`), so submit's do-not-re-run advisory
|
||||
fires even when the count is zero — an accepted-then-died write must
|
||||
never read back as a clean total failure.
|
||||
- **Hardened further (2026-08-20, verify-lane review of #9491):** the
|
||||
sandboxed-verification review surfaced the next layer. (6) The
|
||||
fail-closed refusal now also fires when NO recording exists at all —
|
||||
a `--user-authorized` publish invoked from another directory finds
|
||||
nothing, and the cwd probe alone must not pick the platform of an
|
||||
irreversible write. (7) The gh write rebinds its routing host to the
|
||||
same evidence that selected it (`explicitHost ?? recordedHost`), so a
|
||||
recorded non-canonical host (a GHE instance) no longer posts wherever
|
||||
the ambient env pointed. (8) The REQUEST_CHANGES terminal note is
|
||||
conditioned on the inline Criticals actually posted — a body-only
|
||||
Critical posts no discussion threads, so nothing mechanically blocks
|
||||
the merge and the note says so. (9) `a1Cause` reads the captured
|
||||
stderr, not the execFileSync message — the message embeds the FULL
|
||||
argv (the multi-line comment body), so parsing it surfaced the
|
||||
operator's review text instead of a1's error. (10) The summary
|
||||
skip-guard keys on the posted `summaryMessage`, not the raw body — an
|
||||
empty-body REQUEST_CHANGES still posts its blocking header, the
|
||||
verdict's sole carrier. Host comparison is normalised once
|
||||
(`normalizeHostSpelling`: case/port/trailing-dot) and shared by
|
||||
`hostsEquivalent` and `isAoneCanonicalHost`; the fast-path repo axis
|
||||
binds case-insensitively; the cross-session scan is last-writer-wins
|
||||
by mtime, and the newest same-PR recording decides (host or unbound)
|
||||
instead of harvesting an older session's stale host.
|
||||
- **Hardened again (2026-08-20, third review round of #9491):** the
|
||||
next review pass found the layer under that one. (11) The cwd arm of
|
||||
the write gate now probes the origin through the canonical predicate
|
||||
itself instead of delegating to the registry's family-wildcard
|
||||
detection — a `ghe.alibaba-inc.com` origin no longer takes the a1
|
||||
path. (12) `submit` FORCES context-unavailable into the compose input
|
||||
on the Aone path — the cap no longer rides the model-written state,
|
||||
so an omitted field cannot buy a real platform approval; the docs now
|
||||
say the native approve does not fire this phase. (13) A mid-batch
|
||||
failure now emits `"partial": true` with the landed counts/ids —
|
||||
`posted: false` alone invited a wrapper retry that double-posts; and
|
||||
a deliberate pre-write refusal (drift, oversized) reads as
|
||||
`aone-post-refused`, while an UNEXPECTED pre-write error rethrows
|
||||
(gh parity — nothing landed, a re-run is safe). (14) The floor
|
||||
recovery's host axis binds to the host the write routes at
|
||||
(explicit ?? recorded ?? gh fallback), so a flagless Aone post no
|
||||
longer drops the operator's recorded floor. (15) The batch re-reads
|
||||
the head once after posting and discloses a mid-batch amend
|
||||
(`headMovedDuringPost`) instead of claiming the pins held. The
|
||||
approve-failure and oversized refusals name the USER as the manual
|
||||
actor; the completion contract reads `partial`/`approved`; and the
|
||||
repeat-round caveats (no dedup backing, no self-PR detection) are
|
||||
documented for the user. Still open: dedup/self-PR backing for Aone,
|
||||
`composeUrl`, cleanup audit, AI-comment marking (Q4), the
|
||||
render-adjudication carve-out.
|
||||
- **Phase 4 — semantic gaps.** Incremental-cache ancestry fallback, build-test
|
||||
repo-config escape hatch, publish-assets gating polish, generic-GitLab
|
||||
(glab) evaluation.
|
||||
|
|
|
|||
66
docs/design/model-reasoning-capabilities.md
Normal file
66
docs/design/model-reasoning-capabilities.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Model reasoning capabilities
|
||||
|
||||
## Goal
|
||||
|
||||
Expose accurate reasoning controls for common Alibaba Cloud Coding Plan and
|
||||
Token Plan models without inventing effort levels that their Chat Completions
|
||||
APIs do not support.
|
||||
|
||||
## Research
|
||||
|
||||
Alibaba Cloud documents `qwen3.8-max` with native `low`, `medium`, and `xhigh`
|
||||
reasoning effort levels. It documents `qwen3.6-plus`, `qwen3.6-flash`,
|
||||
`qwen3.7-plus`, `qwen3.7-max`, and `qwen3.5-plus` as hybrid-thinking models that
|
||||
can enable or disable thinking, but use an integer thinking budget instead of
|
||||
discrete Chat Completions effort levels.
|
||||
|
||||
The registered capabilities therefore are:
|
||||
|
||||
| Exact model id | Thinking | Effort control |
|
||||
| --------------- | -------- | ------------------------ |
|
||||
| `qwen3.8-max` | Optional | `low`, `medium`, `xhigh` |
|
||||
| `qwen3.7-max` | Optional | None |
|
||||
| `qwen3.7-plus` | Optional | None |
|
||||
| `qwen3.6-plus` | Optional | None |
|
||||
| `qwen3.6-flash` | Optional | None |
|
||||
| `qwen3.5-plus` | Optional | None |
|
||||
|
||||
Sources:
|
||||
|
||||
- [Qwen Code and Coding Plan model ids](https://help.aliyun.com/zh/model-studio/qwen-code)
|
||||
- [Thinking modes and defaults](https://help.aliyun.com/zh/model-studio/deep-thinking)
|
||||
- [Chat Completions thinking parameters](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions)
|
||||
|
||||
## Design
|
||||
|
||||
The model manifest distinguishes tiered reasoning from toggle-only reasoning
|
||||
and continues to match exact model ids. Toggle-only models produce the same ACP
|
||||
configuration option as tiered models, with two values: `none` and `default`.
|
||||
Selecting `none` disables thinking for the live session. Selecting `default`
|
||||
clears the session override so the existing model or provider default applies.
|
||||
|
||||
The daemon marks toggle-only options in ACP metadata. WebShell maps them to an
|
||||
empty effort list, renders only the Thinking switch, and shows `Thinking` or
|
||||
`Thinking Off` on the model chip. Existing tiered controls retain their effort
|
||||
rows and labels.
|
||||
|
||||
Opening the controls does not mutate generation settings. No provider,
|
||||
authentication, persistence, or runtime-snapshot behavior changes.
|
||||
|
||||
## Deferred models
|
||||
|
||||
DeepSeek, GLM, Kimi, and Grok models are not registered here. Their reasoning
|
||||
parameters or defaults vary between direct and Alibaba Cloud endpoints, while
|
||||
the current manifest is keyed only by model id. Registering them before the
|
||||
provider path carries the relevant capability context could display a control
|
||||
whose selected value is not sent correctly.
|
||||
|
||||
Qwen aliases, dated variants, coder models, and models with a preset default
|
||||
that differs from the model default are also deferred. They require separate
|
||||
capability or resolved-configuration semantics rather than broadened matching.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Older ACP clients can continue to treat the option as a normal select. Older
|
||||
daemons do not advertise the toggle-only metadata, so current WebShell keeps
|
||||
the controls hidden unless the capability is explicit.
|
||||
19
docs/design/web-shell-mid-turn-file-references.md
Normal file
19
docs/design/web-shell-mid-turn-file-references.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Web Shell mid-turn file attachments
|
||||
|
||||
## Problem
|
||||
|
||||
Web Shell turns an `@` file selection into prompt text plus a file input annotation. Annotated prompts currently wait for the next turn, while images can be uploaded and inserted into a running turn. File insertion must use the same durable attachment and rendering path as an ordinary prompt with an attached file.
|
||||
|
||||
## Design
|
||||
|
||||
When the daemon advertises `session_attachments`, Web Shell uploads both composer file attachments and files resolved from annotations to the current session attachment store. Annotated files are read through the selected trusted workspace with the existing bounded workspace-file reader. The returned attachment references travel with the existing mid-turn `content` payload alongside image references. Prompts containing non-file annotations, unavailable workspace ownership, unreadable files, or oversized files continue through the ordinary pending queue or restore to the editor before daemon admission.
|
||||
|
||||
The inserted display text omits annotated `@` tokens because the referenced files are rendered as attachment rows. Pending file attachments appear beside image previews and open in the existing attachment preview panel. Reconciliation and injection echoes recover file rows from the same `resource` attachment references used by an ordinary prompt with files.
|
||||
|
||||
Deleting a queued mid-turn message removes its referenced file attachments after the daemon confirms the message was removed. Failed removals leave the attachments intact because the queued or running message may still need them.
|
||||
|
||||
No new daemon protocol or attachment type is introduced.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Older daemons without `session_attachments` keep annotated prompts on the ordinary queue. Existing image-only mid-turn messages and text insertion are unchanged.
|
||||
|
|
@ -382,7 +382,7 @@ The deterministic halves of the pipeline — argument parsing (`qwen review pars
|
|||
|
||||
**GitHub Enterprise:** reviewing a PR URL on a non-`github.com` host routes every GitHub call at that host — the review subcommands (`match-remote`, `meta`, `fetch-pr`, `pr-context`, `comment-status`, `issue-context`, `fetch-diff`, `comment-body`, `plan-diff`, `test-plan`, `presubmit`, `compose-review`, `submit`, `publish-assets`) accept `--host` and set it in code, so a forgotten host cannot silently retarget the review at `github.com`.
|
||||
|
||||
**Aone Code:** for a clone whose origin is on `gitlab.alibaba-inc.com`, run `/review` from inside that clone — the platform is detected from the remote and the read subcommands work, backed by the `a1` CLI — the target number is the global MR id. `fetch-pr` fetches `refs/merge-requests/<id>/head` and builds the worktree + diff, so the agent review of the worktree is unchanged. In this phase every Aone run is context-unavailable and several flows are skipped (rather than hitting github.com's same-named repo): `pr-context`/`comment-status`/`presubmit` have no Aone backing (verdict caps at `COMMENT`), `test-plan` is unbacked, Agent 0 is skipped, and the `publish-assets` write is skipped — with `--comment` also refused, an Aone run is read-only toward the platform in this phase; findings land in the terminal output and the saved report. See `docs/design/2026-08-15-review-aone-provider.md`.
|
||||
**Aone Code:** for a clone whose origin is on `gitlab.alibaba-inc.com`, run `/review` from inside that clone — the platform is detected from the remote and the subcommands work, backed by the `a1` CLI — the target number is the global MR id. `fetch-pr` fetches `refs/merge-requests/<id>/head` and builds the worktree + diff, so the agent review of the worktree is unchanged. Every Aone run is context-unavailable and several flows are skipped (rather than hitting github.com's same-named repo): `pr-context`/`comment-status`/`presubmit` have no Aone backing (verdict caps at `COMMENT`), `test-plan` is unbacked, Agent 0 is skipped, and the `publish-assets` write is skipped. `--comment` **posts** the review through the `a1` CLI: one comment per inline finding, then the summary comment. Aone has no native request-changes state — on that verdict the summary comment carries a blocking header, and any inline Criticals that were actually posted block the merge through the discussion gate while their discussions stay unresolved (when no inline Critical posted, the header is advisory and nothing mechanically blocks the merge). The native `a1 repo mr approve` is wired for an Approve verdict but does not fire this phase: the context-unavailable cap keeps every Aone verdict at Comment. Two caveats for repeat rounds: there is no dedup backing yet, so a second `--comment` round re-posts every still-valid finding as a new comment, and self-PR detection has no Aone backing. See `docs/design/2026-08-15-review-aone-provider.md`.
|
||||
|
||||
Every run ends with one machine-readable line (`Review complete: <target> — <disposition>`), so scripts and CI wrappers can detect completion and outcome with a single `^Review complete: ` match.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/external-context",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"private": true,
|
||||
"description": "Provider-bound external context for Qwen Code",
|
||||
"type": "module",
|
||||
|
|
|
|||
71
package-lock.json
generated
71
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
},
|
||||
"integrations/external-context": {
|
||||
"name": "@qwen-code/external-context",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"undici": "^7.28.0",
|
||||
|
|
@ -18549,7 +18549,6 @@
|
|||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18571,7 +18570,6 @@
|
|||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18593,7 +18591,6 @@
|
|||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18615,7 +18612,6 @@
|
|||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18637,7 +18633,6 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18659,7 +18654,6 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18681,7 +18675,6 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18703,7 +18696,6 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18725,7 +18717,6 @@
|
|||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18747,7 +18738,6 @@
|
|||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -18769,7 +18759,6 @@
|
|||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
|
@ -28256,7 +28245,7 @@
|
|||
},
|
||||
"packages/acp-bridge": {
|
||||
"name": "@qwen-code/acp-bridge",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@qwen-code/qwen-code-core": "file:../core"
|
||||
|
|
@ -28271,7 +28260,7 @@
|
|||
},
|
||||
"packages/audio-capture": {
|
||||
"name": "@qwen-code/audio-capture",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build": "^4.8.4"
|
||||
|
|
@ -28288,7 +28277,7 @@
|
|||
},
|
||||
"packages/channels/base": {
|
||||
"name": "@qwen-code/channel-base",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1"
|
||||
},
|
||||
|
|
@ -28298,9 +28287,9 @@
|
|||
},
|
||||
"packages/channels/dingtalk": {
|
||||
"name": "@qwen-code/channel-dingtalk",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"dingtalk-stream-sdk-nodejs": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -28309,10 +28298,10 @@
|
|||
},
|
||||
"packages/channels/feishu": {
|
||||
"name": "@qwen-code/channel-feishu",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.45.0",
|
||||
"@qwen-code/channel-base": "0.21.11"
|
||||
"@qwen-code/channel-base": "0.21.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
|
|
@ -28320,10 +28309,10 @@
|
|||
},
|
||||
"packages/channels/github": {
|
||||
"name": "@qwen-code/channel-github",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"https-proxy-agent": "^7.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -28332,10 +28321,10 @@
|
|||
},
|
||||
"packages/channels/gitlab": {
|
||||
"name": "@qwen-code/channel-gitlab",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@gitbeaker/rest": "^42.5.0",
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -28344,7 +28333,7 @@
|
|||
},
|
||||
"packages/channels/plugin-example": {
|
||||
"name": "@qwen-code/channel-plugin-example",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "file:../base",
|
||||
"ws": "^8.18.0"
|
||||
|
|
@ -28358,9 +28347,9 @@
|
|||
},
|
||||
"packages/channels/qqbot": {
|
||||
"name": "@qwen-code/channel-qqbot",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"@tencent-connect/qqbot-connector": "^1.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
|
@ -28370,9 +28359,9 @@
|
|||
},
|
||||
"packages/channels/telegram": {
|
||||
"name": "@qwen-code/channel-telegram",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"grammy": "^1.41.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"telegram-markdown-formatter": "^0.1.2"
|
||||
|
|
@ -28383,9 +28372,9 @@
|
|||
},
|
||||
"packages/channels/wecom": {
|
||||
"name": "@qwen-code/channel-wecom",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"@wecom/aibot-node-sdk": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -28394,9 +28383,9 @@
|
|||
},
|
||||
"packages/channels/weixin": {
|
||||
"name": "@qwen-code/channel-weixin",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11"
|
||||
"@qwen-code/channel-base": "0.21.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
|
|
@ -28404,7 +28393,7 @@
|
|||
},
|
||||
"packages/chrome-extension": {
|
||||
"name": "@qwen-code/chrome-bridge",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.32",
|
||||
|
|
@ -28432,7 +28421,7 @@
|
|||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@google/genai": "2.6.0",
|
||||
|
|
@ -28688,7 +28677,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
|
|
@ -31568,7 +31557,7 @@
|
|||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
|
|
@ -31637,7 +31626,7 @@
|
|||
},
|
||||
"packages/web-shell": {
|
||||
"name": "@qwen-code/web-shell",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/commands": "^6.7.0",
|
||||
|
|
@ -32434,7 +32423,7 @@
|
|||
},
|
||||
"packages/web-templates": {
|
||||
"name": "@qwen-code/web-templates",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
|
@ -32941,7 +32930,7 @@
|
|||
},
|
||||
"packages/webui": {
|
||||
"name": "@qwen-code/webui",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@qwen-code/sdk": "file:../sdk-typescript",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.21.11"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.21.14"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node scripts/start.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/acp-bridge",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "Shared ACP bridge core (createHttpAcpBridge factory, BridgeClient, defaultSpawnChannelFactory, BridgeFileSystem injection seam) + primitives (EventBus, AcpChannel, in-memory channel, PermissionMediator interface) used by qwen serve, channels, IDE, TUI, and remote-control adapters.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -1393,6 +1393,40 @@ describe('createAcpSessionBridge', () => {
|
|||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('wraps a Goal control request in the envelope the agent reads', async () => {
|
||||
// The agent's `sessionGoalControl` handler reads `params['request']`; this
|
||||
// method is its only producer, and a flattened envelope makes every
|
||||
// POST /session/:id/goal fail with "Invalid or missing Goal control
|
||||
// request" while the route and agent tests stay green.
|
||||
const snapshot = { v: 2, activity: 'idle', goal: null };
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: async (method) =>
|
||||
method === SERVE_CONTROL_EXT_METHODS.sessionGoalControl
|
||||
? { snapshot }
|
||||
: {},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const request = { action: 'create' as const, objective: 'ship it' };
|
||||
|
||||
await expect(
|
||||
bridge.controlSessionGoal(session.sessionId, request),
|
||||
).resolves.toEqual({ snapshot });
|
||||
expect(handle.agent.extMethodCalls).toContainEqual({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionGoalControl,
|
||||
params: { sessionId: session.sessionId, request },
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.controlSessionGoal(
|
||||
'11111111-2222-3333-4444-555555555555',
|
||||
request,
|
||||
),
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('serves completed MCP status without restarting an idle channel', async () => {
|
||||
const makeMcpChannel = () =>
|
||||
makeChannel({
|
||||
|
|
@ -29635,6 +29669,103 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa
|
|||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
/**
|
||||
* A Goal turn runs inside the child via `prompt()` directly, so the bridge
|
||||
* never sees a `session/prompt` RPC for it and `pendingPromptCount` stays 0
|
||||
* for its whole duration. The child still drains this queue between tool
|
||||
* batches, so the session is busy: without the `goalTurnActive` check every
|
||||
* mid-turn insert during a Goal turn would be refused as idle — while the
|
||||
* client enables the affordance precisely because a Goal turn is non-idle.
|
||||
*/
|
||||
it('accepts a rejectIfIdle insert while a child-driven Goal turn runs', async () => {
|
||||
const handle = makeChannel({});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
await handle.agentConnection.extNotification('_qwencode/start_turn', {
|
||||
sessionId: session.sessionId,
|
||||
source: 'goal',
|
||||
});
|
||||
|
||||
expect(
|
||||
bridge.enqueueMidTurnMessage(
|
||||
session.sessionId,
|
||||
'insert me',
|
||||
{ clientId: session.clientId },
|
||||
'goal-insert',
|
||||
{ rejectIfIdle: true },
|
||||
),
|
||||
).toEqual({ accepted: true, messageId: 'goal-insert' });
|
||||
// Queued for the child's drain, NOT promoted into a prompt of its own.
|
||||
expect(bridge.getPendingPrompts(session.sessionId)).toEqual([]);
|
||||
expect(
|
||||
bridge.getMidTurnMessages(session.sessionId, {
|
||||
clientId: session.clientId,
|
||||
}).messages,
|
||||
).toEqual([
|
||||
expect.objectContaining({ messageId: 'goal-insert', text: 'insert me' }),
|
||||
]);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('promotes what the ending Goal turn never drained', async () => {
|
||||
let release: (() => void) | undefined;
|
||||
const handle = makeChannel({
|
||||
promptImpl: async () => {
|
||||
await new Promise<void>((res) => {
|
||||
release = res;
|
||||
});
|
||||
return { stopReason: 'end_turn' };
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
await handle.agentConnection.extNotification('_qwencode/start_turn', {
|
||||
sessionId: session.sessionId,
|
||||
source: 'goal',
|
||||
});
|
||||
expect(
|
||||
bridge.enqueueMidTurnMessage(
|
||||
session.sessionId,
|
||||
'never drained',
|
||||
{ clientId: session.clientId },
|
||||
'goal-undrained',
|
||||
{ rejectIfIdle: true },
|
||||
),
|
||||
).toEqual({ accepted: true, messageId: 'goal-undrained' });
|
||||
|
||||
// A Goal turn owns no prompt slot, so its end is the only signal that can
|
||||
// settle what its last drain missed.
|
||||
await handle.agentConnection.extNotification('_qwencode/end_turn', {
|
||||
sessionId: session.sessionId,
|
||||
reason: 'end_turn',
|
||||
source: 'goal',
|
||||
promptId: `${session.sessionId}########1`,
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(bridge.getPendingPrompts(session.sessionId)).toEqual([
|
||||
expect.objectContaining({
|
||||
promptId: 'goal-undrained',
|
||||
text: 'never drained',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
bridge.getMidTurnMessages(session.sessionId, {
|
||||
clientId: session.clientId,
|
||||
}).messages,
|
||||
).toEqual([]);
|
||||
|
||||
release?.();
|
||||
await vi.waitFor(() =>
|
||||
expect(bridge.getPendingPrompts(session.sessionId)).toEqual([]),
|
||||
);
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('rejects a whitespace-only message even while busy', async () => {
|
||||
const { factory, release } = hangingPromptFactory();
|
||||
const bridge = makeBridge({ channelFactory: factory });
|
||||
|
|
@ -30699,10 +30830,13 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa
|
|||
const admission = bridge.enqueueMidTurnMessage(
|
||||
session.sessionId,
|
||||
'leftover',
|
||||
{ clientId: session.clientId },
|
||||
'leftover-public',
|
||||
{ rejectIfIdle: true },
|
||||
);
|
||||
expect(admission).toEqual({
|
||||
accepted: true,
|
||||
messageId: expect.any(String),
|
||||
messageId: 'leftover-public',
|
||||
});
|
||||
releases[0]!();
|
||||
await t1;
|
||||
|
|
@ -30712,6 +30846,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa
|
|||
expect.objectContaining({
|
||||
promptId: admission.messageId,
|
||||
text: 'leftover',
|
||||
originatorClientId: session.clientId,
|
||||
}),
|
||||
]);
|
||||
releases[1]!();
|
||||
|
|
@ -31100,12 +31235,16 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa
|
|||
.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const admission = bridge.enqueueMidTurnMessage(session.sessionId, 'hi', {
|
||||
clientId: session.clientId,
|
||||
});
|
||||
const admission = bridge.enqueueMidTurnMessage(
|
||||
session.sessionId,
|
||||
'hi',
|
||||
{ clientId: session.clientId },
|
||||
'public-mid-turn',
|
||||
{ rejectIfIdle: true },
|
||||
);
|
||||
expect(admission).toEqual({
|
||||
accepted: true,
|
||||
messageId: expect.any(String),
|
||||
messageId: 'public-mid-turn',
|
||||
});
|
||||
|
||||
// Subscribe before the drain so the live injection frame is captured. The
|
||||
|
|
@ -32034,6 +32173,31 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa
|
|||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('rejects a public enqueue on idle only when rejectIfIdle is set', async () => {
|
||||
let promptCalls = 0;
|
||||
const handle = makeChannel({
|
||||
promptImpl: async () => {
|
||||
promptCalls++;
|
||||
return { stopReason: 'end_turn' };
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
expect(
|
||||
bridge.enqueueMidTurnMessage(
|
||||
session.sessionId,
|
||||
'public message',
|
||||
{ clientId: session.clientId },
|
||||
'public-idle',
|
||||
{ rejectIfIdle: true },
|
||||
),
|
||||
).toEqual({ accepted: false });
|
||||
expect(promptCalls).toBe(0);
|
||||
expect(bridge.getPendingPrompts(session.sessionId)).toEqual([]);
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('still queues a queueOnly enqueue while the session is busy', async () => {
|
||||
const release = deferred<void>();
|
||||
const prompts: string[] = [];
|
||||
|
|
|
|||
|
|
@ -1136,6 +1136,13 @@ interface SessionEntry {
|
|||
* an originator clientId is known. Used by the session reaper to avoid
|
||||
* killing sessions mid-prompt. */
|
||||
promptActive: boolean;
|
||||
/**
|
||||
* True while a child-driven Goal turn is running. Maintained by the
|
||||
* `_qwencode/start_turn` / `_qwencode/end_turn` (source `goal`)
|
||||
* notifications in `BridgeClient`; OR-ed into `hasActivePrompt`
|
||||
* summaries because Goal turns never flip `promptActive`.
|
||||
*/
|
||||
goalTurnActive?: boolean;
|
||||
/** Terminal error from the prior turn, cleared when the next turn starts. */
|
||||
turnError?: {
|
||||
message: string;
|
||||
|
|
@ -3619,7 +3626,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
...(entry.sourceType ? { sourceType: entry.sourceType } : {}),
|
||||
...(entry.sourceId !== undefined ? { sourceId: entry.sourceId } : {}),
|
||||
clientCount: entry.clientIds.size,
|
||||
hasActivePrompt: entry.promptActive,
|
||||
hasActivePrompt: entry.promptActive || entry.goalTurnActive === true,
|
||||
isWaitingForPermission,
|
||||
isWaitingForUserQuestion,
|
||||
pendingInteractionCount: entry.pendingInteractions.size,
|
||||
|
|
@ -4018,6 +4025,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
// Child-side automatic title updates change persisted catalog
|
||||
// metadata the bridge never sees; forward the catalog-clock mark.
|
||||
markSessionCatalogChanged,
|
||||
// A Goal turn drains the mid-turn queue but owns no prompt slot, so
|
||||
// nothing else would settle what its last drain missed.
|
||||
settleMidTurnQueueAfterGoalTurn,
|
||||
);
|
||||
const rawConnection = new ClientSideConnection(
|
||||
() =>
|
||||
|
|
@ -6622,7 +6632,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
// Late attachers get the same ACP state the original restore
|
||||
// caller saw; spawn-only sessions don't carry a state payload.
|
||||
state: existing.restoreState ?? {},
|
||||
hasActivePrompt: existing.promptActive,
|
||||
hasActivePrompt:
|
||||
existing.promptActive || existing.goalTurnActive === true,
|
||||
...replayFields,
|
||||
...(historyAnchorRecordId !== undefined
|
||||
? { historyAnchorRecordId }
|
||||
|
|
@ -6768,7 +6779,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
attached: true,
|
||||
clientId,
|
||||
createdAt: entry.createdAt,
|
||||
hasActivePrompt: entry.promptActive,
|
||||
hasActivePrompt: entry.promptActive || entry.goalTurnActive === true,
|
||||
...(waiterReplayFields ?? {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -7213,7 +7224,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
? { sourceId: racedEntry.sourceId }
|
||||
: {}),
|
||||
state: racedEntry.restoreState ?? {},
|
||||
hasActivePrompt: racedEntry.promptActive,
|
||||
hasActivePrompt:
|
||||
racedEntry.promptActive || racedEntry.goalTurnActive === true,
|
||||
...replayFieldsFor(racedEntry, action, liveReplayMode),
|
||||
};
|
||||
}
|
||||
|
|
@ -7313,7 +7325,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
...(artifactRestoreWarnings.length > 0
|
||||
? { artifactWarnings: artifactRestoreWarnings }
|
||||
: {}),
|
||||
hasActivePrompt: entry.promptActive,
|
||||
hasActivePrompt: entry.promptActive || entry.goalTurnActive === true,
|
||||
...replayFieldsFor(entry, action, liveReplayMode),
|
||||
};
|
||||
})().finally(async () => {
|
||||
|
|
@ -7667,6 +7679,61 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hand back every mid-turn message the turn that just ended never drained:
|
||||
* `queueOnly` callers drive their own follow-through, everything else starts
|
||||
* through the normal prompt path.
|
||||
*/
|
||||
const settleUndrainedMidTurnMessages = (
|
||||
entry: SessionEntry,
|
||||
messages: readonly MidTurnQueueEntry[],
|
||||
) => {
|
||||
for (const message of messages) {
|
||||
if (message.queueOnly) {
|
||||
try {
|
||||
message.onSettledWithoutDrain?.();
|
||||
} catch (error) {
|
||||
writeStderrLine(
|
||||
`[mid-turn] session=${JSON.stringify(entry.sessionId)} failed to hand undrained queue-only message ${JSON.stringify(message.messageId)} back to its caller: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
promoteMidTurnMessage(
|
||||
entry,
|
||||
message.messageId,
|
||||
message.text,
|
||||
message.originatorClientId,
|
||||
message.content,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the Goal turn's drain window. A Goal turn drains the mid-turn queue
|
||||
* from inside the child, so a message enqueued after its last drain would
|
||||
* otherwise sit in the queue with nothing scheduled to consume it — the same
|
||||
* race the prompt settle already closes. Promoting is the supported path
|
||||
* while a Goal is still active: the child's `claimGoalTurn` makes the
|
||||
* promoted prompt wait for the permit and run as the next Goal turn.
|
||||
*/
|
||||
const settleMidTurnQueueAfterGoalTurn = (sessionId: string) => {
|
||||
const entry = byId.get(sessionId);
|
||||
if (!entry) return;
|
||||
// A prompt owns the queue and settles it on its own terminal; a Goal turn
|
||||
// that started again already re-armed the child's drain.
|
||||
if (
|
||||
entry.goalTurnActive === true ||
|
||||
entry.pendingPromptCount > 0 ||
|
||||
entry.closing
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const undrained = entry.midTurnMessageQueue.splice(0);
|
||||
if (undrained.length === 0) return;
|
||||
settleUndrainedMidTurnMessages(entry, undrained);
|
||||
};
|
||||
|
||||
const bridgeApi: AcpSessionBridge = {
|
||||
setLiveScreenContextCaptureHandler(handler) {
|
||||
liveScreenContextCaptureHandler = handler;
|
||||
|
|
@ -7715,7 +7782,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
attachCount: entry.attachCount,
|
||||
pendingPromptCount: entry.pendingPromptCount,
|
||||
pendingPermissionCount: entry.pendingPermissionIds.size,
|
||||
hasActivePrompt: entry.promptActive,
|
||||
hasActivePrompt:
|
||||
entry.promptActive || entry.goalTurnActive === true,
|
||||
lastEventId: entry.events.lastEventId,
|
||||
...(entry.sessionLastSeenAt !== undefined
|
||||
? { lastSeenAt: entry.sessionLastSeenAt }
|
||||
|
|
@ -7979,7 +8047,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
...(existing.sourceId !== undefined
|
||||
? { sourceId: existing.sourceId }
|
||||
: {}),
|
||||
hasActivePrompt: existing.promptActive,
|
||||
hasActivePrompt:
|
||||
existing.promptActive || existing.goalTurnActive === true,
|
||||
};
|
||||
}
|
||||
// Coalesce: if another caller is already mid-spawn for this same
|
||||
|
|
@ -8055,7 +8124,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
...session,
|
||||
attached: true,
|
||||
clientId,
|
||||
hasActivePrompt: attachedEntry.promptActive,
|
||||
hasActivePrompt:
|
||||
attachedEntry.promptActive ||
|
||||
attachedEntry.goalTurnActive === true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -8594,6 +8665,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
return copy;
|
||||
})();
|
||||
entry.promptActive = true;
|
||||
// The child serializes Goal turns against RPC prompts, so a
|
||||
// still-set flag here means the goal end_turn signal was
|
||||
// lost; self-heal rather than pin the session active.
|
||||
entry.goalTurnActive = false;
|
||||
entry.activePromptId = pendingEntry.promptId;
|
||||
delete entry.cancelBroadcastWithoutPrompt;
|
||||
delete entry.turnError;
|
||||
|
|
@ -8863,25 +8938,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
// caller synchronously reserves the next FIFO slot, then ordinary
|
||||
// promotions follow it without exposing the fallback as queued.
|
||||
releasePromptSlot();
|
||||
for (const message of undrainedMessages) {
|
||||
if (message.queueOnly) {
|
||||
try {
|
||||
message.onSettledWithoutDrain?.();
|
||||
} catch (error) {
|
||||
writeStderrLine(
|
||||
`[mid-turn] session=${JSON.stringify(entry.sessionId)} failed to hand undrained queue-only message ${JSON.stringify(message.messageId)} back to its caller: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
promoteMidTurnMessage(
|
||||
entry,
|
||||
message.messageId,
|
||||
message.text,
|
||||
message.originatorClientId,
|
||||
message.content,
|
||||
);
|
||||
}
|
||||
settleUndrainedMidTurnMessages(entry, undrainedMessages);
|
||||
// DAEMON-005: deferred close-on-prompt-complete. Lives here (not
|
||||
// in `promptPromise.finally`) so the terminal broadcast — the
|
||||
// `result.then` registered above on this same promise — runs
|
||||
|
|
@ -10134,6 +10191,19 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
);
|
||||
},
|
||||
|
||||
async controlSessionGoal(sessionId, request, context) {
|
||||
const entry = byId.get(sessionId);
|
||||
if (!entry) throw new SessionNotFoundError(sessionId);
|
||||
const info = channelInfoForEntry(entry);
|
||||
if (!info || info.isDying) throw new SessionNotFoundError(sessionId);
|
||||
resolveTrustedClientId(entry, context?.clientId);
|
||||
return requestSessionStatus(
|
||||
sessionId,
|
||||
SERVE_CONTROL_EXT_METHODS.sessionGoalControl,
|
||||
{ request },
|
||||
);
|
||||
},
|
||||
|
||||
async clearSessionGoal(sessionId) {
|
||||
return requestSessionStatus<{ cleared: boolean; condition?: string }>(
|
||||
sessionId,
|
||||
|
|
@ -11058,11 +11128,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
const messageId = requestedMessageId ?? randomUUID();
|
||||
// If the turn settled while the POST was in flight, start it through the
|
||||
// normal prompt path. A client-supplied id keeps retries idempotent.
|
||||
if (entry.pendingPromptCount === 0) {
|
||||
// `queueOnly` callers (live steering) drive the next turn themselves:
|
||||
// a promoted message would run as a bare prompt with no collector
|
||||
// forwarding its response to them or arming a deadline.
|
||||
if (options?.queueOnly) {
|
||||
// A child-driven Goal turn never crosses the `session/prompt` RPC
|
||||
// boundary, so `pendingPromptCount` stays 0 for its whole duration —
|
||||
// but the child drains THIS queue between tool batches from inside that
|
||||
// turn, so the session is genuinely busy and the message belongs in the
|
||||
// queue. Without `goalTurnActive` here every mid-turn insert during a
|
||||
// Goal turn is rejected as idle even though the client enables the
|
||||
// affordance (Goal turns are non-idle in `hasActivePrompt` summaries).
|
||||
if (entry.pendingPromptCount === 0 && entry.goalTurnActive !== true) {
|
||||
// Both modes refuse new ownership once idle. `queueOnly` callers (live
|
||||
// steering) additionally drive the next turn themselves: a promoted
|
||||
// message would have no collector forwarding its response or deadline.
|
||||
if (options?.queueOnly || options?.rejectIfIdle) {
|
||||
writeStderrLine(
|
||||
`[mid-turn] session=${JSON.stringify(entry.sessionId)} rejected id ${JSON.stringify(messageId)}: session idle`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -213,6 +213,96 @@ describe('BridgeClient — background notification turn boundary', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('marks the session active for a goal-turn start signal', async () => {
|
||||
const sessionId = 'session-goal';
|
||||
const publish = vi.fn();
|
||||
const entry = { sessionId, events: { publish }, goalTurnActive: false };
|
||||
const noFlow = () => {
|
||||
throw new Error('test: permission flow should not run');
|
||||
};
|
||||
const client = new BridgeClient(
|
||||
((id: string) => (id === sessionId ? entry : undefined)) as never,
|
||||
noFlow as never,
|
||||
{ request: noFlow } as never,
|
||||
0,
|
||||
Infinity,
|
||||
);
|
||||
|
||||
await client.extNotification('_qwencode/start_turn', {
|
||||
sessionId,
|
||||
source: 'goal',
|
||||
});
|
||||
|
||||
expect(entry.goalTurnActive).toBe(true);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
|
||||
await client.extNotification('_qwencode/end_turn', {
|
||||
sessionId,
|
||||
reason: 'end_turn',
|
||||
source: 'goal',
|
||||
promptId: 'session-goal########1',
|
||||
});
|
||||
|
||||
expect(entry.goalTurnActive).toBe(false);
|
||||
});
|
||||
|
||||
it('publishes a real turn_complete for a goal-turn end signal', async () => {
|
||||
const sessionId = 'session-goal';
|
||||
const publish = vi.fn().mockReturnValue(true);
|
||||
const entry = { sessionId, events: { publish } };
|
||||
const noFlow = () => {
|
||||
throw new Error('test: permission flow should not run');
|
||||
};
|
||||
const client = new BridgeClient(
|
||||
((id: string) => (id === sessionId ? entry : undefined)) as never,
|
||||
noFlow as never,
|
||||
{ request: noFlow } as never,
|
||||
0,
|
||||
Infinity,
|
||||
);
|
||||
|
||||
await client.extNotification('_qwencode/end_turn', {
|
||||
sessionId,
|
||||
reason: 'end_turn',
|
||||
source: 'goal',
|
||||
promptId: 'session-goal########3',
|
||||
});
|
||||
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
type: 'turn_complete',
|
||||
promptId: 'session-goal########3',
|
||||
data: {
|
||||
sessionId,
|
||||
stopReason: 'end_turn',
|
||||
promptId: 'session-goal########3',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a goal-turn end signal without a promptId', async () => {
|
||||
const sessionId = 'session-goal';
|
||||
const publish = vi.fn();
|
||||
const entry = { sessionId, events: { publish } };
|
||||
const noFlow = () => {
|
||||
throw new Error('test: permission flow should not run');
|
||||
};
|
||||
const client = new BridgeClient(
|
||||
((id: string) => (id === sessionId ? entry : undefined)) as never,
|
||||
noFlow as never,
|
||||
{ request: noFlow } as never,
|
||||
0,
|
||||
Infinity,
|
||||
);
|
||||
|
||||
await client.extNotification('_qwencode/end_turn', {
|
||||
sessionId,
|
||||
reason: 'end_turn',
|
||||
source: 'goal',
|
||||
});
|
||||
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops malformed or foreign end-turn signals', async () => {
|
||||
const publish = vi.fn();
|
||||
const entry = { sessionId: 'owned', events: { publish } };
|
||||
|
|
|
|||
|
|
@ -636,6 +636,14 @@ export interface BridgeClientSessionEntry {
|
|||
settledMidTurnMessageIds: string[];
|
||||
/** Complete prompts waiting behind the currently running prompt. */
|
||||
pendingPromptList: PendingPromptEntry[];
|
||||
/**
|
||||
* True while a child-driven Goal turn is running. Set by the
|
||||
* `_qwencode/start_turn` notification and cleared by the matching
|
||||
* `_qwencode/end_turn`; OR-ed into `hasActivePrompt` summaries so
|
||||
* live-state consumers (sidebar activity, daemon status) see Goal turns
|
||||
* that never cross the bridge's `session/prompt` RPC boundary.
|
||||
*/
|
||||
goalTurnActive?: boolean;
|
||||
/** Bridge prompt that owns the child Guard wait for this FIFO. */
|
||||
todoStopGuardAwaitingQueuedPromptOwnerPromptId?: string;
|
||||
/** True while a prompt is executing for this session. */
|
||||
|
|
@ -829,6 +837,14 @@ export class BridgeClient implements Client {
|
|||
* optional so existing direct constructors stay source-compatible.
|
||||
*/
|
||||
private readonly onSessionCatalogChanged?: () => void,
|
||||
/**
|
||||
* Invoked after a child-driven Goal turn clears `goalTurnActive`. The
|
||||
* bridge settles whatever the ending turn's last mid-turn drain missed —
|
||||
* a Goal turn owns no prompt slot, so its terminal is the only signal.
|
||||
* Trailing and optional so existing direct constructors stay
|
||||
* source-compatible.
|
||||
*/
|
||||
private readonly onGoalTurnEnded?: (sessionId: string) => void,
|
||||
) {}
|
||||
|
||||
async requestPermission(
|
||||
|
|
@ -1929,7 +1945,7 @@ export class BridgeClient implements Client {
|
|||
* `qwen/notify/session/prompt-suggestion` (followup assist),
|
||||
* `qwen/notify/session/artifact-event` (hook artifacts),
|
||||
* `qwen/notify/session/terminal-sequence`, and
|
||||
* `_qwencode/end_turn` (background-notification turns), and
|
||||
* `_qwencode/end_turn` (background-notification and goal turns), and
|
||||
* `qwen/notify/session/mcp-budget-event` — each translated into a
|
||||
* session-scoped SSE frame. Unknown methods are dropped silently for
|
||||
* forward-compat.
|
||||
|
|
@ -1961,21 +1977,56 @@ export class BridgeClient implements Client {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (method === '_qwencode/start_turn') {
|
||||
const sessionId = params['sessionId'];
|
||||
if (
|
||||
typeof sessionId !== 'string' ||
|
||||
sessionId.length === 0 ||
|
||||
params['source'] !== 'goal'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const entry = this.resolveEntry(sessionId);
|
||||
if (!entry || !this.ownsSession(sessionId)) return;
|
||||
entry.goalTurnActive = true;
|
||||
return;
|
||||
}
|
||||
if (method === '_qwencode/end_turn') {
|
||||
const sessionId = params['sessionId'];
|
||||
const reason = params['reason'];
|
||||
const source = params['source'];
|
||||
if (
|
||||
typeof sessionId !== 'string' ||
|
||||
sessionId.length === 0 ||
|
||||
typeof reason !== 'string' ||
|
||||
reason.length === 0 ||
|
||||
reason.length > 128 ||
|
||||
params['source'] !== 'background_notification'
|
||||
(source !== 'background_notification' && source !== 'goal')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const entry = this.resolveEntry(sessionId);
|
||||
if (!entry || !this.ownsSession(sessionId)) return;
|
||||
if (source === 'goal') {
|
||||
entry.goalTurnActive = false;
|
||||
// Before the promptId validation below: a malformed id costs the
|
||||
// session its `turn_complete`, but the queue must still be settled.
|
||||
this.onGoalTurnEnded?.(sessionId);
|
||||
const promptId = params['promptId'];
|
||||
if (
|
||||
typeof promptId !== 'string' ||
|
||||
promptId.length === 0 ||
|
||||
promptId.length > 256
|
||||
) {
|
||||
return;
|
||||
}
|
||||
entry.events.publish({
|
||||
type: 'turn_complete',
|
||||
promptId,
|
||||
data: { sessionId, stopReason: reason, promptId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
entry.events.publish({
|
||||
type: 'background_notification_turn_complete',
|
||||
data: { sessionId, reason },
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
|
||||
import type {
|
||||
ApprovalMode,
|
||||
GoalControlRequest,
|
||||
GoalSnapshotV2,
|
||||
GoalStateResponse,
|
||||
SessionGroupPresetColor,
|
||||
TurnResultCode,
|
||||
TurnResultErrorPayload,
|
||||
|
|
@ -1661,6 +1663,13 @@ export interface AcpSessionBridge {
|
|||
sessionId: string,
|
||||
): Promise<{ cleared: boolean; condition?: string }>;
|
||||
|
||||
/** Atomically apply a typed Goal lifecycle control in a live session. */
|
||||
controlSessionGoal(
|
||||
sessionId: string,
|
||||
request: GoalControlRequest,
|
||||
context?: BridgeClientRequestContext,
|
||||
): Promise<GoalStateResponse>;
|
||||
|
||||
/**
|
||||
* Read a live session's Goal state. Throws `SessionNotFoundError` when the
|
||||
* session is not resident because this route addresses the selected runtime.
|
||||
|
|
@ -1849,9 +1858,12 @@ export interface AcpSessionBridge {
|
|||
* authorized against the session like `/prompt` and `/btw` — throws
|
||||
* `InvalidClientIdError` when the id is not bound to the session, and
|
||||
* `SessionNotFoundError` for unknown ids. Ownership is session-wide.
|
||||
* With `options.queueOnly` an idle session rejects instead of promoting. If
|
||||
* a busy session settles before draining the message,
|
||||
* `onSettledWithoutDrain` lets the caller drive the next turn itself.
|
||||
* With `options.rejectIfIdle` an idle session rejects instead of taking
|
||||
* ownership. A message accepted while busy keeps the ordinary public queue
|
||||
* semantics: it is echoed when drained and promoted if the turn settles
|
||||
* first. `options.queueOnly` is reserved for internal live steering; if a
|
||||
* busy session settles before draining one of those messages,
|
||||
* `onSettledWithoutDrain` lets that internal caller drive the next turn.
|
||||
* `options.content` carries image blocks with the message;
|
||||
* an empty `message` is admitted when media blocks are present.
|
||||
*/
|
||||
|
|
@ -1861,6 +1873,7 @@ export interface AcpSessionBridge {
|
|||
context?: BridgeClientRequestContext,
|
||||
messageId?: string,
|
||||
options?: {
|
||||
rejectIfIdle?: boolean;
|
||||
queueOnly?: boolean;
|
||||
onSettledWithoutDrain?: () => void;
|
||||
content?: readonly BridgePromptContentBlock[];
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export const SERVE_CONTROL_EXT_METHODS = {
|
|||
workspaceMemoryDream: 'qwen/control/workspace/memory/dream',
|
||||
// Runtime MCP server mutation ext-methods
|
||||
sessionTaskCancel: 'qwen/control/session/task/cancel',
|
||||
sessionGoalControl: 'qwen/control/session/goal/control',
|
||||
sessionGoalClear: 'qwen/control/session/goal/clear',
|
||||
/**
|
||||
* Read a live session's `/goal` state. The active goal lives only in the
|
||||
|
|
|
|||
|
|
@ -89,14 +89,30 @@ describe('createTranscriptReplayMachine', () => {
|
|||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('replays user-initiated Goal controls as user messages', () => {
|
||||
const projected = updates(
|
||||
createTranscriptReplayMachine(),
|
||||
goalStateRecord('goal-create', 'create', GOAL),
|
||||
);
|
||||
|
||||
expect(projected[0]).toMatchObject({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: `/goal ${GOAL.objective}` },
|
||||
_meta: {
|
||||
source: 'goal_control',
|
||||
'qwen.session.recordId': 'goal-create',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('projects goal_state through v2-first metadata', () => {
|
||||
const projected = updates(
|
||||
createTranscriptReplayMachine(),
|
||||
goalStateRecord('goal-create', 'create', GOAL),
|
||||
);
|
||||
|
||||
expect(projected).toHaveLength(1);
|
||||
expect(projected[0]?._meta).toMatchObject({
|
||||
expect(projected).toHaveLength(2);
|
||||
expect(projected[1]?._meta).toMatchObject({
|
||||
goalState: { v: 2, goal: GOAL, activity: 'idle' },
|
||||
goalStatus: { kind: 'set', condition: GOAL.objective },
|
||||
'qwen.session.recordId': 'goal-create',
|
||||
|
|
@ -115,7 +131,7 @@ describe('createTranscriptReplayMachine', () => {
|
|||
goalStateRecord('goal-clear', 'clear', null),
|
||||
);
|
||||
|
||||
expect(projected[0]?._meta).toMatchObject({
|
||||
expect(projected[1]?._meta).toMatchObject({
|
||||
goalState: { v: 2, goal: null, activity: 'idle' },
|
||||
goalStatus: { kind: 'cleared', condition: GOAL.objective },
|
||||
'qwen.session.recordId': 'goal-clear',
|
||||
|
|
@ -182,7 +198,7 @@ describe('createTranscriptReplayMachine', () => {
|
|||
|
||||
expect(
|
||||
updates(machine, goalStateRecord('goal-create', 'create', GOAL)),
|
||||
).toHaveLength(1);
|
||||
).toHaveLength(2);
|
||||
|
||||
const turned: GoalRecord = {
|
||||
...GOAL,
|
||||
|
|
@ -302,7 +318,7 @@ describe('createTranscriptReplayMachine', () => {
|
|||
|
||||
expect(
|
||||
updates(machine, goalStateRecord('goal-create', 'create', GOAL)),
|
||||
).toHaveLength(1);
|
||||
).toHaveLength(2);
|
||||
|
||||
const turnedOnce: GoalRecord = {
|
||||
...GOAL,
|
||||
|
|
|
|||
|
|
@ -939,9 +939,26 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
|
|||
payload,
|
||||
this.goalState?.goal ?? null,
|
||||
);
|
||||
const goalControlCommand = projectGoalControlCommand(
|
||||
payload.cause,
|
||||
payload.snapshot,
|
||||
);
|
||||
this.goalState = payload.snapshot;
|
||||
this.goalCause = payload.cause;
|
||||
if (bookkeepingOnly) return;
|
||||
if (goalControlCommand) {
|
||||
yield emit(
|
||||
createTranscriptMessageUpdate({
|
||||
role: 'user',
|
||||
text: goalControlCommand,
|
||||
...meta,
|
||||
extra: {
|
||||
source: 'goal_control',
|
||||
'qwen.session.recordId': record.uuid,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const { type: _type, ...goalStatus } = projection.goalStatus;
|
||||
yield emit(
|
||||
createTranscriptMessageUpdate({
|
||||
|
|
@ -1121,6 +1138,40 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
|
|||
}
|
||||
}
|
||||
|
||||
function projectGoalControlCommand(
|
||||
cause: GoalStateCause,
|
||||
snapshot: GoalSnapshotV2,
|
||||
): string | undefined {
|
||||
switch (cause) {
|
||||
case 'create':
|
||||
case 'replace':
|
||||
return snapshot.goal ? `/goal ${snapshot.goal.objective}` : undefined;
|
||||
case 'edit':
|
||||
return snapshot.goal
|
||||
? `/goal edit ${snapshot.goal.objective}`
|
||||
: undefined;
|
||||
case 'pause':
|
||||
case 'resume':
|
||||
case 'clear':
|
||||
return `/goal ${cause}`;
|
||||
case 'turn_finished':
|
||||
case 'checkpoint':
|
||||
case 'verifier_accept':
|
||||
case 'verifier_reject':
|
||||
case 'complete':
|
||||
case 'blocked':
|
||||
case 'usage_limited':
|
||||
case 'migrated':
|
||||
return undefined;
|
||||
default:
|
||||
return assertNever(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unsupported Goal state cause: ${String(value)}`);
|
||||
}
|
||||
|
||||
function parseTranscriptGoalStatus(
|
||||
value: unknown,
|
||||
): TranscriptGoalStatus | undefined {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/audio-capture",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "Native microphone capture backend for Qwen Code voice input",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-base",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "Base channel infrastructure for Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-dingtalk",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "DingTalk channel adapter for Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"dingtalk-stream-sdk-nodejs": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-feishu",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "Feishu (Lark) channel adapter for Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"@larksuiteoapi/node-sdk": "^1.45.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-github",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "GitHub polling channel adapter for Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"https-proxy-agent": "^7.0.6"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-gitlab",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "GitLab polling channel adapter for Qwen Code",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@gitbeaker/rest": "^42.5.0",
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-plugin-example",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-qqbot",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "QQ Bot (QQ机器人) channel adapter for Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"@tencent-connect/qqbot-connector": "^1.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-telegram",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "Telegram channel adapter for Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"grammy": "^1.41.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"telegram-markdown-formatter": "^0.1.2"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-wecom",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "WeCom channel adapter for Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11",
|
||||
"@qwen-code/channel-base": "0.21.14",
|
||||
"@wecom/aibot-node-sdk": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-weixin",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "WeChat (Weixin) channel adapter for Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "0.21.11"
|
||||
"@qwen-code/channel-base": "0.21.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/chrome-bridge",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "Chrome extension bridge for Qwen CLI - enables AI-powered browser interactions",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.21.11"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.21.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
|
|
|
|||
|
|
@ -222,6 +222,19 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
|
|||
GoalPersistenceUnavailableError: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).GoalPersistenceUnavailableError,
|
||||
parseGoalControlRequest: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).parseGoalControlRequest,
|
||||
// The real classes for the same reason as above: `mapGoalControlError`
|
||||
// narrows on them with `instanceof`, and a stand-in (or an omission, which
|
||||
// resolves to undefined) makes every conflict/transition branch throw before
|
||||
// it can be asserted.
|
||||
GoalConflictError: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).GoalConflictError,
|
||||
GoalInvalidTransitionError: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).GoalInvalidTransitionError,
|
||||
SessionIdCaseConflictError: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).SessionIdCaseConflictError,
|
||||
|
|
@ -945,6 +958,8 @@ import {
|
|||
APPROVAL_MODES,
|
||||
ToolNames,
|
||||
GoalPersistenceUnavailableError,
|
||||
GoalConflictError,
|
||||
GoalInvalidTransitionError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { ndJsonStream } from '@qwen-code/acp-bridge/ndJsonStream';
|
||||
import { SESSION_SOURCE_META_KEY } from '@qwen-code/acp-bridge';
|
||||
|
|
@ -3891,6 +3906,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
getHookSystem: vi.fn().mockReturnValue(undefined),
|
||||
getDisableAllHooks: vi.fn().mockReturnValue(true),
|
||||
hasHooksForEvent: vi.fn().mockReturnValue(false),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -7172,6 +7188,79 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('projects toggle-only Qwen reasoning without effort tiers', async () => {
|
||||
const sessionId = 'qwen37-toggle-reasoning-session';
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
const generation: {
|
||||
reasoning?: false | { effort?: string };
|
||||
} = {};
|
||||
innerConfig.getModel = vi.fn().mockReturnValue('qwen3.7-plus');
|
||||
innerConfig.getContentGeneratorConfig = vi.fn(() => generation);
|
||||
innerConfig.getReasoningEffort = vi.fn(() =>
|
||||
generation.reasoning ? generation.reasoning.effort : undefined,
|
||||
);
|
||||
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
try {
|
||||
const session = (await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
})) as {
|
||||
configOptions: Array<{
|
||||
id: string;
|
||||
currentValue: string;
|
||||
options: Array<{ value: string }>;
|
||||
_meta?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
const option = session.configOptions.find(
|
||||
(item) => item.id === 'reasoning_effort',
|
||||
);
|
||||
expect(option).toMatchObject({
|
||||
currentValue: 'default',
|
||||
options: [{ value: 'none' }, { value: 'default' }],
|
||||
_meta: {
|
||||
'qwenCode/reasoning': { toggleOnly: true },
|
||||
},
|
||||
});
|
||||
|
||||
const disabled = (await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: 'reasoning_effort',
|
||||
value: 'none',
|
||||
})) as SetSessionConfigOptionResponse;
|
||||
expect(generation.reasoning).toBe(false);
|
||||
expect(
|
||||
disabled.configOptions.find((item) => item.id === 'reasoning_effort')
|
||||
?.currentValue,
|
||||
).toBe('none');
|
||||
|
||||
const enabled = (await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: 'reasoning_effort',
|
||||
value: 'default',
|
||||
})) as SetSessionConfigOptionResponse;
|
||||
expect(generation.reasoning).toBeUndefined();
|
||||
expect(
|
||||
enabled.configOptions.find((item) => item.id === 'reasoning_effort')
|
||||
?.currentValue,
|
||||
).toBe('default');
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: 'reasoning_effort',
|
||||
value: 'low',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Unknown reasoning effort: low. Choose one of: none, default',
|
||||
);
|
||||
} finally {
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
}
|
||||
});
|
||||
|
||||
it('hides qwen3.8-max reasoning controls when thinking is mandatory', async () => {
|
||||
const sessionId = 'qwen38-mandatory-thinking-session';
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
|
|
@ -9778,6 +9867,158 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
await agentPromise;
|
||||
});
|
||||
|
||||
it('allows reducing Goal work in an untrusted workspace but rejects starting it', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
const snapshot = goalSnapshot({ objective: 'ship it', turnCount: 1 });
|
||||
const dispatch = vi.fn().mockResolvedValue({ snapshot });
|
||||
Object.assign(innerConfig, {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
getGoalRuntimeReady: vi.fn().mockResolvedValue({
|
||||
getSnapshot: () => snapshot,
|
||||
dispatch,
|
||||
}),
|
||||
});
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGoalControl, {
|
||||
sessionId,
|
||||
request: {
|
||||
action: 'pause',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ snapshot });
|
||||
// Every action that starts or expands Goal work is gated, not just create:
|
||||
// dropping any one of them restarts work in an untrusted workspace.
|
||||
for (const request of [
|
||||
{ action: 'create' as const, objective: 'new work' },
|
||||
{
|
||||
action: 'replace' as const,
|
||||
objective: 'new work',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
{
|
||||
action: 'edit' as const,
|
||||
objective: 'revised work',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
{
|
||||
action: 'resume' as const,
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 1,
|
||||
},
|
||||
]) {
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGoalControl, {
|
||||
sessionId,
|
||||
request,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: -32003,
|
||||
data: { errorKind: 'untrusted_workspace', httpStatus: 403 },
|
||||
});
|
||||
}
|
||||
expect(dispatch).toHaveBeenCalledOnce();
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('maps a Goal control dispatch failure onto its wire contract', async () => {
|
||||
// The client's 409 resync reads `data.errorKind` and `data.current`: a
|
||||
// refactor that drops `current`, swaps the `instanceof` order, or changes
|
||||
// the code breaks resync silently. The only other coverage here is the
|
||||
// success path and the untrusted gate, and the gate throws before this
|
||||
// mapping is reachable.
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
const current = goalSnapshot({ objective: 'ship it', revision: 4 });
|
||||
const persistFallback = goalSnapshot({ objective: 'from the runtime' });
|
||||
const dispatch = vi.fn();
|
||||
Object.assign(innerConfig, {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getGoalRuntime: vi.fn().mockReturnValue({
|
||||
getSnapshot: () => persistFallback,
|
||||
dispatch,
|
||||
}),
|
||||
getGoalRuntimeReady: vi.fn().mockResolvedValue({
|
||||
getSnapshot: () => persistFallback,
|
||||
dispatch,
|
||||
}),
|
||||
});
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
|
||||
const control = (request: Record<string, unknown>) =>
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGoalControl, {
|
||||
sessionId,
|
||||
request,
|
||||
});
|
||||
const pause = {
|
||||
action: 'pause',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 1,
|
||||
};
|
||||
|
||||
// A CAS miss carries the daemon's own snapshot so the client can resync
|
||||
// against it rather than re-reading.
|
||||
dispatch.mockRejectedValueOnce(new GoalConflictError(current));
|
||||
await expect(control(pause)).rejects.toMatchObject({
|
||||
code: -32009,
|
||||
data: { errorKind: 'goal_conflict', current },
|
||||
});
|
||||
|
||||
// Same code, different kind: the two are distinguished only by errorKind.
|
||||
dispatch.mockRejectedValueOnce(
|
||||
new GoalInvalidTransitionError('cannot pause a completed goal', current),
|
||||
);
|
||||
await expect(control(pause)).rejects.toMatchObject({
|
||||
code: -32009,
|
||||
message: 'cannot pause a completed goal',
|
||||
data: { errorKind: 'goal_invalid_transition', current },
|
||||
});
|
||||
|
||||
// Anything else is a persistence failure, and its `current` comes from the
|
||||
// runtime — the failure carries no snapshot of its own.
|
||||
dispatch.mockRejectedValueOnce(new Error('disk full'));
|
||||
await expect(control(pause)).rejects.toMatchObject({
|
||||
code: -32603,
|
||||
message: 'disk full',
|
||||
data: { errorKind: 'goal_persist_failed', current: persistFallback },
|
||||
});
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('returns cleared false when no session goal is active', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
|
|
|
|||
|
|
@ -99,8 +99,14 @@ import {
|
|||
extractDaemonTraceContext,
|
||||
withDaemonSpan,
|
||||
emptyGoalSnapshot,
|
||||
GoalConflictError,
|
||||
GoalInvalidTransitionError,
|
||||
GoalPersistenceUnavailableError,
|
||||
parseGoalControlRequest,
|
||||
type GoalControlRequest,
|
||||
type GoalRuntime,
|
||||
type GoalSnapshotV2,
|
||||
type GoalStateResponse,
|
||||
type AgentParams,
|
||||
ApprovalMode,
|
||||
type Config,
|
||||
|
|
@ -424,6 +430,68 @@ const ACP_REASONING_EFFORT_NAMES: Record<ReasoningEffort, string> = {
|
|||
// Must be less than WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS (300s) in bridge.ts.
|
||||
const WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS = 295_000;
|
||||
|
||||
function currentGoalSnapshot(
|
||||
config: Config,
|
||||
runtime?: GoalRuntime,
|
||||
): GoalSnapshotV2 {
|
||||
try {
|
||||
return (runtime ?? config.getGoalRuntime()).getSnapshot();
|
||||
} catch {
|
||||
return emptyGoalSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
function mapGoalControlError(
|
||||
error: unknown,
|
||||
config: Config,
|
||||
runtime?: GoalRuntime,
|
||||
): RequestError {
|
||||
if (error instanceof GoalConflictError) {
|
||||
return new RequestError(-32009, error.message, {
|
||||
errorKind: 'goal_conflict',
|
||||
current: error.current,
|
||||
});
|
||||
}
|
||||
if (error instanceof GoalInvalidTransitionError) {
|
||||
return new RequestError(-32009, error.message, {
|
||||
errorKind: 'goal_invalid_transition',
|
||||
current: error.current,
|
||||
});
|
||||
}
|
||||
return new RequestError(
|
||||
-32603,
|
||||
error instanceof Error ? error.message : 'Goal persistence failed',
|
||||
{
|
||||
errorKind: 'goal_persist_failed',
|
||||
current: currentGoalSnapshot(config, runtime),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function dispatchGoalControl(
|
||||
config: Config,
|
||||
request: GoalControlRequest,
|
||||
): Promise<GoalStateResponse> {
|
||||
const requiresTrustedWorkspace =
|
||||
request.action === 'create' ||
|
||||
request.action === 'replace' ||
|
||||
request.action === 'edit' ||
|
||||
request.action === 'resume';
|
||||
if (requiresTrustedWorkspace && !config.isTrustedFolder()) {
|
||||
throw new RequestError(-32003, 'Workspace is not trusted.', {
|
||||
errorKind: 'untrusted_workspace',
|
||||
httpStatus: 403,
|
||||
});
|
||||
}
|
||||
let runtime: GoalRuntime | undefined;
|
||||
try {
|
||||
runtime = await config.getGoalRuntimeReady();
|
||||
return await runtime.dispatch(request);
|
||||
} catch (error) {
|
||||
throw mapGoalControlError(error, config, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
const TURN_STATUS_SCAN_PAGE_LIMIT = 500;
|
||||
const TURN_STATUS_SCAN_MAX_PAGES = 10;
|
||||
|
||||
|
|
@ -5913,19 +5981,32 @@ class QwenAgent implements Agent {
|
|||
session.getConfig(),
|
||||
);
|
||||
if (modelReasoning) {
|
||||
const effortValues = modelReasoning.toggleOnly
|
||||
? undefined
|
||||
: modelReasoning.efforts;
|
||||
const selected =
|
||||
value === ACP_REASONING_EFFORT_NONE
|
||||
? ACP_REASONING_EFFORT_NONE
|
||||
: modelReasoning.efforts.find((effort) => effort === value);
|
||||
: modelReasoning.toggleOnly
|
||||
? value === ACP_REASONING_EFFORT_DEFAULT
|
||||
? ACP_REASONING_EFFORT_DEFAULT
|
||||
: undefined
|
||||
: effortValues?.find((effort) => effort === value);
|
||||
if (!selected) {
|
||||
const choices = [
|
||||
ACP_REASONING_EFFORT_NONE,
|
||||
...(effortValues ?? [ACP_REASONING_EFFORT_DEFAULT]),
|
||||
];
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Unknown reasoning effort: ${value}. Choose one of: ${ACP_REASONING_EFFORT_NONE}, ${modelReasoning.efforts.join(', ')}`,
|
||||
`Unknown reasoning effort: ${value}. Choose one of: ${choices.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const generation = session.getConfig().getContentGeneratorConfig();
|
||||
if (selected === ACP_REASONING_EFFORT_NONE) {
|
||||
generation.reasoning = false;
|
||||
} else if (selected === ACP_REASONING_EFFORT_DEFAULT) {
|
||||
generation.reasoning = undefined;
|
||||
} else {
|
||||
const current = generation.reasoning;
|
||||
generation.reasoning = {
|
||||
|
|
@ -11101,6 +11182,28 @@ class QwenAgent implements Agent {
|
|||
snapshot: response.snapshot,
|
||||
};
|
||||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.sessionGoalControl: {
|
||||
const sessionId = params['sessionId'];
|
||||
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'Invalid or missing sessionId',
|
||||
);
|
||||
}
|
||||
const request = parseGoalControlRequest(params['request']);
|
||||
if (!request) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'Invalid or missing Goal control request',
|
||||
);
|
||||
}
|
||||
const session = this.sessionOrThrow(sessionId);
|
||||
const response = await dispatchGoalControl(
|
||||
session.getConfig(),
|
||||
request,
|
||||
);
|
||||
return { snapshot: response.snapshot };
|
||||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.sessionGoalGet: {
|
||||
const sessionId = params['sessionId'];
|
||||
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
||||
|
|
@ -13118,24 +13221,36 @@ class QwenAgent implements Agent {
|
|||
currentValue:
|
||||
config.getContentGeneratorConfig().reasoning === false
|
||||
? ACP_REASONING_EFFORT_NONE
|
||||
: (modelReasoning.efforts.find(
|
||||
(effort) => effort === currentModelEffort,
|
||||
) ?? modelReasoning.defaultEffort),
|
||||
: modelReasoning.toggleOnly
|
||||
? ACP_REASONING_EFFORT_DEFAULT
|
||||
: (modelReasoning.efforts.find(
|
||||
(effort) => effort === currentModelEffort,
|
||||
) ?? modelReasoning.defaultEffort),
|
||||
options: [
|
||||
{
|
||||
value: ACP_REASONING_EFFORT_NONE,
|
||||
name: 'Thinking off',
|
||||
description: 'Disable thinking for this session',
|
||||
},
|
||||
...modelReasoning.efforts.map((effort) => ({
|
||||
value: effort,
|
||||
name: ACP_REASONING_EFFORT_NAMES[effort],
|
||||
description: 'Apply this effort to the next request',
|
||||
})),
|
||||
...(modelReasoning.toggleOnly
|
||||
? [
|
||||
{
|
||||
value: ACP_REASONING_EFFORT_DEFAULT,
|
||||
name: 'Thinking on',
|
||||
description: 'Use the model or provider thinking default',
|
||||
},
|
||||
]
|
||||
: modelReasoning.efforts.map((effort) => ({
|
||||
value: effort,
|
||||
name: ACP_REASONING_EFFORT_NAMES[effort],
|
||||
description: 'Apply this effort to the next request',
|
||||
}))),
|
||||
],
|
||||
_meta: {
|
||||
'qwenCode/reasoning': {
|
||||
defaultEffort: modelReasoning.defaultEffort,
|
||||
...(modelReasoning.toggleOnly
|
||||
? { toggleOnly: true }
|
||||
: { defaultEffort: modelReasoning.defaultEffort }),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -13177,7 +13292,9 @@ class QwenAgent implements Agent {
|
|||
const reasoning = getModelConfiguration(config.getModel())?.reasoning;
|
||||
const currentEffort = config.getReasoningEffort?.();
|
||||
return reasoning?.thinking &&
|
||||
(!currentEffort || reasoning.efforts.includes(currentEffort))
|
||||
(reasoning.toggleOnly ||
|
||||
!currentEffort ||
|
||||
reasoning.efforts.includes(currentEffort))
|
||||
? reasoning
|
||||
: undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,33 @@ describe('model configuration manifest', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'qwen3.5-plus',
|
||||
'qwen3.6-plus',
|
||||
'qwen3.6-flash',
|
||||
'qwen3.7-plus',
|
||||
'qwen3.7-max',
|
||||
])('registers toggle-only reasoning for %s', (modelId) => {
|
||||
expect(getModelConfiguration(modelId)).toEqual({
|
||||
reasoning: {
|
||||
thinking: true,
|
||||
toggleOnly: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'qwen3.8-max-preview',
|
||||
'qwen3.8-max-latest',
|
||||
'qwen3.8-max-2026-08-12',
|
||||
'vendor/qwen3.8-max',
|
||||
'qwen3.7-plus-latest',
|
||||
'vendor/qwen3.7-plus',
|
||||
'QWEN3.7-PLUS',
|
||||
'qwen3-max-2026-01-23',
|
||||
'qwen3-coder-plus',
|
||||
'qwen3-coder-next',
|
||||
])('does not broaden the manifest to %s', (modelId) => {
|
||||
expect(getModelConfiguration(modelId)).toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,13 +6,36 @@
|
|||
|
||||
import type { ReasoningEffort } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export interface ModelReasoningConfiguration {
|
||||
readonly thinking: true;
|
||||
readonly efforts: readonly ReasoningEffort[];
|
||||
readonly defaultEffort: ReasoningEffort;
|
||||
}
|
||||
export type ModelReasoningConfiguration =
|
||||
| {
|
||||
readonly thinking: true;
|
||||
readonly toggleOnly: true;
|
||||
}
|
||||
| {
|
||||
readonly thinking: true;
|
||||
readonly toggleOnly?: false;
|
||||
readonly efforts: readonly ReasoningEffort[];
|
||||
readonly defaultEffort: ReasoningEffort;
|
||||
};
|
||||
|
||||
const MODEL_CONFIGURATIONS = {
|
||||
const MODEL_CONFIGURATIONS: Readonly<
|
||||
Record<string, { readonly reasoning?: ModelReasoningConfiguration }>
|
||||
> = {
|
||||
'qwen3.5-plus': {
|
||||
reasoning: { thinking: true, toggleOnly: true },
|
||||
},
|
||||
'qwen3.6-plus': {
|
||||
reasoning: { thinking: true, toggleOnly: true },
|
||||
},
|
||||
'qwen3.6-flash': {
|
||||
reasoning: { thinking: true, toggleOnly: true },
|
||||
},
|
||||
'qwen3.7-plus': {
|
||||
reasoning: { thinking: true, toggleOnly: true },
|
||||
},
|
||||
'qwen3.7-max': {
|
||||
reasoning: { thinking: true, toggleOnly: true },
|
||||
},
|
||||
'qwen3.8-max': {
|
||||
reasoning: {
|
||||
thinking: true,
|
||||
|
|
@ -20,15 +43,12 @@ const MODEL_CONFIGURATIONS = {
|
|||
defaultEffort: 'xhigh',
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{ reasoning?: ModelReasoningConfiguration }
|
||||
>;
|
||||
};
|
||||
|
||||
export function getModelConfiguration(modelId: string | undefined):
|
||||
| {
|
||||
readonly reasoning?: ModelReasoningConfiguration;
|
||||
}
|
||||
| undefined {
|
||||
return modelId === 'qwen3.8-max' ? MODEL_CONFIGURATIONS[modelId] : undefined;
|
||||
return modelId ? MODEL_CONFIGURATIONS[modelId] : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17709,6 +17709,62 @@ describe('Session', () => {
|
|||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notifies the bridge that the Goal turn ended', async () => {
|
||||
const permit: core.GoalTurnPermit = {
|
||||
goalId: 'goal-1',
|
||||
revision: 1,
|
||||
turnId: 'turn-end-signal',
|
||||
};
|
||||
mockGoalRuntime.getSnapshot.mockReturnValue({
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 1,
|
||||
objective: 'check weather',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'cursor-1' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 1234,
|
||||
updatedAt: 1234,
|
||||
},
|
||||
});
|
||||
mockGoalRuntime.permitForTurn.mockImplementation((turnKey: string) =>
|
||||
turnKey === 'goal-runtime:turn-end-signal' ? permit : undefined,
|
||||
);
|
||||
mockChat.sendMessageStream = vi
|
||||
.fn()
|
||||
.mockResolvedValue(createEmptyStream());
|
||||
|
||||
expect(boundGoalHost).toBeDefined();
|
||||
await boundGoalHost!.startGoalTurn({
|
||||
permit,
|
||||
continuationContext: 'check weather',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockClient.extNotification).toHaveBeenCalledWith(
|
||||
'_qwencode/end_turn',
|
||||
{
|
||||
sessionId: 'test-session-id',
|
||||
reason: 'end_turn',
|
||||
source: 'goal',
|
||||
promptId: expect.stringMatching(
|
||||
/^test-session-id########\d+$/,
|
||||
) as unknown as string,
|
||||
},
|
||||
);
|
||||
});
|
||||
expect(mockClient.extNotification).toHaveBeenCalledWith(
|
||||
'_qwencode/start_turn',
|
||||
{
|
||||
sessionId: 'test-session-id',
|
||||
source: 'goal',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('settles a Goal turn whose prompt rejects before the turn body runs', async () => {
|
||||
// `prompt()` rejects ahead of the try whose finally settles the turn
|
||||
// when `assertCanStartTurn` throws — a session that began closing
|
||||
|
|
@ -28444,7 +28500,7 @@ describe('Session', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('drops repeated duplicate provider functionCall ids after the first synthetic response', async () => {
|
||||
it('records repeated duplicate provider calls without returning results', async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
llmContent: 'should not run',
|
||||
returnDisplay: 'should not run',
|
||||
|
|
@ -28473,6 +28529,7 @@ describe('Session', () => {
|
|||
],
|
||||
]),
|
||||
);
|
||||
const usedIds = new Set(['shell_1']);
|
||||
const [duplicatePart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
{
|
||||
|
|
@ -28483,7 +28540,7 @@ describe('Session', () => {
|
|||
},
|
||||
},
|
||||
],
|
||||
new Set(['shell_1']),
|
||||
usedIds,
|
||||
new Set<string>(),
|
||||
);
|
||||
const duplicateCall = duplicatePart.functionCall!;
|
||||
|
|
@ -28493,6 +28550,19 @@ describe('Session', () => {
|
|||
).runToolCalls(new AbortController().signal, 'prompt-history-dup', [
|
||||
duplicateCall,
|
||||
]);
|
||||
const [repeatedPart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
{
|
||||
functionCall: {
|
||||
id: 'shell_1',
|
||||
name: 'read_file',
|
||||
args: { file_path: 'b.ts' },
|
||||
},
|
||||
},
|
||||
],
|
||||
usedIds,
|
||||
new Set<string>(),
|
||||
);
|
||||
const toolLoopState: DaemonToolLoopState = {
|
||||
totalToolCalls: 0,
|
||||
invalidToolParamErrors: new Map<string, number>(),
|
||||
|
|
@ -28502,13 +28572,14 @@ describe('Session', () => {
|
|||
repeatedToolFailureMode: 'off',
|
||||
repeatedToolFailureState: createRepeatedToolFailureGuardState(),
|
||||
};
|
||||
expect(repeatedPart.functionCall?.id).toBe('shell_1__qwen_dup_3');
|
||||
const secondResult = await (
|
||||
session as unknown as ToolCallInternals
|
||||
).runToolCalls(
|
||||
new AbortController().signal,
|
||||
'prompt-history-dup',
|
||||
[
|
||||
duplicateCall,
|
||||
repeatedPart.functionCall!,
|
||||
{ id: 'fresh_shell', name: 'read_file', args: { file_path: 'c.ts' } },
|
||||
],
|
||||
toolLoopState,
|
||||
|
|
@ -28533,9 +28604,39 @@ describe('Session', () => {
|
|||
core.LoopType.GLOBAL_TOOL_CALL_DUPLICATE,
|
||||
);
|
||||
expect(mockChatRecordingService.recordToolResult).toHaveBeenCalledTimes(
|
||||
1,
|
||||
3,
|
||||
);
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mockChatRecordingService.recordToolResult.mock.calls
|
||||
.slice(1)
|
||||
.map(([parts, metadata]) => ({
|
||||
callId: metadata.callId,
|
||||
responseId: parts[0]?.functionResponse?.id,
|
||||
error: parts[0]?.functionResponse?.response?.['error'],
|
||||
status: metadata.status,
|
||||
executionStatus: metadata.executionStatus,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
callId: 'shell_1__qwen_dup_3',
|
||||
responseId: 'shell_1__qwen_dup_3',
|
||||
error: expect.stringContaining(
|
||||
'loop detection stopped the current turn',
|
||||
),
|
||||
status: 'error',
|
||||
executionStatus: 'not_started',
|
||||
},
|
||||
{
|
||||
callId: 'fresh_shell',
|
||||
responseId: 'fresh_shell',
|
||||
error: expect.stringContaining(
|
||||
'loop detection stopped the current turn',
|
||||
),
|
||||
status: 'error',
|
||||
executionStatus: 'not_started',
|
||||
},
|
||||
]);
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('suppresses duplicate TodoWrite calls without emitting plan updates', async () => {
|
||||
|
|
|
|||
|
|
@ -2208,8 +2208,10 @@ export class Session implements SessionContext {
|
|||
this.goalProcessing = true;
|
||||
this.activeGoalTurn = turn;
|
||||
const parts = buildGoalContinuationParts(turn);
|
||||
let result: PromptResponse | undefined;
|
||||
await this.#emitGoalStartTurn();
|
||||
try {
|
||||
await this.prompt(
|
||||
result = await this.prompt(
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
prompt: parts.map((part) => ({
|
||||
|
|
@ -2239,6 +2241,7 @@ export class Session implements SessionContext {
|
|||
}`,
|
||||
);
|
||||
} finally {
|
||||
await this.#emitGoalEndTurn(result);
|
||||
if (this.activeGoalTurn === turn) this.activeGoalTurn = undefined;
|
||||
this.goalProcessing = false;
|
||||
void this.#drainCronQueue();
|
||||
|
|
@ -8489,6 +8492,40 @@ export class Session implements SessionContext {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Goal turns run inside this child via `prompt()` directly, so the daemon
|
||||
* bridge never observes a `session/prompt` RPC boundary for them and would
|
||||
* otherwise publish no `turn_complete` — leaving SSE clients (Web Shell,
|
||||
* SDK) with a streaming state that never settles.
|
||||
*/
|
||||
async #emitGoalStartTurn(): Promise<void> {
|
||||
try {
|
||||
await this.client.extNotification('_qwencode/start_turn', {
|
||||
sessionId: this.sessionId,
|
||||
source: 'goal',
|
||||
});
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Goal start-turn extNotification dropped: ${this.#formatError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #emitGoalEndTurn(result: PromptResponse | undefined): Promise<void> {
|
||||
try {
|
||||
await this.client.extNotification('_qwencode/end_turn', {
|
||||
sessionId: this.sessionId,
|
||||
reason: result?.stopReason ?? 'cancelled',
|
||||
source: 'goal',
|
||||
promptId: this.config.getSessionId() + '########' + String(this.turn),
|
||||
});
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Goal end-turn extNotification dropped: ${this.#formatError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||
try {
|
||||
await this.sendAvailableCommandsUpdateOrThrow();
|
||||
|
|
@ -9018,11 +9055,22 @@ export class Session implements SessionContext {
|
|||
} else {
|
||||
debugLogger.warn(message);
|
||||
}
|
||||
return await finalizeRunToolResult({
|
||||
await Promise.all(
|
||||
dedupedFunctionCalls.map((fc) =>
|
||||
recordSkippedToolCall(
|
||||
fc,
|
||||
LOOP_DETECTED_SKIP_MESSAGE,
|
||||
false,
|
||||
ToolErrorType.UNKNOWN,
|
||||
),
|
||||
),
|
||||
);
|
||||
const result = await finalizeRunToolResult({
|
||||
parts: [],
|
||||
stopAfterPermissionCancel: false,
|
||||
loopDetected: true,
|
||||
});
|
||||
return { ...result, parts: [] };
|
||||
}
|
||||
|
||||
const pushDuplicateBatch = (
|
||||
|
|
|
|||
|
|
@ -603,7 +603,11 @@ describe('history replay page', () => {
|
|||
return 'next-cursor';
|
||||
},
|
||||
});
|
||||
expect(firstPage.updates).toHaveLength(2);
|
||||
expect(firstPage.updates).toHaveLength(3);
|
||||
expect(firstPage.updates[0]).toMatchObject({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: `/goal ${goal.objective}` },
|
||||
});
|
||||
expect(nextReplay).toMatchObject({ goalCause: 'verifier_reject' });
|
||||
|
||||
const recommittedGoal = {
|
||||
|
|
|
|||
|
|
@ -2865,7 +2865,7 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
|
|||
incremental: {
|
||||
since: 'a'.repeat(40),
|
||||
effective: false,
|
||||
reason: 'hunks-outside-pr-diff',
|
||||
reason: 'nothing-to-narrow',
|
||||
diffBase: 'de17aba5e',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1541,11 +1541,13 @@ export function buildRoleBrief(
|
|||
`\`${wt}\`. Do not \`cd\` elsewhere and do not build the user's main checkout.`,
|
||||
);
|
||||
}
|
||||
// On a delta-scoped incremental round the probe's range must match the
|
||||
// round's scope: test-efficacy recomputes its own diff as base..HEAD, and
|
||||
// handed the merge base it would reverse hunks and delete mutants from
|
||||
// commits an earlier round already reviewed — spending the probe budget
|
||||
// out of scope and reporting survivors this round's diff never contains.
|
||||
// On a narrowed incremental round the probe's range must cover the
|
||||
// published scope: test-efficacy recomputes its own diff as base..HEAD.
|
||||
// The published hunks are hunks of `diffBase..head` — the merge-base
|
||||
// range the producer assembled them from — so that range covers every
|
||||
// one of them and never a byte the PR's diff does not display; the
|
||||
// anchor range, by contrast, can carry hunks an undo round netted out
|
||||
// of the PR's diff, which no comment can anchor on.
|
||||
const inc = report.incremental as
|
||||
| { effective?: unknown; upToDate?: unknown; diffBase?: unknown }
|
||||
| undefined;
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Drives the containment oracle against captures REAL git produced, on a real
|
||||
// three-commit history, under the flags `fetch-pr` actually pins.
|
||||
//
|
||||
// The oracle's unit fixtures are hand-written diffs, and a hand-written diff
|
||||
// encodes what its author believed git emits. The defect this file exists for
|
||||
// was invisible to every one of them: under `--unified=3` a deletion arrives
|
||||
// wrapped in context, so the hunk is not `newCount === 0` and its surviving
|
||||
// new-side range is just that context — which the covering hunk contains for
|
||||
// free. Only a capture git chose the hunk boundaries for shows that shape.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { containmentRuling } from './fetch-pr.js';
|
||||
import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from './lib/diff-flags.js';
|
||||
import { isolateHostGitConfig } from './lib/test-utils.js';
|
||||
|
||||
let repo: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
let gitIsolation: ReturnType<typeof isolateHostGitConfig>;
|
||||
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', args, { cwd: repo, encoding: 'utf8', env });
|
||||
|
||||
/** Capture exactly as `fetch-pr` does. */
|
||||
const capture = (from: string, to: string) =>
|
||||
execFileSync(
|
||||
'git',
|
||||
[...PINNED_DIFF_CONFIG, 'diff', ...PINNED_DIFF_FLAGS, from, to],
|
||||
{ cwd: repo, maxBuffer: 1 << 28, env },
|
||||
).toString('utf8');
|
||||
|
||||
const baseLines = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => `L${String(i + 1).padStart(2, '0')}`,
|
||||
);
|
||||
|
||||
const commit = (file: string, lines: string[], msg: string) => {
|
||||
writeFileSync(join(repo, file), lines.join('\n') + '\n');
|
||||
git('add', '-A');
|
||||
git('commit', '-qm', msg, '--no-verify');
|
||||
return git('rev-parse', 'HEAD').trim();
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
repo = mkdtempSync(join(tmpdir(), 'fetch-pr-it-'));
|
||||
gitIsolation = isolateHostGitConfig();
|
||||
env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
||||
git('init', '-q', '--template=', '.');
|
||||
git('config', 'user.email', 'test@example.com');
|
||||
git('config', 'user.name', 'test');
|
||||
git('config', 'commit.gpgsign', 'false');
|
||||
git('config', 'core.autocrlf', 'false');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (repo) rmSync(repo, { recursive: true, force: true });
|
||||
gitIsolation.dispose();
|
||||
});
|
||||
|
||||
describe('containmentRuling on real-git captures', () => {
|
||||
it('refuses a delta that deletes lines the PR diff never displays', () => {
|
||||
// The "undo per feedback" round. Round 1 landed two edits and three extra
|
||||
// lines; the next round takes the three lines back out. Those lines stood
|
||||
// at neither the merge base nor the head, so the PR's own diff mentions
|
||||
// them on neither side — yet the delta's only content is their removal.
|
||||
const base = commit('undo.ts', baseLines, 'base');
|
||||
|
||||
const anchor = [...baseLines];
|
||||
anchor[4] = 'L05-MOD';
|
||||
anchor[11] = 'L12-MOD';
|
||||
anchor.splice(8, 0, 'X1', 'X2', 'X3');
|
||||
const round1 = commit('undo.ts', anchor, 'round 1');
|
||||
|
||||
const head = [...baseLines];
|
||||
head[4] = 'L05-MOD';
|
||||
head[11] = 'L12-MOD';
|
||||
const headSha = commit('undo.ts', head, 'undo per feedback');
|
||||
|
||||
const delta = capture(round1, headSha);
|
||||
const full = capture(base, headSha);
|
||||
|
||||
// The shape that defeats a range-only rule: git wrapped the deletion in
|
||||
// context, so the delta hunk's new-side range sits INSIDE the full
|
||||
// capture's — while the deleted text appears nowhere in the full capture.
|
||||
expect(delta).toContain('-X1');
|
||||
expect(full).not.toContain('X1');
|
||||
expect(delta).toContain('@@ -6,9 +6,6 @@'); // new side [6, 11]
|
||||
expect(full).toContain('@@ -2,14 +2,14 @@'); // new side [2, 15] — covers it
|
||||
|
||||
expect(containmentRuling(delta, full)).toEqual({
|
||||
ok: false,
|
||||
unverified: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a delta whose deletion the PR diff performs too', () => {
|
||||
// The control that keeps the rule from being "refuse every deletion":
|
||||
// these lines stood at the merge base, so the PR deletes them as well and
|
||||
// GitHub displays them.
|
||||
const base = commit('shared.ts', baseLines, 'shared base');
|
||||
|
||||
const anchor = [...baseLines];
|
||||
anchor[4] = 'L05-MOD';
|
||||
const round1 = commit('shared.ts', anchor, 'shared round 1');
|
||||
|
||||
const head = [...anchor];
|
||||
head.splice(19, 3); // L20..L22, all present at the base
|
||||
const headSha = commit('shared.ts', head, 'shared head');
|
||||
|
||||
const delta = capture(round1, headSha);
|
||||
const full = capture(base, headSha);
|
||||
|
||||
expect(delta).toContain('-L20');
|
||||
expect(full).toContain('-L20');
|
||||
|
||||
expect(containmentRuling(delta, full)).toEqual({
|
||||
ok: true,
|
||||
unverified: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -51,6 +51,7 @@ import {
|
|||
refExists,
|
||||
releaseWorktree,
|
||||
} from './lib/git.js';
|
||||
import { narrowToDelta } from './lib/narrow-diff.js';
|
||||
import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from './lib/diff-flags.js';
|
||||
import {
|
||||
REVIEW_TMP_DIR,
|
||||
|
|
@ -61,7 +62,6 @@ import {
|
|||
import { planEffortField } from './lib/effort.js';
|
||||
import {
|
||||
buildDiffPlan,
|
||||
parseDiff,
|
||||
DEFAULT_MAX_CHUNK_LINES,
|
||||
READ_FILE_CHAR_CAP,
|
||||
} from './lib/diff-plan.js';
|
||||
|
|
@ -242,7 +242,8 @@ type FetchPrResult = PlanReport & {
|
|||
* Present when `--since <sha>` was passed: the incremental-review scoping
|
||||
* decision, validated HERE so the orchestrator never hand-runs git against
|
||||
* an anchor. `effective: true` without `upToDate` means the diff and plan
|
||||
* in this report cover `since..fetchedSha` instead of the merge-base range.
|
||||
* in this report are the merge-base range narrowed to what changed since
|
||||
* the anchor, rather than the whole merge-base range.
|
||||
* `upToDate: true` means nothing has landed since the anchor (the anchor is
|
||||
* the head, or the commits since it change no bytes) — a fact about the
|
||||
* anchor, proven without consulting the base. The diff and plan then cover
|
||||
|
|
@ -255,13 +256,22 @@ type FetchPrResult = PlanReport & {
|
|||
* reason names a CAUSE: a rebase or force-push (`not-an-ancestor`), a sha
|
||||
* this history has never seen (`unknown-commit`), an anchor older than the
|
||||
* merge base that would scope WIDER than the PR's diff
|
||||
* (`behind-merge-base`), a delta carrying hunks the PR's own diff does not
|
||||
* contain (`hunks-outside-pr-diff` — an "undo per feedback" revert makes an
|
||||
* in-range anchor produce them), a containment check that could not be
|
||||
* RULED because the parser cannot name a path (`containment-unverified`),
|
||||
* a merge base too stale to rule the clamp on (`base-untrusted`), a
|
||||
* capture that threw (`capture-failed`), or a partitioner that refused to
|
||||
* tile (`partition-failed`).
|
||||
* (`behind-merge-base`), a merge base too stale to rule the clamp on
|
||||
* (`base-untrusted`), a capture that threw OR a base-side fault — the
|
||||
* base fetch or the merge-base resolution — failed (`capture-failed`), a
|
||||
* partitioner that refused to tile (`partition-failed`), or a narrowing
|
||||
* that found nothing it could publish (`nothing-to-narrow`). That last one
|
||||
* exists because the scope is BUILT from the PR's own diff rather than
|
||||
* checked against it, and it covers every shape the build can refuse,
|
||||
* deliberately alike: an "undo per feedback" round whose commits put lines
|
||||
* back the way the base had them, so the PR no longer displays the undone
|
||||
* FILE at all (a file the PR still carries publishes its section whole
|
||||
* instead of refusing); a capture on either side whose bytes do not
|
||||
* survive UTF-8; a delta the
|
||||
* parser cannot read; and the fail-closed refusal — the two captures key
|
||||
* the same change differently (a path or a rename git resolves differently
|
||||
* across the two ranges), so narrowing would drop a change the PR's diff
|
||||
* displays. Every shape keeps the full range: wider, never wrong.
|
||||
*
|
||||
* Whether a PLAN exists is a separate fact, and it is `diffPath`: null
|
||||
* means this round has no diff to review, whatever refused the anchor. A
|
||||
|
|
@ -280,20 +290,21 @@ export interface IncrementalDecision {
|
|||
| 'unknown-commit'
|
||||
| 'not-an-ancestor'
|
||||
| 'behind-merge-base'
|
||||
| 'hunks-outside-pr-diff'
|
||||
| 'containment-unverified'
|
||||
| 'nothing-to-narrow'
|
||||
| 'cross-model-anchor'
|
||||
| 'base-untrusted'
|
||||
| 'capture-failed'
|
||||
| 'partition-failed';
|
||||
/**
|
||||
* The scoped range's left side as a FULL sha, present exactly when the
|
||||
* report's diff is the delta (`effective` and not `upToDate`). Downstream
|
||||
* consumers that recompute their own ranges read it instead of
|
||||
* `mergeBaseSha` — Agent 7's test-efficacy probe welds `--base` into its
|
||||
* brief, and probing the full range on a delta-scoped round would spend
|
||||
* the probe budget on already-reviewed hunks and report survivors from
|
||||
* outside this round's scope.
|
||||
* The left side of the range the published scope was assembled from, as a
|
||||
* FULL sha, present exactly when the report's diff is the narrowed scope
|
||||
* (`effective` and not `upToDate`). Downstream consumers that recompute
|
||||
* their own ranges read it — Agent 7's test-efficacy probe welds `--base`
|
||||
* into its brief. It is the merge base, never the anchor: the published
|
||||
* hunks are byte-identical hunks of `mergeBase..head`, so that range
|
||||
* covers every one of them and never a byte the PR's diff does not
|
||||
* display, while the anchor range can carry hunks an undo round netted
|
||||
* out of the PR's diff.
|
||||
*/
|
||||
diffBase?: string;
|
||||
}
|
||||
|
|
@ -424,213 +435,6 @@ function fileLineCount(ref: string, path: string): number {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does every hunk of `inner` fall inside `outer`, per file?
|
||||
*
|
||||
* This is the containment an ancestry clamp cannot give. An anchor can be a
|
||||
* proper ancestor of the head and still produce a delta whose hunks are absent
|
||||
* from the PR's own diff: an "undo per feedback" commit reverts some of the
|
||||
* previous round's lines back to base content, so those lines are changed in
|
||||
* `anchor..head` and unchanged in `base..head`. A comment anchored on such a
|
||||
* hunk 422s the whole Create Review call.
|
||||
*
|
||||
* The result is TWO facts, not one: DISPROVED containment and an oracle that
|
||||
* could not rule are different, and only the first is what
|
||||
* `hunks-outside-pr-diff` asserts. A boolean wrapper over this used to exist
|
||||
* for the tests' convenience; it collapsed exactly the split the refusal enum
|
||||
* pays to keep, so callers take the pair.
|
||||
*
|
||||
* The grammar is NOT re-implemented here. Three rounds of review found a new
|
||||
* shape-tolerance defect in a hand-rolled parser every time — count-less
|
||||
* headers, trailing function context, quoted rename headers, deletion
|
||||
* junctions — so this reads the sections and hunks out of `parseDiff`, the
|
||||
* parser the chunk planner already trusts on these exact captures (it
|
||||
* unquotes paths, tracks hunk bodies, and knows the binary and rename
|
||||
* shapes). A ruling is then set arithmetic over its output.
|
||||
*/
|
||||
export function containmentRuling(
|
||||
inner: string,
|
||||
outer: string,
|
||||
): { ok: boolean; unverified: boolean } {
|
||||
// Both captures reach here already decoded as UTF-8, and that decode is
|
||||
// LOSSY: every byte git emitted that is not valid UTF-8 — in a path or in a
|
||||
// line's content — arrives as one U+FFFD. Distinct bytes therefore become
|
||||
// the same character, and everything below compares decoded strings: two
|
||||
// filenames differing only in an invalid byte share one map key, so one
|
||||
// file's hunks get judged against the other's ranges; two byte-distinct
|
||||
// deleted lines match each other 1:1. Neither is detectable after the
|
||||
// decode, so the oracle declines to rule rather than ruling on text it
|
||||
// knows is not the text git produced. A file that legitimately contains
|
||||
// U+FFFD refuses too — a full review, which is the safe direction.
|
||||
if (inner.includes('<27>') || outer.includes('<27>')) {
|
||||
return { ok: false, unverified: true };
|
||||
}
|
||||
const innerSections = sectionsOf(inner);
|
||||
const outerSections = sectionsOf(outer);
|
||||
if (innerSections === null || outerSections === null) {
|
||||
return { ok: false, unverified: true };
|
||||
}
|
||||
return {
|
||||
ok: sectionsContained(innerSections, outerSections),
|
||||
unverified: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What one HUNK contributes to a ruling.
|
||||
*
|
||||
* Two facts, because the two sides of a diff are comparable in different ways.
|
||||
* The captures share a head tree, so their NEW-side line numbers name the same
|
||||
* lines and compare as numbers. Their OLD sides are different trees — the
|
||||
* anchor and the merge base — so old-side line numbers name nothing in common
|
||||
* and deletions compare only by CONTENT.
|
||||
*
|
||||
* The pairing is what makes the content comparison sound. Held per FILE, a
|
||||
* `-X` the PR displays in one hunk cleared a `-X` the delta performs thirty
|
||||
* lines away in another — a line displayed nowhere near where the delta
|
||||
* deletes it. Locality is available (the head tree is shared, which is the
|
||||
* same fact the range check already rests on), so it is used: a deletion is
|
||||
* matched only against hunks that ENCLOSE the hunk performing it.
|
||||
*/
|
||||
interface HunkFacts {
|
||||
/** New-side range of this hunk. */
|
||||
range: [number, number];
|
||||
/**
|
||||
* This hunk body's `-` lines as `content@junction`.
|
||||
*
|
||||
* The junction is the new-side cursor where the deleted line stood: context
|
||||
* and `+` lines advance it, `-` lines do not — the same walk `parseDiff`
|
||||
* performs. Content alone was not enough. Two hunks can delete the same text
|
||||
* at different places, and matching by text let a delta's `-dup` be cleared
|
||||
* by a `-dup` the PR displays thirty lines away, in a hunk that never
|
||||
* touches the delta's junction. Junctions are comparable for the same reason
|
||||
* ranges are: both captures end at the same head tree.
|
||||
*/
|
||||
deletions: string[];
|
||||
}
|
||||
|
||||
/** `path -> hunks`, via the shared parser. Null if it found nothing in a
|
||||
* non-empty diff, which is the "could not rule" state. */
|
||||
function sectionsOf(diffText: string): Map<string, HunkFacts[]> | null {
|
||||
const { files } = parseDiff(diffText);
|
||||
if (diffText.trim() !== '' && files.length === 0) return null;
|
||||
// Split once: `containmentRuling` runs on every incremental capture, and
|
||||
// re-splitting per hunk made it quadratic in the diff size.
|
||||
const lines = diffText.split('\n');
|
||||
const out = new Map<string, HunkFacts[]>();
|
||||
for (const f of files) {
|
||||
// A section with no hunk at all — a mode change, a binary replacement, a
|
||||
// pure rename — carries nothing to compare. It enters as an EMPTY list so
|
||||
// the path check still runs: each used to pass vacuously, which is how a
|
||||
// delta whose only content is a file the PR's own diff never mentions
|
||||
// became the scope.
|
||||
const hunks = out.get(f.path) ?? [];
|
||||
for (const h of f.hunks) {
|
||||
// A pure deletion (`newCount === 0`) sits BETWEEN two post-image lines;
|
||||
// `parseDiff` already clamps its range to the junction, and comparing
|
||||
// that junction against a covering hunk is what keeps a deletion the
|
||||
// PR's own diff performs from being refused.
|
||||
const deletions: string[] = [];
|
||||
// Body lines only. `diffStart` is the `@@` header's own 1-based line
|
||||
// number, so the body begins at that index and ends at `diffEnd - 1`;
|
||||
// starting at the header would read `---` file metadata as a deletion.
|
||||
let cursor = h.newStart;
|
||||
for (let i = h.diffStart; i < h.diffEnd; i++) {
|
||||
const line = lines[i];
|
||||
if (line === undefined) continue;
|
||||
if (line.startsWith('-')) {
|
||||
// Where this line stood on the new side: between the lines the
|
||||
// cursor has and has not yet reached.
|
||||
deletions.push(`${cursor}\u0000${line.slice(1)}`);
|
||||
} else if (
|
||||
line.startsWith('+') ||
|
||||
line === '' ||
|
||||
line.startsWith(' ')
|
||||
) {
|
||||
// Both occupy a new-side line. A `\ No newline at end of file`
|
||||
// marker is neither, and must not move the cursor.
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
hunks.push({ range: [h.newStart, h.newEnd], deletions });
|
||||
}
|
||||
out.set(f.path, hunks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The containment loop over already-parsed sections. */
|
||||
function sectionsContained(
|
||||
inner: Map<string, HunkFacts[]>,
|
||||
outer: Map<string, HunkFacts[]>,
|
||||
): boolean {
|
||||
for (const [file, hunks] of inner) {
|
||||
const covering = outer.get(file);
|
||||
if (!covering) return false;
|
||||
// A delta section with nothing comparable — a mode change, a pure rename,
|
||||
// a binary replacement — carries no hunk at all, so the loop below iterates
|
||||
// zero times and the section passes vacuously. That is the right answer
|
||||
// only when the PR's own section is equally contentless (two binary
|
||||
// sections, say). When the covering section HAS hunks, the delta is
|
||||
// asserting a change of a kind the PR's diff does not show — an "undo per
|
||||
// feedback" round that reverts round 1's `chmod +x` is exactly this shape —
|
||||
// and vacuous truth is the wrong verdict for it.
|
||||
if (hunks.length === 0 && covering.length > 0) return false;
|
||||
|
||||
// Keyed by `content@junction`, not content. The entry a delta deletion
|
||||
// consumes must be the one the PR displays AT THAT PLACE: matching by text
|
||||
// alone let a `-dup` the PR shows near the top of the file clear a `-dup`
|
||||
// the delta performs thirty lines down, at a junction the PR's diff never
|
||||
// touches. Junctions are comparable for the same reason ranges are — both
|
||||
// captures end at the same head tree.
|
||||
//
|
||||
// ONE budget for the whole file, consumed across every delta hunk, so a
|
||||
// single displayed deletion is spent once. Measured honestly: with the
|
||||
// junction in the key this is not observable — two delta hunks cannot
|
||||
// delete at the same junction — so it is the invariant stated where it
|
||||
// belongs rather than a live guard. Rebuilding it per hunk would make
|
||||
// correctness depend on junction-uniqueness without saying so.
|
||||
const budget = new Map<string, number>();
|
||||
for (const o of covering) {
|
||||
for (const d of o.deletions) budget.set(d, (budget.get(d) ?? 0) + 1);
|
||||
}
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const [start, end] = hunk.range;
|
||||
// Strict containment, no slack. Both captures share the head tree, so
|
||||
// a deletion the PR's own diff performs yields an identical junction
|
||||
// range and is covered at equality; slack for it bought nothing and
|
||||
// accepted a delta hunk one line past the covering hunk — a line
|
||||
// GitHub's PR diff does not display, where an anchored comment 422s
|
||||
// the entire all-or-nothing Create Review call.
|
||||
if (!covering.some((o) => o.range[0] <= start && end <= o.range[1])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deleted lines occupy NO new-side line, so the range check above is
|
||||
// blind to them: what survives a deletion hunk on the new side is its
|
||||
// context, which the covering hunk contains for free. A delta that
|
||||
// deletes a line the PR's own diff never displays passed the range check
|
||||
// outright.
|
||||
//
|
||||
// The discriminator is where the line came from. `-X` in the delta means
|
||||
// X stood at the anchor and is gone at head. If X also stood at the merge
|
||||
// base then the PR — which ends at that same head — must delete it too,
|
||||
// so `-X` appears in the full capture, at the same junction. So the
|
||||
// converse is the refusal: no such entry means the PR introduced X after
|
||||
// the base and took it back out, and GitHub's PR diff shows that line on
|
||||
// neither side. An inline comment anchored there 422s the entire
|
||||
// all-or-nothing Create Review call.
|
||||
for (const deleted of hunk.deletions) {
|
||||
const left = budget.get(deleted) ?? 0;
|
||||
if (left === 0) return false;
|
||||
budget.set(deleted, left - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlist shape for a server-controlled branch name reaching git's argv:
|
||||
* a plain branch name and nothing else (twin of aone.ts's guard — see that
|
||||
|
|
@ -683,16 +487,47 @@ const gitProbe: GitProbe = {
|
|||
// refs/tags and refs/heads FIRST, so a tag or branch named
|
||||
// `origin/<ref>` — likewise pushable, auto-carried at clone time — would
|
||||
// satisfy the check with no tracking ref present.
|
||||
//
|
||||
// The exit status is KEPT (gitExit, not gitOpt), like the sibling probes,
|
||||
// but it splits nothing here: git exits 128 identically for a transient
|
||||
// fault and for a deterministic refusal (the base branch deleted on the
|
||||
// remote — the refspec fetch fails every time), so the bound on retrying
|
||||
// the deterministic member lives where the class is ruled — the demotion
|
||||
// arm below and SKILL.md's once-cap — never on the status.
|
||||
fetch: (remote, ref) =>
|
||||
gitOpt(
|
||||
gitExit(
|
||||
'fetch',
|
||||
remote,
|
||||
'--',
|
||||
`+refs/heads/${ref}:refs/remotes/${remote}/${ref}`,
|
||||
) !== null && refExists(`refs/remotes/${remote}/${ref}`),
|
||||
).status === 0 && refExists(`refs/remotes/${remote}/${ref}`),
|
||||
refExists,
|
||||
mergeBase: (a, b) =>
|
||||
gitOpt('-c', 'core.commitGraph=false', 'merge-base', a, b),
|
||||
mergeBase: (a, b) => {
|
||||
// Three-way exit split like the anchor probes: exit 1 is the only
|
||||
// deterministic "no common ancestor"; any other status — an exit-128
|
||||
// fatal, the 120s timeout kill, a spawn failure — is the surface being
|
||||
// unavailable, thrown so the round demotes to the retryable class
|
||||
// instead of folding onto the same null and stamping the deterministic
|
||||
// reason. One member folds in anyway, and no exit-status resolution can
|
||||
// split it: git ALSO exits 1 when it cannot read the object store on
|
||||
// the walk, so a fault there is indistinguishable from an orphan
|
||||
// history. The arm below discloses it.
|
||||
//
|
||||
// `core.commitGraph=false` is #9092's pin, kept: the commit-graph is a
|
||||
// cache, and a stale or truncated one answers this walk from data the
|
||||
// object store no longer agrees with — a wrong merge base, which is the
|
||||
// one input every clamp and the whole narrowing are computed against.
|
||||
const { out, status } = gitExit(
|
||||
'-c',
|
||||
'core.commitGraph=false',
|
||||
'merge-base',
|
||||
a,
|
||||
b,
|
||||
);
|
||||
if (status === 0) return out;
|
||||
if (status === 1) return null;
|
||||
throw new GitUnavailable();
|
||||
},
|
||||
};
|
||||
|
||||
function tryRemove(action: () => void): void {
|
||||
|
|
@ -1101,14 +936,29 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
|||
// chunk agents read ranges out of it and `diffHashOf` hashes it. What
|
||||
// the round trip does not do is normalise CRLF (that would rewrite
|
||||
// every hunk of a CRLF file) or drop the trailing newline.
|
||||
const { sha: mergeBaseSha, baseFetchFailed } = resolveMergeBase(
|
||||
remote,
|
||||
meta.baseRefName,
|
||||
// QUALIFIED — the head side is dwim-shadowable exactly like the
|
||||
// fetchedSha read above.
|
||||
`refs/heads/${ref}`,
|
||||
gitProbe,
|
||||
);
|
||||
let mergeBaseSha: string | null;
|
||||
let baseFetchFailed: boolean;
|
||||
/** The merge-base probe threw: the surface, not the history. */
|
||||
let mergeBaseUnavailable = false;
|
||||
try {
|
||||
({ sha: mergeBaseSha, baseFetchFailed } = resolveMergeBase(
|
||||
remote,
|
||||
meta.baseRefName,
|
||||
// QUALIFIED — the head side is dwim-shadowable exactly like the
|
||||
// fetchedSha read above.
|
||||
`refs/heads/${ref}`,
|
||||
gitProbe,
|
||||
));
|
||||
} catch (err) {
|
||||
if (!(err instanceof GitUnavailable)) throw err;
|
||||
// An exit other than the deterministic "no common ancestor" — the
|
||||
// probe's exit split throws it. The round degrades like any base-less
|
||||
// one; the fetch result is lost in the throw, and with no sha and the
|
||||
// retryable reason stamped below, nothing rules on it.
|
||||
mergeBaseSha = null;
|
||||
baseFetchFailed = false;
|
||||
mergeBaseUnavailable = true;
|
||||
}
|
||||
if (baseFetchFailed) {
|
||||
writeStderrLine(
|
||||
`WARNING: could not fetch ${remote}/${meta.baseRefName}. The merge-base ` +
|
||||
|
|
@ -1338,7 +1188,8 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
|||
}
|
||||
/** True when the FINAL published diff is the incremental delta. */
|
||||
let scopedDelta = false;
|
||||
let ruling = { ok: true, unverified: false };
|
||||
/** The PR's own hunks, narrowed to what changed since the anchor. */
|
||||
let narrowed: Buffer | null = null;
|
||||
if (anchor?.diffBase) {
|
||||
// An anchor that resolved to the merge base names the range already in
|
||||
// hand: re-running the identical `git diff` would spend the capture (and
|
||||
|
|
@ -1359,54 +1210,72 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
|||
// below for the flows that continue anyway (a model change,
|
||||
// --comment).
|
||||
anchor.incremental.upToDate = true;
|
||||
} else if (fullText === null && mergeBaseSha !== null) {
|
||||
// The oracle was LOST, not absent: a base was resolved and its capture
|
||||
// threw (the 120s git timeout on the large long-lived PR `--since`
|
||||
// exists for). Scoping now would publish a delta no containment check
|
||||
// ever ran against — the same unchecked scope this guard exists to
|
||||
// refuse, arrived at by an infrastructure failure instead of a bad
|
||||
// anchor.
|
||||
} else if (mergeBaseUnavailable) {
|
||||
// `git merge-base` could not answer: the probe's exit split throws on
|
||||
// every status except the deterministic exit-1 "no common ancestor"
|
||||
// — an exit-128 fatal, the 120s timeout kill, a spawn failure.
|
||||
// Something did fail, and the re-run re-runs exactly that probe, so
|
||||
// this is the retryable class — the same ruling the anchor probes'
|
||||
// GitUnavailable gets.
|
||||
demote('capture-failed');
|
||||
} else if (fullText === null) {
|
||||
// Base-FREE: no merge base resolved, so there is no PR diff to be
|
||||
// contained in. That used to be read as licence to publish the delta
|
||||
// unchecked — the one arm where an uncontained scope shipped by design.
|
||||
// But "no diff to check against" is not proof of containment, it is the
|
||||
// absence of any, and every other arm here fails closed on exactly that
|
||||
// distinction. GitHub still renders SOMETHING for the PR, and a delta
|
||||
// never checked against it can still anchor a comment on a line that
|
||||
// render does not display.
|
||||
demote('containment-unverified');
|
||||
} else if (!(ruling = containmentRuling(delta, fullText)).ok) {
|
||||
// Two different facts, one refusal: the oracle DISPROVED containment,
|
||||
// or it could not rule at all (a path shape it does not model). Only
|
||||
// the first is what `hunks-outside-pr-diff` asserts; the second is an
|
||||
// unavailable oracle, reported as `containment-unverified` so the
|
||||
// reason a reader keys on stays true.
|
||||
//
|
||||
// Ancestry containment is not HUNK containment. An ordinary "undo per
|
||||
// feedback" commit reverts some of the anchor round's lines back to
|
||||
// base content: the delta then carries hunks the PR's own diff does
|
||||
// NOT contain, agents review them, and one comment anchored there
|
||||
// 422s the entire Create Review call — all-or-nothing, taking every
|
||||
// other finding with it. The clamp cannot see this (it compares
|
||||
// history, not content), so the delta is checked against the PR's
|
||||
// diff before it is allowed to be the review's scope.
|
||||
demote(
|
||||
ruling.unverified
|
||||
? 'containment-unverified'
|
||||
: 'hunks-outside-pr-diff',
|
||||
);
|
||||
} else if (mergeBaseSha === null && baseFetchFailed) {
|
||||
// No merge base because the FETCH failed and no local base ref
|
||||
// remained to resolve one from. (A merge-base walk that failed on the
|
||||
// surface is the arm above, not this one.) The class has TWO members
|
||||
// the exit
|
||||
// status cannot split — git exits 128 for BOTH: a transient fault (a
|
||||
// fresh CI clone whose base fetch hit a network blip), where the
|
||||
// re-run re-runs exactly the component that failed and can succeed,
|
||||
// and a deterministic refusal (the base branch deleted on the remote
|
||||
// — the refspec fetch fails identically every time), where it never
|
||||
// will. Something did fail, so this keeps the retryable reason;
|
||||
// SKILL.md's recovery paragraph bounds the retry to ONCE so the
|
||||
// deterministic member cannot re-fail every round until the cap.
|
||||
demote('capture-failed');
|
||||
} else if (mergeBaseSha === null) {
|
||||
// No merge base although the fetch SUCCEEDED: `git merge-base` found
|
||||
// no common ancestor — an unrelated-history PR. There is no PR diff to
|
||||
// narrow against, so no scope is built; but nothing THREW, and calling
|
||||
// it `capture-failed` asserts an infrastructure fault that did not
|
||||
// happen and puts the round in the class SKILL.md's recovery flow
|
||||
// retries. A re-run reproduces this exactly, so it names the
|
||||
// deterministic reason instead. Exit 1 is the only "no common
|
||||
// ancestor" signal the probe keeps — every other exit takes the
|
||||
// retryable arm above. One member folds in anyway: git ALSO exits 1
|
||||
// when it cannot read the object store on the walk, so a fault there
|
||||
// stamps this reason at any exit-status resolution, and the
|
||||
// determinism claimed here is unprovable for that member.
|
||||
demote('nothing-to-narrow');
|
||||
} else if (fullBytes === null || fullText === null) {
|
||||
// A base WAS resolved and its capture threw — the 120s git timeout the
|
||||
// large long-lived PR `--since` exists for. That is infrastructure,
|
||||
// and a re-run can succeed, so this one keeps `capture-failed`.
|
||||
demote('capture-failed');
|
||||
} else if ((narrowed = narrowToDelta(fullBytes, deltaBytes)) === null) {
|
||||
// The narrowing found nothing it could publish — all safe, because
|
||||
// keeping the full range costs a wider review and never a wrong one:
|
||||
// the "undo per feedback" round whose commits put lines back the way
|
||||
// the base had them, so the undone FILE no longer appears in
|
||||
// `base..head` at all (an undone file the PR's diff still carries
|
||||
// does not land here — the join fails closed and publishes its
|
||||
// section whole instead); a capture whose bytes do not survive
|
||||
// UTF-8; a delta the parser cannot read; and the fail-closed
|
||||
// refusal — the two captures key the same change differently (a path
|
||||
// or a rename), so narrowing would drop a change the PR's diff
|
||||
// displays.
|
||||
demote('nothing-to-narrow');
|
||||
} else {
|
||||
if (publish(deltaBytes)) {
|
||||
if (publish(narrowed)) {
|
||||
scopedDelta = true;
|
||||
// The scoped range's left side, full-sha, for downstream consumers
|
||||
// that recompute their own diffs (Agent 7's test-efficacy probe
|
||||
// welds --base into its brief) — without it they would probe the
|
||||
// full merge-base range on a delta-scoped round.
|
||||
anchor.incremental.diffBase = anchor.diffBase;
|
||||
// The published hunks are byte-identical hunks of
|
||||
// `mergeBaseSha..head`, so that range is what downstream consumers
|
||||
// recomputing their own diffs must probe (Agent 7's test-efficacy
|
||||
// probe welds --base into its brief): it covers every published hunk
|
||||
// and never a byte the PR's diff does not display, while the anchor
|
||||
// range can carry hunks an undo round netted out of it.
|
||||
anchor.incremental.diffBase = mergeBaseSha;
|
||||
} else {
|
||||
// The delta captured but could not be written: degrade like any
|
||||
// The scope was built but could not be written: degrade like any
|
||||
// other capture failure rather than scoping to a file nobody has.
|
||||
demote('capture-failed');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
// `{"comment":{"effective":true}}` to any file and point at it; it cannot
|
||||
// retroactively edit the user's own keystrokes.
|
||||
|
||||
import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { lstatSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
skillArgsPath,
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from '../../../services/skill-args-file.js';
|
||||
import { parseReviewArgs } from '../parse-args.js';
|
||||
import { isOwnerRepo } from './gh.js';
|
||||
import { hostsEquivalent } from './remote-match.js';
|
||||
|
||||
/**
|
||||
* Where the CLI records a skill's invocation arguments, verbatim, before the
|
||||
|
|
@ -134,14 +135,17 @@ const RECORDED_ARGS_MAX_BYTES = 64 * 1024;
|
|||
* this same store) — a planted link must not be followed;
|
||||
* - reads are size-bounded (RECORDED_ARGS_MAX_BYTES).
|
||||
*
|
||||
* Lookup order: the session-scoped args file first, then the sibling
|
||||
* session directories (sorted). The args file is named for the session
|
||||
* that recorded the review, and a `--user-authorized` publish
|
||||
* characteristically runs in a DIFFERENT session ("post the review we
|
||||
* saved") — without the sibling scan the file is simply absent there and
|
||||
* a recorded Aone target posts at github.com's same-named repo. Any
|
||||
* read/parse trouble still degrades gracefully and never blocks a
|
||||
* user-authorised publish.
|
||||
* Candidate set: the session-scoped args file (the publishing session's
|
||||
* own recording — it may post an OLDER same-PR recording than a sibling
|
||||
* session's, so it joins the ordering instead of preceding it), every
|
||||
* sibling session directory's recording, and the sessionless root-level
|
||||
* recording. ALL of them order by the recording FILE's mtime, newest
|
||||
* first. The args file is named for the session that recorded the
|
||||
* review, and a `--user-authorized` publish characteristically runs in a
|
||||
* DIFFERENT session ("post the review we saved") — without the sibling
|
||||
* scan the file is simply absent there and a recorded Aone target posts
|
||||
* at github.com's same-named repo. Any read/parse trouble still degrades
|
||||
* gracefully and never blocks a user-authorised publish.
|
||||
*/
|
||||
function lookupRecordedHost(
|
||||
req: WriteAuthorizationRequest,
|
||||
|
|
@ -153,7 +157,15 @@ function lookupRecordedHost(
|
|||
const parsed = parseReviewArgs(raw, { comment: req.defaultComment });
|
||||
const t = parsed.target;
|
||||
if (t.type === 'pr-url') {
|
||||
return t.number === req.pr && `${t.owner}/${t.repo}` === req.repo
|
||||
// Repo axis case-INSENSITIVE — the slow-path gate and the floor
|
||||
// recovery both lowercase both sides, and GitHub resolves
|
||||
// owner/repo case-insensitively server-side. A case-drifted
|
||||
// `--repo` used to make this binding vanish silently, dropping the
|
||||
// recording out of platform selection between two writable
|
||||
// platforms.
|
||||
return t.number === req.pr &&
|
||||
`${t.owner}/${t.repo}`.toLowerCase() ===
|
||||
(req.repo ?? '').toLowerCase()
|
||||
? t.host
|
||||
: null;
|
||||
}
|
||||
|
|
@ -165,41 +177,58 @@ function lookupRecordedHost(
|
|||
return null;
|
||||
}
|
||||
};
|
||||
const isReadableRecording = (path: string): boolean => {
|
||||
try {
|
||||
if (lstatSync(path).isSymbolicLink()) return false;
|
||||
return statSync(path).size <= RECORDED_ARGS_MAX_BYTES;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const candidates: string[] = [
|
||||
// The FULL candidate set: the session-scoped (or override) recording,
|
||||
// every sibling session recording, and the sessionless root recording.
|
||||
// A Set dedupes the publishing session's own directory, which the
|
||||
// sibling scan reaches again.
|
||||
const candidatePaths = new Set<string>([
|
||||
currentSessionId() === '' && req.skillArgs
|
||||
? req.skillArgs
|
||||
: defaultSkillArgsPath(),
|
||||
];
|
||||
]);
|
||||
try {
|
||||
const entries = readdirSync(SKILL_ARGS_DIR, {
|
||||
withFileTypes: true,
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
for (const entry of readdirSync(SKILL_ARGS_DIR, { withFileTypes: true })) {
|
||||
// Session directories ONLY — `.qwen/tmp/` also holds review
|
||||
// worktrees materialized from the reviewed PR's own tree; their
|
||||
// content is attacker-controlled and must never supply a host.
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
||||
if (!/^s-/.test(entry.name)) continue;
|
||||
candidates.push(
|
||||
candidatePaths.add(
|
||||
join(SKILL_ARGS_DIR, entry.name, 'qwen-skill-args-review.txt'),
|
||||
);
|
||||
}
|
||||
candidates.push(join(SKILL_ARGS_DIR, 'qwen-skill-args-review.txt'));
|
||||
candidatePaths.add(join(SKILL_ARGS_DIR, 'qwen-skill-args-review.txt'));
|
||||
} catch {
|
||||
// No recorded-args directory at all — the session-scoped candidate
|
||||
// above is the only one.
|
||||
}
|
||||
let sawSamePrRecording = false;
|
||||
for (const path of candidates) {
|
||||
if (!isReadableRecording(path)) continue;
|
||||
// Order every candidate by the recording FILE's mtime, newest first.
|
||||
// Session ids are arbitrary strings, so name order is a coin flip; the
|
||||
// record itself is last-writer-wins and the cross-session scan must
|
||||
// read it the same way, or an OLDER session's same-number recording
|
||||
// (Aone's small global MR ids collide with GitHub PR numbers easily)
|
||||
// supplies a stale host that masks the newest recording's hostlessness.
|
||||
// The DIRECTORY's mtime is NOT the key: writeSkillArgs rewrites the
|
||||
// recording in place (O_WRONLY|O_CREAT|O_TRUNC, no unlink/rename),
|
||||
// which advances the file's mtime and never the parent directory's —
|
||||
// and any other skill's args file created in the session dir bumps it.
|
||||
// Keying the sort on the directory let a plain re-run of an older
|
||||
// session's review (the re-run the unbound refusal's remedy prescribes)
|
||||
// lose its newest-wins position, routing an irreversible write on
|
||||
// stale evidence. Symlinks are skipped at the file level, mirroring
|
||||
// writeSkillArgs' O_NOFOLLOW policy on the write side of this store.
|
||||
const candidates: Array<{ path: string; mtime: number }> = [];
|
||||
for (const path of candidatePaths) {
|
||||
try {
|
||||
const st = lstatSync(path);
|
||||
if (st.isSymbolicLink() || st.size > RECORDED_ARGS_MAX_BYTES) continue;
|
||||
candidates.push({ path, mtime: st.mtimeMs });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => b.mtime - a.mtime);
|
||||
for (const { path } of candidates) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, 'utf8');
|
||||
|
|
@ -208,10 +237,13 @@ function lookupRecordedHost(
|
|||
}
|
||||
const bound = bindHost(raw);
|
||||
if (bound === null) continue;
|
||||
sawSamePrRecording = true;
|
||||
if (bound !== undefined) return { host: bound, unbound: false };
|
||||
// The FIRST (newest) same-PR recording decides: it yields its host, or
|
||||
// — when it carries none — the unbound verdict. Scanning PAST a
|
||||
// hostless newest recording to harvest an older session's host is the
|
||||
// stale-evidence hole the mtime ordering exists to close.
|
||||
return { host: bound, unbound: bound === undefined };
|
||||
}
|
||||
return { host: undefined, unbound: sawSamePrRecording };
|
||||
return { host: undefined, unbound: false };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -244,6 +276,16 @@ export function reviewWriteAuthorization(req: WriteAuthorizationRequest): {
|
|||
* runtime environment alone. Absent on the refusal paths.
|
||||
*/
|
||||
recordedUnbound?: boolean;
|
||||
/**
|
||||
* True when the slow path authorised from a caller-supplied
|
||||
* `--skill-args` path (honoured only when no session id is present) —
|
||||
* a recording that belongs to ANOTHER cwd. The write gate must not let
|
||||
* the submission cwd's origin probe stand in for such a recording's
|
||||
* missing platform evidence: the probe names submit's clone, not the
|
||||
* review's, so a hostless override recording fails closed instead.
|
||||
* Absent on the fast path and on refusals.
|
||||
*/
|
||||
viaSkillArgsOverride?: boolean;
|
||||
} {
|
||||
if (req.userAuthorized) {
|
||||
const lookup = lookupRecordedHost(req);
|
||||
|
|
@ -264,8 +306,13 @@ export function reviewWriteAuthorization(req: WriteAuthorizationRequest): {
|
|||
}
|
||||
|
||||
const sessionScoped = defaultSkillArgsPath();
|
||||
const path =
|
||||
currentSessionId() === '' && req.skillArgs ? req.skillArgs : sessionScoped;
|
||||
// The caller-supplied seam is honoured ONLY when no session id is
|
||||
// present (see WriteAuthorizationRequest.skillArgs). When it is used,
|
||||
// the recording belongs to another cwd, and the write gate must know:
|
||||
// the submission cwd's origin probe is not platform evidence for it.
|
||||
const skillArgsOverride =
|
||||
currentSessionId() === '' && req.skillArgs ? req.skillArgs : undefined;
|
||||
const path = skillArgsOverride ?? sessionScoped;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, 'utf8');
|
||||
|
|
@ -341,8 +388,14 @@ export function reviewWriteAuthorization(req: WriteAuthorizationRequest): {
|
|||
// The host check stands on its own, NOT nested under the repo binding —
|
||||
// and it binds in BOTH directions: an absent req.host means the write
|
||||
// routes at github.com, which is a host like any other, not an exemption.
|
||||
// Hosts compare through hostsEquivalent, not raw equality — Aone is one
|
||||
// platform under TWO names (the CR URL records the web host
|
||||
// `code.alibaba-inc.com`; the skill's own `--host` rule for Aone targets
|
||||
// carries the git host `gitlab.alibaba-inc.com`). Raw equality refused
|
||||
// every codereview-URL target that followed that rule — the whole review
|
||||
// ran, and the write died at the gate.
|
||||
const writeHost = (req.host ?? 'github.com').toLowerCase();
|
||||
if (t.host.toLowerCase() !== writeHost) {
|
||||
if (!hostsEquivalent(t.host.toLowerCase(), writeHost)) {
|
||||
return {
|
||||
ok: false,
|
||||
why:
|
||||
|
|
@ -359,11 +412,21 @@ export function reviewWriteAuthorization(req: WriteAuthorizationRequest): {
|
|||
: `\`review.comment\` is enabled in settings, and the review arguments name #${authorisedPr}`,
|
||||
// Mirror of the fast-path binding: a bare-number recording supplies
|
||||
// the recorded `--host` flag (its only host evidence). The UNBOUND
|
||||
// fail-closed does NOT ride the slow path: a same-session Aone review
|
||||
// runs inside an Aone clone, so the write gate's cwd arm already
|
||||
// refuses it — marking every bare-number slow-path recording unbound
|
||||
// would refuse the canonical same-session github posting flow instead.
|
||||
// fail-closed does NOT ride the slow path — the reason is not what it
|
||||
// was when first written (the write gate's cwd arm REFUSED then; it
|
||||
// SELECTS now). It survives because the slow path reads the CURRENT
|
||||
// SESSION's args file, so it is same-session by construction: the cwd
|
||||
// probe the write gate falls back to names the clone the review
|
||||
// itself ran in — sound evidence, not a guess — and it no longer
|
||||
// reads the ambient GH_HOST (aligned with read detection).
|
||||
// Cross-session publishes are the fast path's business, where the
|
||||
// unbound refusal covers the same bare-number shape. The ONE shape
|
||||
// that is NOT same-session by construction — a session-less caller
|
||||
// reading a caller-supplied `--skill-args` override — rides
|
||||
// `viaSkillArgsOverride` below, and the write gate fails closed on
|
||||
// its hostless form instead of probing the submission cwd.
|
||||
recordedHost: t.type === 'pr-url' ? t.host : verdict.host,
|
||||
viaSkillArgsOverride: skillArgsOverride !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -485,7 +548,12 @@ export function recordedSeverityFloor(opts: {
|
|||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (t.host.toLowerCase() !== host) return undefined;
|
||||
// hostsEquivalent, not raw equality — the same shape the `--comment`
|
||||
// gate above binds: an Aone CR-URL record carries the web host while
|
||||
// the submission carries the git host (one platform, two names). Raw
|
||||
// equality silently discarded the operator's floor exactly on the
|
||||
// Aone shape this repo supports.
|
||||
if (!hostsEquivalent(t.host.toLowerCase(), host)) return undefined;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,13 @@ export function classifyPath(path: string): PathKind {
|
|||
export interface DiffFile {
|
||||
/** New-side path, or the old path for a deletion. */
|
||||
path: string;
|
||||
/**
|
||||
* Old-side path of a rename (`rename from` header) — absent otherwise.
|
||||
* The narrowing join keys a rename by BOTH paths: the two captures can
|
||||
* resolve the same move differently, and the new path alone does not say
|
||||
* whether they keyed the same change.
|
||||
*/
|
||||
renameFrom?: string;
|
||||
kind: PathKind;
|
||||
/** Range within the diff FILE, covering header + all hunks. */
|
||||
diffStart: number;
|
||||
|
|
@ -377,6 +384,10 @@ export function parseDiff(diffText: string): {
|
|||
// Lua comments, for instance. Treating those as headers overwrites the
|
||||
// file's path and swallows the line from the add/remove counts.
|
||||
if (!curHunk) {
|
||||
if (line.startsWith('rename from ')) {
|
||||
cur.renameFrom = unquote(line.slice('rename from '.length));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('rename to ')) {
|
||||
// A rename states its new path outright, without an `a/`/`b/` prefix.
|
||||
cur.path = unquote(line.slice('rename to '.length));
|
||||
|
|
|
|||
|
|
@ -101,6 +101,19 @@ describe('resolveMergeBase', () => {
|
|||
expect(git.calls[1]).toBe('refExists refs/remotes/upstream/develop');
|
||||
});
|
||||
|
||||
it('propagates a mergeBase throw — a surface failure is not "none"', () => {
|
||||
// The caller demotes on this propagation: a catch added HERE would fold
|
||||
// a surface failure back into {sha: null} and let the deterministic
|
||||
// reason be stamped over an exit that a re-run might fix.
|
||||
const git = fakeGit({ refs: ['refs/remotes/origin/main'] });
|
||||
git.mergeBase = () => {
|
||||
throw new Error('surface unavailable');
|
||||
};
|
||||
expect(() => resolveMergeBase('origin', 'main', 'pr-head', git)).toThrow(
|
||||
'surface unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('never merge-bases through an origin/<name> shadow tag', () => {
|
||||
// A tag literally named `origin/main` — a pushable, server-controlled
|
||||
// refname a plain clone auto-carries — resolves FIRST for the
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ export interface GitProbe {
|
|||
fetch(remote: string, ref: string): boolean;
|
||||
/** Does this ref resolve locally? */
|
||||
refExists(ref: string): boolean;
|
||||
/** Merge-base of two refs, or null when there is none. */
|
||||
/**
|
||||
* Merge-base of two refs, or null when there is none. An implementation
|
||||
* may THROW when the git surface cannot answer — distinct from answering
|
||||
* "none" — and the throw propagates to the caller.
|
||||
*/
|
||||
mergeBase(a: string, b: string): string | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
1212
packages/cli/src/commands/review/lib/narrow-diff.integration.test.ts
Normal file
1212
packages/cli/src/commands/review/lib/narrow-diff.integration.test.ts
Normal file
File diff suppressed because it is too large
Load diff
182
packages/cli/src/commands/review/lib/narrow-diff.ts
Normal file
182
packages/cli/src/commands/review/lib/narrow-diff.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Narrow a PR's own diff to the part that changed since an anchor.
|
||||
//
|
||||
// This replaces a containment ORACLE. The previous design captured
|
||||
// `anchor..head` separately, published it as the review scope, and then tried
|
||||
// to prove after the fact that every hunk in it also appeared in the PR's own
|
||||
// `base..head` diff — because a comment anchored on a line GitHub's PR diff
|
||||
// does not display answers 422 and takes the whole all-or-nothing Create
|
||||
// Review call with it.
|
||||
//
|
||||
// That proof was a hand-written match over two rendered unified diffs, and its
|
||||
// acceptance surface was unbounded: six review rounds each closed the reported
|
||||
// entrances and the next round found new ones — count-less headers, deletion
|
||||
// junctions, lossy UTF-8 decodes, cross-hunk double-spends, content matched
|
||||
// without position. Every one was the same shape: something the delta carried
|
||||
// that the PR's diff did not display, arriving through a gap in the match.
|
||||
//
|
||||
// So the scope is not checked against the PR's diff any more; it is BUILT from
|
||||
// it. The delta is read only to learn which post-image line ranges changed
|
||||
// since the anchor, and the published text is assembled out of the full
|
||||
// capture's own hunks. Every line the review sees is therefore a line GitHub
|
||||
// displays, by construction rather than by proof, and the whole family of
|
||||
// defects — along with the two refusal reasons that existed to report it —
|
||||
// cannot recur.
|
||||
//
|
||||
// The one judgment left — which of the full capture's hunks the delta's
|
||||
// ranges corroborate — fails closed the same way. A delta hunk no full hunk
|
||||
// corroborates (overlaps its new-side range AND shares a changed line with,
|
||||
// keyed by new-side junction) is a netted-out undo OR a Myers misplacement,
|
||||
// and two alignment-dependent
|
||||
// rendered diffs cannot tell those apart, so its section is emitted whole:
|
||||
// over-inclusion re-reviews lines GitHub displays, while a dropped change
|
||||
// would be certified unreviewed by the ledger.
|
||||
//
|
||||
// The two captures' NEW-side line numbers are comparable because both end at
|
||||
// the same head commit. That is the only cross-capture fact this needs, and it
|
||||
// is the one fact that was never in doubt.
|
||||
|
||||
import { parseDiff } from './diff-plan.js';
|
||||
|
||||
/**
|
||||
* The PR's own hunks that overlap what changed since the anchor.
|
||||
*
|
||||
* `fullBytes` is `base..head` — exactly what GitHub renders. `deltaBytes` is
|
||||
* `anchor..head`, read for its post-image ranges and nothing else: not one of
|
||||
* its bytes reaches the result.
|
||||
*
|
||||
* Returns null when there is nothing to narrow to — the caller keeps the full
|
||||
* range, which is always safe because it is the review the round would have
|
||||
* done anyway. Null covers, deliberately treated alike: a capture on EITHER
|
||||
* side that did not decode, a delta carrying a path the full capture does not
|
||||
* carry at all — the canonical "undo per feedback" round lands here when the
|
||||
* undone file no longer appears in `base..head` — and a rename the full
|
||||
* capture keys differently (git's rename detection resolved differently
|
||||
* across the two ranges, so the change would drop from the scope under the
|
||||
* key mismatch). A delta whose ranges miss the full capture's hunks does NOT
|
||||
* land here: a missed hunk might be a netted-out undo, but it might equally
|
||||
* be a change the two captures position disjointly, so the join fails closed
|
||||
* for it — the section is emitted whole, never dropped.
|
||||
*/
|
||||
export function narrowToDelta(
|
||||
fullBytes: Buffer,
|
||||
deltaBytes: Buffer,
|
||||
): Buffer | null {
|
||||
// Bytes in, bytes out. The selection below runs on decoded text, because
|
||||
// that is what `parseDiff` reads — so a capture that does not survive UTF-8
|
||||
// cannot be reassembled faithfully: re-encoding would write bytes git never
|
||||
// produced and give `diffSha256` a value naming a file nobody captured. A
|
||||
// fatal decode rejects exactly those bytes, without materializing a
|
||||
// re-encoded full-size copy just to compare, and it runs on BOTH captures:
|
||||
// a lossily pre-decoded delta folds an invalid path byte onto U+FFFD, which
|
||||
// can collide with a legitimate U+FFFD path the full capture carries and
|
||||
// select hunks of a file that never changed since the anchor. Such a round
|
||||
// keeps the full range, which is the original bytes untouched.
|
||||
const decode = (bytes: Buffer): string | null => {
|
||||
try {
|
||||
return new TextDecoder('utf-8', {
|
||||
fatal: true,
|
||||
ignoreBOM: true,
|
||||
}).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const fullText = decode(fullBytes);
|
||||
const deltaText = decode(deltaBytes);
|
||||
if (fullText === null || deltaText === null) return null;
|
||||
if (fullText.trim() === '' || deltaText.trim() === '') return null;
|
||||
const full = parseDiff(fullText);
|
||||
const delta = parseDiff(deltaText);
|
||||
if (full.files.length === 0 || delta.files.length === 0) return null;
|
||||
|
||||
/**
|
||||
* Every path the delta touched.
|
||||
*
|
||||
* A set, not ranges. Narrowing is per FILE now, so the only question a path
|
||||
* has to answer is whether the round touched it at all — which also makes a
|
||||
* hunk-less section (a mode change, a pure rename, a binary replacement)
|
||||
* ordinary rather than a special case: it touches the path, so its section
|
||||
* is emitted, exactly like any other.
|
||||
*/
|
||||
const touched = new Set(delta.files.map((f) => f.path));
|
||||
|
||||
// The two captures can key the same change differently whenever git's
|
||||
// rename detection resolves differently across the two ranges —
|
||||
// `base..head` is a two-tree diff with no intermediate tree. Either shape
|
||||
// of divergence is a change the PR's diff displays that would silently drop
|
||||
// from the published scope, so refuse to narrow instead: the round keeps
|
||||
// the full range, which still displays it.
|
||||
//
|
||||
// Shape one: a delta path the full capture does not carry at all.
|
||||
const fullPaths = new Set(full.files.map((f) => f.path));
|
||||
for (const p of touched) {
|
||||
if (!fullPaths.has(p)) return null;
|
||||
}
|
||||
// Shape two: a rename the full capture does not key as the SAME rename.
|
||||
// The path guard cannot see it — the delta keys the rename under the NEW
|
||||
// path, which the full capture also carries (as a plain addition), while
|
||||
// the rename's deletion half sits under the OLD path, keyed only in the
|
||||
// full capture.
|
||||
const fullRenames = new Map<string, string>();
|
||||
for (const f of full.files) {
|
||||
if (f.renameFrom !== undefined) fullRenames.set(f.path, f.renameFrom);
|
||||
}
|
||||
for (const f of delta.files) {
|
||||
if (
|
||||
f.renameFrom !== undefined &&
|
||||
fullRenames.get(f.path) !== f.renameFrom
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 1-based line numbers throughout, matching `parseDiff`'s own coordinates.
|
||||
const lines = fullText.split('\n');
|
||||
|
||||
// Whole SECTIONS, not selected hunks.
|
||||
//
|
||||
// The two captures are independent Myers alignments over overlapping
|
||||
// content, so the hunk a change lands in is not stable between them: a run
|
||||
// of identical lines — blank runs, repeated imports, regenerated tables —
|
||||
// lets the same edit be attributed to the run's front in one capture and
|
||||
// its back in the other. Four rounds of review each closed the reported
|
||||
// position-divergence entrance and the next round found another, because
|
||||
// matching hunks across two alignments is a heuristic over arbitrary
|
||||
// content, exactly like the containment oracle this file replaced. The
|
||||
// failure was worse than the oracle's, too: a dropped hunk left the round
|
||||
// reporting `effective: true`, and the ledger then certified head as the
|
||||
// next anchor, so the change was never reviewed by any round.
|
||||
//
|
||||
// What IS stable is which FILE a change belongs to — file identity, which
|
||||
// the path and rename guards above already fail closed on. So the unit of
|
||||
// narrowing is the file: a section the delta touched is emitted whole, and
|
||||
// a section it did not touch is dropped. Nothing the delta performed can
|
||||
// fall out of a section that is emitted entire, and every emitted line is
|
||||
// still a line the PR's own diff displays.
|
||||
//
|
||||
// The cost is real and bounded: within a touched file the round reviews all
|
||||
// of that file's PR hunks, not only the ones that moved since the anchor.
|
||||
// The saving incremental review exists for is the untouched files — a round
|
||||
// touching 2 of 40 reviews 2 — and that is untouched by this.
|
||||
const selected: Array<[number, number]> = [];
|
||||
for (const file of full.files) {
|
||||
if (!touched.has(file.path)) continue;
|
||||
selected.push([file.diffStart, file.diffEnd]);
|
||||
}
|
||||
|
||||
if (selected.length === 0) return null;
|
||||
// Assemble without spreading the ranges into a single `push`: a section can
|
||||
// exceed the argument-count ceiling (~125k lines), and this path exists for
|
||||
// exactly the large long-lived PRs that carry such sections. Safe to
|
||||
// encode: every line here came from text that decoded cleanly above.
|
||||
const parts = selected.map(([from, to]) =>
|
||||
lines.slice(from - 1, to).join('\n'),
|
||||
);
|
||||
return Buffer.from(parts.join('\n') + '\n', 'utf8');
|
||||
}
|
||||
|
|
@ -129,6 +129,153 @@ describe('pathRulesFor — scoped, or it is noise', () => {
|
|||
// hook from a README, so it declines to guess — a visible decision.
|
||||
['.husky/pre-commit', false],
|
||||
['hooks/prepush', false],
|
||||
// Consumer-facing contract documentation. Governance comes from LOCATION —
|
||||
// a reference section or an SDK package — and never from a keyword in a
|
||||
// filename; every alternative of both the location branches and the
|
||||
// genre exclusion is pinned here, or narrowing one ships green.
|
||||
['docs/developers/qwen-serve-protocol.md', true],
|
||||
['docs/developer/api.md', true],
|
||||
['docs/api/sessions.mdx', true],
|
||||
['docs/reference/routes.md', true],
|
||||
['docs/protocol/frames.md', true],
|
||||
['docs/protocols/session.md', true],
|
||||
['packages/sdk-typescript/README.md', true],
|
||||
['packages/sdk-java/docs/usage.md', true],
|
||||
['sdks/go/README.md', true],
|
||||
['docs/developers/schema.json', false],
|
||||
// A keyword in the name buys nothing outside a governed location: this is
|
||||
// the branch that was withdrawn, and these rows are what keep it withdrawn.
|
||||
['integrations/wire-format.md', false],
|
||||
['integrations/openapi.md', false],
|
||||
['PROTOCOL.md', false],
|
||||
['docs/users/features/live-state-protocol.md', false],
|
||||
['docs/users/features/code-review.md', false],
|
||||
['src/sdkstuff/notes.md', false],
|
||||
// The genre exclusion, in BOTH forms, for every member of its list. The
|
||||
// directory rows sit outside a governed location on purpose only where the
|
||||
// genre is the whole point; the rest are inside one, so the exclusion is
|
||||
// the deciding clause and a deleted member flips the row.
|
||||
['docs/api/design/overview.md', false],
|
||||
['docs/api/designs/overview.md', false],
|
||||
['docs/api/plan/rollout.md', false],
|
||||
['docs/api/plans/rollout.md', false],
|
||||
['docs/api/rfc/0001.md', false],
|
||||
['docs/api/rfcs/0001.md', false],
|
||||
['docs/api/proposal/idea.md', false],
|
||||
['docs/api/proposals/idea.md', false],
|
||||
['docs/api/adr/0003.md', false],
|
||||
['docs/api/adrs/0003.md', false],
|
||||
['docs/api/changelog/2026-08.md', false],
|
||||
['docs/api/changelogs/2026-08.md', false],
|
||||
['docs/developers/changelog/entries.md', false],
|
||||
['docs/api/design.md', false],
|
||||
['docs/api/plan-b.md', false],
|
||||
['docs/api/rfc-0001.md', false],
|
||||
['docs/api/proposal.md', false],
|
||||
['docs/api/adr-0003.md', false],
|
||||
['docs/api/CHANGELOG.md', false],
|
||||
['packages/sdk-typescript/CHANGELOG.md', false],
|
||||
['docs/design/2026-08-18-live-state-protocol.md', false],
|
||||
['docs/plans/rollout-protocol.md', false],
|
||||
['docs/rfcs/0001-protocol.md', false],
|
||||
['proposals/wire-protocol.md', false],
|
||||
['adrs/0001-protocol.md', false],
|
||||
// A stem that merely STARTS with the letters of a genre is not that genre.
|
||||
['docs/api/designer-notes.md', true],
|
||||
['docs/api/planning-board.md', true],
|
||||
// …but a genre as a whole trailing token IS that genre: the exclusion is
|
||||
// separator-anchored, not start-anchored, so a numbered or dated genre
|
||||
// filename inside a governed section stays out.
|
||||
['docs/api/0003-adr.md', false],
|
||||
['docs/api/2026-changelog.md', false],
|
||||
['docs/api/the-plan.md', false],
|
||||
['docs/api/plan_b.md', false],
|
||||
['docs/api/rfc.0001.md', false],
|
||||
['docs/api/designs.md', false],
|
||||
// Per-version history is the same genre as a changelog, and this repository
|
||||
// ships one inside a governed location.
|
||||
['packages/sdk-java/qwencode/RELEASE.md', false],
|
||||
['docs/api/RELEASE-NOTES.md', false],
|
||||
['docs/api/release/2026-08.md', false],
|
||||
// Repository-meta and agent-context files, matched whole — the exact match
|
||||
// is what lets `qwen` exclude an agent-context file without touching
|
||||
// `qwen-serve-protocol.md` two rows up.
|
||||
['docs/developers/contributing.md', false],
|
||||
['docs/developers/roadmap.md', false],
|
||||
['packages/sdk-java/client/QWEN.md', false],
|
||||
['docs/api/AGENTS.md', false],
|
||||
// Accepted residue, pinned so the decision is visible rather than assumed:
|
||||
// non-contract prose inside a reference tree that no closed set describes.
|
||||
['docs/developers/architecture.md', true],
|
||||
// Both section names are plural-tolerant, like every genre member.
|
||||
['docs/references/routes.md', true],
|
||||
['docs/apis/routes.md', true],
|
||||
// The markdown extension family, all spellings — governance is by location,
|
||||
// so which spelling the file uses cannot decide it.
|
||||
['docs/api/routes.markdown', true],
|
||||
['docs/developers/protocol.mdown', true],
|
||||
['docs/api/ROUTES.MD', true],
|
||||
['docs/API/routes.md', true],
|
||||
['docs/api/Design/overview.md', false],
|
||||
// A reference section nested inside a package is still a reference section.
|
||||
['packages/foo/docs/api/routes.md', true],
|
||||
// The SDK branch is anchored to a package root. Digits and multiple
|
||||
// hyphenated suffixes are part of real package names; an `sdk*` segment
|
||||
// deeper in the tree is the tree-wide entrance the keyword branch was
|
||||
// withdrawn for.
|
||||
['packages/sdk-core-v2/README.md', true],
|
||||
['packages/sdk-v2/README.md', true],
|
||||
['lib/sdk-go/README.md', true],
|
||||
['libs/sdk-go/README.md', true],
|
||||
['docs/users/features/sdk-notes/overview.md', false],
|
||||
['docs/users/sdk/quickstart.md', false],
|
||||
['examples/sdk/README.md', false],
|
||||
['test/fixtures/sdk-python/README.md', false],
|
||||
['vendor/sdk-go/notes.md', false],
|
||||
// R3: the members and boundaries a narrowing or a widening would flip.
|
||||
// Genre plurals, both the directory and the stem form.
|
||||
['docs/api/releases/2026-08.md', false],
|
||||
['docs/api/releases-notes.md', false],
|
||||
// Every remaining member of the closed meta/agent-context set.
|
||||
['docs/api/LICENSE.md', false],
|
||||
['docs/api/LICENCE.md', false],
|
||||
['docs/developers/CODE_OF_CONDUCT.md', false],
|
||||
['docs/api/code-of-conduct.md', false],
|
||||
['packages/sdk-typescript/SUPPORT.md', false],
|
||||
['docs/api/AUTHORS.md', false],
|
||||
['docs/api/GOVERNANCE.md', false],
|
||||
['docs/developers/CLAUDE.md', false],
|
||||
// The section TERMINATOR: both location branches match a directory, so a
|
||||
// dropped `/` would revive governance-by-filename — the withdrawn branch.
|
||||
['docs/api.md', false],
|
||||
['docs/protocol.md', false],
|
||||
['docs/developers.md', false],
|
||||
['sdk-notes.md', false],
|
||||
['packages/sdk-go.md', false],
|
||||
// Case-insensitivity of the SDK branch, and the space member of the stem
|
||||
// separator class — both flip nothing without a row of their own.
|
||||
['SDK-GO/README.md', true],
|
||||
['docs/api/release notes.md', false],
|
||||
// Trees whose prose the diff's author does not own, even when they carry a
|
||||
// whole reference section.
|
||||
['vendor/some-lib/docs/api/reference.md', false],
|
||||
['third_party/docs/protocols/frames.md', false],
|
||||
['test/fixtures/docs/reference/sample.md', false],
|
||||
['node_modules/pkg/docs/api/x.md', false],
|
||||
// The members, boundary anchors and /i flag the rows above leave unpinned:
|
||||
// each survives a one-edit mutation of the closed set without a row.
|
||||
['vendors/lib/docs/api/x.md', false],
|
||||
['third-party/lib/docs/protocols/frames.md', false],
|
||||
['src/__fixtures__/docs/api/x.md', false],
|
||||
['Vendor/some-lib/docs/api/reference.md', false],
|
||||
['Third_Party/lib/docs/api/x.md', false],
|
||||
// The separator-less spelling is a conventional vendor directory name in
|
||||
// its own right, so the separator is optional.
|
||||
['thirdparty/some-lib/docs/api/reference.md', false],
|
||||
// A segment that merely contains or adjoins a member is not the member:
|
||||
// dropping either boundary anchor flips nothing without these rows.
|
||||
['docs/api/myvendor/x.md', true],
|
||||
['docs/api/vendor-notes.md', true],
|
||||
])('%s → governed by a rule: %s', (path, governed) => {
|
||||
expect(PATH_RULES.some((r) => r.matches(path))).toBe(governed);
|
||||
});
|
||||
|
|
@ -430,6 +577,53 @@ describe('pathRulesFor — matcher cost stays linear on attacker-shaped paths',
|
|||
const path = `.github/workflows/${'.github/workflows/'.repeat(16_000)}x`;
|
||||
expect(msOf(() => pathRulesFor([path]))).toBeLessThan(BOUND_MS);
|
||||
}, 60_000);
|
||||
|
||||
// Every arm above ends without a documentation extension, so the contract-doc
|
||||
// rule short-circuits on all of them and its own bound went unpinned — which
|
||||
// is how a quadratic keyword matcher shipped: `[^/]*(protocol)[^/]*\.mdx?$`
|
||||
// paid a failing tail scan once per keyword occurrence, 403 ms on a 96 kB
|
||||
// path git accepts, synchronously inside every agent-brief build. These arms
|
||||
// end in `.md` so the gate lets them through to the matcher underneath.
|
||||
|
||||
// A timing arm only measures what it makes FAIL. An input that matches its
|
||||
// target regex at the first anchor never reaches the scan the arm is named
|
||||
// for: two language-identical quadratic mutants — one in the genre exclusion,
|
||||
// one in the location branch — shipped with the whole suite green because
|
||||
// both arms below used to match at anchor 0. Every arm here is a NEAR MISS:
|
||||
// it matches the regex's prefix at every anchor and fails on the last
|
||||
// character, so the full failing scan is what gets timed.
|
||||
|
||||
it('the contract-doc rule pays no per-keyword cost on a repeated-keyword path', () => {
|
||||
// The withdrawn branch's exact hostile shape. Governance is location-only
|
||||
// now, so this input is silent — and it must be silent CHEAPLY, or a future
|
||||
// keyword branch reintroduces the blowup with every other timing test green.
|
||||
const path = `${'protocol'.repeat(12_000)}/notes.md`;
|
||||
expect(msOf(() => pathRulesFor([path]))).toBeLessThan(BOUND_MS);
|
||||
}, 60_000);
|
||||
|
||||
it('the location branch pays no per-anchor cost on a near-miss section', () => {
|
||||
// `docs/apix/` matches `docs/api` at every anchor and then fails on `x`, so
|
||||
// the branch pays its whole failing scan once per anchor. The earlier form
|
||||
// (`docs/api/` repeated) matched immediately and measured nothing.
|
||||
const path = `docs/apix/${'docs/apix/'.repeat(40_000)}notes.md`;
|
||||
expect(msOf(() => pathRulesFor([path]))).toBeLessThan(BOUND_MS);
|
||||
}, 60_000);
|
||||
|
||||
it("the contract-doc rule's SDK arm pays no nested-quantifier cost", () => {
|
||||
// `sdks?(-[a-z0-9]+)*` is the one nested quantifier in the matcher. Its
|
||||
// iterations are separated by `-`, which the inner class cannot match, so
|
||||
// the split points are forced — pin that, because widening the inner class
|
||||
// to include `-` would make it catastrophic and nothing else would say so.
|
||||
const path = `sdk${'-a'.repeat(48_000)}x.md`;
|
||||
expect(msOf(() => pathRulesFor([path]))).toBeLessThan(BOUND_MS);
|
||||
}, 60_000);
|
||||
|
||||
it('the genre exclusion pays no per-anchor cost on a near-miss genre', () => {
|
||||
// `design-x/` matches `design` at every anchor and fails on `-`, which is
|
||||
// the failing scan; `design/` repeated matched at once and measured nothing.
|
||||
const path = `${'design-x/'.repeat(40_000)}notes.md`;
|
||||
expect(msOf(() => pathRulesFor([path]))).toBeLessThan(BOUND_MS);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('pathRulesFor — the Java/JVM rule', () => {
|
||||
|
|
@ -735,3 +929,178 @@ describe('pathRulesFor — the Java/JVM rule', () => {
|
|||
expect(out).toContain('no base side');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathRulesFor — the consumer-facing contract documentation rule', () => {
|
||||
it('attaches when a protocol reference changes, and names only that file', () => {
|
||||
const out = pathRulesFor([
|
||||
'docs/developers/qwen-serve-protocol.md',
|
||||
'src/pay.ts',
|
||||
]);
|
||||
expect(out).toContain('Consumer-facing contract documentation');
|
||||
expect(out).toContain('docs/developers/qwen-serve-protocol.md');
|
||||
expect(out).not.toContain('src/pay.ts');
|
||||
});
|
||||
|
||||
it('stays silent on the documentation genres that are not contracts', () => {
|
||||
// The whole cost control. /review runs on repositories whose maintainers did
|
||||
// not ask for a documentation lens, and a rule that fires on every docs PR is
|
||||
// a rule that gets skimmed. A design doc describes behaviour the tree does
|
||||
// not have yet — that is the genre, not a defect — and a user guide belongs
|
||||
// to the sibling-parity lens, not to this one.
|
||||
for (const quiet of [
|
||||
// Inside a governed section, so ONLY the genre exclusion keeps them out —
|
||||
// without these the test passed by location alone and stayed green with
|
||||
// the whole exclusion deleted.
|
||||
'docs/api/design/overview.md',
|
||||
'docs/api/CHANGELOG.md',
|
||||
'docs/api/rfc-0001.md',
|
||||
// Outside one, silent for the simpler reason.
|
||||
'docs/design/2026-08-18-live-state-protocol.md',
|
||||
'docs/plans/rollout-protocol.md',
|
||||
'docs/rfcs/0001-protocol.md',
|
||||
'docs/users/features/code-review.md',
|
||||
'CHANGELOG.md',
|
||||
]) {
|
||||
expect(pathRulesFor([quiet])).not.toContain(
|
||||
'Consumer-facing contract documentation',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('takes governance from location, never from a keyword in the name', () => {
|
||||
// The withdrawn branch, and why. A keyword-in-filename test fires anywhere in
|
||||
// the tree: it put a user guide, a changelog entry and a flat-layout RFC under
|
||||
// a checklist whose blockers are graded Critical, and closing that entrance by
|
||||
// entrance did not converge — every audit round found another form. Location
|
||||
// is the signal that enumerates: two shapes, and everything else is silent.
|
||||
const governed = 'Consumer-facing contract documentation';
|
||||
// Same filename. Inside a reference section, governed; outside one, silent.
|
||||
expect(pathRulesFor(['docs/reference/wire-protocol.md'])).toContain(
|
||||
governed,
|
||||
);
|
||||
expect(pathRulesFor(['integrations/wire-protocol.md'])).not.toContain(
|
||||
governed,
|
||||
);
|
||||
// And the recall this costs, stated as a test rather than left to be
|
||||
// rediscovered: a wire reference outside a docs or SDK tree is NOT governed.
|
||||
expect(pathRulesFor(['PROTOCOL.md'])).toBe('');
|
||||
expect(pathRulesFor(['spec/wire.md'])).toBe('');
|
||||
});
|
||||
|
||||
it('applies the genre exclusion in both its directory and filename forms', () => {
|
||||
// The two forms come from one shared list so they cannot drift; before that
|
||||
// they had, and a changelog DIRECTORY under a governed section and a
|
||||
// flat-layout `rfc-0001.md` both reached the checklist. Every member is
|
||||
// pinned in the matcher table; this pins that both spellings are enforced.
|
||||
const governed = 'Consumer-facing contract documentation';
|
||||
expect(pathRulesFor(['docs/api/changelog/2026-08.md'])).not.toContain(
|
||||
governed,
|
||||
);
|
||||
expect(pathRulesFor(['docs/api/rfc-0001.md'])).not.toContain(governed);
|
||||
// The control that makes those two meaningful: the same governed section,
|
||||
// with a name that is not a genre, is still governed.
|
||||
expect(pathRulesFor(['docs/api/sessions.md'])).toContain(governed);
|
||||
});
|
||||
|
||||
it('stacks with the code rules when a diff touches both', () => {
|
||||
const out = pathRulesFor([
|
||||
'.github/workflows/ci.yml',
|
||||
'docs/developers/protocol.md',
|
||||
]);
|
||||
expect(out).toContain('GitHub Actions workflows');
|
||||
expect(out).toContain('Consumer-facing contract documentation');
|
||||
});
|
||||
|
||||
it('asks the question no other lens asks: is the prose TRUE', () => {
|
||||
// Every dimension reads the code and asks whether the code is right; the
|
||||
// documentation-parity item asks whether a doc EXISTS. Nobody checks the
|
||||
// document the PR ships against the behaviour the PR ships — and for a wire
|
||||
// contract that document is what an integrator builds on.
|
||||
const out = pathRulesFor(['docs/developers/protocol.md']);
|
||||
expect(out).toContain('cannot read your code');
|
||||
expect(out).toMatch(/whether the code makes it true/);
|
||||
// A paragraph is not one claim: rule on the smallest falsifiable statements.
|
||||
expect(out).toContain('split, then rule');
|
||||
// And resolve each against the code, never against the document itself.
|
||||
expect(out).toMatch(/Never resolve a doc claim by re-reading the doc/);
|
||||
});
|
||||
|
||||
it('requires a positive control before a documented negative is confirmed', () => {
|
||||
// Reference prose is mostly negatives — "X never advances it", "the field is
|
||||
// absent before Y". A negative is only as good as an instrument that would
|
||||
// have seen the positive, the same control the mutation harness owes its
|
||||
// survivors. Without it, "the invariant holds" and "my probe never looked"
|
||||
// are the same observation.
|
||||
const out = pathRulesFor(['docs/developers/protocol.md']);
|
||||
expect(out).toContain('A negative claim needs a positive control');
|
||||
expect(out).toMatch(/my probe never looked/);
|
||||
});
|
||||
|
||||
it('names the three blocker shapes, including the one a diff-scoped read cannot see', () => {
|
||||
const out = pathRulesFor(['docs/developers/protocol.md']);
|
||||
expect(out).toContain('A statement the code cannot satisfy');
|
||||
// The important one: the wrong lines are the ones the diff did NOT touch.
|
||||
expect(out).toContain('Prose this change silently falsified');
|
||||
expect(out).toContain(
|
||||
'the wrong lines are the ones the diff did **not** touch',
|
||||
);
|
||||
expect(out).toContain('A guarantee wider than the code');
|
||||
});
|
||||
|
||||
it('refuses to become a copy edit', () => {
|
||||
// The failure mode that would make this rule net-negative: an agent handed a
|
||||
// prose document files comma findings, and the author stops reading the whole
|
||||
// review. Pin every exclusion that keeps it off that path.
|
||||
const out = pathRulesFor(['docs/developers/protocol.md']);
|
||||
expect(out).toContain('Not a finding, ever');
|
||||
expect(out).toMatch(/Wording, tone, grammar/);
|
||||
expect(out).toContain('not a copy edit');
|
||||
// Silence belongs to the sibling-parity lens; this rule owns wrongness only.
|
||||
expect(out).toContain('not statements that are missing');
|
||||
// Roadmap text the document itself marks as such is exempt.
|
||||
expect(out).toContain('not yet implemented');
|
||||
// And the last two bullets, which had no pin: unfalsifiable adjectives, and
|
||||
// a claim about a system this repository does not contain.
|
||||
expect(out).toContain('are not falsifiable and are not defects');
|
||||
expect(out).toContain('outside this repository');
|
||||
});
|
||||
|
||||
it('keeps the scoping and precision discipline of the other rules', () => {
|
||||
const out = pathRulesFor(['docs/developers/protocol.md']);
|
||||
expect(out).toContain('reviewing this diff, not auditing this file');
|
||||
expect(out).toContain('Favour precision over recall');
|
||||
// And says which side of a mismatch is usually wrong — a finding that only
|
||||
// reports "these two disagree" is not actionable.
|
||||
expect(out).toContain('is right and the sentence is what has to change');
|
||||
});
|
||||
|
||||
it('strips the extension before matching a whole-stem meta filename', () => {
|
||||
// `NON_CONTRACT_FILE` matches a stem exactly, which is what lets `qwen`
|
||||
// exclude an agent-context file without touching `qwen-serve-protocol`.
|
||||
// Exact matching only works on a stripped stem, so the strip decides these
|
||||
// — a review found the clause decision-dead before this exclusion existed,
|
||||
// and it is load-bearing now, so it gets a pin rather than a comment.
|
||||
const governed = 'Consumer-facing contract documentation';
|
||||
expect(pathRulesFor(['docs/api/CONTRIBUTING.md'])).not.toContain(governed);
|
||||
expect(pathRulesFor(['packages/sdk-java/client/QWEN.md'])).not.toContain(
|
||||
governed,
|
||||
);
|
||||
// The control that makes the exactness meaningful: a longer name that
|
||||
// merely begins with a meta word is still governed.
|
||||
expect(pathRulesFor(['docs/developers/qwen-serve-protocol.md'])).toContain(
|
||||
governed,
|
||||
);
|
||||
expect(pathRulesFor(['docs/api/supported-frames.md'])).toContain(governed);
|
||||
});
|
||||
|
||||
it('caps the path list like every other rule', () => {
|
||||
const many = Array.from(
|
||||
{ length: 13 },
|
||||
(_, i) => `docs/developers/route-${i}.md`,
|
||||
);
|
||||
const out = pathRulesFor(many);
|
||||
expect(out).toContain('…and 3 more');
|
||||
expect(out).toContain('docs/developers/route-9.md');
|
||||
expect(out).not.toContain('docs/developers/route-10.md');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -187,8 +187,140 @@ A finding that a method "can no longer be inlined" needs the **dynamic** tier
|
|||
**Favour precision over recall here.** A guessed JVM finding is the easiest kind for an author to dismiss, and one dismissal teaches them to skip the rest of the review. Every finding needs the concrete hot path and the concrete cost, like any other. Performance findings are **Suggestions** — slow is a cost, not incorrect behaviour — **except where the cost is itself the wrongness**: unbounded allocation, quadratic work, or unbounded cache growth on attacker-reachable input is a denial-of-service hole, which the severity ladder grades Critical, not a Suggestion. Name the reachable input that triggers it; "this loop is slow" with no attacker-reachable trigger stays a Suggestion.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Genres that describe intent or history rather than a contract. One list, used
|
||||
* for both the directory form (`docs/rfcs/x.md`) and the filename form
|
||||
* (`docs/api/rfc-0001.md`), so the two cannot drift apart — a review found the
|
||||
* exclusion enforced for the directory form of proposals and the filename form
|
||||
* of changelogs and for neither of the other two, which let a changelog
|
||||
* directory and a flat-layout RFC through.
|
||||
*
|
||||
* `releases?` is here for the same reason `changelogs?` is: per-version history,
|
||||
* not a contract. This repository ships one (`packages/sdk-java/qwencode/RELEASE.md`)
|
||||
* inside a governed location, so the member has a live trigger, not a
|
||||
* hypothetical one.
|
||||
*/
|
||||
const NON_CONTRACT_GENRE =
|
||||
'designs?|plans?|rfcs?|proposals?|adrs?|changelogs?|releases?';
|
||||
const NON_CONTRACT_DIR = new RegExp(`(?:^|/)(?:${NON_CONTRACT_GENRE})/`, 'i');
|
||||
/**
|
||||
* Separator-anchored rather than start-anchored: a genre is a whole token
|
||||
* wherever it sits, so `0003-adr` and `2026-changelog` are the genre their name
|
||||
* says they are, while `designer-notes` and `planning-board` — where the genre
|
||||
* letters merely begin a longer word — are not.
|
||||
*/
|
||||
const NON_CONTRACT_STEM = new RegExp(
|
||||
`(?:^|[-_. ])(?:${NON_CONTRACT_GENRE})(?:[-_. ]|$)`,
|
||||
'i',
|
||||
);
|
||||
/**
|
||||
* Repository-meta and agent-context documents, matched whole. Unlike a genre
|
||||
* this is a CLOSED set of conventional filenames — the reason it can be
|
||||
* enumerated at all, and the reason it is exact-match: `qwen` excludes an
|
||||
* agent-context file without touching `qwen-serve-protocol`.
|
||||
*/
|
||||
const NON_CONTRACT_FILE =
|
||||
/^(?:contributing|roadmap|code[-_]of[-_]conduct|license|licence|authors|governance|support|qwen|agents|claude)$/i;
|
||||
/**
|
||||
* Trees whose prose the author of a diff does not own. A vendored library or a
|
||||
* doc-shaped fixture can carry a whole reference section, and the location
|
||||
* branches would govern it — findings against third-party prose are noise the
|
||||
* author cannot act on. Another closed, conventional set, for the same reason
|
||||
* the meta filenames above are one.
|
||||
*/
|
||||
const NOT_OURS =
|
||||
/(?:^|\/)(?:vendor|vendors|third[-_]?party|node_modules|fixtures|__fixtures__)\//i;
|
||||
|
||||
const CONTRACT_DOCS: PathRule = {
|
||||
title: 'Consumer-facing contract documentation',
|
||||
// Governance comes from WHERE the document lives, never from a keyword in its
|
||||
// name. A keyword branch was tried and withdrawn: `protocol` in a filename
|
||||
// fires anywhere in the tree, which put a user guide, a changelog entry and a
|
||||
// flat-layout RFC under a checklist whose blockers are graded Critical, and no
|
||||
// amount of adding exclusions closed it — each audit round found another form,
|
||||
// which is the signal that the entrance space does not enumerate. It also
|
||||
// backtracked quadratically: `[^/]*(keyword)[^/]*\.mdx?$` paid a failing tail
|
||||
// scan once per keyword occurrence, 403 ms on a 96 kB path that git accepts,
|
||||
// synchronously inside every agent-brief build — the shape the JAVA checklist
|
||||
// in this same file grades a denial-of-service hole.
|
||||
//
|
||||
// The cost of location-only is real and deliberate: a repository that keeps
|
||||
// its wire reference outside a documentation or SDK tree (`PROTOCOL.md` at the
|
||||
// root, `spec/wire.md`) is not governed. That is the precision side of the
|
||||
// trade this file already takes everywhere else, and a project that wants the
|
||||
// checklist anyway has `.qwen/review-rules.md`.
|
||||
//
|
||||
// The reverse cost — non-contract prose inside a governed section — is
|
||||
// answered only where it can be answered by a closed set (the genres above,
|
||||
// the meta filenames above). Beyond that it is accepted rather than
|
||||
// enumerated: measured on this repository, 44 of the 55 tracked documents
|
||||
// under `docs/developers/` are integrator references (the daemon and tool
|
||||
// references, the SDK guides, the wire protocol), and deciding document KIND
|
||||
// inside a reference tree is the same non-converging shape that took the
|
||||
// keyword branch out. An agent handed a contributing guide finds nothing —
|
||||
// the checklist is diff-scoped and its exclusions are explicit — so the
|
||||
// residue is tokens, not false findings.
|
||||
matches: (p) => {
|
||||
if (!/\.(?:mdx?|markdown|mdown)$/i.test(p)) return false;
|
||||
if (NOT_OURS.test(p)) return false;
|
||||
if (NON_CONTRACT_DIR.test(p)) return false;
|
||||
// Extension-stripped: `NON_CONTRACT_FILE` matches a whole stem, so the
|
||||
// strip is what decides `docs/api/CONTRIBUTING.md`.
|
||||
const stem = p.slice(p.lastIndexOf('/') + 1).replace(/\.[a-z]+$/i, '');
|
||||
if (NON_CONTRACT_FILE.test(stem)) return false;
|
||||
if (NON_CONTRACT_STEM.test(stem)) return false;
|
||||
return (
|
||||
// Any depth on purpose: a package-local reference section
|
||||
// (`packages/foo/docs/api/`) is an ordinary monorepo shape. What that
|
||||
// admits and should not — a vendored or fixture tree carrying its own
|
||||
// docs section — is answered by `NOT_OURS` above rather than by anchoring
|
||||
// this branch, which would drop the package-local case with it.
|
||||
/(?:^|\/)docs\/(?:developers?|apis?|references?|protocols?)\//i.test(p) ||
|
||||
// Anchored to a package root: an `sdk*` segment at any depth also matched
|
||||
// `docs/users/sdk/quickstart.md` and `examples/sdk/README.md`, which is
|
||||
// the tree-wide entrance the keyword branch was withdrawn for.
|
||||
/^(?:packages\/|libs?\/)?sdks?(?:-[a-z0-9]+)*\//i.test(p)
|
||||
);
|
||||
},
|
||||
checklist: `The reader of this document cannot read your code. It is a wire protocol, an API reference or an SDK guide — someone writes a client against it, ships that client, and never sees the implementation that was supposed to back the sentence they built on. Every other lens in this review reads the code and asks whether it is right. This one reads the **prose the diff added** and asks whether the code makes it true.
|
||||
|
||||
That prose is also the only place in the PR where the contract appears in falsifiable form. Code comments state intent and tests state examples; a reference document states the general rule — "advances exactly once", "is written before the event is published", "is absent until the first such terminal", "is never earlier than the creation time". Each of those has a truth value, the author already wrote it, and nothing else in this review is looking at it. In a live verification of one protocol-doc change, a single changed hunk carried **twelve** independently falsifiable assertions.
|
||||
|
||||
**You are reviewing this diff, not auditing this file.** A reference document is long and some of it has been wrong for years. In scope: prose this diff **adds or changes**, and prose this diff **leaves standing that its own code change falsifies**. Out of scope: everything else on the page.
|
||||
|
||||
**Method — split, then rule.** A paragraph is not one claim. Break the changed prose into the smallest statements that can independently be true or false and rule on each separately; a paragraph judged as a unit gets the verdict of its most plausible sentence. Resolve each statement the way this review resolves any failure scenario: trace it to the code path that implements it, or — when it is runnable and reading has not settled it — **run it**. Never resolve a doc claim by re-reading the doc, and never by quoting the same sentence back as its own evidence.
|
||||
|
||||
**A negative claim needs a positive control.** Reference prose is full of negatives — "X never advances it", "this does not invalidate the cache", "the field is absent before the first Y". A negative is only as good as an instrument that would have seen the positive, so before reporting that a documented "never" holds, show the same probe observing the case where it **does** happen. Without that, "the documented invariant holds" and "my probe never looked" are the same observation.
|
||||
|
||||
**Blockers (Critical):**
|
||||
|
||||
- **A statement the code cannot satisfy.** The document says a field is always present and a live path returns without setting it; it says a value is monotonic and a path assigns it from a source that can go backwards; it says a request is idempotent and the second call is not. An integrator builds on that sentence and their client is wrong in a way neither their tests nor yours will show, because both sides believe the document. Report the statement verbatim, the code path that falsifies it, and the observation that settles it.
|
||||
- **Prose this change silently falsified.** The diff changes an externally-observable behaviour — a status code, a field's presence or type, an ordering, an error shape, a default — and the paragraph describing the old behaviour is still on the page, unchanged, now wrong. This is the failure a diff-scoped read misses by construction: the wrong lines are the ones the diff did **not** touch. Take each externally-observable behaviour the diff changes and grep the reference docs for the sentence that describes it.
|
||||
- **A guarantee wider than the code's.** The document generalizes ("always", "never", "any", "every") where the implementation covers one case, or promises a property — ordering, exactly-once, durability, atomicity — the code provides only on the happy path. Name the input or the state that leaves the guarantee.
|
||||
|
||||
**Recommendations (Suggestion):**
|
||||
|
||||
- **A documented behaviour nothing tests.** The statement is true today and no test pins it, so the next refactor is free to break the contract without turning anything red. Name the statement and the test that should exist — one Suggestion for the set, never one per sentence.
|
||||
- **A contract carried only in prose that the type or schema could carry.** An optional field the document says is "absent before X" while the published type says merely optional; an enumerated set of values the document lists and the schema leaves as a bare string. The document is doing work the machine-readable artifact could do, and only one of the two is checked by anybody's build.
|
||||
|
||||
**Not a finding, ever:**
|
||||
|
||||
- Wording, tone, grammar, heading level, ordering, or formatting of the prose. This is not a copy edit, and a review that opens with a comma is a review the author stops reading.
|
||||
- A document that is merely **silent** about something the diff adds. Silence is the sibling-parity question and another lens already owns it; this rule is about statements that are **wrong**, not statements that are missing.
|
||||
- Aspirational or roadmap text the document itself marks as such ("planned", "not yet implemented", "experimental"), and any prose describing a deliberately unimplemented surface.
|
||||
- Imprecision no reasonable integrator could act on wrongly. "Fast", "small", "recent" are not falsifiable and are not defects.
|
||||
- A claim about behaviour outside this repository — another service's response, a platform's guarantee — unless the diff itself is what asserts it.
|
||||
|
||||
**Favour precision over recall here.** A documentation finding is the cheapest kind for an author to dismiss, and the second dismissal teaches them to skim the rest of the review. Every finding names the exact sentence, the exact code path or observation that contradicts it, and what the sentence should say instead — a finding that reports a mismatch without saying which side is wrong is not actionable, and on this rule the answer is often that the **code** is right and the sentence is what has to change.`,
|
||||
};
|
||||
|
||||
/** Every rule, in the order their checklists are appended. */
|
||||
export const PATH_RULES: PathRule[] = [GITHUB_ACTIONS, SHELL_LANES, JAVA];
|
||||
export const PATH_RULES: PathRule[] = [
|
||||
GITHUB_ACTIONS,
|
||||
SHELL_LANES,
|
||||
JAVA,
|
||||
CONTRACT_DOCS,
|
||||
];
|
||||
|
||||
function isOutOfScope(p: string): boolean {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
// Mock execFileSync before aone-client.ts is loaded — same shape as
|
||||
// gh.test.ts: vi.mock is hoisted above all imports.
|
||||
const mockExecFileSync = vi.hoisted(() => vi.fn());
|
||||
vi.mock('node:child_process', () => ({
|
||||
default: { execFileSync: mockExecFileSync },
|
||||
execFileSync: mockExecFileSync,
|
||||
}));
|
||||
|
||||
import { a1, a1JsonOnce, a1Once } from './aone-client.js';
|
||||
|
||||
function transientError(): Error {
|
||||
// The message shape execFileSync produces, carrying a transient marker
|
||||
// the retry policy recognises.
|
||||
return new Error(
|
||||
'Command failed: a1 repo mr comment create\nHTTP 502 Bad Gateway\n',
|
||||
);
|
||||
}
|
||||
|
||||
describe('aone-client write discipline', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('a1Once NEVER retries — a transient failure after an accepted write must not double-post', () => {
|
||||
// The read path retries this exact error class; a write must surface
|
||||
// the first failure instead, or a retry behind a swallowed 502 posts
|
||||
// the same comment twice.
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw transientError();
|
||||
});
|
||||
expect(() =>
|
||||
a1Once('repo', 'mr', 'comment', 'create', '--mr', '7'),
|
||||
).toThrow();
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a1JsonOnce parses the write result and appends --format json', () => {
|
||||
mockExecFileSync.mockReturnValue('{"id": 42}\n');
|
||||
const out = a1JsonOnce<{ id: number }>(
|
||||
'repo',
|
||||
'mr',
|
||||
'comment',
|
||||
'create',
|
||||
'--mr',
|
||||
'7',
|
||||
);
|
||||
expect(out).toEqual({ id: 42 });
|
||||
// Pin the FULL argv — the caller args AND the appended --format tail.
|
||||
// A botched rest-parameter spread would exec `a1` with no
|
||||
// --mr/--message and die only at the irreversible write itself; no
|
||||
// other test observes this passthrough (aone.test.ts mocks the module
|
||||
// wholesale).
|
||||
const args = mockExecFileSync.mock.calls[0][1] as string[];
|
||||
expect(args).toEqual([
|
||||
'repo',
|
||||
'mr',
|
||||
'comment',
|
||||
'create',
|
||||
'--mr',
|
||||
'7',
|
||||
'--format',
|
||||
'json',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a1JsonOnce returns undefined (not a throw) when an ACCEPTED write answers unparseably', () => {
|
||||
// The exec SUCCEEDED, so the write is accepted. A result that fails to
|
||||
// parse is a platform anomaly, not a failed post — throwing would let a
|
||||
// caller count the accepted comment as unposted and re-run it into a
|
||||
// duplicate. undefined = "landed, result unreadable".
|
||||
mockExecFileSync.mockReturnValue('this is not json\n');
|
||||
const out = a1JsonOnce<{ id: number }>(
|
||||
'repo',
|
||||
'mr',
|
||||
'comment',
|
||||
'create',
|
||||
'--mr',
|
||||
'7',
|
||||
);
|
||||
expect(out).toBeUndefined();
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a1JsonOnce still PROPAGATES an exec failure (the write genuinely failed)', () => {
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error('Command failed: a1 repo mr comment create\nboom\n');
|
||||
});
|
||||
expect(() =>
|
||||
a1JsonOnce('repo', 'mr', 'comment', 'create', '--mr', '7'),
|
||||
).toThrow();
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a1JsonOnce NEVER retries a TRANSIENT error either — the comment-write invariant', () => {
|
||||
// a1JsonOnce is the helper every comment write rides (createMrComment).
|
||||
// The "a write is never retried" invariant must hold for IT, not only
|
||||
// for a1Once: routing it through the retrying path would survive every
|
||||
// other test while double-posting a finding after a 502 that arrived
|
||||
// once the server had accepted the create.
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error(
|
||||
'Command failed: a1 repo mr comment create\nHTTP 502 Bad Gateway\n',
|
||||
);
|
||||
});
|
||||
expect(() =>
|
||||
a1JsonOnce('repo', 'mr', 'comment', 'create', '--mr', '7'),
|
||||
).toThrow();
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a1 (the read path) surfaces a NON-transient error at once', () => {
|
||||
// Only the transient class retries; anything else must not pay the
|
||||
// delay (and this exercises the shared exec path without its sleep).
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error('Command failed: a1 repo mr view 7\nnot found\n');
|
||||
});
|
||||
expect(() => a1('repo', 'mr', 'view', '7')).toThrow();
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a1 (the read path) transient-error retry — the POSITIVE side', () => {
|
||||
// Without a succeed-after-retry test, deleting the retry entirely
|
||||
// (execA1(args, false), or dropping the `retry &&` conjunct) leaves the
|
||||
// suite green — silently stripping the read path's 502/reset absorption.
|
||||
// Mirrors the four-test transient block in gh.test.ts, Atomics.wait
|
||||
// spied so the delay is skipped.
|
||||
let atomsWaitSpy: MockInstance<typeof Atomics.wait>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
atomsWaitSpy = vi.spyOn(Atomics, 'wait').mockReturnValue('ok');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
atomsWaitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('retries a transient HTTP 502 and succeeds on the second attempt', () => {
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
mockExecFileSync
|
||||
.mockImplementationOnce(() => {
|
||||
throw transientError();
|
||||
})
|
||||
.mockReturnValueOnce('{"ok":true}\n');
|
||||
|
||||
const result = a1('repo', 'mr', 'view', '7');
|
||||
expect(result).toBe('{"ok":true}');
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(2);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('retrying in 3000ms'),
|
||||
);
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('exhausts MAX_RETRIES on a persistent transient error, then throws', () => {
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw transientError();
|
||||
});
|
||||
expect(() => a1('repo', 'mr', 'view', '7')).toThrow();
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
||||
});
|
||||
});
|
||||
|
|
@ -31,7 +31,7 @@ function sleepSync(ms: number): void {
|
|||
Atomics.wait(new Int32Array(sab), 0, 0, ms);
|
||||
}
|
||||
|
||||
function execA1WithRetry(args: string[]): string {
|
||||
function execA1(args: string[], retry: boolean): string {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return execFileSync(A1_BINARY, args, {
|
||||
|
|
@ -56,7 +56,11 @@ function execA1WithRetry(args: string[]): string {
|
|||
e.stderr?.toString() ?? '',
|
||||
].join('\n'),
|
||||
);
|
||||
if (attempt < MAX_RETRIES && TRANSIENT_RE.test(rebuilt.message)) {
|
||||
if (
|
||||
retry &&
|
||||
attempt < MAX_RETRIES &&
|
||||
TRANSIENT_RE.test(rebuilt.message)
|
||||
) {
|
||||
const delay = BASE_DELAY_MS * (attempt + 1);
|
||||
// The sibling gh.ts prints one trace line per retry; a silent 3–9 s
|
||||
// blocking sleep reads as a hang in CI logs.
|
||||
|
|
@ -71,9 +75,18 @@ function execA1WithRetry(args: string[]): string {
|
|||
}
|
||||
}
|
||||
|
||||
/** Run `a1` with args and return trimmed stdout. */
|
||||
/** Run `a1` with args and return trimmed stdout. Idempotent reads ride a
|
||||
* transient retry. */
|
||||
export function a1(...args: string[]): string {
|
||||
return execA1WithRetry(args);
|
||||
return execA1(args, true);
|
||||
}
|
||||
|
||||
/** Run `a1` for a WRITE — exactly once, never retried. A transient retry
|
||||
* after the server ACCEPTED the call would duplicate the write (a
|
||||
* double-posted comment), so a write surfaces its first error and the
|
||||
* caller reports what already landed. */
|
||||
export function a1Once(...args: string[]): string {
|
||||
return execA1(args, false);
|
||||
}
|
||||
|
||||
/** Run `a1 … --format json` and parse the result. The long `--format` flag is
|
||||
|
|
@ -82,6 +95,22 @@ export function a1Json<T>(...args: string[]): T {
|
|||
return JSON.parse(a1(...args, '--format', 'json')) as T;
|
||||
}
|
||||
|
||||
/** The JSON shape of `a1Once` — the WRITE that reads its result back (the
|
||||
* created comment's id). TOLERANT on purpose, and only here: an exec
|
||||
* failure propagates (the write genuinely failed), but once the exec
|
||||
* SUCCEEDED the write is ACCEPTED — an answer that then fails to parse is
|
||||
* a platform anomaly, not a failed post, and must degrade to `undefined`
|
||||
* ("landed, result unreadable"). A throw instead would let the caller
|
||||
* count an accepted comment as unposted and re-run it into a duplicate. */
|
||||
export function a1JsonOnce<T>(...args: string[]): T | undefined {
|
||||
const raw = a1Once(...args, '--format', 'json');
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail fast with an actionable message when `a1` cannot run. A missing
|
||||
* binary (ENOENT — the dominant first-run state for this new dependency) is
|
||||
|
|
|
|||
|
|
@ -6,8 +6,17 @@
|
|||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { a1JsonMock, ensureAuthMock, gitMock, gitRawMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
a1JsonMock,
|
||||
a1JsonOnceMock,
|
||||
a1OnceMock,
|
||||
ensureAuthMock,
|
||||
gitMock,
|
||||
gitRawMock,
|
||||
} = vi.hoisted(() => ({
|
||||
a1JsonMock: vi.fn(),
|
||||
a1JsonOnceMock: vi.fn(),
|
||||
a1OnceMock: vi.fn(),
|
||||
ensureAuthMock: vi.fn(),
|
||||
gitMock: vi.fn(),
|
||||
gitRawMock: vi.fn(),
|
||||
|
|
@ -15,6 +24,8 @@ const { a1JsonMock, ensureAuthMock, gitMock, gitRawMock } = vi.hoisted(() => ({
|
|||
|
||||
vi.mock('./aone-client.js', () => ({
|
||||
a1Json: a1JsonMock,
|
||||
a1JsonOnce: a1JsonOnceMock,
|
||||
a1Once: a1OnceMock,
|
||||
a1: vi.fn(),
|
||||
ensureAoneAuthenticated: ensureAuthMock,
|
||||
}));
|
||||
|
|
@ -24,7 +35,12 @@ vi.mock('../git.js', () => ({
|
|||
gitRaw: gitRawMock,
|
||||
}));
|
||||
|
||||
import { aoneReader, parseRemoteUrl } from './aone.js';
|
||||
import {
|
||||
AonePartialPostError,
|
||||
aoneReader,
|
||||
parseRemoteUrl,
|
||||
submitAoneReview,
|
||||
} from './aone.js';
|
||||
import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from '../diff-flags.js';
|
||||
|
||||
describe('parseRemoteUrl hardening', () => {
|
||||
|
|
@ -760,3 +776,516 @@ describe('aoneReader.fetchDiff', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitAoneReview (the a1 write path)', () => {
|
||||
function mrView(head: string | undefined) {
|
||||
// a1Json serves the READ calls (mr view); a1JsonOnce the writes.
|
||||
// `undefined` OMITS the sourceBranch key entirely — the shape
|
||||
// AoneMrView types as optional, which `mrView('')` structurally
|
||||
// could not express.
|
||||
a1JsonMock.mockImplementation((...args: string[]) => {
|
||||
if (args.includes('view')) {
|
||||
return {
|
||||
mergeRequest: {
|
||||
...(head === undefined ? {} : { sourceBranch: head }),
|
||||
detailUrl: 'https://code.alibaba-inc.com/g/p/codereview/7',
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected read call: ${args.join(' ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mrView('sha-head');
|
||||
a1JsonOnceMock.mockReturnValue({ id: 100 });
|
||||
});
|
||||
|
||||
const req = (over: Record<string, unknown> = {}) => ({
|
||||
prNumber: 7,
|
||||
ownerRepo: 'g/p',
|
||||
commitId: 'sha-head',
|
||||
event: 'COMMENT' as const,
|
||||
body: 'summary body',
|
||||
comments: [
|
||||
{ path: 'a.ts', line: 3, body: '**[Critical]** one' },
|
||||
{ path: 'b.ts', line: 9, body: '**[Suggestion]** two' },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
it('posts inline first, summary last — one comment create per finding', () => {
|
||||
const result = submitAoneReview(req());
|
||||
// Two inline creates + one summary create, in that order.
|
||||
expect(a1JsonOnceMock).toHaveBeenCalledTimes(3);
|
||||
const calls = a1JsonOnceMock.mock.calls.map((c) => c as string[]);
|
||||
expect(calls[0]).toEqual([
|
||||
'repo',
|
||||
'mr',
|
||||
'comment',
|
||||
'create',
|
||||
'--mr',
|
||||
'7',
|
||||
'--repo',
|
||||
'g/p',
|
||||
'--file',
|
||||
'a.ts',
|
||||
'--line',
|
||||
'3',
|
||||
'--message',
|
||||
'**[Critical]** one',
|
||||
]);
|
||||
// The MIDDLE create pinned exactly too — a loop regression pairing
|
||||
// comments[i] with the wrong body, or re-posting the first body, must
|
||||
// not pass while only the two ends are watched.
|
||||
expect(calls[1]).toEqual([
|
||||
'repo',
|
||||
'mr',
|
||||
'comment',
|
||||
'create',
|
||||
'--mr',
|
||||
'7',
|
||||
'--repo',
|
||||
'g/p',
|
||||
'--file',
|
||||
'b.ts',
|
||||
'--line',
|
||||
'9',
|
||||
'--message',
|
||||
'**[Suggestion]** two',
|
||||
]);
|
||||
expect(calls[2]).toEqual([
|
||||
'repo',
|
||||
'mr',
|
||||
'comment',
|
||||
'create',
|
||||
'--mr',
|
||||
'7',
|
||||
'--repo',
|
||||
'g/p',
|
||||
'--message',
|
||||
'summary body',
|
||||
]);
|
||||
// The `repo mr view` read is the SOLE input of the head-drift gate —
|
||||
// pin its argv exactly, or a transposed prNumber/ownerRepo anchors the
|
||||
// gate on the wrong MR and every test above stays green.
|
||||
expect(a1JsonMock).toHaveBeenCalledWith(
|
||||
'repo',
|
||||
'mr',
|
||||
'view',
|
||||
'7',
|
||||
'--repo',
|
||||
'g/p',
|
||||
);
|
||||
// COMMENT posts no approval.
|
||||
expect(a1OnceMock).not.toHaveBeenCalled();
|
||||
expect(result.postedInline).toBe(2);
|
||||
expect(result.summaryPosted).toBe(true);
|
||||
expect(result.approved).toBe(false);
|
||||
expect(result.webUrl).toBe('https://code.alibaba-inc.com/g/p/codereview/7');
|
||||
expect(ensureAuthMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('APPROVE runs the native approve AFTER the summary lands', () => {
|
||||
a1JsonOnceMock
|
||||
.mockReturnValueOnce({ id: 101 })
|
||||
.mockReturnValueOnce({ id: 102 })
|
||||
.mockReturnValueOnce({ id: 103 });
|
||||
const result = submitAoneReview(req({ event: 'APPROVE' }));
|
||||
expect(a1OnceMock).toHaveBeenCalledTimes(1);
|
||||
expect(a1OnceMock).toHaveBeenCalledWith(
|
||||
'repo',
|
||||
'mr',
|
||||
'approve',
|
||||
'7',
|
||||
'--repo',
|
||||
'g/p',
|
||||
);
|
||||
// Ordering is the POINT: the approve must interleave AFTER every
|
||||
// comment create. An approve-before-writes mutation (MR approved but
|
||||
// carrying no review content if the summary create then fails) must
|
||||
// not pass — invocationCallOrder is comparable across the two mocks.
|
||||
expect(a1OnceMock.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
Math.max(...a1JsonOnceMock.mock.invocationCallOrder),
|
||||
);
|
||||
expect(result.approved).toBe(true);
|
||||
expect(result.approveError).toBeUndefined();
|
||||
expect(result.inlineCommentIds).toEqual([101, 102]);
|
||||
expect(result.summaryCommentId).toBe(103);
|
||||
});
|
||||
|
||||
it('REQUEST_CHANGES prefixes the blocking header (no native reject on Aone)', () => {
|
||||
submitAoneReview(req({ event: 'REQUEST_CHANGES' }));
|
||||
const calls = a1JsonOnceMock.mock.calls.map((c) => c as string[]);
|
||||
const summaryMessage = calls[2][calls[2].length - 1];
|
||||
expect(summaryMessage).toBe('**Request changes**\n\nsummary body');
|
||||
expect(a1OnceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses BEFORE writing when the head drifted', () => {
|
||||
expect(() => submitAoneReview(req({ commitId: 'stale-sha' }))).toThrow(
|
||||
/the MR head moved/,
|
||||
);
|
||||
expect(a1JsonOnceMock).not.toHaveBeenCalled();
|
||||
expect(a1OnceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an empty sourceBranch cannot gate — the post proceeds unanchored', () => {
|
||||
mrView('');
|
||||
const result = submitAoneReview(req());
|
||||
expect(result.postedInline).toBe(2);
|
||||
expect(result.summaryPosted).toBe(true);
|
||||
});
|
||||
|
||||
it('a MISSING sourceBranch key cannot gate either (the typed-optional shape)', () => {
|
||||
// AoneMrView types sourceBranch optional; the guard defends with
|
||||
// `(view.sourceBranch ?? '')`. A refactor to `view.sourceBranch.trim()`
|
||||
// would crash with a raw TypeError before any write on the answer
|
||||
// lacking the key, instead of the intended unanchored post.
|
||||
mrView(undefined);
|
||||
const result = submitAoneReview(req());
|
||||
expect(result.postedInline).toBe(2);
|
||||
expect(result.summaryPosted).toBe(true);
|
||||
});
|
||||
|
||||
it('a mid-batch failure throws AonePartialPostError naming what landed', () => {
|
||||
a1JsonOnceMock
|
||||
.mockReturnValueOnce({ id: 101 })
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Command failed: boom');
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
submitAoneReview(req());
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AonePartialPostError);
|
||||
const partial = caught as AonePartialPostError;
|
||||
expect(partial.postedInline).toBe(1);
|
||||
expect(partial.inlineCommentIds).toEqual([101]);
|
||||
expect(partial.summaryPosted).toBe(false);
|
||||
expect(partial.message).toContain('1 of 2');
|
||||
// An exec failure cannot tell "refused" from "accepted, then the
|
||||
// transport died" — the failing write may be live on the MR though
|
||||
// the count never saw it. Ambiguous, so submit's advisory fires.
|
||||
expect(partial.ambiguous).toBe(true);
|
||||
// The summary and any approve never ran.
|
||||
expect(a1JsonOnceMock).toHaveBeenCalledTimes(2);
|
||||
expect(a1OnceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses WHOLE, before any write, when a message overruns the a1 argv limit', () => {
|
||||
// Linux caps one argv element at 131072 BYTES. compose-review's cap
|
||||
// counts CHARACTERS (65536) — a CJK char is 3 bytes in UTF-8 — so a
|
||||
// long Chinese summary is inside the composer's cap and outside the
|
||||
// OS limit. Without this guard the summary create would die with
|
||||
// E2BIG only after every inline already landed.
|
||||
const huge = '中'.repeat(50000); // 150 000 bytes of UTF-8
|
||||
let caught: unknown;
|
||||
try {
|
||||
submitAoneReview(req({ body: huge }));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toContain(
|
||||
'over the 131072-byte single-argument limit',
|
||||
);
|
||||
// The remedy names the USER as the actor — Step 7 forbids the agent
|
||||
// every hand-run `a1` write, and an actorless "post them manually"
|
||||
// would hand the agent the exact call the rule exists to prevent.
|
||||
expect((caught as Error).message).toContain(
|
||||
'the USER can post them by hand',
|
||||
);
|
||||
// Nothing posted — neither a comment create nor an approve ran, and
|
||||
// the failure is NOT a partial post (nothing is ambiguous either).
|
||||
expect(caught).not.toBeInstanceOf(AonePartialPostError);
|
||||
expect(a1JsonOnceMock).not.toHaveBeenCalled();
|
||||
expect(a1OnceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('guards an oversized INLINE comment too, naming it', () => {
|
||||
const huge = 'x'.repeat(140000);
|
||||
let caught: unknown;
|
||||
try {
|
||||
submitAoneReview(
|
||||
req({ comments: [{ path: 'big.ts', line: 1, body: huge }] }),
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as Error).message).toContain('inline comment 1');
|
||||
expect(a1JsonOnceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an approve failure alone does not fail the post', () => {
|
||||
a1OnceMock.mockImplementation(() => {
|
||||
throw Object.assign(new Error('Command failed: a1 repo mr approve'), {
|
||||
stderr: 'approval denied\n',
|
||||
});
|
||||
});
|
||||
const result = submitAoneReview(req({ event: 'APPROVE' }));
|
||||
expect(result.approved).toBe(false);
|
||||
expect(result.approveError).toContain('approval denied');
|
||||
expect(result.postedInline).toBe(2);
|
||||
expect(result.summaryPosted).toBe(true);
|
||||
});
|
||||
|
||||
it('an empty-stderr failure reports EXIT FACTS, never the argv-bearing message', () => {
|
||||
// The 120 s deadline kill / SIGKILL / OOM shape: no stderr at all.
|
||||
// Parsing the message would quote a line of the operator's own review
|
||||
// body (Node embeds the full argv) as the "cause".
|
||||
a1JsonOnceMock.mockImplementationOnce(() => {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'Command failed: a1 repo mr comment create --message the body text\nmore body',
|
||||
),
|
||||
{ status: undefined, signal: 'SIGTERM' },
|
||||
);
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
submitAoneReview(
|
||||
req({ comments: [{ path: 'a.ts', line: 3, body: 'b' }] }),
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
const partial = caught as AonePartialPostError;
|
||||
expect(caught).toBeInstanceOf(AonePartialPostError);
|
||||
expect(partial.message).toContain('a1 failed without stderr');
|
||||
expect(partial.message).toContain('signal SIGTERM');
|
||||
expect(partial.message).not.toContain('more body');
|
||||
});
|
||||
|
||||
it('an empty summary body posts no summary comment', () => {
|
||||
const result = submitAoneReview(req({ body: ' ' }));
|
||||
expect(result.summaryPosted).toBe(false);
|
||||
// Two inline creates only.
|
||||
expect(a1JsonOnceMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads the created id back best-effort (nested shapes tolerated)', () => {
|
||||
a1JsonOnceMock
|
||||
.mockReturnValueOnce({ comment: { id: 201 } })
|
||||
.mockReturnValueOnce({ note: { id: 202 } })
|
||||
.mockReturnValueOnce({ unrelated: true });
|
||||
const result = submitAoneReview(req());
|
||||
expect(result.inlineCommentIds).toEqual([201, 202]);
|
||||
expect(result.postedInline).toBe(2);
|
||||
expect(result.summaryCommentId).toBeUndefined();
|
||||
// The summary's accepted-but-unreadable shape is still POSTED — the
|
||||
// first-class "accepted, id unknown" state. summaryPosted must not be
|
||||
// conditioned on the id reading back.
|
||||
expect(result.summaryPosted).toBe(true);
|
||||
});
|
||||
|
||||
it('reads ids from the result/data nestings too — the tolerance is PINNED, not merely alive', () => {
|
||||
// createdCommentId tolerates {result:{id}} and {data:{id}} — but a
|
||||
// mutation dropping those keys from the loop survived the suite
|
||||
// (correct behavior, zero pins). This cell kills it.
|
||||
a1JsonOnceMock
|
||||
.mockReturnValueOnce({ result: { id: 301 } })
|
||||
.mockReturnValueOnce({ data: { id: 302 } })
|
||||
.mockReturnValueOnce({ result: { id: 303 } });
|
||||
const result = submitAoneReview(req());
|
||||
expect(result.inlineCommentIds).toEqual([301, 302]);
|
||||
expect(result.summaryCommentId).toBe(303);
|
||||
});
|
||||
|
||||
it('counts an accepted-but-unreadable answer as POSTED — no undercount, no throw', () => {
|
||||
// a1JsonOnce yields undefined when an accepted write answers
|
||||
// unparseably. The first inline then reads back no id — but it LANDED,
|
||||
// so postedInline must still count it; only the id list drops it.
|
||||
// (Undercounting here is what would re-post the comment on a retry.)
|
||||
a1JsonOnceMock
|
||||
.mockReturnValueOnce(undefined) // inline #1: accepted, unreadable
|
||||
.mockReturnValueOnce({ id: 202 }) // inline #2
|
||||
.mockReturnValueOnce({ id: 203 }); // summary
|
||||
const result = submitAoneReview(req());
|
||||
expect(result.postedInline).toBe(2);
|
||||
expect(result.inlineCommentIds).toEqual([202]);
|
||||
expect(result.summaryCommentId).toBe(203);
|
||||
expect(result.summaryPosted).toBe(true);
|
||||
});
|
||||
|
||||
it('a SUMMARY-create failure reports the inlines landed, not the summary', () => {
|
||||
// The last write dying must not read back as "and the summary landed"
|
||||
// (a summaryPosted-before-call mutation would) — the operator would be
|
||||
// told a verdict summary is on the MR when it is not, or re-post it.
|
||||
a1JsonOnceMock
|
||||
.mockReturnValueOnce({ id: 101 })
|
||||
.mockReturnValueOnce({ id: 102 })
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Command failed: summary died');
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
submitAoneReview(req());
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
const partial = caught as AonePartialPostError;
|
||||
expect(caught).toBeInstanceOf(AonePartialPostError);
|
||||
expect(partial.postedInline).toBe(2);
|
||||
expect(partial.summaryPosted).toBe(false);
|
||||
expect(partial.message).toContain('2 of 2');
|
||||
expect(partial.message).not.toContain('and the summary');
|
||||
// State the summary's fate explicitly: "2 of 2 landed" alone reads
|
||||
// as a complete review, but the verdict carrier is absent from the
|
||||
// MR — the one fact remainder-completion needs.
|
||||
expect(partial.message).toContain('the summary did NOT land');
|
||||
expect(partial.ambiguous).toBe(true);
|
||||
});
|
||||
|
||||
it('counts an accepted-but-unreadable inline, THEN a failing write — count stays exact', () => {
|
||||
// The ambiguous count includes undefined ids: an earlier inline
|
||||
// accepted with an unparseable answer, then a later create dying,
|
||||
// must report BOTH in postedInline even though the id list holds only
|
||||
// the readable one (an ids.length mutation undercounts exactly here).
|
||||
a1JsonOnceMock
|
||||
.mockReturnValueOnce(undefined) // inline #1: accepted, unreadable
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Command failed: second died');
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
submitAoneReview(req());
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
const partial = caught as AonePartialPostError;
|
||||
expect(caught).toBeInstanceOf(AonePartialPostError);
|
||||
expect(partial.postedInline).toBe(1);
|
||||
expect(partial.inlineCommentIds).toEqual([]);
|
||||
expect(partial.message).toContain('1 of 2');
|
||||
expect(partial.ambiguous).toBe(true);
|
||||
});
|
||||
|
||||
it('the size gate pins the boundary operator — 131072 refused, 131071 posts', () => {
|
||||
// Far-above-limit fixtures let a `>=`→`>` mutation survive: a summary
|
||||
// of EXACTLY 131072 bytes would pass the gate and die E2BIG at exec
|
||||
// time after every inline already landed.
|
||||
let caught: unknown;
|
||||
try {
|
||||
submitAoneReview(req({ body: 'x'.repeat(131072), comments: [] }));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as Error).message).toContain(
|
||||
'over the 131072-byte single-argument limit',
|
||||
);
|
||||
expect(a1JsonOnceMock).not.toHaveBeenCalled();
|
||||
|
||||
a1JsonOnceMock.mockClear();
|
||||
const result = submitAoneReview(req({ body: 'x'.repeat(131071) }));
|
||||
expect(result.summaryPosted).toBe(true);
|
||||
});
|
||||
|
||||
it('the size gate measures the RC HEADER-PREFIXED summary, not the raw body', () => {
|
||||
// A REQUEST_CHANGES body just under the limit whose header-prefixed
|
||||
// summaryMessage crosses it must refuse whole — measuring req.body
|
||||
// instead would let the summary (deliberately LAST) die E2BIG after
|
||||
// every inline already landed.
|
||||
const header = '**Request changes**\n\n';
|
||||
const body = 'x'.repeat(131072 - header.length + 1);
|
||||
let caught: unknown;
|
||||
try {
|
||||
submitAoneReview(req({ event: 'REQUEST_CHANGES', body, comments: [] }));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as Error).message).toContain(
|
||||
'over the 131072-byte single-argument limit',
|
||||
);
|
||||
expect(a1JsonOnceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an empty-body REQUEST_CHANGES still posts the blocking header', () => {
|
||||
// compose-review produces RC with an EMPTY body today (C≥1, all
|
||||
// Criticals inline). The header is the verdict's sole carrier on
|
||||
// Aone — the skip guard keys on the posted summaryMessage, so a
|
||||
// header-only summary posts instead of being dropped.
|
||||
const result = submitAoneReview(
|
||||
req({ event: 'REQUEST_CHANGES', body: '' }),
|
||||
);
|
||||
expect(result.summaryPosted).toBe(true);
|
||||
const calls = a1JsonOnceMock.mock.calls.map((c) => c as string[]);
|
||||
expect(calls).toHaveLength(3); // 2 inlines + header-only summary
|
||||
expect(calls[2][calls[2].length - 1]).toBe('**Request changes**\n\n');
|
||||
});
|
||||
|
||||
it('reports a1 stderr as the cause, not a line of the comment body', () => {
|
||||
// Node embeds the FULL argv — multi-line comment body included — in
|
||||
// the "Command failed:" preamble, so parsing the message surfaces the
|
||||
// operator's own review text. The real error rides the captured
|
||||
// stderr property; the report must carry IT.
|
||||
a1JsonOnceMock.mockImplementationOnce(() => {
|
||||
const err = Object.assign(
|
||||
new Error(
|
||||
'Command failed: a1 repo mr comment create --message line one\nline two of the body',
|
||||
),
|
||||
{ stderr: 'HTTP 422: real a1 error\n' },
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
submitAoneReview(
|
||||
req({ comments: [{ path: 'a.ts', line: 3, body: 'b' }] }),
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
const partial = caught as AonePartialPostError;
|
||||
expect(caught).toBeInstanceOf(AonePartialPostError);
|
||||
expect(partial.message).toContain('HTTP 422: real a1 error');
|
||||
expect(partial.message).not.toContain('line two of the body');
|
||||
});
|
||||
|
||||
it('discloses a head that moved DURING the batch (the gate is check-then-post)', () => {
|
||||
// The gate reads the head once, BEFORE the batch; an AGit-Flow amend
|
||||
// pushed mid-batch slips it. The success report must disclose the
|
||||
// orphaned pins instead of claiming they held.
|
||||
a1JsonMock
|
||||
.mockReturnValueOnce({
|
||||
mergeRequest: {
|
||||
sourceBranch: 'sha-head',
|
||||
detailUrl: 'https://code.alibaba-inc.com/g/p/codereview/7',
|
||||
},
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
mergeRequest: {
|
||||
sourceBranch: 'sha-amended',
|
||||
detailUrl: 'https://code.alibaba-inc.com/g/p/codereview/7',
|
||||
},
|
||||
});
|
||||
const result = submitAoneReview(req());
|
||||
expect(result.postedInline).toBe(2);
|
||||
expect(result.headMovedDuringPost).toBe(true);
|
||||
});
|
||||
|
||||
it('a stable head through the batch reports no mid-batch drift', () => {
|
||||
const result = submitAoneReview(req());
|
||||
expect(result.headMovedDuringPost).toBe(false);
|
||||
});
|
||||
|
||||
it('a post-batch re-read failure does not fail a successful post', () => {
|
||||
a1JsonMock
|
||||
.mockReturnValueOnce({
|
||||
mergeRequest: {
|
||||
sourceBranch: 'sha-head',
|
||||
detailUrl: 'https://code.alibaba-inc.com/g/p/codereview/7',
|
||||
},
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Command failed: a1 repo mr view — network gone');
|
||||
});
|
||||
const result = submitAoneReview(req());
|
||||
expect(result.postedInline).toBe(2);
|
||||
expect(result.summaryPosted).toBe(true);
|
||||
expect(result.headMovedDuringPost).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ import { git, gitRaw } from '../git.js';
|
|||
import { isOwnerRepo } from '../gh.js';
|
||||
import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from '../diff-flags.js';
|
||||
import { isAoneHostFamily } from '../remote-match.js';
|
||||
import { a1Json, ensureAoneAuthenticated } from './aone-client.js';
|
||||
import {
|
||||
a1Json,
|
||||
a1JsonOnce,
|
||||
a1Once,
|
||||
ensureAoneAuthenticated,
|
||||
} from './aone-client.js';
|
||||
import type {
|
||||
ClosingIssueRef,
|
||||
CommentKind,
|
||||
|
|
@ -593,3 +598,331 @@ export const aoneReader: ReviewPlatformReader = {
|
|||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write path — the Aone half of `qwen review submit` (Phase 3 of
|
||||
// docs/design/2026-08-13-review-platform-provider-abstraction.md).
|
||||
//
|
||||
// Aone has no Create-Review batch API: a review is N+1 calls — one
|
||||
// `a1 repo mr comment create` per inline finding, one for the summary,
|
||||
// plus `a1 repo mr approve` on an APPROVE. The order is the design's Q5
|
||||
// policy: inline first, summary LAST (the summary never references
|
||||
// something not yet posted), so a mid-batch failure leaves a state the
|
||||
// terminal report can describe exactly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One inline finding as it lands on the MR. */
|
||||
export interface AoneInlineComment {
|
||||
path: string;
|
||||
/** The new-side line — a multi-line range posts on its END line. */
|
||||
line: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface AoneSubmitRequest {
|
||||
prNumber: number;
|
||||
ownerRepo: string;
|
||||
/** The head SHA the review was composed against (GitHub's commit_id). */
|
||||
commitId: string;
|
||||
event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT';
|
||||
/** The composed summary body. */
|
||||
body: string;
|
||||
comments: AoneInlineComment[];
|
||||
}
|
||||
|
||||
export interface AoneSubmitResult {
|
||||
/** Ids of the inline comments created (only the ones a1 reported). */
|
||||
inlineCommentIds: number[];
|
||||
/** How many inline comments were created — ids are best-effort. */
|
||||
postedInline: number;
|
||||
summaryCommentId?: number;
|
||||
summaryPosted: boolean;
|
||||
/** False only when the event was APPROVE and the approve call failed. */
|
||||
approved: boolean;
|
||||
approveError?: string;
|
||||
/** True when the head moved DURING the posting batch — the pre-write
|
||||
* drift gate is check-then-post, so an amend pushed mid-batch orphans
|
||||
* every inline comment; the post stands but the pins may not. */
|
||||
headMovedDuringPost?: boolean;
|
||||
webUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A write that FAILED MID-BATCH. The MR already carries part of the
|
||||
* review; the structured counts keep submit's report exact, and its
|
||||
* do-not-re-run advice keeps a retry from double-posting what landed.
|
||||
*
|
||||
* `ambiguous` says the FAILED write itself may have reached the server:
|
||||
* an exec error cannot tell "refused" from "accepted, then the transport
|
||||
* died" — a1 killed by the deadline AFTER the POST committed, a
|
||||
* connection reset mid-response, an HTTP 5xx after the server wrote. The
|
||||
* comment is then live on the MR while the count says it never landed,
|
||||
* and a retry posts it twice. So an ambiguous failure is counted as
|
||||
* LANDED for the do-not-re-run advisory — overcounting by one is a
|
||||
* cosmetic lie; undercounting is a duplicate post.
|
||||
*/
|
||||
export class AonePartialPostError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly postedInline: number,
|
||||
readonly inlineCommentIds: number[],
|
||||
readonly summaryPosted: boolean,
|
||||
readonly ambiguous: boolean = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AonePartialPostError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The created comment's id, read back best-effort — shapes tolerated:
|
||||
* `{id}`, or one level nested (`{comment|note|result|data: {id}}`). The
|
||||
* id feeds the failure report and tomorrow's audit; a miss degrades to
|
||||
* "posted, id unknown", never to a failed submit. */
|
||||
function createdCommentId(out: unknown): number | undefined {
|
||||
if (out === null || typeof out !== 'object') return undefined;
|
||||
const o = out as Record<string, unknown>;
|
||||
if (typeof o['id'] === 'number') return o['id'];
|
||||
for (const key of ['comment', 'note', 'result', 'data']) {
|
||||
const nested = o[key];
|
||||
if (nested !== null && typeof nested === 'object') {
|
||||
const id = (nested as Record<string, unknown>)['id'];
|
||||
if (typeof id === 'number') return id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createMrComment(
|
||||
prNumber: number,
|
||||
ownerRepo: string,
|
||||
message: string,
|
||||
inline?: { path: string; line: number },
|
||||
): number | undefined {
|
||||
// a1JsonOnce is the tolerant read-back: an exec FAILURE propagates (a real
|
||||
// post failure — the partial-post path counts what landed before it), but a
|
||||
// SUCCEEDED exec whose answer does not parse is "accepted, id unknown", not
|
||||
// a failure. Throwing on the parse miss would undercount the partial-post
|
||||
// report by exactly this comment and, if it was the first, suppress the
|
||||
// do-not-re-run advisory altogether (see aone-client.ts).
|
||||
const out = a1JsonOnce<unknown>(
|
||||
'repo',
|
||||
'mr',
|
||||
'comment',
|
||||
'create',
|
||||
'--mr',
|
||||
String(prNumber),
|
||||
'--repo',
|
||||
ownerRepo,
|
||||
...(inline ? ['--file', inline.path, '--line', String(inline.line)] : []),
|
||||
'--message',
|
||||
message,
|
||||
);
|
||||
return createdCommentId(out);
|
||||
}
|
||||
|
||||
/** The cause of an a1 failure for a terminal report — the one line the
|
||||
* user reads, capped so a kilobyte stack trace never lands there. */
|
||||
function a1Cause(err: unknown): string {
|
||||
const e = err as Error & {
|
||||
stderr?: Buffer | string;
|
||||
status?: number;
|
||||
signal?: string;
|
||||
};
|
||||
const firstLine = (text: string): string | undefined =>
|
||||
text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.find(Boolean);
|
||||
// The message an execFileSync failure raises is NEVER a text source
|
||||
// here: its first line is the "Command failed: a1 …" preamble, and Node
|
||||
// embeds the FULL argv in that preamble — for a comment create, the
|
||||
// ENTIRE multi-line comment body. a1's real error rides the captured
|
||||
// `stderr` property. An empty-stderr failure (the 120 s deadline kill
|
||||
// — aone-client's own note: "usually no stderr" — SIGKILL/OOM, an a1
|
||||
// crash before writing) has no trustworthy text source at all, so the
|
||||
// fallback reports the EXIT FACTS, never the message: parsing it would
|
||||
// quote a line of the operator's own review text as the "cause".
|
||||
const stderr = e.stderr === undefined ? undefined : String(e.stderr);
|
||||
const cause =
|
||||
(stderr === undefined ? undefined : firstLine(stderr)) ??
|
||||
`a1 failed without stderr` +
|
||||
(typeof e.status === 'number' ? ` (exit ${e.status})` : '') +
|
||||
(e.signal ? ` (signal ${e.signal})` : '');
|
||||
return cause.length > 300 ? `${cause.slice(0, 300)}…` : cause;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a composed review to an Aone MR. The verdict mapping is the
|
||||
* design's D6: APPROVE runs the native `mr approve` AFTER the summary
|
||||
* lands; COMMENT is the summary alone; REQUEST_CHANGES has NO native
|
||||
* equivalent — the summary carries an explicit blocking header, and the
|
||||
* unresolved inline Criticals carry the blocking semantics through the
|
||||
* discussion merge gate.
|
||||
*
|
||||
* Throws BEFORE writing when the head drifted (the commit_id check
|
||||
* GitHub's API performs server-side). Throws AonePartialPostError when
|
||||
* a write fails mid-batch; an approve failure alone does NOT throw —
|
||||
* the review is fully posted, only the native approval is missing, and
|
||||
* the result says so.
|
||||
*/
|
||||
export function submitAoneReview(req: AoneSubmitRequest): AoneSubmitResult {
|
||||
checkOwnerRepo(req.ownerRepo);
|
||||
ensureAoneAuthenticated();
|
||||
|
||||
const view = mrView(req.prNumber, req.ownerRepo);
|
||||
// a1 comments carry no commit anchor — the drift gate GitHub's Create
|
||||
// Review API enforces server-side (422 on a moved commit_id) lives
|
||||
// here. Under AGit-Flow an update AMENDS the single commit: posting a
|
||||
// review composed against the orphaned head would pin every inline
|
||||
// comment at code the author already replaced. An empty sourceBranch
|
||||
// cannot gate — nothing to compare against — and posts unanchored.
|
||||
const liveHead = (view.sourceBranch ?? '').trim();
|
||||
if (liveHead !== '' && liveHead !== req.commitId) {
|
||||
throw new Error(
|
||||
`refusing to post: the MR head moved — the review was composed ` +
|
||||
`against ${req.commitId}, but the live head is ${liveHead}. ` +
|
||||
`Re-review the new head before posting.`,
|
||||
);
|
||||
}
|
||||
|
||||
// a1 takes the whole comment body as ONE argv element, and Linux caps a
|
||||
// single element at MAX_ARG_STRLEN = 131072 BYTES (not characters).
|
||||
// compose-review's BODY_MAX_CHARS is 65536 *characters* — a limit written
|
||||
// for GitHub and counted in chars — a CJK character is 3 bytes in UTF-8,
|
||||
// and a bilingual body folds the full Chinese copy in again. A long
|
||||
// Chinese review therefore sits comfortably inside the composer's cap and
|
||||
// outside the OS argv limit: the summary create — deliberately LAST —
|
||||
// would die with E2BIG only after every inline comment already landed,
|
||||
// stranding the MR with blockers and no verdict. Guard every message up
|
||||
// front so the batch refuses WHOLE, before anything posts. (The GitHub
|
||||
// branch streams over stdin precisely to dodge this; a1 has no stdin or
|
||||
// file input for `--message`, so a size gate is the honest substitute.)
|
||||
const A1_ARG_MAX_BYTES = 131072;
|
||||
const summaryMessage =
|
||||
req.event === 'REQUEST_CHANGES'
|
||||
? `**Request changes**\n\n${req.body}`
|
||||
: req.body;
|
||||
const oversized = [
|
||||
...req.comments.map((c, i) => ({
|
||||
what: `inline comment ${i + 1} (${c.path}:${c.line})`,
|
||||
text: c.body,
|
||||
})),
|
||||
{ what: 'the summary comment', text: summaryMessage },
|
||||
].find((m) => Buffer.byteLength(m.text, 'utf8') >= A1_ARG_MAX_BYTES);
|
||||
if (oversized) {
|
||||
throw new Error(
|
||||
`refusing to post: ${oversized.what} is ` +
|
||||
`${Buffer.byteLength(oversized.text, 'utf8')} bytes — over the ` +
|
||||
`${A1_ARG_MAX_BYTES}-byte single-argument limit a1 must pass it ` +
|
||||
`as. Nothing was written; the findings are in the terminal ` +
|
||||
`output and the saved report, and the USER can post them by ` +
|
||||
`hand — hand-posting is never an agent action.`,
|
||||
);
|
||||
}
|
||||
|
||||
const postedIds: Array<number | undefined> = [];
|
||||
let summaryPosted = false;
|
||||
let summaryCommentId: number | undefined;
|
||||
try {
|
||||
for (const c of req.comments) {
|
||||
postedIds.push(
|
||||
createMrComment(req.prNumber, req.ownerRepo, c.body, {
|
||||
path: c.path,
|
||||
line: c.line,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// An empty summary posts nothing: `-m ''` is refused by a1, and an
|
||||
// empty summary comment would be noise. Guard on the MESSAGE actually
|
||||
// posted (`summaryMessage`), not the raw body — on REQUEST_CHANGES the
|
||||
// blocking header is prepended, so a header-only summary still posts
|
||||
// even when the composed body is empty (which compose-review produces
|
||||
// today: C≥1 with inline-only Criticals → RC with body ''). The same
|
||||
// `summaryMessage` is what the size gate above measures — one view of
|
||||
// the decision, not two. (For COMMENT/APPROVE, summaryMessage ===
|
||||
// req.body, so an empty body still skips.)
|
||||
if (summaryMessage.trim() !== '') {
|
||||
summaryCommentId = createMrComment(
|
||||
req.prNumber,
|
||||
req.ownerRepo,
|
||||
summaryMessage,
|
||||
);
|
||||
summaryPosted = true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Every error that reaches here is a write's EXEC failure — parse
|
||||
// misses are tolerated one layer down and never throw. An exec
|
||||
// failure cannot distinguish "refused" from "accepted, then the
|
||||
// transport died", so the failing write may ALREADY be live on the
|
||||
// MR even though the count never saw it: mark the failure ambiguous
|
||||
// so submit's do-not-re-run advisory fires regardless of the count.
|
||||
const ids = postedIds.filter((n): n is number => typeof n === 'number');
|
||||
// State the summary's fate explicitly when it was the write that died:
|
||||
// "N of N inline comment(s) landed" alone reads as a complete review,
|
||||
// but the verdict carrier (the blocking header on a Request changes)
|
||||
// is then absent from the MR — the one fact remainder-completion needs.
|
||||
const summaryFate =
|
||||
!summaryPosted && postedIds.length === req.comments.length
|
||||
? `; the summary did NOT land`
|
||||
: '';
|
||||
throw new AonePartialPostError(
|
||||
`posting to MR ${req.prNumber} of ${req.ownerRepo} failed after ` +
|
||||
`${postedIds.length} of ${req.comments.length} inline comment(s)` +
|
||||
`${summaryPosted ? ' and the summary' : ''} landed` +
|
||||
`${summaryFate}: ` +
|
||||
a1Cause(err),
|
||||
postedIds.length,
|
||||
ids,
|
||||
summaryPosted,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
let approved = false;
|
||||
let approveError: string | undefined;
|
||||
if (req.event === 'APPROVE') {
|
||||
try {
|
||||
a1Once(
|
||||
'repo',
|
||||
'mr',
|
||||
'approve',
|
||||
String(req.prNumber),
|
||||
'--repo',
|
||||
req.ownerRepo,
|
||||
);
|
||||
approved = true;
|
||||
} catch (err) {
|
||||
// Not a failed review — inline + summary are posted; only the
|
||||
// native approval is missing. Report it and let the user re-run
|
||||
// the one missing command.
|
||||
approveError = a1Cause(err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inlineCommentIds: postedIds.filter(
|
||||
(n): n is number => typeof n === 'number',
|
||||
),
|
||||
postedInline: postedIds.length,
|
||||
summaryCommentId,
|
||||
summaryPosted,
|
||||
approved,
|
||||
approveError,
|
||||
// The drift gate above is check-then-post; the batch is N+1 sequential
|
||||
// execs (minutes for a long review), so a head that moves DURING it
|
||||
// slips the gate. Re-read once and disclose — the success report must
|
||||
// not claim the pins held. A read failure after a successful post must
|
||||
// not fail the post.
|
||||
headMovedDuringPost: (() => {
|
||||
try {
|
||||
const after = mrView(req.prNumber, req.ownerRepo);
|
||||
const afterHead = (after.sourceBranch ?? '').trim();
|
||||
return afterHead !== '' && afterHead !== req.commitId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
webUrl: view.detailUrl ?? '',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
matchRemotes,
|
||||
normalizeSegment,
|
||||
hostsEquivalent,
|
||||
isAoneCanonicalHost,
|
||||
} from './remote-match.js';
|
||||
|
||||
describe('parseRemoteUrl', () => {
|
||||
|
|
@ -429,4 +430,45 @@ describe('hostsEquivalent', () => {
|
|||
expect(hostsEquivalent('github.com', 'gitlab.alibaba-inc.com')).toBe(false);
|
||||
expect(hostsEquivalent('a.com', 'b.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('equates the alias across spelling variants (port, dot, case)', () => {
|
||||
// The CR-URL grammar keeps `(?::\d+)?` inside the host capture, so a
|
||||
// review recorded from `code.alibaba-inc.com:443` must still bind a
|
||||
// submission carrying the skill-mandated `gitlab.alibaba-inc.com` —
|
||||
// raw spelling equality died at the gate after the whole review ran.
|
||||
expect(
|
||||
hostsEquivalent('code.alibaba-inc.com:443', 'gitlab.alibaba-inc.com'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hostsEquivalent('CODE.ALIBABA-INC.COM', 'gitlab.alibaba-inc.com.'),
|
||||
).toBe(true);
|
||||
// Same-host spellings with variants are identical too.
|
||||
expect(hostsEquivalent('github.com:443', 'GITHUB.COM')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAoneCanonicalHost', () => {
|
||||
it('accepts only the canonical Aone web/git pair', () => {
|
||||
expect(isAoneCanonicalHost('code.alibaba-inc.com')).toBe(true);
|
||||
expect(isAoneCanonicalHost('gitlab.alibaba-inc.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes port, trailing dot and case like the family predicate', () => {
|
||||
expect(isAoneCanonicalHost('CODE.ALIBABA-INC.COM')).toBe(true);
|
||||
expect(isAoneCanonicalHost('gitlab.alibaba-inc.com:443')).toBe(true);
|
||||
expect(isAoneCanonicalHost('code.alibaba-inc.com.')).toBe(true);
|
||||
});
|
||||
|
||||
it('REJECTS the family wildcard — a GHE host is not Aone', () => {
|
||||
// The `.alibaba-inc.com` suffix also names GitHub Enterprise
|
||||
// instances; a write must not select a1 on a family resemblance.
|
||||
expect(isAoneCanonicalHost('ghe.alibaba-inc.com')).toBe(false);
|
||||
expect(isAoneCanonicalHost('github.alibaba-inc.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-Aone and empty hosts', () => {
|
||||
expect(isAoneCanonicalHost('github.com')).toBe(false);
|
||||
expect(isAoneCanonicalHost(undefined)).toBe(false);
|
||||
expect(isAoneCanonicalHost('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,10 +36,34 @@ export function normalizeSegment(value: string): string {
|
|||
// `…/codereview/<id>` target (web host) matches its clone's remote (git host).
|
||||
const AONE_HOSTS = new Set(['code.alibaba-inc.com', 'gitlab.alibaba-inc.com']);
|
||||
|
||||
/** The ONE host spelling normalization: a port, one trailing dot (FQDN
|
||||
* form), and case all spell the same DNS name. Both host predicates route
|
||||
* through it so the authorisation gate and the write router can never
|
||||
* normalize differently — the CR-URL grammar keeps `(?::\d+)?` inside the
|
||||
* host capture, so a predicate that skipped this refused
|
||||
* `code.alibaba-inc.com:443` against the skill-mandated
|
||||
* `gitlab.alibaba-inc.com` after the whole review ran. */
|
||||
function normalizeHostSpelling(host: string): string {
|
||||
return host.toLowerCase().replace(/:\d+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
/** Hosts compare equal when identical, or both are an Aone web/git alias. */
|
||||
export function hostsEquivalent(a: string, b: string): boolean {
|
||||
if (a === b) return true;
|
||||
return AONE_HOSTS.has(a) && AONE_HOSTS.has(b);
|
||||
const na = normalizeHostSpelling(a);
|
||||
const nb = normalizeHostSpelling(b);
|
||||
if (na === nb) return true;
|
||||
return AONE_HOSTS.has(na) && AONE_HOSTS.has(nb);
|
||||
}
|
||||
|
||||
/** The CANONICAL Aone hosts, normalized through the shared spelling helper
|
||||
* — but strict: no `.alibaba-inc.com` wildcard. Write routing keys on
|
||||
* THIS, not the family: a bare `*.alibaba-inc.com` suffix also names
|
||||
* GitHub Enterprise instances (an org's `ghe.alibaba-inc.com`), and an
|
||||
* irreversible public write must not select the a1 path on a family
|
||||
* resemblance. */
|
||||
export function isAoneCanonicalHost(host: string | undefined): boolean {
|
||||
if (!host) return false;
|
||||
return AONE_HOSTS.has(normalizeHostSpelling(host));
|
||||
}
|
||||
|
||||
/** Hosts that count as the Aone platform family — one canonical predicate,
|
||||
|
|
|
|||
|
|
@ -244,7 +244,11 @@ describe('readTranscripts — defensive parsing', () => {
|
|||
// and two look-alikes — a `.bak` sibling and a shell command that only
|
||||
// NAMES the diff — refused.
|
||||
const b = { agentId: 'a1', agentName: 'general-purpose', sessionId: 'S1' };
|
||||
const call = (name: string, args: object): object[] => [
|
||||
const call = (
|
||||
name: string,
|
||||
args: object,
|
||||
response: object = { output: 'ok' },
|
||||
): object[] => [
|
||||
{
|
||||
...b,
|
||||
type: 'assistant',
|
||||
|
|
@ -255,32 +259,53 @@ describe('readTranscripts — defensive parsing', () => {
|
|||
type: 'tool_result',
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name, response: { output: 'ok' } } }],
|
||||
parts: [{ functionResponse: { name, response } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
file(
|
||||
'agent-a1.jsonl',
|
||||
[
|
||||
JSON.stringify({
|
||||
// A plain object literal like its siblings — pre-stringifying it here
|
||||
// would let the trailing `.map` encode it twice, so `parseTranscript`
|
||||
// parses a bare string and silently drops the launch line.
|
||||
{
|
||||
...b,
|
||||
type: 'user',
|
||||
message: { role: 'user', parts: [{ text: 'chunk 1 of 1' }] },
|
||||
}),
|
||||
},
|
||||
...call('read_file', { file_path: '/d.txt', offset: 0, limit: 40 }),
|
||||
...call('read_file', { file_path: '/d.txt.bak' }),
|
||||
...call('run_shell_command', { command: 'rm /d.txt' }),
|
||||
// A FAILED read of the diff: names it, but the response is an error.
|
||||
// Hoisting the `diffToolCalls++` / `diffReads.push` out of the
|
||||
// `!isErrorPart` branch would count this as a diff read.
|
||||
...call(
|
||||
'read_file',
|
||||
{ file_path: '/d.txt', offset: 40, limit: 40 },
|
||||
{ error: 'denied' },
|
||||
),
|
||||
]
|
||||
.map((r) => JSON.stringify(r))
|
||||
.join('\n') + '\n',
|
||||
);
|
||||
const [rec] = readTranscripts(undefined, ENV, '/d.txt');
|
||||
// The launch line survived — proof the fixture is single-encoded.
|
||||
expect(rec.launchPrompt).toBe('chunk 1 of 1');
|
||||
// Only the ONE successful, exact-path read counts: not the `.bak`
|
||||
// sibling, not the shell mention, not the denied read.
|
||||
expect(rec.diffToolCalls).toBe(1);
|
||||
// The RANGE too, not only the count: `range` is wired through the same
|
||||
// `namedTheDiff` decision, so dropping that wiring leaves the count
|
||||
// right and every chunk-coverage ruling — which reads the lines, not
|
||||
// the tally — with nothing to rule on.
|
||||
// the tally — with nothing to rule on. The denied read's [41, 80] is
|
||||
// absent, pinning the success gate on `diffReads` as well.
|
||||
expect(rec.diffReads).toEqual([[1, 40]]);
|
||||
// The same gate guards the evidence lists the certification atoms read
|
||||
// (`openedBrief`, `readBrief`, `readFindingsPointer`): the denied read
|
||||
// must stay out of them too, not only out of the diff fields.
|
||||
expect(rec.successfulCallArgs).toHaveLength(3);
|
||||
expect(rec.successfulReadFileArgs).toHaveLength(2);
|
||||
// And with no diffPath the field stays 0, whatever was read.
|
||||
expect(readTranscripts(undefined, ENV)[0].diffToolCalls).toBe(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2029,7 +2029,7 @@ describe('renderLedgerSection', () => {
|
|||
// The CONDITION, not just the instruction. Dropping the clause leaves the
|
||||
// tail telling the orchestrator, unconditionally and in imperative tone,
|
||||
// to re-run with a sha that may already have been deterministically
|
||||
// refused — `not-an-ancestor`, `hunks-outside-pr-diff`, `partition-failed`
|
||||
// refused — `not-an-ancestor`, `nothing-to-narrow`, `partition-failed`
|
||||
// — which the recovered-anchor flow says must NOT be retried.
|
||||
expect(anchored).toContain(
|
||||
"when Step 1's recovered-anchor check rules a re-run admissible",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -32,6 +32,9 @@ const ghMock = vi.hoisted(() =>
|
|||
vi.fn((_payload: string, ..._rest: string[]) => ''),
|
||||
);
|
||||
const ghViewMock = vi.hoisted(() => vi.fn((..._args: string[]) => ''));
|
||||
// The Aone write seam — an Aone-routed post must reach THIS, never a real
|
||||
// `a1` (a platform write is never a test fixture), and never gh.
|
||||
const aoneSubmitMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./lib/gh.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./lib/gh.js')>();
|
||||
return {
|
||||
|
|
@ -41,8 +44,16 @@ vi.mock('./lib/gh.js', async (importOriginal) => {
|
|||
setGhHost: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('./lib/platform/aone.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./lib/platform/aone.js')>();
|
||||
return {
|
||||
...actual,
|
||||
submitAoneReview: aoneSubmitMock,
|
||||
};
|
||||
});
|
||||
|
||||
// The Aone refusal guard probes the platform (cwd origin via
|
||||
// The Aone detection probes the platform (cwd origin via
|
||||
// node:child_process) when no host is passed; pin it to GitHub so these
|
||||
// GitHub tests neither spawn a real `git` in the vitest cwd nor couple to the
|
||||
// machine's actual clone origin. importOriginal keeps the real exports
|
||||
|
|
@ -134,6 +145,16 @@ function args(over: Record<string, unknown> = {}) {
|
|||
pr: 6771,
|
||||
repo: 'QwenLM/qwen-code',
|
||||
review: file(`review-${seq++}.json`, REVIEW),
|
||||
// Real runs always carry a recording (writeSkillArgs at /review start),
|
||||
// and it is the platform evidence the write gate binds. Give the
|
||||
// default one a github.com host WITHOUT --comment: the fast path then
|
||||
// has host evidence (posts proceed), while slow-path refusal tests
|
||||
// still refuse (no --comment). Tests that override `skillArgs` or
|
||||
// `userAuthorized` steer their own shape.
|
||||
skillArgs: file(
|
||||
`skill-args-${seq++}.txt`,
|
||||
'https://github.com/QwenLM/qwen-code/pull/6771',
|
||||
),
|
||||
userAuthorized: false,
|
||||
dryRun: false,
|
||||
...over,
|
||||
|
|
@ -144,16 +165,25 @@ beforeEach(() => {
|
|||
dir = mkdtempSync(join(tmpdir(), 'review-submit-'));
|
||||
ghMock.mockClear();
|
||||
ghViewMock.mockClear();
|
||||
aoneSubmitMock.mockClear();
|
||||
aoneSubmitMock.mockReturnValue({
|
||||
inlineCommentIds: [],
|
||||
postedInline: 0,
|
||||
summaryPosted: true,
|
||||
approved: false,
|
||||
webUrl: '',
|
||||
});
|
||||
writeStdoutSpy.mockClear();
|
||||
writeStderrSpy.mockClear();
|
||||
reviewSettingsMock.mockReturnValue({ attribution: true });
|
||||
process.exitCode = undefined;
|
||||
savedSessionId = process.env['QWEN_CODE_SESSION_ID'];
|
||||
delete process.env['QWEN_CODE_SESSION_ID'];
|
||||
// The Aone refusal reads the AMBIENT GH_HOST (its env arm), and the org's
|
||||
// standard intranet export pattern is an Aone-family host — without
|
||||
// isolating it, every recorded-host-less posting test below refuses
|
||||
// instead of posting on exactly the population this PR targets.
|
||||
// Belt and braces: the write routing never consults the ambient GH_HOST
|
||||
// (submit's platform gate documents this, and the registry reader above
|
||||
// is pinned to github) — but `resolveGhHost` still falls back to it for
|
||||
// the gate's host BINDING, so keep the org's standard Aone-family
|
||||
// intranet export out of these tests anyway.
|
||||
savedGhHost = process.env['GH_HOST'];
|
||||
delete process.env['GH_HOST'];
|
||||
});
|
||||
|
|
@ -330,6 +360,24 @@ describe('authorization — URL-shaped host and repo binding at the submit call
|
|||
{ callerRepo: 'o/r', callerHost: 'ghe.corp.example' },
|
||||
),
|
||||
).toEqual({ floor: 'critical', source: 'explicit' });
|
||||
// …and it recovers an Aone record across the web/git host ALIAS: the
|
||||
// CR-URL record carries the web host (code.) while the submission
|
||||
// carries the git host (gitlab.). Raw equality silently discarded the
|
||||
// operator's floor exactly on this shape (the --comment gate above
|
||||
// binds through the same hostsEquivalent).
|
||||
expect(
|
||||
recoverFloor(
|
||||
'https://code.alibaba-inc.com/o/r/codereview/123 --severity-floor critical',
|
||||
{ callerRepo: 'o/r', callerHost: 'gitlab.alibaba-inc.com' },
|
||||
),
|
||||
).toEqual({ floor: 'critical', source: 'explicit' });
|
||||
// A genuinely different host still recovers nothing.
|
||||
expect(
|
||||
recoverFloor(
|
||||
'https://code.alibaba-inc.com/o/r/codereview/123 --severity-floor critical',
|
||||
{ callerRepo: 'o/r', callerHost: 'github.com' },
|
||||
),
|
||||
).toBeUndefined();
|
||||
// …and the CALLER's identity outranks the plan's on every axis — repo:
|
||||
// a mis-transcribed planPath naming another repo must not stand the
|
||||
// CLI-typed repo's bar down…
|
||||
|
|
@ -564,6 +612,31 @@ describe('authorization — URL-shaped host and repo binding at the submit call
|
|||
userAuthorized: true,
|
||||
}).recordedHost,
|
||||
).toBe('code.alibaba-inc.com');
|
||||
// The NON-Aone recorded host pins too — a gate regression keeping only
|
||||
// Aone-family hosts would drop this binding and leave the cwd probe to
|
||||
// select the platform (a github-recorded review, published from an
|
||||
// Aone-origin clone, posts at Aone's same-named repo).
|
||||
expect(
|
||||
authFor('123 --host github.com --comment', { userAuthorized: true })
|
||||
.recordedHost,
|
||||
).toBe('github.com');
|
||||
// The repo axis binds case-INSENSITIVELY — GitHub resolves owner/repo
|
||||
// case-insensitively server-side, so any casing variant is a valid
|
||||
// target; a case-sensitive comparison silently dropped the recording
|
||||
// out of platform selection (the slow path and the floor recovery both
|
||||
// lowercase both sides).
|
||||
expect(
|
||||
authFor('https://code.alibaba-inc.com/O/R/codereview/123 --comment', {
|
||||
userAuthorized: true,
|
||||
repo: 'o/r',
|
||||
}).recordedHost,
|
||||
).toBe('code.alibaba-inc.com');
|
||||
expect(
|
||||
authFor('https://code.alibaba-inc.com/o/r/codereview/123 --comment', {
|
||||
userAuthorized: true,
|
||||
repo: 'O/R',
|
||||
}).recordedHost,
|
||||
).toBe('code.alibaba-inc.com');
|
||||
const bare = authFor('123 --comment', { userAuthorized: true });
|
||||
expect(bare.recordedHost).toBeUndefined();
|
||||
expect(bare.recordedUnbound).toBe(true);
|
||||
|
|
@ -580,12 +653,15 @@ describe('authorization — URL-shaped host and repo binding at the submit call
|
|||
});
|
||||
});
|
||||
|
||||
describe('the user-authorized fast path keeps the refusal shut (round-6 witness)', () => {
|
||||
describe('the user-authorized fast path binds a recorded Aone target (round-6 witness)', () => {
|
||||
// End to end through the REAL gate: a review recorded against an Aone
|
||||
// codereview URL, then `submit --user-authorized` with no --host and no
|
||||
// GH_HOST from a cwd whose probe reads GitHub (the registry mock). Before
|
||||
// the fast path surfaced recordedHost, the refusal's environment fallback
|
||||
// saw nothing Aone and the review POSTed at github.com's same-named repo.
|
||||
// the fast path surfaced recordedHost, the environment fallback saw
|
||||
// nothing Aone and the review POSTed at github.com's same-named repo.
|
||||
// Now that Aone is a posting target, the same binding routes the write
|
||||
// at the a1 seam — the wrong-host leak class is unchanged, only the
|
||||
// platform the correct post lands on moved.
|
||||
let savedGhHost: string | undefined;
|
||||
beforeEach(() => {
|
||||
savedGhHost = process.env['GH_HOST'];
|
||||
|
|
@ -596,7 +672,7 @@ describe('the user-authorized fast path keeps the refusal shut (round-6 witness)
|
|||
else process.env['GH_HOST'] = savedGhHost;
|
||||
});
|
||||
|
||||
it('refuses a recorded Aone target even when the user authorised the post', () => {
|
||||
it('posts a recorded Aone target through a1, never gh', () => {
|
||||
const skillArgs = file(
|
||||
'fast-path-aone.txt',
|
||||
'https://code.alibaba-inc.com/g/p/codereview/123 --comment\n',
|
||||
|
|
@ -608,11 +684,12 @@ describe('the user-authorized fast path keeps the refusal shut (round-6 witness)
|
|||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(3);
|
||||
const out = JSON.parse(
|
||||
writeStdoutSpy.mock.calls.map((c) => String(c[0])).join(''),
|
||||
) as { posted?: boolean; reason?: string };
|
||||
expect(out).toEqual({ posted: false, reason: 'aone-read-only-phase' });
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(aoneSubmitMock).toHaveBeenCalledTimes(1);
|
||||
expect(aoneSubmitMock.mock.calls[0][0]).toMatchObject({
|
||||
prNumber: 123,
|
||||
ownerRepo: 'g/p',
|
||||
});
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -638,7 +715,7 @@ describe('the user-authorized fast path binds the recorded host cross-session',
|
|||
rmSync(siblingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('refuses when a SIBLING session recorded the same PR on Aone', () => {
|
||||
it('routes at a1 when a SIBLING session recorded the same PR on Aone', () => {
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({ userAuthorized: true, pr: 42, repo: 'maxcompute/odps_src' }),
|
||||
|
|
@ -646,18 +723,21 @@ describe('the user-authorized fast path binds the recorded host cross-session',
|
|||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(3);
|
||||
const out = JSON.parse(
|
||||
writeStdoutSpy.mock.calls.map((c) => String(c[0])).join(''),
|
||||
) as { posted?: boolean; reason?: string };
|
||||
expect(out).toEqual({ posted: false, reason: 'aone-read-only-phase' });
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(aoneSubmitMock).toHaveBeenCalledTimes(1);
|
||||
expect(aoneSubmitMock.mock.calls[0][0]).toMatchObject({
|
||||
prNumber: 42,
|
||||
ownerRepo: 'maxcompute/odps_src',
|
||||
});
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not bind a sibling recording of a DIFFERENT PR', () => {
|
||||
// A stale recording of another PR must not supply a host — the refusal
|
||||
// would fire on the wrong target, and a stale non-Aone host would
|
||||
// suppress the environment arms.
|
||||
// A stale recording of another PR must not supply a host. Under the
|
||||
// fail-closed gate, a write whose number no recording names is refused
|
||||
// as unbound — which is itself the proof the stale host was NOT used:
|
||||
// if it had been, the recorded Aone host would bind and the review
|
||||
// would post at Aone instead of refusing.
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({ userAuthorized: true, pr: 999, repo: 'maxcompute/odps_src' }),
|
||||
|
|
@ -665,15 +745,19 @@ describe('the user-authorized fast path binds the recorded host cross-session',
|
|||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
// ghWithInput is aliased onto ghMock in this file: the post reached the
|
||||
// wire (the write proceeded instead of refusing on a stale host).
|
||||
expect(ghMock).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(
|
||||
JSON.parse(writeStdoutSpy.mock.calls.map((c) => String(c[0])).join('')),
|
||||
).toEqual({ posted: false, reason: 'target-platform-unbound' });
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('binds the repo too — a different-repo same-number recording supplies nothing', () => {
|
||||
// The recording names PR 42 of ANOTHER repo; the write targets
|
||||
// maxcompute/odps_src — the host must not cross the repo boundary.
|
||||
// maxcompute/odps_src — the host must not cross the repo boundary. The
|
||||
// unbound refusal is the proof: had the other repo's host bound, this
|
||||
// would post at Aone instead of refusing.
|
||||
writeFileSync(
|
||||
siblingFile,
|
||||
'https://code.alibaba-inc.com/other/repo/codereview/42 --comment\n',
|
||||
|
|
@ -686,18 +770,22 @@ describe('the user-authorized fast path binds the recorded host cross-session',
|
|||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(ghMock).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(
|
||||
JSON.parse(writeStdoutSpy.mock.calls.map((c) => String(c[0])).join('')),
|
||||
).toEqual({ posted: false, reason: 'target-platform-unbound' });
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FAILS CLOSED on a bare-number recording with no host evidence', () => {
|
||||
// The canonical Aone invocation shape (`/review <global-MR-id>`)
|
||||
// records a bare number — no URL, no host. A cross-session publish of
|
||||
// it cannot prove the target is NOT Aone, and the runtime environment
|
||||
// (cwd pinned non-Aone here, no --host, no GH_HOST) cannot either:
|
||||
// the write refuses and names the remedy instead of posting the
|
||||
// review at github.com's same-named repo (the round-12 witness: this
|
||||
// once exited 0 and POSTed).
|
||||
// it cannot prove WHERE the target lives, and the runtime environment
|
||||
// (cwd pinned non-Aone here, no --host, no GH_HOST) cannot either.
|
||||
// Both platforms are writable now, so the guess would land the review
|
||||
// on the wrong one's same-named repo — the write refuses and names
|
||||
// the remedy (the round-12 witness: this once exited 0 and POSTed).
|
||||
writeFileSync(siblingFile, '42 --comment\n', 'utf8');
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
|
|
@ -710,14 +798,46 @@ describe('the user-authorized fast path binds the recorded host cross-session',
|
|||
const out = JSON.parse(
|
||||
writeStdoutSpy.mock.calls.map((c) => String(c[0])).join(''),
|
||||
) as { posted?: boolean; reason?: string };
|
||||
expect(out).toEqual({ posted: false, reason: 'aone-read-only-phase' });
|
||||
expect(out).toEqual({ posted: false, reason: 'target-platform-unbound' });
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FAILS CLOSED when NO recording exists at all (fast path, no host evidence)', () => {
|
||||
// recordedUnbound is only set when a recording EXISTS but carries no
|
||||
// host. When there is NO recording (writeSkillArgs never throws,
|
||||
// recordings are cwd-relative — a publish invoked from another
|
||||
// directory finds nothing), the lookup returns unbound: false. Before
|
||||
// this fix the refusal keyed on recordedUnbound alone, so the no-
|
||||
// recording case fell through to the cwd probe picking the platform of
|
||||
// an irreversible write. It must fail closed the same way.
|
||||
// (skillArgs points at a missing file — overriding args()'s default
|
||||
// recording — and no sibling session names #6771.)
|
||||
rmSync(siblingFile, { force: true });
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({
|
||||
userAuthorized: true,
|
||||
skillArgs: join(dir, 'no-recording-anywhere.txt'),
|
||||
}),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(3);
|
||||
const out = JSON.parse(
|
||||
writeStdoutSpy.mock.calls.map((c) => String(c[0])).join(''),
|
||||
) as { posted?: boolean; reason?: string };
|
||||
expect(out).toEqual({ posted: false, reason: 'target-platform-unbound' });
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a bare-number recording WITH a recorded --host binds the platform', () => {
|
||||
// The remedy the refusal names: the host flag recorded beside the
|
||||
// bare number is the platform evidence. A github-recorded host lets
|
||||
// the write through; an Aone-recorded host refuses it.
|
||||
// bare number is the platform evidence, and it now SELECTS the
|
||||
// platform the write lands on — a github-recorded host posts via gh,
|
||||
// an Aone-recorded host via the a1 seam.
|
||||
writeFileSync(siblingFile, '42 --host github.com --comment\n', 'utf8');
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
|
|
@ -728,8 +848,10 @@ describe('the user-authorized fast path binds the recorded host cross-session',
|
|||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(ghMock).toHaveBeenCalled();
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
|
||||
ghMock.mockClear();
|
||||
aoneSubmitMock.mockClear();
|
||||
writeStdoutSpy.mockClear();
|
||||
writeFileSync(
|
||||
siblingFile,
|
||||
|
|
@ -743,8 +865,326 @@ describe('the user-authorized fast path binds the recorded host cross-session',
|
|||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
expect(aoneSubmitMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('the --host remedy LIFTS the unbound refusal — the re-run posts', () => {
|
||||
// The refusal names `--host` as the remedy; an explicit flag on the
|
||||
// re-run is platform proof, so it must post, not refuse again (the
|
||||
// futile retry loop the refusal wording exists to prevent).
|
||||
writeFileSync(siblingFile, '42 --comment\n', 'utf8');
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({
|
||||
userAuthorized: true,
|
||||
pr: 42,
|
||||
repo: 'maxcompute/odps_src',
|
||||
host: 'gitlab.alibaba-inc.com',
|
||||
}),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(aoneSubmitMock).toHaveBeenCalledTimes(1);
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
|
||||
aoneSubmitMock.mockClear();
|
||||
ghMock.mockClear();
|
||||
writeStdoutSpy.mockClear();
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({
|
||||
userAuthorized: true,
|
||||
pr: 42,
|
||||
repo: 'maxcompute/odps_src',
|
||||
host: 'github.com',
|
||||
}),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
expect(ghMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a codereview-URL recording posts with the ALIASED git host (web vs git name of one platform)', () => {
|
||||
// parse-args records the CR URL's WEB host (code.alibaba-inc.com);
|
||||
// the skill's own --host rule for Aone targets carries the GIT host
|
||||
// (gitlab.alibaba-inc.com). The SLOW path binds hosts through
|
||||
// hostsEquivalent — raw equality refused this after the whole review
|
||||
// already ran. (userAuthorized stays OFF: the fast path never runs
|
||||
// the host binding this test pins.)
|
||||
const rec = file(
|
||||
'aone-url-slow.txt',
|
||||
'https://code.alibaba-inc.com/maxcompute/odps_src/codereview/42 --comment',
|
||||
);
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({
|
||||
skillArgs: rec,
|
||||
userAuthorized: false,
|
||||
pr: 42,
|
||||
repo: 'maxcompute/odps_src',
|
||||
host: 'gitlab.alibaba-inc.com',
|
||||
}),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(aoneSubmitMock).toHaveBeenCalledTimes(1);
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
|
||||
// A genuinely DIFFERENT host still refuses — the alias is not a
|
||||
// blanket exemption.
|
||||
aoneSubmitMock.mockClear();
|
||||
writeStdoutSpy.mockClear();
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({
|
||||
skillArgs: rec,
|
||||
userAuthorized: false,
|
||||
pr: 42,
|
||||
repo: 'maxcompute/odps_src',
|
||||
host: 'github.com',
|
||||
}),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a contradicting --host beside a recorded host refuses — the flag does not retarget the recorded review', () => {
|
||||
// The explicit flag FILLS a gap in the recorded evidence; it does
|
||||
// not override the recording's answer. A bare-number recording with
|
||||
// a recorded Aone host, submitted with an explicit github.com,
|
||||
// would retarget the irreversible write at github.com's same-named
|
||||
// repo — the fast path performs no gate host comparison of its own,
|
||||
// so the platform gate must refuse the contradiction itself.
|
||||
writeFileSync(siblingFile, '42 --host code.alibaba-inc.com --comment\n');
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({
|
||||
userAuthorized: true,
|
||||
pr: 42,
|
||||
repo: 'maxcompute/odps_src',
|
||||
host: 'github.com',
|
||||
}),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(
|
||||
JSON.parse(writeStdoutSpy.mock.calls.map((c) => String(c[0])).join('')),
|
||||
).toEqual({ posted: false, reason: 'target-platform-conflict' });
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
|
||||
// The ALIASED spelling is one platform, not a contradiction: the
|
||||
// canonical Aone post shape (CR-URL record + git-host flag) passes.
|
||||
process.exitCode = undefined;
|
||||
writeStdoutSpy.mockClear();
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({
|
||||
userAuthorized: true,
|
||||
pr: 42,
|
||||
repo: 'maxcompute/odps_src',
|
||||
host: 'gitlab.alibaba-inc.com',
|
||||
}),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(aoneSubmitMock).toHaveBeenCalledTimes(1);
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the cross-session scan is last-writer-wins by the FILE mtime, not name order and not the directory mtime', () => {
|
||||
// Session ids are arbitrary strings, so name order is a coin flip. The
|
||||
// record itself is last-writer-wins; the scan must read it the same
|
||||
// way, or an OLDER session's same-number recording supplies a stale
|
||||
// host that masks the newest recording's hostlessness. Aone's small
|
||||
// global MR ids collide with GitHub PR numbers easily, so the stale
|
||||
// host routes an irreversible write at the wrong platform.
|
||||
//
|
||||
// The sort key is the recording FILE's mtime — writeSkillArgs
|
||||
// rewrites the file in place (O_WRONLY|O_CREAT|O_TRUNC, no
|
||||
// unlink/rename), which advances the file's mtime and never the
|
||||
// parent directory's. The directory mtimes below are stamped
|
||||
// BACKWARDS on purpose: a scan keyed on them would decide the
|
||||
// opposite way in both arms.
|
||||
const oldDir = join('.qwen', 'tmp', 's-mtime-old');
|
||||
const newDir = join('.qwen', 'tmp', 's-mtime-new');
|
||||
const oldFile = join(oldDir, 'qwen-skill-args-review.txt');
|
||||
const newFile = join(newDir, 'qwen-skill-args-review.txt');
|
||||
mkdirSync(oldDir, { recursive: true });
|
||||
mkdirSync(newDir, { recursive: true });
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// OLDER session carried a host; NEWER session recorded a bare number.
|
||||
writeFileSync(oldFile, '7 --host gitlab.alibaba-inc.com --comment\n');
|
||||
writeFileSync(newFile, '7 --comment\n');
|
||||
utimesSync(oldFile, now - 3600, now - 3600);
|
||||
utimesSync(newFile, now, now);
|
||||
utimesSync(oldDir, now, now);
|
||||
utimesSync(newDir, now - 3600, now - 3600);
|
||||
// The newest same-PR recording (hostless) decides → unbound refusal,
|
||||
// NOT a post at the stale session's Aone host — even though the
|
||||
// stale session's DIRECTORY is the newer one.
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({ userAuthorized: true, pr: 7, repo: 'maxcompute/odps_src' }),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(
|
||||
JSON.parse(writeStdoutSpy.mock.calls.map((c) => String(c[0])).join('')),
|
||||
).toEqual({ posted: false, reason: 'target-platform-unbound' });
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
|
||||
// Reverse the FILE mtimes: the host-carrying recording is now the
|
||||
// newest, so it binds and the write posts at its Aone host — even
|
||||
// though its directory is now the older one.
|
||||
process.exitCode = undefined;
|
||||
aoneSubmitMock.mockClear();
|
||||
writeStdoutSpy.mockClear();
|
||||
utimesSync(oldFile, now, now);
|
||||
utimesSync(newFile, now - 3600, now - 3600);
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({ userAuthorized: true, pr: 7, repo: 'maxcompute/odps_src' }),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(aoneSubmitMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rmSync(oldDir, { recursive: true, force: true });
|
||||
rmSync(newDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('the sessionless root recording joins the mtime ordering — newest decides', () => {
|
||||
// writeSkillArgs records at the ROOT level when no session id is
|
||||
// present. That recording is a candidate like any other — pinned
|
||||
// last, it could never win, and a newer hostless root record (the
|
||||
// ordinary headless re-run) would let an older session's stale host
|
||||
// bind the write. Under vitest the session-scoped candidate IS the
|
||||
// root file, so this also pins that the publishing session's own
|
||||
// recording joins the ordering instead of preceding it.
|
||||
const rootFile = join('.qwen', 'tmp', 'qwen-skill-args-review.txt');
|
||||
const siblingDir = join('.qwen', 'tmp', 's-root-mtime-sibling');
|
||||
const siblingFile = join(siblingDir, 'qwen-skill-args-review.txt');
|
||||
mkdirSync(siblingDir, { recursive: true });
|
||||
try {
|
||||
// The describe's beforeEach plants a sibling recording of this same
|
||||
// target with a fresh mtime; the stamps below reach past it in BOTH
|
||||
// directions so the ordering under test is the one under test.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Older sibling carries a host; NEWER root recording is hostless.
|
||||
writeFileSync(
|
||||
siblingFile,
|
||||
'https://code.alibaba-inc.com/maxcompute/odps_src/codereview/42 --comment\n',
|
||||
);
|
||||
writeFileSync(rootFile, '42 --comment\n');
|
||||
utimesSync(siblingFile, now - 3600, now - 3600);
|
||||
utimesSync(rootFile, now + 3600, now + 3600);
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({ userAuthorized: true, pr: 42, repo: 'maxcompute/odps_src' }),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(
|
||||
JSON.parse(writeStdoutSpy.mock.calls.map((c) => String(c[0])).join('')),
|
||||
).toEqual({ posted: false, reason: 'target-platform-unbound' });
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
|
||||
// Reverse: the hosted recording is newest, the hostless root
|
||||
// record must not veto it from a pinned-first position.
|
||||
process.exitCode = undefined;
|
||||
aoneSubmitMock.mockClear();
|
||||
writeStdoutSpy.mockClear();
|
||||
utimesSync(siblingFile, now + 3600, now + 3600);
|
||||
utimesSync(rootFile, now - 3600, now - 3600);
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({ userAuthorized: true, pr: 42, repo: 'maxcompute/odps_src' }),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(aoneSubmitMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rmSync(rootFile, { force: true });
|
||||
rmSync(siblingDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('a HOSTLESS recording read via the --skill-args seam refuses — the cwd probe must not stand in for the record of another cwd', () => {
|
||||
// Under vitest there is no session id, so the slow path reads the
|
||||
// caller-supplied seam file — the cross-cwd shape: the recording
|
||||
// belongs to another cwd, and the submission cwd's origin probe is
|
||||
// not platform evidence for it. A bare-number hostless recording
|
||||
// fails closed (the platform is unprovable); the --host remedy
|
||||
// lifts the refusal.
|
||||
const rec = file('override-hostless.txt', '7 --comment');
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({ skillArgs: rec, pr: 7, repo: 'maxcompute/odps_src' }),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(
|
||||
JSON.parse(writeStdoutSpy.mock.calls.map((c) => String(c[0])).join('')),
|
||||
).toEqual({ posted: false, reason: 'target-platform-unbound' });
|
||||
expect(
|
||||
writeStderrSpy.mock.calls.some((c) =>
|
||||
String(c[0]).includes('--skill-args'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
|
||||
// The remedy works: the explicit flag is platform proof.
|
||||
process.exitCode = undefined;
|
||||
writeStdoutSpy.mockClear();
|
||||
ghMock.mockClear();
|
||||
expect(() =>
|
||||
runSubmit(
|
||||
args({
|
||||
skillArgs: rec,
|
||||
pr: 7,
|
||||
repo: 'maxcompute/odps_src',
|
||||
host: 'github.com',
|
||||
}),
|
||||
'unknown',
|
||||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(ghMock).toHaveBeenCalled();
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never reads recordings planted OUTSIDE session dirs (worktree vector)', () => {
|
||||
|
|
@ -752,7 +1192,10 @@ describe('the user-authorized fast path binds the recorded host cross-session',
|
|||
// own tree — a malicious PR can plant a root-level args file that a
|
||||
// review materializes at a scanned path. Only `s-*` session
|
||||
// directories are scanned, so the planted host never reaches the
|
||||
// binding.
|
||||
// binding. With the legit session recording removed, NO recording
|
||||
// names #42 — the write gate fails closed (the planted host must not
|
||||
// be the evidence that saves it): if the planted file were scanned,
|
||||
// its Aone host would bind and the review would post at Aone.
|
||||
const plantedDir = join('.qwen', 'tmp', 'review-pr-42');
|
||||
mkdirSync(plantedDir, { recursive: true });
|
||||
writeFileSync(
|
||||
|
|
@ -774,10 +1217,14 @@ describe('the user-authorized fast path binds the recorded host cross-session',
|
|||
{ defaultComment: false },
|
||||
),
|
||||
).not.toThrow();
|
||||
// The planted Aone host did NOT bind: the write proceeds (cwd pinned
|
||||
// non-Aone by the registry mock).
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(ghMock).toHaveBeenCalled();
|
||||
// Refused as unbound — and, the proof the planted host never
|
||||
// reached the binding: no Aone post happened.
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(
|
||||
JSON.parse(writeStdoutSpy.mock.calls.map((c) => String(c[0])).join('')),
|
||||
).toEqual({ posted: false, reason: 'target-platform-unbound' });
|
||||
expect(aoneSubmitMock).not.toHaveBeenCalled();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(plantedDir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -932,7 +1379,16 @@ describe('the posting gate', () => {
|
|||
});
|
||||
|
||||
it('posts when the user typed `--comment`', () => {
|
||||
runSubmit(args({ skillArgs: file('skill-args.txt', '6771 --comment') }));
|
||||
// The bare-number recording carries no host, and this test runs
|
||||
// through the session-less --skill-args seam — the submission cwd's
|
||||
// platform must not stand in for the recording's missing evidence
|
||||
// (it refuses without the flag; the explicit host is the remedy).
|
||||
runSubmit(
|
||||
args({
|
||||
skillArgs: file('skill-args.txt', '6771 --comment'),
|
||||
host: 'github.com',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(ghMock).toHaveBeenCalledOnce();
|
||||
const call = ghMock.mock.calls[0] as unknown as string[];
|
||||
|
|
@ -946,6 +1402,23 @@ describe('the posting gate', () => {
|
|||
expect(call).toContain('-');
|
||||
});
|
||||
|
||||
it('refuses a malformed contextUnavailable on the GitHub path — the claim passes through raw', () => {
|
||||
// The gh path hands the state's context claim through RAW so
|
||||
// compose-review's deliberate shape check still refuses a
|
||||
// stringified boolean. Coercing the claim to a boolean first
|
||||
// (`=== true`) silently dropped the context-unavailable cap the
|
||||
// malformed value was asking for — a payload the archived
|
||||
// compose-review boundary refuses must not compose here.
|
||||
const review = file('ctx-malformed.json', {
|
||||
...REVIEW,
|
||||
state: { ...REVIEW.state, contextUnavailable: 'true' },
|
||||
});
|
||||
expect(() => runSubmit(args({ review, userAuthorized: true }))).toThrow(
|
||||
/does not compose into a verdict/,
|
||||
);
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts when the user asked for it in so many words', () => {
|
||||
runSubmit(args({ userAuthorized: true }));
|
||||
expect(ghMock).toHaveBeenCalledOnce();
|
||||
|
|
@ -1079,15 +1552,26 @@ describe('payload consistency — refuse before GitHub sees it', () => {
|
|||
return plan;
|
||||
}
|
||||
|
||||
/** Run with the transcript env the stripped-`env` compose path reads. */
|
||||
/** Run with the transcript env the stripped-`env` compose path reads.
|
||||
* Also seeds the session-scoped recording the write gate binds when a
|
||||
* session id is present (the caller-supplied skillArgs is ignored by
|
||||
* design then) — a github.com pr-url record, the platform evidence. */
|
||||
function withVerifyEnv(fn: () => void): void {
|
||||
const prevDir = process.env['QWEN_CODE_PROJECT_DIR'];
|
||||
const prevSession = process.env['QWEN_CODE_SESSION_ID'];
|
||||
process.env['QWEN_CODE_PROJECT_DIR'] = dir;
|
||||
process.env['QWEN_CODE_SESSION_ID'] = 'SUBV';
|
||||
const sessionRecDir = join('.qwen', 'tmp', 's-SUBV');
|
||||
mkdirSync(sessionRecDir, { recursive: true });
|
||||
const sessionRec = join(sessionRecDir, 'qwen-skill-args-review.txt');
|
||||
writeFileSync(
|
||||
sessionRec,
|
||||
'https://github.com/QwenLM/qwen-code/pull/6771\n',
|
||||
);
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
rmSync(sessionRecDir, { recursive: true, force: true });
|
||||
if (prevDir === undefined) delete process.env['QWEN_CODE_PROJECT_DIR'];
|
||||
else process.env['QWEN_CODE_PROJECT_DIR'] = prevDir;
|
||||
if (prevSession === undefined) delete process.env['QWEN_CODE_SESSION_ID'];
|
||||
|
|
@ -1190,10 +1674,15 @@ describe('payload consistency — refuse before GitHub sees it', () => {
|
|||
// Wiring leg: hardcoded or dropped `defaultComment` in the handler would
|
||||
// leave the direct runSubmit test green while production submissions
|
||||
// ignore the setting. The args file names the PR but carries no
|
||||
// --comment; only the setting authorises.
|
||||
// --comment; only the setting authorises. The explicit host is the
|
||||
// platform evidence the hostless seam recording lacks (see the
|
||||
// session-less override refusal).
|
||||
reviewSettingsMock.mockReturnValue({ attribution: true, comment: true });
|
||||
await submitCommand.handler?.(
|
||||
args({ skillArgs: file('handler-comment-args.txt', '6771') }) as never,
|
||||
args({
|
||||
skillArgs: file('handler-comment-args.txt', '6771'),
|
||||
host: 'github.com',
|
||||
}) as never,
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
|
|
@ -1219,6 +1708,7 @@ describe('payload consistency — refuse before GitHub sees it', () => {
|
|||
args({
|
||||
review,
|
||||
skillArgs: file('handler-floor-args.txt', '6771 --comment'),
|
||||
host: 'github.com',
|
||||
}) as never,
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalledOnce();
|
||||
|
|
@ -1654,10 +2144,13 @@ describe('payload consistency — refuse before GitHub sees it', () => {
|
|||
|
||||
it('the standing review.comment setting authorises a post without --comment in the args', () => {
|
||||
// The setting replaces the flag, not the binding: the recorded arguments
|
||||
// still name the PR, and only that PR.
|
||||
runSubmit(args({ skillArgs: file('skill-args.txt', '6771') }), 'unknown', {
|
||||
defaultComment: true,
|
||||
});
|
||||
// still name the PR, and only that PR. The explicit host is the
|
||||
// platform evidence the hostless seam recording lacks.
|
||||
runSubmit(
|
||||
args({ skillArgs: file('skill-args.txt', '6771'), host: 'github.com' }),
|
||||
'unknown',
|
||||
{ defaultComment: true },
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalled();
|
||||
|
||||
ghMock.mockClear();
|
||||
|
|
@ -2037,6 +2530,7 @@ describe('payload consistency — refuse before GitHub sees it', () => {
|
|||
'floor-args.txt',
|
||||
'6771 --comment --severity-floor critical',
|
||||
),
|
||||
host: 'github.com',
|
||||
}),
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalledOnce();
|
||||
|
|
@ -2081,6 +2575,7 @@ describe('payload consistency — refuse before GitHub sees it', () => {
|
|||
`floor-equal-args-${stateFloor}.txt`,
|
||||
'6771 --comment --severity-floor critical',
|
||||
),
|
||||
host: 'github.com',
|
||||
}),
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalledOnce();
|
||||
|
|
@ -2116,6 +2611,7 @@ describe('payload consistency — refuse before GitHub sees it', () => {
|
|||
'floor-auto-args.txt',
|
||||
'6771 --comment --severity-floor auto',
|
||||
),
|
||||
host: 'github.com',
|
||||
}),
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalledOnce();
|
||||
|
|
@ -2184,6 +2680,7 @@ describe('payload consistency — refuse before GitHub sees it', () => {
|
|||
'floor-reverse-args.txt',
|
||||
'6771 --comment --severity-floor suggestion',
|
||||
),
|
||||
host: 'github.com',
|
||||
}),
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalledOnce();
|
||||
|
|
@ -2215,6 +2712,7 @@ describe('payload consistency — refuse before GitHub sees it', () => {
|
|||
args({
|
||||
review,
|
||||
skillArgs: file('floor-configured-args.txt', '6771 --comment'),
|
||||
host: 'github.com',
|
||||
}),
|
||||
'unknown',
|
||||
{ defaultSeverityFloor: 'critical' },
|
||||
|
|
@ -2686,12 +3184,21 @@ describe('the ledger marker on the body that reaches GitHub', () => {
|
|||
process.env['QWEN_CODE_PROJECT_DIR'] = dir;
|
||||
process.env['QWEN_CODE_SESSION_ID'] = SESSION;
|
||||
process.env['QWEN_CODE_MODEL'] = 'the-session-model';
|
||||
// Seed the session-scoped recording the write gate binds when a
|
||||
// session id is present — the platform evidence.
|
||||
const sessionRecDir = join('.qwen', 'tmp', `s-${SESSION}`);
|
||||
mkdirSync(sessionRecDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sessionRecDir, 'qwen-skill-args-review.txt'),
|
||||
'https://github.com/QwenLM/qwen-code/pull/6771\n',
|
||||
);
|
||||
try {
|
||||
runSubmit(authorized({ review }));
|
||||
const ledger = parseLedger(posted().body);
|
||||
expect(ledger?.sha).toBe('deadbeef00112233');
|
||||
expect(ledger?.model).toBe('the-session-model');
|
||||
} finally {
|
||||
rmSync(sessionRecDir, { recursive: true, force: true });
|
||||
for (const [key, prev] of [
|
||||
['QWEN_CODE_PROJECT_DIR', prevDir],
|
||||
['QWEN_CODE_SESSION_ID', prevSession],
|
||||
|
|
|
|||
|
|
@ -67,7 +67,17 @@ import {
|
|||
recordedSeverityFloor,
|
||||
reviewWriteAuthorization,
|
||||
} from './lib/authorization.js';
|
||||
import { getPlatformReader, isAoneHost } from './lib/platform/registry.js';
|
||||
import {
|
||||
hostsEquivalent,
|
||||
isAoneCanonicalHost,
|
||||
parseRemoteUrl,
|
||||
} from './lib/remote-match.js';
|
||||
import { gitOpt } from './lib/git.js';
|
||||
import {
|
||||
AonePartialPostError,
|
||||
submitAoneReview,
|
||||
type AoneSubmitResult,
|
||||
} from './lib/platform/aone.js';
|
||||
import {
|
||||
CRITICAL_PREFIX,
|
||||
SUGGESTION_PREFIX,
|
||||
|
|
@ -201,6 +211,7 @@ function authorization(
|
|||
why: string;
|
||||
recordedHost?: string;
|
||||
recordedUnbound?: boolean;
|
||||
viaSkillArgsOverride?: boolean;
|
||||
} {
|
||||
return reviewWriteAuthorization({
|
||||
userAuthorized: args.userAuthorized,
|
||||
|
|
@ -237,6 +248,17 @@ function compose(
|
|||
cliVersion: string,
|
||||
attribution: boolean,
|
||||
runtimeModelId: string | undefined,
|
||||
/**
|
||||
* The Aone write path FORCES context-unavailable, whatever the
|
||||
* model-written state claims: this phase has no Aone backing for
|
||||
* pr-context/comment-status/presubmit, so no Aone run can have read the
|
||||
* MR's existing discussion. Letting the state's `contextUnavailable`
|
||||
* decide would let a forged or omitted field compose an APPROVE that the
|
||||
* a1 path then turns into a REAL platform approval — the exact forgery
|
||||
* class this command exists to defeat. The cap lives HERE, where
|
||||
* `aoneWrite` is a fact, not in the state.
|
||||
*/
|
||||
aoneWrite: boolean,
|
||||
): {
|
||||
event: string;
|
||||
body: string;
|
||||
|
|
@ -276,6 +298,13 @@ function compose(
|
|||
const r = composeReview(
|
||||
{
|
||||
...rest,
|
||||
// Forced for the Aone write path — see the parameter comment. For
|
||||
// GitHub the state's own claim stands (the reads are backed there)
|
||||
// and is handed through RAW: compose-review's boundary deliberately
|
||||
// refuses a malformed non-boolean here, and coercing the claim to a
|
||||
// boolean first would silently drop the context-unavailable cap a
|
||||
// stringified "true" was asking for.
|
||||
contextUnavailable: aoneWrite ? true : rest.contextUnavailable,
|
||||
criticalsInline,
|
||||
suggestionsInline,
|
||||
draftedComments: comments,
|
||||
|
|
@ -475,7 +504,6 @@ export function runSubmit(
|
|||
} = {},
|
||||
): void {
|
||||
const { attribution = true, defaultComment = false } = opts;
|
||||
setGhHost(args.host);
|
||||
|
||||
// The repo goes straight into the API path. A malformed value does not fail
|
||||
// safely — it fails as a confusing 404 from a URL nobody meant to build.
|
||||
|
|
@ -540,64 +568,61 @@ export function runSubmit(
|
|||
return;
|
||||
}
|
||||
|
||||
// Posting is GitHub-only in this phase. On an Aone target the Create
|
||||
// Review API does not exist — refuse with the SAME shape as an
|
||||
// unauthorised refusal (stderr explanation, stdout `{"posted": false}`,
|
||||
// exit 3): the skill's Step 7 treats that shape as a complete, correct
|
||||
// outcome, and a throw instead would surface as a failed command an agent
|
||||
// might retry or route around. The refusal sits BELOW the authorisation
|
||||
// gate on purpose — an unauthorised Aone run takes the normal exit-3
|
||||
// path above, and the command no longer dies with a throw before the gate
|
||||
// can rule (an authorised Aone `--dry-run` lands on this same exit-3
|
||||
// refusal: a payload that can never post has no posting-consistency to
|
||||
// validate).
|
||||
//
|
||||
// The platform decision is bound in BOTH directions, because the
|
||||
// runtime-effective host alone fails both ways:
|
||||
// - Recorded Aone target + non-Aone effective host (an ambient GH_HOST
|
||||
// export beside a bare-MR-number Aone review) must still refuse —
|
||||
// otherwise the read-only guarantee leaks and the review POSTs to the
|
||||
// wrong host's same-named repo. So a recorded Aone host always
|
||||
// refuses, whatever the environment resolves.
|
||||
// - Recorded non-Aone target (pr-url host binding) must NOT be vetoed
|
||||
// by the cwd probe from an Aone-origin clone — the recorded binding
|
||||
// is the explicit signal the registry's precedence documents.
|
||||
// - RECORDED but hostless (a bare-MR-number recording with no `--host`
|
||||
// flag — the canonical Aone invocation shape carries no URL): the
|
||||
// recording proves a review exists but not WHERE it lives, and the
|
||||
// runtime environment cannot prove it either. For a public,
|
||||
// irreversible write that is fail-CLOSED: refuse and name the remedy
|
||||
// (`--host`), instead of trusting the environment and posting the
|
||||
// review at github.com's same-named repo.
|
||||
// - No recording at all: fall back to the flag, then GH_HOST (ghEnv
|
||||
// inherits the operator's export when no module host is set, so an
|
||||
// Aone-pointing GH_HOST must hit this refusal, not an opaque gh
|
||||
// failure), then the cwd clone.
|
||||
// resolveGhHost trims, so a padded `--host` cannot slip past detection.
|
||||
// The findings are not lost: they are in the terminal output and the
|
||||
// saved report.
|
||||
// Which PLATFORM this write lands on. Evidence precedence mirrors the
|
||||
// registry's documented detection order — an EXPLICIT host flag, else
|
||||
// the recorded binding, else the cwd probe — with four write-specific
|
||||
// disciplines:
|
||||
// - The predicate is the CANONICAL Aone pair, not the family wildcard:
|
||||
// `*.alibaba-inc.com` also names GitHub Enterprise instances (an
|
||||
// org's `ghe.alibaba-inc.com`), and an irreversible write must not
|
||||
// take the a1 path on a family resemblance.
|
||||
// - The ambient GH_HOST export is NEVER consulted here. It is a
|
||||
// GitHub-ROUTING variable; a read would never detect Aone from it
|
||||
// (detectPlatformKind does not read it), and a write that did could
|
||||
// READ from one platform and WRITE to another.
|
||||
// - The FAST path with no host evidence at all — a recording that
|
||||
// names no host (a bare-MR-number recording without `--host`), or NO
|
||||
// recording found (writeSkillArgs never throws, recordings are
|
||||
// cwd-relative — a publish invoked from another directory finds
|
||||
// nothing) — fails CLOSED and names the remedy (`--host`), which
|
||||
// this gate honours: an explicit flag on the re-run is platform
|
||||
// proof, so it lifts the refusal instead of meeting it again. The
|
||||
// cwd probe may still decide a SLOW-path publish — that path reads
|
||||
// the current session's own recording, so it is same-session by
|
||||
// construction and the cwd names the clone the review ran in. The
|
||||
// ONE slow-path shape that is not — a session-less caller reading a
|
||||
// `--skill-args` override, another cwd's record — fails closed on
|
||||
// its hostless form too: the probe names submit's clone there, not
|
||||
// the review's.
|
||||
// - An explicit `--host` and a recorded host are ONE evidence chain
|
||||
// about where the reviewed target lives: the flag FILLS the gap
|
||||
// when the recording names no host (the remedy above), it does not
|
||||
// override the recording's answer. Two hosts that are not the same
|
||||
// platform (through hostsEquivalent, so the Aone web/git alias
|
||||
// passes) name a contradiction — the review ran on one, and the
|
||||
// write would land on the other's same-named repo — so the gate
|
||||
// refuses instead of choosing. The recorded host is the user's own
|
||||
// keystrokes; a caller-typed flag is not entitled to retarget it.
|
||||
const recordedHost = auth.recordedHost;
|
||||
const aoneWrite =
|
||||
isAoneHost(recordedHost) ||
|
||||
auth.recordedUnbound === true ||
|
||||
(recordedHost === undefined &&
|
||||
(isAoneHost(resolveGhHost(args.host)) ||
|
||||
getPlatformReader({ host: args.host?.trim() || undefined }).kind ===
|
||||
'aone'));
|
||||
if (aoneWrite) {
|
||||
const explicitHost = args.host?.trim() || undefined;
|
||||
if (
|
||||
explicitHost !== undefined &&
|
||||
recordedHost !== undefined &&
|
||||
!hostsEquivalent(explicitHost, recordedHost)
|
||||
) {
|
||||
writeStderrLine(
|
||||
`REFUSED to post to ${args.repo}#${args.pr}: posting review comments ` +
|
||||
`to Aone Code is not supported yet (read-only phase). The findings ` +
|
||||
`are in the terminal output and the saved report; post them ` +
|
||||
`manually or wait for the write phase.` +
|
||||
(auth.recordedUnbound === true && !isAoneHost(recordedHost)
|
||||
? ` (the recorded target names no platform — pass \`--host\` to ` +
|
||||
`prove it is not an Aone MR)`
|
||||
: ''),
|
||||
`REFUSED to post to ${args.repo}#${args.pr}: the explicit ` +
|
||||
`\`--host ${explicitHost}\` contradicts the host the recorded ` +
|
||||
`review names (\`${recordedHost}\`) — the two are not the same ` +
|
||||
`platform, and a public write must not be retargeted from the ` +
|
||||
`platform its review ran on to another platform's same-named ` +
|
||||
`repo. Re-run without \`--host\` to post where the recorded ` +
|
||||
`review ran, or re-run the review for ${explicitHost} first. ` +
|
||||
`The findings are in the terminal output and the saved report.`,
|
||||
);
|
||||
writeStdoutLine(
|
||||
JSON.stringify(
|
||||
{ posted: false, reason: 'aone-read-only-phase' },
|
||||
{ posted: false, reason: 'target-platform-conflict' },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
|
|
@ -605,6 +630,67 @@ export function runSubmit(
|
|||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
const overrideHostless =
|
||||
!args.userAuthorized &&
|
||||
auth.viaSkillArgsOverride === true &&
|
||||
recordedHost === undefined;
|
||||
const fastPathHostless =
|
||||
auth.recordedUnbound === true ||
|
||||
(args.userAuthorized && recordedHost === undefined);
|
||||
if ((fastPathHostless || overrideHostless) && explicitHost === undefined) {
|
||||
// Same exit-3 shape as an unauthorised refusal — Step 7 treats it as
|
||||
// a complete, correct outcome; a throw would surface as a failed
|
||||
// command an agent might retry or route around.
|
||||
writeStderrLine(
|
||||
`REFUSED to post to ${args.repo}#${args.pr}: nothing this gate ` +
|
||||
`can read names the platform the target lives on — ` +
|
||||
(auth.recordedUnbound === true
|
||||
? `the recorded review is a bare PR number with no \`--host\``
|
||||
: overrideHostless
|
||||
? `the authorising recording came from the \`--skill-args\` ` +
|
||||
`override — another cwd's record that names no host — and ` +
|
||||
`the submission cwd's platform must not stand in for it`
|
||||
: `no recorded review names this target at all`) +
|
||||
` — and a public write must not guess between GitHub and Aone ` +
|
||||
`Code. Re-run with \`--host <host>\` naming the host the target ` +
|
||||
`lives on. The findings are in the terminal output and the saved ` +
|
||||
`report.`,
|
||||
);
|
||||
writeStdoutLine(
|
||||
JSON.stringify(
|
||||
{ posted: false, reason: 'target-platform-unbound' },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
// The cwd arm probes the origin's host through the SAME canonical
|
||||
// predicate — it must not delegate to the registry's detection, which
|
||||
// matches the `*.alibaba-inc.com` FAMILY wildcard: safe for reads, not
|
||||
// for writes — an origin on an org GHE family host (ghe.alibaba-inc.com)
|
||||
// would take the a1 path with nothing proving a canonical Aone target.
|
||||
// A family-only resemblance falls through to the gh path.
|
||||
const cwdOriginUrl = gitOpt('remote', 'get-url', 'origin');
|
||||
const cwdOriginHost = cwdOriginUrl
|
||||
? parseRemoteUrl(cwdOriginUrl)?.host
|
||||
: undefined;
|
||||
const aoneWrite =
|
||||
isAoneCanonicalHost(explicitHost ?? recordedHost) ||
|
||||
(auth.viaSkillArgsOverride !== true &&
|
||||
explicitHost === undefined &&
|
||||
recordedHost === undefined &&
|
||||
isAoneCanonicalHost(cwdOriginHost));
|
||||
// The gh write binds its routing host to the SAME evidence that selected
|
||||
// it: an explicit flag, else the recorded binding, else the cwd origin
|
||||
// the selection arm ran on. Without the rebind a recorded non-Aone host
|
||||
// (e.g. a GHE instance) posted wherever the ambient env pointed —
|
||||
// github.com's same-named repo — instead of where the review actually
|
||||
// ran; and a cwd-selected post restored ambient env inheritance, routing
|
||||
// the write past the very clone that chose the platform. setGhHost
|
||||
// validates its input; a1 writes never touch the gh host state.
|
||||
if (!aoneWrite) setGhHost(explicitHost ?? recordedHost ?? cwdOriginHost);
|
||||
|
||||
// What the caller may not bring, checked before anything is computed from it: a
|
||||
// verdict of its own, or no state to compute one from. "Your state does not
|
||||
|
|
@ -653,7 +739,15 @@ export function runSubmit(
|
|||
: undefined,
|
||||
callerPr: args.pr,
|
||||
callerRepo: args.repo,
|
||||
callerHost: resolveGhHost(args.host),
|
||||
// The host axis binds to the host the WRITE actually routes at — the
|
||||
// SAME evidence chain the routing bind uses: explicit flag, else the
|
||||
// recorded binding, else the cwd origin the selection arm ran on,
|
||||
// else the gh fallback. resolveGhHost alone never yields a recorded
|
||||
// Aone host, so a flagless Aone post (routed via the recorded
|
||||
// binding) would bind the floor to github.com/ambient and silently
|
||||
// drop the operator's recorded floor.
|
||||
callerHost:
|
||||
explicitHost ?? recordedHost ?? cwdOriginHost ?? resolveGhHost(args.host),
|
||||
defaultSeverityFloor: opts.defaultSeverityFloor,
|
||||
skillArgs: args.skillArgs,
|
||||
});
|
||||
|
|
@ -699,6 +793,7 @@ export function runSubmit(
|
|||
// forgeable posture DESIGN.md records for the cache path.
|
||||
// The identity this round runs under — see lib/round-model.ts.
|
||||
roundModelIdFrom(process.env),
|
||||
aoneWrite,
|
||||
));
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
|
|
@ -735,42 +830,51 @@ export function runSubmit(
|
|||
);
|
||||
}
|
||||
|
||||
// What GitHub actually receives: the caller's findings, under the verdict this
|
||||
// command computed. `event` and `body` were never in the object the caller wrote.
|
||||
// What the platform receives: the caller's findings, under the verdict
|
||||
// this command computed. `event` and `body` were never in the object the
|
||||
// caller wrote. Both posting paths carry the SAME comments — the
|
||||
// attribution-off rewrite below is a property of the post, not of GitHub.
|
||||
// Attribution-off strips the severity markers from the POSTED bodies —
|
||||
// the one place the bracket-prefix template is visible. Everything above
|
||||
// (counting, the unmarked gate, the ledger) already ran on the marked
|
||||
// payload, so the verdict this post carries is unchanged. The invisible
|
||||
// comment marker goes on in the markers' place, carrying the severity
|
||||
// the visible prefix carried: presubmit dedups on it, and pr-context
|
||||
// re-promotes an unresolved Critical to the re-check section off it.
|
||||
// Pre-existing marker strings are stripped first — the shape is public,
|
||||
// and a reviewed file can quote it into a comment body; only the
|
||||
// canonical trailing marker may survive.
|
||||
const finalComments = attribution
|
||||
? (payload.comments ?? [])
|
||||
: (payload.comments ?? []).map((c) => {
|
||||
if (typeof c.body !== 'string') return c;
|
||||
// The gate above refuses unmarked bodies, so the severity is
|
||||
// always known here.
|
||||
const sev = severityOf(c);
|
||||
if (sev === null) return c;
|
||||
return {
|
||||
...c,
|
||||
// Exactly the body the gate above validated: a forged footer
|
||||
// the fixpoint chain exposes at the tail survives the
|
||||
// anywhere-strips' caps, and only the trailing strip removes
|
||||
// it — posting the gate's view is how the two cannot drift.
|
||||
body: `${stripReviewFooter(stripForUnattributedPost(c.body))}\n\n${commentMarker(sev)}`,
|
||||
};
|
||||
});
|
||||
|
||||
const post = {
|
||||
commit_id: payload.commit_id,
|
||||
event,
|
||||
body,
|
||||
// Attribution-off strips the severity markers from the POSTED bodies —
|
||||
// the one place the bracket-prefix template is visible. Everything above
|
||||
// (counting, the unmarked gate, the ledger) already ran on the marked
|
||||
// payload, so the verdict this post carries is unchanged. The invisible
|
||||
// comment marker goes on in the markers' place, carrying the severity
|
||||
// the visible prefix carried: presubmit dedups on it, and pr-context
|
||||
// re-promotes an unresolved Critical to the re-check section off it.
|
||||
// Pre-existing marker strings are stripped first — the shape is public,
|
||||
// and a reviewed file can quote it into a comment body; only the
|
||||
// canonical trailing marker may survive.
|
||||
comments: attribution
|
||||
? (payload.comments ?? [])
|
||||
: (payload.comments ?? []).map((c) => {
|
||||
if (typeof c.body !== 'string') return c;
|
||||
// The gate above refuses unmarked bodies, so the severity is
|
||||
// always known here.
|
||||
const sev = severityOf(c);
|
||||
if (sev === null) return c;
|
||||
return {
|
||||
...c,
|
||||
// Exactly the body the gate above validated: a forged footer
|
||||
// the fixpoint chain exposes at the tail survives the
|
||||
// anywhere-strips' caps, and only the trailing strip removes
|
||||
// it — posting the gate's view is how the two cannot drift.
|
||||
body: `${stripReviewFooter(stripForUnattributedPost(c.body))}\n\n${commentMarker(sev)}`,
|
||||
};
|
||||
}),
|
||||
comments: finalComments,
|
||||
};
|
||||
|
||||
const target = `repos/${args.repo}/pulls/${args.pr}/reviews`;
|
||||
const target = aoneWrite
|
||||
? `a1 repo mr comment create --mr ${args.pr} --repo ${args.repo}` +
|
||||
` (${finalComments.length} inline + summary` +
|
||||
(event === 'APPROVE' ? ' + a1 repo mr approve' : '') +
|
||||
`)`
|
||||
: `repos/${args.repo}/pulls/${args.pr}/reviews`;
|
||||
if (args.dryRun) {
|
||||
writeStderrLine(
|
||||
`Authorised (${auth.why}) and the payload is consistent. ` +
|
||||
|
|
@ -793,6 +897,170 @@ export function runSubmit(
|
|||
return;
|
||||
}
|
||||
|
||||
if (aoneWrite) {
|
||||
// The Aone posting path — one `a1 repo mr comment create` per inline
|
||||
// finding, the summary last, `a1 repo mr approve` on an APPROVE.
|
||||
// GitHub's Create Review is atomic; this is N+1 calls, so the failure
|
||||
// shapes differ: the provider throws AonePartialPostError when a write
|
||||
// fails mid-batch, and the report below names exactly what landed.
|
||||
let result: AoneSubmitResult;
|
||||
try {
|
||||
result = submitAoneReview({
|
||||
prNumber: args.pr,
|
||||
ownerRepo: args.repo,
|
||||
// The structural gate above refused a payload without one.
|
||||
commitId: payload.commit_id as string,
|
||||
event: event as 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT',
|
||||
body,
|
||||
// The consistency gate above refused every comment lacking these;
|
||||
// the `??` defaults exist only for the type.
|
||||
comments: finalComments.map((c) => ({
|
||||
path: c.path ?? '',
|
||||
line: c.line ?? 0,
|
||||
body: c.body ?? '',
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
const partial = err instanceof AonePartialPostError ? err : undefined;
|
||||
if (partial === undefined) {
|
||||
// Two shapes here. The DELIBERATE pre-write refusals (head drift,
|
||||
// oversized message) keep the exit-3 refusal shape: deterministic,
|
||||
// nothing landed, named in the skill's refusal-shape list.
|
||||
// EVERYTHING else — auth expiry, a DNS blip in the mr view read,
|
||||
// the 120 s deadline — is an ordinary command failure with
|
||||
// provably nothing landed: RETHROW it, the same shape the gh path
|
||||
// gives, so a recoverable blip is retryable instead of reading as
|
||||
// "a complete, correct outcome" and losing the authorised review.
|
||||
if (!((err as Error)?.message ?? '').startsWith('refusing to post:')) {
|
||||
throw err;
|
||||
}
|
||||
writeStderrLine(
|
||||
`REFUSED to post the review to ${args.repo}#${args.pr} on ` +
|
||||
`Aone Code: ${(err as Error).message} Nothing was written; ` +
|
||||
`the findings are in the terminal output and the saved report.`,
|
||||
);
|
||||
writeStdoutLine(
|
||||
JSON.stringify(
|
||||
{ posted: false, reason: 'aone-post-refused' },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
// A mid-batch failure: part of the review IS on the MR. The JSON
|
||||
// carries the structured counts AonePartialPostError exists for —
|
||||
// `posted: false` alone would let a wrapper that retries on
|
||||
// "not posted" double-post everything that landed. `partial: true`
|
||||
// is the do-not-retry signal; the ids make "inspect the MR"
|
||||
// concrete. `ambiguous` counts as landed: the FAILED write may have
|
||||
// reached the server (accepted, then the transport died), so the MR
|
||||
// can carry a comment the count never saw — and it rides the stdout
|
||||
// JSON too: all-zero counts with a silent ambiguous flag read as a
|
||||
// clean total failure, and a user hand-posting the "remainder"
|
||||
// double-posts the comment the count never saw.
|
||||
const landed =
|
||||
partial.postedInline > 0 || partial.summaryPosted || partial.ambiguous;
|
||||
writeStderrLine(
|
||||
`FAILED to post the review to ${args.repo}#${args.pr} on Aone ` +
|
||||
`Code: ${partial.message}` +
|
||||
(landed
|
||||
? ` Part of the review may already be on the MR — do NOT ` +
|
||||
`re-run submit (it would post twice); inspect the MR. ` +
|
||||
`Posting any remainder is the USER's call to make by hand ` +
|
||||
`— it is never an agent action.`
|
||||
: ''),
|
||||
);
|
||||
writeStdoutLine(
|
||||
JSON.stringify(
|
||||
{
|
||||
posted: false,
|
||||
reason: 'aone-post-failed',
|
||||
partial: true,
|
||||
postedInline: partial.postedInline,
|
||||
postedCommentIds: partial.inlineCommentIds,
|
||||
summaryPosted: partial.summaryPosted,
|
||||
ambiguous: partial.ambiguous,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
writeStderrLine(
|
||||
`Posted ${event} to ${args.repo}#${args.pr} — ${auth.why}` +
|
||||
(cappedBy.length ? ` (capped by ${cappedBy.join(', ')})` : '') +
|
||||
'.' +
|
||||
(result.webUrl ? ` ${result.webUrl}` : ''),
|
||||
);
|
||||
if (event === 'REQUEST_CHANGES') {
|
||||
// D6: no native reject exists on Aone — the blocking header and any
|
||||
// unresolved inline Critical discussions carry the semantics a GitHub
|
||||
// REQUEST_CHANGES event carries natively. But a REQUEST_CHANGES can
|
||||
// post with ZERO inline Criticals (they were all body-level), and
|
||||
// then nothing mechanically blocks the merge — say which shape this
|
||||
// was, counted off the same comments the consistency gate marked.
|
||||
const criticalsPosted = (payload.comments ?? []).filter(
|
||||
(c) => severityOf(c) === 'critical',
|
||||
).length;
|
||||
writeStderrLine(
|
||||
criticalsPosted > 0
|
||||
? `Note: Aone Code has no native request-changes state — the ` +
|
||||
`summary comment carries the blocking header, and the ` +
|
||||
`${criticalsPosted} inline Critical(s) block the merge ` +
|
||||
`while their discussions stay unresolved.`
|
||||
: `Note: Aone Code has no native request-changes state — the ` +
|
||||
`summary comment carries the blocking header, but this ` +
|
||||
`review posted NO inline Critical discussions, so nothing ` +
|
||||
`mechanically blocks the merge; the header is advisory.`,
|
||||
);
|
||||
}
|
||||
if (event === 'APPROVE' && !result.approved) {
|
||||
// Inline + summary are posted; only the native approval is missing.
|
||||
// The post stands — name the one command that completes it, and name
|
||||
// the USER as its actor: Step 7 forbids the agent every `a1` write,
|
||||
// and "run it by hand" without an actor would hand the agent the
|
||||
// exact call the rule exists to prevent.
|
||||
writeStderrLine(
|
||||
`WARNING: the review is posted but \`a1 repo mr approve ` +
|
||||
`${args.pr} --repo ${args.repo}\` failed` +
|
||||
(result.approveError ? ` (${result.approveError})` : '') +
|
||||
` — ask the USER to run that command to complete the approval; ` +
|
||||
`it is never an agent action.`,
|
||||
);
|
||||
}
|
||||
if (result.headMovedDuringPost) {
|
||||
// The drift gate is check-then-post; an AGit-Flow amend pushed
|
||||
// DURING the (minutes-long) batch orphans every inline comment. The
|
||||
// post stands — disclose that the pins may not.
|
||||
writeStderrLine(
|
||||
`WARNING: the MR head MOVED during posting — the inline comments ` +
|
||||
`may reference code the author already replaced. Re-review the ` +
|
||||
`new head before relying on the posted pins.`,
|
||||
);
|
||||
}
|
||||
writeStdoutLine(
|
||||
JSON.stringify(
|
||||
{
|
||||
posted: true,
|
||||
event,
|
||||
cappedBy,
|
||||
inlineComments: result.postedInline,
|
||||
floorEnforced: floorEnforced.length,
|
||||
summaryPosted: result.summaryPosted,
|
||||
...(event === 'APPROVE' ? { approved: result.approved } : {}),
|
||||
...(result.webUrl ? { url: result.webUrl } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the bytes we validated, over stdin — not the pathname. `--input <file>`
|
||||
// re-opens the file here, so another workspace process (or a symlink swap)
|
||||
// could replace or truncate it between the validation above and this call, and
|
||||
|
|
@ -873,7 +1141,7 @@ export function runSubmit(
|
|||
export const submitCommand: CommandModule = {
|
||||
command: 'submit',
|
||||
describe:
|
||||
'Post the review to GitHub — the ONLY write in this skill. Refuses unless the run is authorised to publish.',
|
||||
'Post the review to the pull request — GitHub via gh, Aone Code via a1 — the ONLY write in this skill. Refuses unless the run is authorised to publish.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('pr', {
|
||||
|
|
@ -905,7 +1173,8 @@ export const submitCommand: CommandModule = {
|
|||
})
|
||||
.option('host', {
|
||||
type: 'string',
|
||||
describe: 'GitHub Enterprise host (routes gh via GH_HOST)',
|
||||
describe:
|
||||
'The host the target lives on. SELECTS the platform the write lands on: a canonical Aone host (code./gitlab. alibaba-inc.com) routes the post at a1, anything else at gh (a GitHub Enterprise host routes gh via GH_HOST). It is also the remedy the target-platform-unbound refusal names.',
|
||||
})
|
||||
.option('dry-run', {
|
||||
type: 'boolean',
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@ const activeGoal = (
|
|||
};
|
||||
};
|
||||
|
||||
const goalWithStatus = (
|
||||
condition: string,
|
||||
status: 'paused' | 'blocked' | 'usage_limited' | 'complete',
|
||||
): BridgeSessionGoal => {
|
||||
const base = activeGoal(condition);
|
||||
return {
|
||||
...base,
|
||||
snapshot: {
|
||||
...base.snapshot,
|
||||
goal: { ...base.snapshot.goal!, status },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const noGoal: BridgeSessionGoal = {
|
||||
snapshot: { v: 2, activity: 'idle', goal: null },
|
||||
active: null,
|
||||
|
|
@ -165,6 +179,7 @@ describe('GET /goals', () => {
|
|||
iterations: 0,
|
||||
setAt: 2000,
|
||||
hasActivePrompt: true,
|
||||
snapshot: goals['s2'].snapshot,
|
||||
},
|
||||
{
|
||||
sessionId: 's1',
|
||||
|
|
@ -174,10 +189,54 @@ describe('GET /goals', () => {
|
|||
setAt: 1000,
|
||||
lastReason: 'two tests still fail',
|
||||
hasActivePrompt: false,
|
||||
snapshot: goals['s1'].snapshot,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['paused', 'blocked', 'usage_limited'] as const)(
|
||||
'lists a %s goal so its controls stay reachable',
|
||||
async (status) => {
|
||||
// A stopped goal is exactly the one the user needs to find in order to
|
||||
// resume it; listing only active goals hides it from the Goals page.
|
||||
const goals: Record<string, BridgeSessionGoal> = {
|
||||
s1: goalWithStatus('resume me', status),
|
||||
};
|
||||
const app = makeApp({
|
||||
listWorkspaceSessions: () => [summary('s1')],
|
||||
getSessionGoal: async (id) => goals[id],
|
||||
});
|
||||
|
||||
const res = await request(app).get('/goals');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.goals).toHaveLength(1);
|
||||
expect(res.body.goals[0]).toMatchObject({
|
||||
sessionId: 's1',
|
||||
condition: 'resume me',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('filters out a completed goal', async () => {
|
||||
// Without the exclusion a finished goal is listed forever.
|
||||
const goals: Record<string, BridgeSessionGoal> = {
|
||||
s1: goalWithStatus('already done', 'complete'),
|
||||
s2: activeGoal('still running'),
|
||||
};
|
||||
const app = makeApp({
|
||||
listWorkspaceSessions: () => [summary('s1'), summary('s2')],
|
||||
getSessionGoal: async (id) => goals[id],
|
||||
});
|
||||
|
||||
const res = await request(app).get('/goals');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
res.body.goals.map((goal: { sessionId: string }) => goal.sessionId),
|
||||
).toEqual(['s2']);
|
||||
});
|
||||
|
||||
it('drops a session whose probe rejects rather than failing the whole list', async () => {
|
||||
vi.mocked(writeStderrLine).mockClear();
|
||||
const app = makeApp({
|
||||
|
|
@ -199,6 +258,7 @@ describe('GET /goals', () => {
|
|||
iterations: 0,
|
||||
setAt: 1000,
|
||||
hasActivePrompt: false,
|
||||
snapshot: activeGoal('keep going').snapshot,
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,8 @@
|
|||
* (up to `PROBE_CONCURRENCY`), so a wedged child costs one timeout rather than
|
||||
* one per session.
|
||||
*
|
||||
* Read-only: clearing a goal stays on `POST /session/:id/goal/clear`, and
|
||||
* setting one stays a prompt (`/goal <objective>` updates the owning runtime,
|
||||
* which schedules the first Goal turn).
|
||||
* Controls use the canonical `POST /session/:id/goal` route. This listing stays
|
||||
* read-only and only projects each live runtime's current snapshot.
|
||||
*/
|
||||
|
||||
import type { Application } from 'express';
|
||||
|
|
@ -86,7 +85,7 @@ async function allSettledWithLimit<T, R>(
|
|||
return results;
|
||||
}
|
||||
|
||||
/** One row of the Goals page. */
|
||||
/** One non-terminal Goal shown on the Goals page. */
|
||||
interface GoalView {
|
||||
sessionId: string;
|
||||
/** The session's label, when it has one — otherwise the client shows the id. */
|
||||
|
|
@ -102,6 +101,7 @@ interface GoalView {
|
|||
* that the goal specifically is running.
|
||||
*/
|
||||
hasActivePrompt: boolean;
|
||||
snapshot: BridgeSessionGoal['snapshot'];
|
||||
}
|
||||
|
||||
export function registerGoalsRoutes(
|
||||
|
|
@ -145,17 +145,19 @@ export function registerGoalsRoutes(
|
|||
continue;
|
||||
}
|
||||
const { session, goal } = outcome.value;
|
||||
if (!goal.active) continue;
|
||||
const record = goal.snapshot.goal;
|
||||
if (!record || record.status === 'complete') continue;
|
||||
goals.push({
|
||||
sessionId: session.sessionId,
|
||||
displayName: session.displayName ?? null,
|
||||
condition: goal.active.condition,
|
||||
iterations: goal.active.iterations,
|
||||
setAt: goal.active.setAt,
|
||||
...(goal.active.lastReason !== undefined
|
||||
? { lastReason: goal.active.lastReason }
|
||||
condition: record.objective,
|
||||
iterations: record.turnCount,
|
||||
setAt: record.createdAt,
|
||||
...(record.lastReason !== undefined
|
||||
? { lastReason: record.lastReason }
|
||||
: {}),
|
||||
hasActivePrompt: session.hasActivePrompt,
|
||||
snapshot: goal.snapshot,
|
||||
});
|
||||
}
|
||||
if (dropped.length > 0) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
type SessionGroupColor,
|
||||
type SessionGroupPresetColor,
|
||||
type SessionArchiveState,
|
||||
parseGoalControlRequest,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts';
|
||||
import {
|
||||
|
|
@ -4357,6 +4358,46 @@ export function registerSessionRoutes(
|
|||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/session/:id/goal',
|
||||
mutate({ strict: true }),
|
||||
withOwnerMutableSession(
|
||||
'POST /session/:id/goal',
|
||||
async (req, res, sessionId, runtime) => {
|
||||
const request = parseGoalControlRequest(safeBody(req));
|
||||
if (!request) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid Goal control request',
|
||||
code: 'invalid_goal_control_request',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = parseClientIdHeader(req, res);
|
||||
if (clientId === null) return;
|
||||
res
|
||||
.status(200)
|
||||
.json(
|
||||
await runtime.bridge.controlSessionGoal(
|
||||
sessionId,
|
||||
request,
|
||||
clientId === undefined ? undefined : { clientId },
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/session/:id/goal',
|
||||
withOwnerReadSession(
|
||||
'GET /session/:id/goal',
|
||||
async (_req, res, sessionId, runtime) => {
|
||||
const goal = await runtime.bridge.getSessionGoal(sessionId);
|
||||
res.status(200).json({ snapshot: goal.snapshot });
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/session/:id/goal/clear',
|
||||
mutate({ strict: true }),
|
||||
|
|
@ -4416,29 +4457,22 @@ export function registerSessionRoutes(
|
|||
withOwnerMutableSession(
|
||||
'POST /session/:id/attachments',
|
||||
async (req, res, sessionId, runtime) => {
|
||||
const encodedName = req.headers['x-qwen-attachment-name'];
|
||||
const name = req.query['name'];
|
||||
const contentType = req.headers['content-type']
|
||||
?.split(';', 1)[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (
|
||||
typeof encodedName !== 'string' ||
|
||||
typeof name !== 'string' ||
|
||||
!contentType ||
|
||||
!Buffer.isBuffer(req.body)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'request body, Content-Type, and X-Qwen-Attachment-Name are required',
|
||||
'request body, Content-Type, and name query parameter are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
let name: string;
|
||||
try {
|
||||
name = decodeURIComponent(encodedName);
|
||||
} catch {
|
||||
res.status(400).json({ error: 'attachment name is invalid' });
|
||||
return;
|
||||
}
|
||||
const clientId = parseClientIdHeader(req, res);
|
||||
if (clientId === null) return;
|
||||
if (
|
||||
|
|
@ -6257,7 +6291,10 @@ export function registerSessionRoutes(
|
|||
trimmed,
|
||||
clientId !== undefined ? { clientId } : undefined,
|
||||
typeof messageId === 'string' ? messageId : undefined,
|
||||
mediaBlocks ? { content: mediaBlocks } : undefined,
|
||||
{
|
||||
rejectIfIdle: true,
|
||||
...(mediaBlocks ? { content: mediaBlocks } : {}),
|
||||
},
|
||||
);
|
||||
res.status(200).json(result);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ import {
|
|||
type PrepareExtensionInstallOptions,
|
||||
type PreparedExtensionMutation,
|
||||
type SessionListItem,
|
||||
type GoalControlRequest,
|
||||
type GoalSnapshotV2,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as qwenCore from '@qwen-code/qwen-code-core';
|
||||
import type { DaemonStatusProvider } from '@qwen-code/acp-bridge';
|
||||
|
|
@ -797,6 +799,7 @@ interface FakeBridgeOpts {
|
|||
message: string,
|
||||
context?: BridgeClientRequestContext,
|
||||
messageId?: string,
|
||||
options?: Parameters<AcpSessionBridge['enqueueMidTurnMessage']>[4],
|
||||
) => { accepted: boolean; messageId?: string };
|
||||
removeMidTurnImpl?: (
|
||||
sessionId: string,
|
||||
|
|
@ -915,6 +918,12 @@ interface FakeBridgeOpts {
|
|||
clearSessionGoalImpl?: (
|
||||
sessionId: string,
|
||||
) => Promise<{ cleared: boolean; condition?: string }>;
|
||||
controlSessionGoalImpl?: (
|
||||
sessionId: string,
|
||||
request: GoalControlRequest,
|
||||
context?: BridgeClientRequestContext,
|
||||
) => Promise<{ snapshot: GoalSnapshotV2 }>;
|
||||
getSessionGoalImpl?: AcpSessionBridge['getSessionGoal'];
|
||||
continueSessionImpl?: (sessionId: string) => Promise<{
|
||||
accepted: boolean;
|
||||
interruption: 'none' | 'interrupted_prompt' | 'interrupted_turn';
|
||||
|
|
@ -1116,6 +1125,7 @@ interface FakeBridge extends AcpSessionBridge {
|
|||
message: string;
|
||||
context?: BridgeClientRequestContext;
|
||||
messageId?: string;
|
||||
options?: Parameters<AcpSessionBridge['enqueueMidTurnMessage']>[4];
|
||||
}>;
|
||||
removeMidTurnCalls: Array<{
|
||||
sessionId: string;
|
||||
|
|
@ -1202,6 +1212,11 @@ interface FakeBridge extends AcpSessionBridge {
|
|||
taskKind: 'agent' | 'shell' | 'monitor';
|
||||
}>;
|
||||
clearSessionGoalCalls: string[];
|
||||
controlSessionGoalCalls: Array<{
|
||||
sessionId: string;
|
||||
request: GoalControlRequest;
|
||||
context?: BridgeClientRequestContext;
|
||||
}>;
|
||||
continueSessionCalls: string[];
|
||||
continueSessionContexts: Array<BridgeClientRequestContext | undefined>;
|
||||
sessionHooksCalls: string[];
|
||||
|
|
@ -1381,6 +1396,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
|
|||
const sessionTranscriptCalls: FakeBridge['sessionTranscriptCalls'] = [];
|
||||
const cancelSessionTaskCalls: FakeBridge['cancelSessionTaskCalls'] = [];
|
||||
const clearSessionGoalCalls: string[] = [];
|
||||
const controlSessionGoalCalls: FakeBridge['controlSessionGoalCalls'] = [];
|
||||
const continueSessionCalls: string[] = [];
|
||||
const continueSessionContexts: Array<BridgeClientRequestContext | undefined> =
|
||||
[];
|
||||
|
|
@ -1722,6 +1738,34 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
|
|||
opts.cancelSessionTaskImpl ?? (async () => ({ cancelled: true }));
|
||||
const clearSessionGoalImpl =
|
||||
opts.clearSessionGoalImpl ?? (async () => ({ cleared: true }));
|
||||
const controlSessionGoalImpl =
|
||||
opts.controlSessionGoalImpl ??
|
||||
(async (_sessionId, request) => ({
|
||||
snapshot: {
|
||||
v: 2 as const,
|
||||
activity: 'idle' as const,
|
||||
goal:
|
||||
request.action === 'create'
|
||||
? null
|
||||
: {
|
||||
goalId: request.expectedGoalId,
|
||||
revision: request.expectedRevision,
|
||||
objective: 'ship it',
|
||||
status: 'active' as const,
|
||||
evidenceCursor: { recordId: null },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
}));
|
||||
const getSessionGoalImpl =
|
||||
opts.getSessionGoalImpl ??
|
||||
(async () => ({
|
||||
snapshot: { v: 2 as const, activity: 'idle' as const, goal: null },
|
||||
active: null,
|
||||
}));
|
||||
const continueSessionImpl =
|
||||
opts.continueSessionImpl ??
|
||||
(async () => ({ accepted: false, interruption: 'none' as const }));
|
||||
|
|
@ -1963,6 +2007,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
|
|||
sessionTranscriptCalls,
|
||||
cancelSessionTaskCalls,
|
||||
clearSessionGoalCalls,
|
||||
controlSessionGoalCalls,
|
||||
continueSessionCalls,
|
||||
continueSessionContexts,
|
||||
sessionHooksCalls,
|
||||
|
|
@ -2255,6 +2300,17 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
|
|||
clearSessionGoalCalls.push(sessionId);
|
||||
return clearSessionGoalImpl(sessionId);
|
||||
},
|
||||
async controlSessionGoal(sessionId, request, context) {
|
||||
controlSessionGoalCalls.push({
|
||||
sessionId,
|
||||
request,
|
||||
...(context ? { context } : {}),
|
||||
});
|
||||
return controlSessionGoalImpl(sessionId, request, context);
|
||||
},
|
||||
async getSessionGoal(sessionId) {
|
||||
return getSessionGoalImpl(sessionId);
|
||||
},
|
||||
async continueSession(sessionId, context) {
|
||||
continueSessionCalls.push(sessionId);
|
||||
continueSessionContexts.push(context);
|
||||
|
|
@ -2374,7 +2430,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
|
|||
...(messageId ? { messageId } : {}),
|
||||
...(options ? { options } : {}),
|
||||
});
|
||||
return enqueueMidTurnImpl(sessionId, message, context, messageId);
|
||||
return enqueueMidTurnImpl(
|
||||
sessionId,
|
||||
message,
|
||||
context,
|
||||
messageId,
|
||||
options,
|
||||
);
|
||||
},
|
||||
removeMidTurnMessage(sessionId, messageId, context) {
|
||||
removeMidTurnCalls.push({
|
||||
|
|
@ -9390,6 +9452,86 @@ describe('createServeApp', () => {
|
|||
expect(bridge.clearSessionGoalCalls).toEqual(['s-1']);
|
||||
});
|
||||
|
||||
it('reads and controls the canonical session Goal', async () => {
|
||||
const snapshot: GoalSnapshotV2 = {
|
||||
v: 2,
|
||||
activity: 'idle',
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 3,
|
||||
objective: 'ship it',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: null },
|
||||
turnCount: 1,
|
||||
activeTimeMs: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
};
|
||||
const bridge = fakeBridge({
|
||||
getSessionGoalImpl: async () => ({ snapshot, active: null }),
|
||||
controlSessionGoalImpl: async () => ({ snapshot }),
|
||||
knownClientIds: ['client-1'],
|
||||
});
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
|
||||
const read = await request(app)
|
||||
.get('/session/s-1/goal')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret');
|
||||
const controlled = await request(app)
|
||||
.post('/session/s-1/goal')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({
|
||||
action: 'pause',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 3,
|
||||
});
|
||||
|
||||
expect(read.status).toBe(200);
|
||||
expect(read.body).toEqual({ snapshot });
|
||||
expect(controlled.status).toBe(200);
|
||||
expect(controlled.body).toEqual({ snapshot });
|
||||
expect(bridge.controlSessionGoalCalls).toEqual([
|
||||
{
|
||||
sessionId: 's-1',
|
||||
request: {
|
||||
action: 'pause',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 3,
|
||||
},
|
||||
context: { clientId: 'client-1' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an invalid Goal control before bridge dispatch', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/session/s-1/goal')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.send({ action: 'pause', expectedGoalId: 'goal-1' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('invalid_goal_control_request');
|
||||
expect(bridge.controlSessionGoalCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps goal clear bridge errors', async () => {
|
||||
const bridge = fakeBridge({
|
||||
clearSessionGoalImpl: async (sessionId) => {
|
||||
|
|
@ -9576,10 +9718,10 @@ describe('createServeApp', () => {
|
|||
);
|
||||
const uploaded = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: 'notes 你好.txt' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'text/plain')
|
||||
.set('X-Qwen-Attachment-Name', encodeURIComponent('notes 你好.txt'))
|
||||
.send(Buffer.from('hello'));
|
||||
|
||||
expect(uploaded.status).toBe(201);
|
||||
|
|
@ -9599,10 +9741,10 @@ describe('createServeApp', () => {
|
|||
);
|
||||
const uploaded = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: 'empty.txt' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'text/plain')
|
||||
.set('X-Qwen-Attachment-Name', 'empty.txt')
|
||||
.set('Content-Length', '0')
|
||||
.send(Buffer.alloc(0));
|
||||
|
||||
|
|
@ -9623,10 +9765,10 @@ describe('createServeApp', () => {
|
|||
);
|
||||
const uploaded = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: 'empty.png' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'image/png')
|
||||
.set('X-Qwen-Attachment-Name', 'empty.png')
|
||||
.set('Content-Length', '0')
|
||||
.send(Buffer.alloc(0));
|
||||
|
||||
|
|
@ -9644,10 +9786,10 @@ describe('createServeApp', () => {
|
|||
);
|
||||
const uploaded = await request(app)
|
||||
.post('/SESSION/s-1/ATTACHMENTS')
|
||||
.query({ name: 'notes.txt' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'text/plain')
|
||||
.set('X-Qwen-Attachment-Name', 'notes.txt')
|
||||
.send(Buffer.from('hello'));
|
||||
|
||||
expect(uploaded.status).toBe(201);
|
||||
|
|
@ -9665,10 +9807,10 @@ describe('createServeApp', () => {
|
|||
);
|
||||
const uploaded = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: 'data.json' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Qwen-Attachment-Name', 'data.json')
|
||||
.send('{"enabled":true}');
|
||||
|
||||
expect(uploaded.status).toBe(201);
|
||||
|
|
@ -9689,10 +9831,10 @@ describe('createServeApp', () => {
|
|||
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const uploaded = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: 'image.png' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'image/png')
|
||||
.set('X-Qwen-Attachment-Name', 'image.png')
|
||||
.send(bytes);
|
||||
|
||||
expect(uploaded.status).toBe(201);
|
||||
|
|
@ -9742,7 +9884,7 @@ describe('createServeApp', () => {
|
|||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects malformed encoded attachment names', async () => {
|
||||
it('rejects repeated attachment name query parameters', async () => {
|
||||
const app = createServeApp(
|
||||
{ ...baseOpts, token: 'secret', workspace: WS_BOUND },
|
||||
undefined,
|
||||
|
|
@ -9750,14 +9892,17 @@ describe('createServeApp', () => {
|
|||
);
|
||||
const response = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: ['one.txt', 'two.txt'] })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'text/plain')
|
||||
.set('X-Qwen-Attachment-Name', '%E0%A4%A')
|
||||
.send('hello');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'attachment name is invalid' });
|
||||
expect(response.body).toEqual({
|
||||
error:
|
||||
'request body, Content-Type, and name query parameter are required',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps attachment name and Content-Type mismatches to 400', async () => {
|
||||
|
|
@ -9772,10 +9917,10 @@ describe('createServeApp', () => {
|
|||
);
|
||||
const response = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: 'screenshot.png' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'text/plain')
|
||||
.set('X-Qwen-Attachment-Name', 'screenshot.png')
|
||||
.send('hello');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
|
@ -9792,10 +9937,10 @@ describe('createServeApp', () => {
|
|||
);
|
||||
const response = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: 'image.svg' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'image/svg+xml')
|
||||
.set('X-Qwen-Attachment-Name', 'image.svg')
|
||||
.send(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'));
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
|
@ -9818,10 +9963,10 @@ describe('createServeApp', () => {
|
|||
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const uploaded = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: 'image.png' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'image/png')
|
||||
.set('X-Qwen-Attachment-Name', 'image.png')
|
||||
.send(bytes);
|
||||
expect(uploaded.status).toBe(201);
|
||||
|
||||
|
|
@ -9843,10 +9988,10 @@ describe('createServeApp', () => {
|
|||
);
|
||||
const response = await request(app)
|
||||
.post('/session/s-1/attachments')
|
||||
.query({ name: 'image.png' })
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('Content-Type', 'image/png')
|
||||
.set('X-Qwen-Attachment-Name', 'image.png')
|
||||
.send(Buffer.alloc(8 * 1024 * 1024 + 1));
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
|
|
@ -9898,6 +10043,7 @@ describe('createServeApp', () => {
|
|||
message: 'hello',
|
||||
context: { clientId: 'client-9' },
|
||||
messageId: 'client-mid-1',
|
||||
options: { rejectIfIdle: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -9914,10 +10060,38 @@ describe('createServeApp', () => {
|
|||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(bridge.enqueueMidTurnCalls).toEqual([
|
||||
{ sessionId: 's-1', message: 'hi', context: { clientId: 'client-9' } },
|
||||
{
|
||||
sessionId: 's-1',
|
||||
message: 'hi',
|
||||
context: { clientId: 'client-9' },
|
||||
options: { rejectIfIdle: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an in-flight enqueue that reaches an idle session', async () => {
|
||||
const bridge = fakeBridge({
|
||||
enqueueMidTurnImpl: (
|
||||
_sessionId,
|
||||
_message,
|
||||
_context,
|
||||
_messageId,
|
||||
options,
|
||||
) => (options?.rejectIfIdle ? { accepted: false } : { accepted: true }),
|
||||
});
|
||||
|
||||
const res = await midTurnPost(midTurnApp(bridge), 's-1', {
|
||||
message: 'late steering',
|
||||
messageId: 'late-steering-1',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ accepted: false });
|
||||
expect(bridge.enqueueMidTurnCalls[0]?.options).toEqual({
|
||||
rejectIfIdle: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([[''], [123], ['x'.repeat(129)]])(
|
||||
'400 when `messageId` is invalid: %j',
|
||||
async (messageId) => {
|
||||
|
|
@ -9970,6 +10144,7 @@ describe('createServeApp', () => {
|
|||
sessionId: 's-1',
|
||||
message: 'see this',
|
||||
options: {
|
||||
rejectIfIdle: true,
|
||||
content: [{ type: 'image', data: 'aW1n', mimeType: 'image/png' }],
|
||||
},
|
||||
},
|
||||
|
|
@ -9996,7 +10171,7 @@ describe('createServeApp', () => {
|
|||
{
|
||||
sessionId: 's-1',
|
||||
message: 'read this',
|
||||
options: { content: [resource] },
|
||||
options: { rejectIfIdle: true, content: [resource] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -10098,6 +10273,7 @@ describe('createServeApp', () => {
|
|||
sessionId: 's-1',
|
||||
message: 'see this',
|
||||
options: {
|
||||
rejectIfIdle: true,
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
|
|
|
|||
|
|
@ -138,6 +138,61 @@ describe('sendBridgeError session writer errors', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('maps an untrusted workspace bridge error to 403', () => {
|
||||
const { response, status, json } = responseMock();
|
||||
const error = Object.assign(new Error('Workspace is not trusted'), {
|
||||
data: { errorKind: 'untrusted_workspace', httpStatus: 403 },
|
||||
});
|
||||
|
||||
sendBridgeError(response, error);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(403);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
error: 'Workspace is not trusted',
|
||||
code: 'untrusted_workspace',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['goal_conflict', 409],
|
||||
['goal_invalid_transition', 409],
|
||||
['goal_persist_failed', 500],
|
||||
] as const)('maps %s to %i', (kind, expectedStatus) => {
|
||||
// A persistence failure is not retryable; surfacing it as a 409 sends the
|
||||
// client back to re-sync `current` and retry a write that cannot succeed,
|
||||
// and the inverse turns an ordinary conflict into a 500.
|
||||
const { response, status, json } = responseMock();
|
||||
const error = Object.assign(new Error('goal control failed'), {
|
||||
data: { errorKind: kind },
|
||||
});
|
||||
|
||||
sendBridgeError(response, error);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(expectedStatus);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
error: 'goal control failed',
|
||||
code: kind,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the current Goal snapshot on a conflict', () => {
|
||||
// The client re-syncs from `current` before retrying; dropping it leaves it
|
||||
// retrying against the revision the daemon just rejected.
|
||||
const { response, json } = responseMock();
|
||||
const current = { v: 2, activity: 'idle', goal: null };
|
||||
const error = Object.assign(new Error('goal revision changed'), {
|
||||
data: { errorKind: 'goal_conflict', current },
|
||||
});
|
||||
|
||||
sendBridgeError(response, error);
|
||||
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
error: 'goal revision changed',
|
||||
code: 'goal_conflict',
|
||||
current,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid_session_attachment_reference', 400],
|
||||
['session_attachment_gone', 410],
|
||||
|
|
|
|||
|
|
@ -662,6 +662,26 @@ export function sendBridgeError(
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (kind === 'untrusted_workspace') {
|
||||
res.status(403).json({
|
||||
error: errorMessage(err),
|
||||
code: kind,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
kind === 'goal_conflict' ||
|
||||
kind === 'goal_invalid_transition' ||
|
||||
kind === 'goal_persist_failed'
|
||||
) {
|
||||
const d = data as { current?: unknown };
|
||||
res.status(kind === 'goal_persist_failed' ? 500 : 409).json({
|
||||
error: errorMessage(err),
|
||||
code: kind,
|
||||
...(d.current !== undefined ? { current: d.current } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (kind === 'branch_point_invalid') {
|
||||
res.status(409).json({
|
||||
error: errorMessage(err),
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => {
|
|||
.map(({ method, path }) => `${method} ${path}`)
|
||||
.sort();
|
||||
|
||||
expect(registered).toHaveLength(59);
|
||||
expect(registered).toHaveLength(61);
|
||||
expect(registered).toEqual(catalog);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1062,17 +1062,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
|
|||
});
|
||||
|
||||
describe('legacy session telemetry route catalog', () => {
|
||||
it('contains 59 unique routes with the audited 57/2 attribution split', () => {
|
||||
it('contains 61 unique routes with the audited 59/2 attribution split', () => {
|
||||
const keys = legacySessionTelemetryRoutes.map(
|
||||
({ method, path }) => `${method} ${path}`,
|
||||
);
|
||||
expect(keys).toHaveLength(59);
|
||||
expect(new Set(keys).size).toBe(59);
|
||||
expect(keys).toHaveLength(61);
|
||||
expect(new Set(keys).size).toBe(61);
|
||||
expect(
|
||||
legacySessionTelemetryRoutes.filter(
|
||||
({ attribution }) => attribution === 'handler_resolved',
|
||||
),
|
||||
).toHaveLength(57);
|
||||
).toHaveLength(59);
|
||||
expect(
|
||||
legacySessionTelemetryRoutes.filter(
|
||||
({ attribution }) => attribution === 'pre_resolved',
|
||||
|
|
|
|||
|
|
@ -181,6 +181,18 @@ export const legacySessionTelemetryRoutes = [
|
|||
attribution: 'handler_resolved',
|
||||
route: 'POST /session/:id/tasks/:taskId/cancel',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/session/:id/goal',
|
||||
attribution: 'handler_resolved',
|
||||
route: 'POST /session/:id/goal',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/session/:id/goal',
|
||||
attribution: 'handler_resolved',
|
||||
route: 'GET /session/:id/goal',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/session/:id/goal/clear',
|
||||
|
|
|
|||
|
|
@ -884,6 +884,226 @@ describe('<MainContent />', () => {
|
|||
expect(lastFrame()).toMatch(/VP_ITEM:1[\s\S]*VP_ITEM:2/);
|
||||
});
|
||||
|
||||
// Shared fixtures for the #9420 collapse tests. A tool batch renders
|
||||
// twice transiently — the committed history copy plus the live pending
|
||||
// copy — and both copies carry the same scheduler-minted `batchId`.
|
||||
// The collapse matches on that identity, never on callIds: callIds are
|
||||
// not globally unique (deterministic ids re-minted after core-history
|
||||
// compaction, provider wire-id reuse).
|
||||
const toolGroupFixture = (...callIds: string[]) => ({
|
||||
type: 'tool_group' as const,
|
||||
tools: callIds.map((callId) => ({
|
||||
callId,
|
||||
name: 'read_file',
|
||||
description: `read ${callId}`,
|
||||
status: ToolCallStatus.Executing,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
})),
|
||||
});
|
||||
const batched = (batchId: string, ...callIds: string[]) => ({
|
||||
...toolGroupFixture(...callIds),
|
||||
batchId,
|
||||
});
|
||||
const lastVpDataIds = () =>
|
||||
scrollableListPropsSpy.mock.calls
|
||||
.at(-1)?.[0]
|
||||
.data.map((item: { id: number }) => item.id);
|
||||
|
||||
it('collapses a tool_group duplicated across history and pending (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
history: [{ id: 1, ...batched('batch-1', 'call-A', 'call-B') }],
|
||||
pendingHistoryItems: [batched('batch-1', 'call-A', 'call-B')],
|
||||
}),
|
||||
);
|
||||
|
||||
// The same in-flight tool batch must render once (the live pending
|
||||
// copy, negative id), not twice (history id 1 + pending id -1). The
|
||||
// exact list data is pinned so a duplicate that both replaces and
|
||||
// appends is caught (a substring check passes with two -1 rows).
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, -1]);
|
||||
});
|
||||
|
||||
it('collapses a duplicated tool_group separated by pending items (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
history: [{ id: 1, ...batched('batch-1', 'dup-call') }],
|
||||
// The real lifecycle: onComplete commits the batch to history, then
|
||||
// the continuation stream appends thought/content pending items
|
||||
// before the scheduler clears the stale live copy — so the two
|
||||
// copies are not adjacent in the combined list.
|
||||
pendingHistoryItems: [
|
||||
{ type: 'gemini_thought' as const, text: 'thinking' },
|
||||
batched('batch-1', 'dup-call'),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, -1, -2]);
|
||||
});
|
||||
|
||||
it('collapses a tool_group duplicated within the pending list (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
pendingHistoryItems: [
|
||||
batched('batch-1', 'dup-call'),
|
||||
batched('batch-1', 'dup-call'),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Only the later (more current) copy survives.
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, -2]);
|
||||
});
|
||||
|
||||
it('collapses pending duplicates separated by other pending items (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
pendingHistoryItems: [
|
||||
batched('batch-1', 'dup-call'),
|
||||
{ type: 'gemini_thought' as const, text: 'between' },
|
||||
batched('batch-1', 'dup-call'),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// The later (more current) copy survives even though the copies are
|
||||
// not adjacent; it keeps its original positional id (-3).
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, -2, -3]);
|
||||
});
|
||||
|
||||
it('keeps tool_groups of unrelated batches side by side (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
history: [
|
||||
{ id: 1, ...batched('batch-1', 'call-A') },
|
||||
{ id: 2, ...batched('batch-2', 'call-B') },
|
||||
],
|
||||
pendingHistoryItems: [batched('batch-3', 'call-C')],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, 1, 2, -1]);
|
||||
});
|
||||
|
||||
it('keeps committed tool_groups sharing callIds when no pending copy is live (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
// Two committed batches with identical callIds (e.g. accepted-
|
||||
// speculation runs whose synthetic callIds collide) and no live
|
||||
// pending copy: nothing is duplicated, both rows must survive.
|
||||
// Restored-session groups carry no batchId and take this shape.
|
||||
history: [
|
||||
{ id: 1, ...toolGroupFixture('dup-call') },
|
||||
{ id: 2, type: 'gemini_thought' as const, text: 'between' },
|
||||
{ id: 3, ...toolGroupFixture('dup-call') },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('keeps an unrelated committed batch that collides with a live batch before it commits (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
// Pre-commit shape: the live batch ('batch-new') has not been
|
||||
// committed yet, so it has no stale copy in history. The
|
||||
// committed row belongs to an unrelated earlier batch whose
|
||||
// callIds merely collide (ids re-minted after core-history
|
||||
// compaction, provider wire-id reuse) and must keep rendering
|
||||
// for the whole execution window of the new batch.
|
||||
history: [{ id: 1, ...batched('batch-old', 'dup-call') }],
|
||||
pendingHistoryItems: [batched('batch-new', 'dup-call')],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, 1, -1]);
|
||||
});
|
||||
|
||||
it('keeps earlier committed batches whose callIds collide with the live pending batch (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
// The unrelated earlier batch ('batch-old') sharing the live
|
||||
// batch's callIds must keep rendering; only the stale committed
|
||||
// copy of the live batch itself (id 3, 'batch-live') collapses
|
||||
// against the pending copy.
|
||||
history: [
|
||||
{ id: 1, ...batched('batch-old', 'dup-call') },
|
||||
{ id: 2, type: 'gemini_thought' as const, text: 'between' },
|
||||
{ id: 3, ...batched('batch-live', 'dup-call') },
|
||||
],
|
||||
pendingHistoryItems: [batched('batch-live', 'dup-call')],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, 1, 2, -1]);
|
||||
});
|
||||
|
||||
it('collapses two distinct duplicated tool_groups pending at once (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
history: [
|
||||
{ id: 1, ...batched('batch-A', 'call-A') },
|
||||
{ id: 2, ...batched('batch-B', 'call-B') },
|
||||
],
|
||||
pendingHistoryItems: [
|
||||
batched('batch-A', 'call-A'),
|
||||
batched('batch-B', 'call-B'),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Each live batch collapses exactly its own committed copy — the
|
||||
// bookkeeping must key per batch, not collapse into one shared slot.
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, -1, -2]);
|
||||
});
|
||||
|
||||
it('collapses a stale committed copy that is not the last history item (#9420)', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
renderMainContent(
|
||||
createUIState({
|
||||
useTerminalBuffer: true,
|
||||
history: [
|
||||
{ id: 1, ...batched('batch-1', 'dup-call') },
|
||||
{ id: 2, type: 'gemini_thought' as const, text: 'after' },
|
||||
],
|
||||
pendingHistoryItems: [batched('batch-1', 'dup-call')],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastVpDataIds()).toEqual([Number.MIN_SAFE_INTEGER, 2, -1]);
|
||||
});
|
||||
|
||||
it('requests a full-height measurement only for pending plain-text confirmations', () => {
|
||||
scrollableListPropsSpy.mockClear();
|
||||
|
||||
|
|
|
|||
|
|
@ -289,14 +289,55 @@ export const MainContent = ({ footerRef }: MainContentProps) => {
|
|||
// Combine completed history + live pending items for the virtualized list.
|
||||
// The banner sentinel is prepended so it scrolls with content (not pinned).
|
||||
// Pending items get negative IDs (-(i+1)) so renderItem can tell them apart.
|
||||
const allVirtualItems = useMemo(
|
||||
(): VpItem[] => [
|
||||
const allVirtualItems = useMemo((): VpItem[] => {
|
||||
const combined: VpItem[] = [
|
||||
VP_BANNER_ITEM,
|
||||
...visibleHistory,
|
||||
...pendingHistoryItems.map((item, i) => ({ ...item, id: -(i + 1) })),
|
||||
],
|
||||
[visibleHistory, pendingHistoryItems],
|
||||
);
|
||||
];
|
||||
// Collapse duplicate tool_group rows (#9420): the same in-flight batch
|
||||
// renders from both committed history and the live pending list between
|
||||
// the onComplete commit and the scheduler clearing its display state.
|
||||
// Continuation thought/content items can land between the copies, so
|
||||
// match across the whole list — never by adjacency — on the scheduler-
|
||||
// minted batchId stamped on both copies of one batch, and only when a
|
||||
// live pending counterpart exists (it keeps updating, so it wins).
|
||||
// callIds are NOT an identity (ids are re-minted after core-history
|
||||
// compaction and providers can reuse wire ids), so unrelated batches
|
||||
// whose callIds collide keep rendering. Groups without a batchId
|
||||
// (adapters) are never collapsed; restored-history ids are unique per
|
||||
// mount, so they can never match a live pending batch either.
|
||||
const livePendingBatchIds = new Set<string>();
|
||||
for (const item of pendingHistoryItems) {
|
||||
if (item.type === 'tool_group' && item.batchId !== undefined) {
|
||||
livePendingBatchIds.add(item.batchId);
|
||||
}
|
||||
}
|
||||
if (livePendingBatchIds.size === 0) return combined;
|
||||
const dropped = new Set<VpItem>();
|
||||
const committedByBatchId = new Map<string, VpItem>();
|
||||
// Same batch twice within the pending list: keep the latest copy only.
|
||||
const keptPendingByBatchId = new Map<string, VpItem>();
|
||||
for (const item of combined) {
|
||||
if (
|
||||
item.type !== 'tool_group' ||
|
||||
item.batchId === undefined ||
|
||||
!livePendingBatchIds.has(item.batchId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (item.id > 0) {
|
||||
committedByBatchId.set(item.batchId, item);
|
||||
} else {
|
||||
const kept = keptPendingByBatchId.get(item.batchId);
|
||||
if (kept) dropped.add(kept);
|
||||
keptPendingByBatchId.set(item.batchId, item);
|
||||
}
|
||||
}
|
||||
for (const item of committedByBatchId.values()) dropped.add(item);
|
||||
if (dropped.size === 0) return combined;
|
||||
return combined.filter((item) => !dropped.has(item));
|
||||
}, [visibleHistory, pendingHistoryItems]);
|
||||
|
||||
// Source-copy index offsets propagation. The legacy <Static> path threads
|
||||
// per-item offsets so `/copy mermaid N` / `/copy latex N` hints under each
|
||||
|
|
|
|||
|
|
@ -1659,6 +1659,334 @@ describe('useGeminiStream', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('stamps the committed tool_group with the batch id minted at schedule time (#9420)', async () => {
|
||||
const makeCompletedTool = (callId: string): TrackedCompletedToolCall =>
|
||||
({
|
||||
request: {
|
||||
callId,
|
||||
name: 'testTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-batch-id',
|
||||
},
|
||||
status: 'success',
|
||||
responseSubmittedToGemini: false,
|
||||
response: {
|
||||
callId,
|
||||
responseParts: [{ text: `${callId} response` }],
|
||||
errorType: undefined,
|
||||
},
|
||||
tool: { displayName: 'MockTool' },
|
||||
invocation: {
|
||||
getDescription: () => callId,
|
||||
} as unknown as AnyToolInvocation,
|
||||
}) as unknown as TrackedCompletedToolCall;
|
||||
|
||||
let capturedOnComplete:
|
||||
| ((completedTools: TrackedToolCall[]) => Promise<void>)
|
||||
| null = null;
|
||||
mockUseReactToolScheduler.mockImplementation((onComplete) => {
|
||||
capturedOnComplete = onComplete;
|
||||
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
true,
|
||||
mockLoadedSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
),
|
||||
);
|
||||
|
||||
// Completing 'setup-tool' submits its result; the continuation stream
|
||||
// schedules 'next-tool', minting the batch identity for its callIds.
|
||||
mockSendMessageStream.mockReturnValueOnce(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'next-tool', name: 'testTool', args: {} },
|
||||
};
|
||||
})(),
|
||||
);
|
||||
await act(async () => {
|
||||
await capturedOnComplete?.([makeCompletedTool('setup-tool')]);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockScheduleToolCalls).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const findCommittedGroup = (callId: string) =>
|
||||
mockAddItem.mock.calls
|
||||
.map((call) => call[0])
|
||||
.find(
|
||||
(item) =>
|
||||
item?.type === 'tool_group' &&
|
||||
item.tools.some(
|
||||
(tool: { callId: string }) => tool.callId === callId,
|
||||
),
|
||||
);
|
||||
|
||||
// 'setup-tool' completed without ever being scheduled through the
|
||||
// stream path, so its committed copy carries no batch identity
|
||||
// (restored-session shape — never collapsed).
|
||||
expect(findCommittedGroup('setup-tool')?.batchId).toBeUndefined();
|
||||
|
||||
mockAddItem.mockClear();
|
||||
await act(async () => {
|
||||
await capturedOnComplete?.([makeCompletedTool('next-tool')]);
|
||||
});
|
||||
|
||||
// The scheduled batch's committed copy carries the minted batchId so
|
||||
// MainContent can collapse it against the live pending copy.
|
||||
expect(findCommittedGroup('next-tool')?.batchId).toEqual(
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the next batch identity when a provider reuses a callId in the continuation (#9420)', async () => {
|
||||
const makeCompletedTool = (callId: string): TrackedCompletedToolCall =>
|
||||
({
|
||||
request: {
|
||||
callId,
|
||||
name: 'testTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-batch-id',
|
||||
},
|
||||
status: 'success',
|
||||
responseSubmittedToGemini: false,
|
||||
response: {
|
||||
callId,
|
||||
responseParts: [{ text: `${callId} response` }],
|
||||
errorType: undefined,
|
||||
},
|
||||
tool: { displayName: 'MockTool' },
|
||||
invocation: {
|
||||
getDescription: () => callId,
|
||||
} as unknown as AnyToolInvocation,
|
||||
}) as unknown as TrackedCompletedToolCall;
|
||||
|
||||
let capturedOnComplete:
|
||||
| ((completedTools: TrackedToolCall[]) => Promise<void>)
|
||||
| null = null;
|
||||
mockUseReactToolScheduler.mockImplementation((onComplete) => {
|
||||
capturedOnComplete = onComplete;
|
||||
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
true,
|
||||
mockLoadedSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
),
|
||||
);
|
||||
|
||||
// Completing a batch submits its result; the continuation stream
|
||||
// schedules the next batch under the same wire callId.
|
||||
const completeAndScheduleReuse = async (
|
||||
completedCallId: string,
|
||||
continuationArgs: Record<string, unknown>,
|
||||
) => {
|
||||
mockSendMessageStream.mockReturnValueOnce(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'reused-X',
|
||||
name: 'testTool',
|
||||
args: continuationArgs,
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
await act(async () => {
|
||||
await capturedOnComplete?.([makeCompletedTool(completedCallId)]);
|
||||
});
|
||||
};
|
||||
|
||||
// Batch 1: the setup tool's continuation registers 'reused-X'.
|
||||
await completeAndScheduleReuse('setup-tool', { step: 1 });
|
||||
await waitFor(() => {
|
||||
expect(mockScheduleToolCalls).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Batch 2: completing 'reused-X' registers the continuation batch
|
||||
// under the same callId inside the awaited handleCompletedTools,
|
||||
// before batch 1's cleanup runs — the cleanup must not destroy it.
|
||||
await completeAndScheduleReuse('reused-X', { step: 2 });
|
||||
await waitFor(() => {
|
||||
expect(mockScheduleToolCalls).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await capturedOnComplete?.([makeCompletedTool('reused-X')]);
|
||||
});
|
||||
|
||||
const committedReusedGroups = mockAddItem.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter(
|
||||
(item) =>
|
||||
item?.type === 'tool_group' &&
|
||||
item.tools.some(
|
||||
(tool: { callId: string }) => tool.callId === 'reused-X',
|
||||
),
|
||||
);
|
||||
|
||||
// Both continuation batches committed under 'reused-X'; each must
|
||||
// carry its own minted batchId, or the collapse is silently disabled
|
||||
// for the batch whose mapping the previous cleanup destroyed.
|
||||
expect(committedReusedGroups).toHaveLength(2);
|
||||
expect(committedReusedGroups[0]?.batchId).toEqual(expect.any(String));
|
||||
expect(committedReusedGroups[1]?.batchId).toEqual(expect.any(String));
|
||||
expect(committedReusedGroups[1]?.batchId).not.toEqual(
|
||||
committedReusedGroups[0]?.batchId,
|
||||
);
|
||||
});
|
||||
|
||||
it('mints batch ids that cannot collide across mounts (checkpoint restore, #9420)', async () => {
|
||||
const makeCompletedTool = (callId: string): TrackedCompletedToolCall =>
|
||||
({
|
||||
request: {
|
||||
callId,
|
||||
name: 'testTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-batch-id',
|
||||
},
|
||||
status: 'success',
|
||||
responseSubmittedToGemini: false,
|
||||
response: {
|
||||
callId,
|
||||
responseParts: [{ text: `${callId} response` }],
|
||||
errorType: undefined,
|
||||
},
|
||||
tool: { displayName: 'MockTool' },
|
||||
invocation: {
|
||||
getDescription: () => callId,
|
||||
} as unknown as AnyToolInvocation,
|
||||
}) as unknown as TrackedCompletedToolCall;
|
||||
|
||||
let capturedOnComplete:
|
||||
| ((completedTools: TrackedToolCall[]) => Promise<void>)
|
||||
| null = null;
|
||||
mockUseReactToolScheduler.mockImplementation((onComplete) => {
|
||||
capturedOnComplete = onComplete;
|
||||
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
|
||||
});
|
||||
|
||||
const renderStream = () =>
|
||||
renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
true,
|
||||
mockLoadedSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
),
|
||||
);
|
||||
|
||||
// Completing the setup tool submits its result; the continuation stream
|
||||
// schedules the next tool, whose completion then commits the group with
|
||||
// the batchId minted at schedule time.
|
||||
let scheduleCallsSeen = 0;
|
||||
const mintCommittedBatchId = async (
|
||||
callId: string,
|
||||
): Promise<string | undefined> => {
|
||||
mockSendMessageStream.mockReturnValueOnce(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.ToolCallRequest,
|
||||
value: { callId, name: 'testTool', args: {} },
|
||||
};
|
||||
})(),
|
||||
);
|
||||
await act(async () => {
|
||||
await capturedOnComplete?.([makeCompletedTool(`setup-${callId}`)]);
|
||||
});
|
||||
scheduleCallsSeen += 1;
|
||||
await waitFor(() => {
|
||||
expect(mockScheduleToolCalls).toHaveBeenCalledTimes(scheduleCallsSeen);
|
||||
});
|
||||
mockAddItem.mockClear();
|
||||
await act(async () => {
|
||||
await capturedOnComplete?.([makeCompletedTool(callId)]);
|
||||
});
|
||||
return mockAddItem.mock.calls
|
||||
.map((call) => call[0])
|
||||
.find(
|
||||
(item) =>
|
||||
item?.type === 'tool_group' &&
|
||||
item.tools.some(
|
||||
(tool: { callId: string }) => tool.callId === callId,
|
||||
),
|
||||
)?.batchId;
|
||||
};
|
||||
|
||||
const firstMount = renderStream();
|
||||
const firstBatchId = await mintCommittedBatchId('next-tool-a');
|
||||
firstMount.unmount();
|
||||
|
||||
// Checkpoint JSON persists stamped history and /restore loads it into a
|
||||
// fresh session whose counter restarts at 0. If the second mount minted
|
||||
// the same id, MainContent would collapse the restored committed row
|
||||
// against the fresh in-flight batch — an unrelated completed tool group
|
||||
// vanishing from the transcript for the batch's whole execution window.
|
||||
const secondMount = renderStream();
|
||||
const secondBatchId = await mintCommittedBatchId('next-tool-b');
|
||||
secondMount.unmount();
|
||||
|
||||
expect(firstBatchId).toEqual(expect.any(String));
|
||||
expect(secondBatchId).toEqual(expect.any(String));
|
||||
expect(secondBatchId).not.toEqual(firstBatchId);
|
||||
});
|
||||
|
||||
it('forwards one exact Goal context across a ToolResult batch', async () => {
|
||||
const permit: GoalTurnPermit = {
|
||||
goalId: 'goal-tools',
|
||||
|
|
|
|||
|
|
@ -882,6 +882,35 @@ export const useGeminiStream = (
|
|||
} = useSessionStats();
|
||||
const storage = config.storage;
|
||||
|
||||
// Batch identity for tool_group duplicate collapsing (#9420): minted when
|
||||
// a batch is scheduled, stamped on both the live pending display group and
|
||||
// the history item committed by onComplete below, so MainContent can
|
||||
// collapse the transient double render of one batch by identity — callIds
|
||||
// are not an identity (ids are re-minted after core-history compaction and
|
||||
// providers can reuse wire ids across turns).
|
||||
const toolBatchIdByCallIdRef = useRef(new Map<string, string>());
|
||||
const toolBatchCounterRef = useRef(0);
|
||||
// Per-mount nonce: checkpoint JSON persists stamped history and /restore
|
||||
// loads it into a session whose counter restarts at 0 — without it, a
|
||||
// restored committed row would collide with a freshly minted batch and
|
||||
// the collapse would drop the wrong row.
|
||||
const toolBatchNonceRef = useRef(Math.random().toString(36).slice(2));
|
||||
const registerToolBatch = useCallback(
|
||||
(requests: ToolCallRequestInfo | ToolCallRequestInfo[]) => {
|
||||
const batchNumber = ++toolBatchCounterRef.current;
|
||||
const batchId = `tool-batch-${toolBatchNonceRef.current}-${batchNumber}`;
|
||||
for (const request of Array.isArray(requests) ? requests : [requests]) {
|
||||
toolBatchIdByCallIdRef.current.set(request.callId, batchId);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const getToolBatchId = useCallback(
|
||||
(callId: string): string | undefined =>
|
||||
toolBatchIdByCallIdRef.current.get(callId),
|
||||
[],
|
||||
);
|
||||
|
||||
const [toolCalls, scheduleToolCalls, markToolsAsSubmitted] =
|
||||
useReactToolScheduler(
|
||||
async (completedToolCallsFromScheduler) => {
|
||||
|
|
@ -890,6 +919,12 @@ export const useGeminiStream = (
|
|||
const releaseToolCompletionActivity = isSubmittingQueryRef.current
|
||||
? retainSubmissionActivity(submissionLeaseGenerationRef.current)
|
||||
: undefined;
|
||||
// Captured before the await: the continuation scheduled inside
|
||||
// handleCompletedTools may re-register a reused callId for the
|
||||
// NEXT batch, and the cleanup below must not delete that entry.
|
||||
const batchId = getToolBatchId(
|
||||
completedToolCallsFromScheduler[0].request.callId,
|
||||
);
|
||||
try {
|
||||
const projectRoot = config.getProjectRoot();
|
||||
// Add the final state of these tools to the history for display.
|
||||
|
|
@ -897,6 +932,7 @@ export const useGeminiStream = (
|
|||
completedToolCallsFromScheduler as TrackedToolCall[],
|
||||
projectRoot,
|
||||
);
|
||||
toolGroupDisplay.batchId = batchId;
|
||||
addItem(toolGroupDisplay, Date.now());
|
||||
|
||||
// Handle tool response submission immediately when tools complete
|
||||
|
|
@ -905,6 +941,19 @@ export const useGeminiStream = (
|
|||
);
|
||||
} finally {
|
||||
releaseToolCompletionActivity?.();
|
||||
// Entries are only needed until the batch commits; the scheduler
|
||||
// clears its display copy right after this callback returns.
|
||||
// Delete only entries still pointing at this batch's id: a
|
||||
// provider reusing a wire callId may have already registered
|
||||
// the next batch under the same key during the await above.
|
||||
for (const tc of completedToolCallsFromScheduler) {
|
||||
if (
|
||||
toolBatchIdByCallIdRef.current.get(tc.request.callId) ===
|
||||
batchId
|
||||
) {
|
||||
toolBatchIdByCallIdRef.current.delete(tc.request.callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -914,13 +963,15 @@ export const useGeminiStream = (
|
|||
canUseToolResultFullTurnModel,
|
||||
);
|
||||
|
||||
const pendingToolCallGroupDisplay = useMemo(
|
||||
() =>
|
||||
toolCalls.length
|
||||
? mapTrackedToolCallsToDisplay(toolCalls, config.getProjectRoot())
|
||||
: undefined,
|
||||
[toolCalls, config],
|
||||
);
|
||||
const pendingToolCallGroupDisplay = useMemo(() => {
|
||||
if (!toolCalls.length) return undefined;
|
||||
const group = mapTrackedToolCallsToDisplay(
|
||||
toolCalls,
|
||||
config.getProjectRoot(),
|
||||
);
|
||||
group.batchId = getToolBatchId(toolCalls[0].request.callId);
|
||||
return group;
|
||||
}, [toolCalls, config, getToolBatchId]);
|
||||
|
||||
const activeToolPtyId = useMemo(() => {
|
||||
const executingShellTool = toolCalls?.find(
|
||||
|
|
@ -1417,6 +1468,7 @@ export const useGeminiStream = (
|
|||
isClientInitiated: true,
|
||||
prompt_id,
|
||||
};
|
||||
registerToolBatch(toolCallRequest);
|
||||
scheduleToolCalls([toolCallRequest], abortSignal);
|
||||
return {
|
||||
queryToSend: null,
|
||||
|
|
@ -1579,6 +1631,7 @@ export const useGeminiStream = (
|
|||
handleSlashCommand,
|
||||
logger,
|
||||
shellModeActive,
|
||||
registerToolBatch,
|
||||
scheduleToolCalls,
|
||||
applyVisionBridgeIfNeeded,
|
||||
],
|
||||
|
|
@ -2956,6 +3009,7 @@ export const useGeminiStream = (
|
|||
}
|
||||
}
|
||||
scheduledToolContinuation = true;
|
||||
registerToolBatch(executableToolCallRequests);
|
||||
scheduleToolCalls(
|
||||
executableToolCallRequests,
|
||||
signal,
|
||||
|
|
@ -2973,6 +3027,7 @@ export const useGeminiStream = (
|
|||
handleThoughtEvent,
|
||||
handleUserCancelledEvent,
|
||||
handleErrorEvent,
|
||||
registerToolBatch,
|
||||
scheduleToolCalls,
|
||||
geminiClient,
|
||||
handleChatCompressionEvent,
|
||||
|
|
|
|||
|
|
@ -319,6 +319,16 @@ export type HistoryItemToolGroup = HistoryItemBase & {
|
|||
/** Count of tool calls that read from managed-auto-memory files. Pre-computed for badge rendering. */
|
||||
memoryReadCount?: number;
|
||||
isUserInitiated?: boolean;
|
||||
/**
|
||||
* Identity of the scheduler batch that produced this group (#9420).
|
||||
* Minted when the batch is scheduled and stamped on both the live
|
||||
* pending copy and the committed copy, so the transient double render
|
||||
* of one batch collapses by identity — never by callIds, which collide
|
||||
* across unrelated batches. Unique per mount, so ids persisted in
|
||||
* checkpoints can never match newly minted ones; adapter-built groups
|
||||
* carry no id. Neither is ever collapsed.
|
||||
*/
|
||||
batchId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.21.11",
|
||||
"version": "0.21.14",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,13 @@ export interface GoalSnapshotV2 {
|
|||
v: typeof GOAL_STATE_VERSION;
|
||||
goal: GoalRecord | null;
|
||||
activity: GoalActivity;
|
||||
clearedGoal?: GoalOrder;
|
||||
}
|
||||
|
||||
export interface GoalOrder {
|
||||
goalId: string;
|
||||
revision: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -625,6 +625,23 @@ describe('goal reducer', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('parses clear snapshots with their cleared goal order', () => {
|
||||
const value = {
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
clearedGoal: { goalId: 'g-1', revision: 3, updatedAt: 42 },
|
||||
} as const;
|
||||
|
||||
expect(parseGoalSnapshotV2(value)).toEqual(value);
|
||||
expect(
|
||||
parseGoalSnapshotV2({
|
||||
...value,
|
||||
clearedGoal: { ...value.clearedGoal, revision: 0 },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['evidence_catalog', 'checkpoint_request'] as const)(
|
||||
'round-trips a %s limitKind through a persisted snapshot',
|
||||
(limitKind) => {
|
||||
|
|
|
|||
|
|
@ -268,25 +268,48 @@ export function parseGoalSnapshotV2(
|
|||
): GoalSnapshotV2 | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasOnlyKeys(value, ['v', 'goal', 'activity']) ||
|
||||
!hasOnlyKeys(value, ['v', 'goal', 'activity', 'clearedGoal']) ||
|
||||
value['v'] !== GOAL_STATE_VERSION ||
|
||||
!isGoalActivity(value['activity'])
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (value['goal'] === null) {
|
||||
const clearedGoal = parseGoalOrder(value['clearedGoal']);
|
||||
if (value['clearedGoal'] !== undefined && !clearedGoal) return undefined;
|
||||
return {
|
||||
v: GOAL_STATE_VERSION,
|
||||
goal: null,
|
||||
activity: value['activity'],
|
||||
...(clearedGoal ? { clearedGoal } : {}),
|
||||
};
|
||||
}
|
||||
if (value['clearedGoal'] !== undefined) return undefined;
|
||||
const goal = parseGoalRecord(value['goal']);
|
||||
return goal
|
||||
? { v: GOAL_STATE_VERSION, goal, activity: value['activity'] }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseGoalOrder(value: unknown): GoalSnapshotV2['clearedGoal'] {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasOnlyKeys(value, ['goalId', 'revision', 'updatedAt']) ||
|
||||
typeof value['goalId'] !== 'string' ||
|
||||
!value['goalId'] ||
|
||||
!isNonNegativeInteger(value['revision']) ||
|
||||
value['revision'] === 0 ||
|
||||
!isFiniteNumber(value['updatedAt'])
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
goalId: value['goalId'],
|
||||
revision: value['revision'],
|
||||
updatedAt: value['updatedAt'],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGoalStateCause(
|
||||
value: unknown,
|
||||
): GoalStateCause | undefined {
|
||||
|
|
|
|||
|
|
@ -3817,6 +3817,11 @@ describe('goal runtime', () => {
|
|||
expect(host.preemptGoalTurn).toHaveBeenCalledOnce();
|
||||
expect(host.started).toHaveLength(2);
|
||||
expect(runtime.getSnapshot().goal).toBeNull();
|
||||
expect(runtime.getSnapshot().clearedGoal).toEqual({
|
||||
goalId: replaced.snapshot.goal!.goalId,
|
||||
revision: 1,
|
||||
updatedAt: replaced.snapshot.goal!.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('defensively copies response, subscriber, and getter snapshots', async () => {
|
||||
|
|
|
|||
|
|
@ -1419,6 +1419,15 @@ export function createGoalRuntime(
|
|||
v: GOAL_STATE_VERSION,
|
||||
goal: nextGoal,
|
||||
activity: 'idle',
|
||||
...(request.action === 'clear' && snapshot.goal
|
||||
? {
|
||||
clearedGoal: {
|
||||
goalId: snapshot.goal.goalId,
|
||||
revision: snapshot.goal.revision,
|
||||
updatedAt: snapshot.goal.updatedAt,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
try {
|
||||
await options.journal.recordGoalState(recordUuid, {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -143,10 +143,32 @@ describe('bundled review skill', () => {
|
|||
'Every other reason is deterministic for the same sha and must NOT be retried',
|
||||
);
|
||||
expect(body).toContain('Retry that one, once.');
|
||||
// …and the exception's OTHER condition: a null merge base has two causes
|
||||
// and only the fetch-failure one is retryable.
|
||||
// The once-cap's re-keyed shape: a base-less `capture-failed` is the
|
||||
// retryable class, but git's exit status cannot split its transient
|
||||
// member from its deterministic one (a deleted remote base exits 128
|
||||
// identically), so the retry is bounded to one.
|
||||
expect(body).toContain(
|
||||
'One shape of `capture-failed` retries ONCE, not forever',
|
||||
);
|
||||
expect(body).toContain('`baseFetchFailed: true`');
|
||||
// The re-key's premise: a planless partition failure cannot be
|
||||
// base-less, so the cap no longer keys on `partition-failed` at all.
|
||||
expect(body).toContain(
|
||||
'a planless `partition-failed` always carries a `mergeBaseSha`',
|
||||
);
|
||||
// The narrowing reason is deterministic for the same sha like every other
|
||||
// non-infrastructure one: the same two captures select the same hunks. A
|
||||
// future edit moving it into the retryable set would re-narrow to nothing
|
||||
// every round, forever.
|
||||
expect(body).toContain('`nothing-to-narrow` re-narrows identically');
|
||||
expect(body).toContain('found no common ancestor at all');
|
||||
// The narrowing reason's definition in the enumeration and the retryable
|
||||
// set's membership, pinned outright: the recovery loop reads both, and a
|
||||
// rename of the one or a widening of the other ships green without them.
|
||||
expect(body).toContain(
|
||||
'`nothing-to-narrow` (the narrowing found nothing it could publish',
|
||||
);
|
||||
expect(body).toContain('(`base-untrusted`, `capture-failed`:');
|
||||
});
|
||||
|
||||
it('records the range the round actually reviewed in provenance', () => {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ import type {
|
|||
DaemonWorkspaceRemovalResult,
|
||||
DaemonWorkspaceUpdate,
|
||||
HeartbeatResult,
|
||||
GoalControlRequest,
|
||||
GoalStateResponse,
|
||||
PermissionResponse,
|
||||
PromptContentBlock,
|
||||
PromptResult,
|
||||
|
|
@ -3016,23 +3018,37 @@ export class DaemonClient {
|
|||
);
|
||||
}
|
||||
|
||||
async sessionGoalClear(
|
||||
sessionGoalClear(
|
||||
sessionId: string,
|
||||
clientId?: string,
|
||||
): Promise<{ cleared: boolean; condition?: string }> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/session/${urlEncode(sessionId)}/goal/clear`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.headers({ 'Content-Type': 'application/json' }, clientId),
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
throw await this.failOnError(res, 'POST /session/:id/goal/clear');
|
||||
}
|
||||
return (await res.json()) as { cleared: boolean; condition?: string };
|
||||
},
|
||||
return this.jsonRequest<{ cleared: boolean; condition?: string }>(
|
||||
`/session/${urlEncode(sessionId)}/goal/clear`,
|
||||
'POST /session/:id/goal/clear',
|
||||
{ method: 'POST', body: {}, clientId },
|
||||
);
|
||||
}
|
||||
|
||||
sessionGoal(
|
||||
sessionId: string,
|
||||
clientId?: string,
|
||||
): Promise<GoalStateResponse> {
|
||||
return this.jsonRequest<GoalStateResponse>(
|
||||
`/session/${urlEncode(sessionId)}/goal`,
|
||||
'GET /session/:id/goal',
|
||||
{ clientId },
|
||||
);
|
||||
}
|
||||
|
||||
sessionGoalControl(
|
||||
sessionId: string,
|
||||
request: GoalControlRequest,
|
||||
clientId?: string,
|
||||
): Promise<GoalStateResponse> {
|
||||
return this.jsonRequest<GoalStateResponse>(
|
||||
`/session/${urlEncode(sessionId)}/goal`,
|
||||
'POST /session/:id/goal',
|
||||
{ method: 'POST', body: request, clientId },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3301,16 +3317,10 @@ export class DaemonClient {
|
|||
opts?: { signal?: AbortSignal; clientId?: string },
|
||||
): Promise<DaemonSessionAttachmentReference> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/session/${urlEncode(sessionId)}/attachments`,
|
||||
`${this.baseUrl}/session/${urlEncode(sessionId)}/attachments?name=${urlEncode(name)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.headers(
|
||||
{
|
||||
'Content-Type': mimeType,
|
||||
'X-Qwen-Attachment-Name': encodeURIComponent(name),
|
||||
},
|
||||
opts?.clientId,
|
||||
),
|
||||
headers: this.headers({ 'Content-Type': mimeType }, opts?.clientId),
|
||||
body: data,
|
||||
signal: opts?.signal,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ import type {
|
|||
DaemonSessionTaskStatus,
|
||||
DaemonSessionTasksStatus,
|
||||
HeartbeatResult,
|
||||
GoalControlRequest,
|
||||
GoalStateResponse,
|
||||
PermissionResponse,
|
||||
PromptContentBlock,
|
||||
PromptResult,
|
||||
|
|
@ -614,50 +616,43 @@ export class DaemonSessionClient {
|
|||
* policy. Forwards the bound `clientId` so identified clients update
|
||||
* their per-client timestamp instead of just the session-wide one.
|
||||
*/
|
||||
async heartbeat(): Promise<HeartbeatResult> {
|
||||
return await this.client.heartbeat(this.sessionId, this.clientId);
|
||||
heartbeat(): Promise<HeartbeatResult> {
|
||||
return this.client.heartbeat(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async artifacts(): Promise<DaemonSessionArtifactsEnvelope> {
|
||||
return await this.client.listSessionArtifacts(
|
||||
this.sessionId,
|
||||
this.clientId,
|
||||
);
|
||||
artifacts(): Promise<DaemonSessionArtifactsEnvelope> {
|
||||
return this.client.listSessionArtifacts(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async addArtifact(
|
||||
addArtifact(
|
||||
artifact: DaemonSessionArtifactInput,
|
||||
): Promise<DaemonSessionArtifactMutationResult> {
|
||||
return await this.client.addSessionArtifact(
|
||||
return this.client.addSessionArtifact(
|
||||
this.sessionId,
|
||||
artifact,
|
||||
this.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
async removeArtifact(
|
||||
removeArtifact(
|
||||
artifactId: string,
|
||||
): Promise<DaemonSessionArtifactMutationResult> {
|
||||
return await this.client.removeSessionArtifact(
|
||||
return this.client.removeSessionArtifact(
|
||||
this.sessionId,
|
||||
artifactId,
|
||||
this.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
async setModel(modelId: string): Promise<SetModelResult> {
|
||||
return await this.client.setSessionModel(
|
||||
this.sessionId,
|
||||
modelId,
|
||||
this.clientId,
|
||||
);
|
||||
setModel(modelId: string): Promise<SetModelResult> {
|
||||
return this.client.setSessionModel(this.sessionId, modelId, this.clientId);
|
||||
}
|
||||
|
||||
async setConfigOption(
|
||||
setConfigOption(
|
||||
configId: 'reasoning_effort',
|
||||
value: string,
|
||||
): Promise<DaemonSessionConfigOptionResult> {
|
||||
return await this.client.setSessionConfigOption(
|
||||
return this.client.setSessionConfigOption(
|
||||
this.sessionId,
|
||||
configId,
|
||||
value,
|
||||
|
|
@ -665,17 +660,17 @@ export class DaemonSessionClient {
|
|||
);
|
||||
}
|
||||
|
||||
async getRewindSnapshots(): Promise<{
|
||||
getRewindSnapshots(): Promise<{
|
||||
snapshots: DaemonRewindSnapshotInfo[];
|
||||
}> {
|
||||
return await this.client.getRewindSnapshots(this.sessionId);
|
||||
return this.client.getRewindSnapshots(this.sessionId);
|
||||
}
|
||||
|
||||
async rewind(
|
||||
rewind(
|
||||
promptId: string,
|
||||
opts?: { rewindFiles?: boolean },
|
||||
): Promise<DaemonRewindResult> {
|
||||
return await this.client.rewindSession(this.sessionId, promptId, {
|
||||
return this.client.rewindSession(this.sessionId, promptId, {
|
||||
clientId: this.clientId,
|
||||
...(opts?.rewindFiles !== undefined
|
||||
? { rewindFiles: opts.rewindFiles }
|
||||
|
|
@ -683,8 +678,8 @@ export class DaemonSessionClient {
|
|||
});
|
||||
}
|
||||
|
||||
async fork(directive: string): Promise<DaemonForkSessionResult> {
|
||||
return await this.client.forkSession(
|
||||
fork(directive: string): Promise<DaemonForkSessionResult> {
|
||||
return this.client.forkSession(
|
||||
this.sessionId,
|
||||
{ directive },
|
||||
this.clientId,
|
||||
|
|
@ -699,10 +694,8 @@ export class DaemonSessionClient {
|
|||
* child both run to completion regardless (no cross-process abort
|
||||
* plumbing in v1).
|
||||
*/
|
||||
async recap(opts?: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<DaemonSessionRecapResult> {
|
||||
return await this.client.recapSession(this.sessionId, {
|
||||
recap(opts?: { signal?: AbortSignal }): Promise<DaemonSessionRecapResult> {
|
||||
return this.client.recapSession(this.sessionId, {
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(this.clientId ? { clientId: this.clientId } : {}),
|
||||
});
|
||||
|
|
@ -718,11 +711,11 @@ export class DaemonSessionClient {
|
|||
});
|
||||
}
|
||||
|
||||
async btw(
|
||||
btw(
|
||||
question: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<DaemonSessionBtwResult> {
|
||||
return await this.client.btwSession(this.sessionId, question, {
|
||||
return this.client.btwSession(this.sessionId, question, {
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(this.clientId ? { clientId: this.clientId } : {}),
|
||||
});
|
||||
|
|
@ -734,7 +727,7 @@ export class DaemonSessionClient {
|
|||
* create/attach. Accepted requests become daemon-owned even when the active
|
||||
* turn settles while the request is in flight.
|
||||
*/
|
||||
async enqueueMidTurnMessage(
|
||||
enqueueMidTurnMessage(
|
||||
message: string,
|
||||
opts?: {
|
||||
signal?: AbortSignal;
|
||||
|
|
@ -742,7 +735,7 @@ export class DaemonSessionClient {
|
|||
content?: PromptContentBlock[];
|
||||
},
|
||||
): Promise<DaemonMidTurnMessageResult> {
|
||||
return await this.client.enqueueMidTurnMessage(this.sessionId, message, {
|
||||
return this.client.enqueueMidTurnMessage(this.sessionId, message, {
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(opts?.messageId ? { messageId: opts.messageId } : {}),
|
||||
...(opts?.content && opts.content.length > 0
|
||||
|
|
@ -752,10 +745,10 @@ export class DaemonSessionClient {
|
|||
});
|
||||
}
|
||||
|
||||
async removeMidTurnMessage(
|
||||
removeMidTurnMessage(
|
||||
messageId: string,
|
||||
): Promise<DaemonRemoveMidTurnMessageResult> {
|
||||
return await this.client.removeMidTurnMessage(this.sessionId, messageId, {
|
||||
return this.client.removeMidTurnMessage(this.sessionId, messageId, {
|
||||
...(this.clientId ? { clientId: this.clientId } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -818,10 +811,10 @@ export class DaemonSessionClient {
|
|||
};
|
||||
}
|
||||
|
||||
async removePendingPrompt(
|
||||
removePendingPrompt(
|
||||
promptId: string,
|
||||
): Promise<DaemonRemovePendingPromptResult> {
|
||||
return await this.client.removePendingPrompt(this.sessionId, promptId, {
|
||||
return this.client.removePendingPrompt(this.sessionId, promptId, {
|
||||
...(this.clientId ? { clientId: this.clientId } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -832,54 +825,47 @@ export class DaemonSessionClient {
|
|||
* automatically forwards the client id bound when the session was created
|
||||
* or attached.
|
||||
*/
|
||||
async shellCommand(
|
||||
shellCommand(
|
||||
command: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DaemonShellCommandResult> {
|
||||
return await this.client.shellCommand(this.sessionId, command, {
|
||||
return this.client.shellCommand(this.sessionId, command, {
|
||||
...(signal ? { signal } : {}),
|
||||
...(this.clientId ? { clientId: this.clientId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async context(): Promise<DaemonSessionContextStatus> {
|
||||
return await this.client.sessionContext(this.sessionId, this.clientId);
|
||||
context(): Promise<DaemonSessionContextStatus> {
|
||||
return this.client.sessionContext(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async status(): Promise<DaemonSessionSummary> {
|
||||
return await this.client.sessionStatus(this.sessionId, this.clientId);
|
||||
status(): Promise<DaemonSessionSummary> {
|
||||
return this.client.sessionStatus(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async contextUsage(
|
||||
contextUsage(
|
||||
opts: { detail?: boolean } = {},
|
||||
): Promise<DaemonSessionContextUsageStatus> {
|
||||
return await this.client.sessionContextUsage(
|
||||
this.sessionId,
|
||||
opts,
|
||||
this.clientId,
|
||||
);
|
||||
return this.client.sessionContextUsage(this.sessionId, opts, this.clientId);
|
||||
}
|
||||
|
||||
async supportedCommands(): Promise<DaemonSessionSupportedCommandsStatus> {
|
||||
return await this.client.sessionSupportedCommands(
|
||||
this.sessionId,
|
||||
this.clientId,
|
||||
);
|
||||
supportedCommands(): Promise<DaemonSessionSupportedCommandsStatus> {
|
||||
return this.client.sessionSupportedCommands(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async tasks(): Promise<DaemonSessionTasksStatus> {
|
||||
return await this.client.sessionTasks(this.sessionId, this.clientId);
|
||||
tasks(): Promise<DaemonSessionTasksStatus> {
|
||||
return this.client.sessionTasks(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async lspStatus(): Promise<DaemonSessionLspStatus> {
|
||||
return await this.client.sessionLspStatus(this.sessionId, this.clientId);
|
||||
lspStatus(): Promise<DaemonSessionLspStatus> {
|
||||
return this.client.sessionLspStatus(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async cancelTask(
|
||||
cancelTask(
|
||||
taskId: string,
|
||||
kind: DaemonSessionTaskStatus['kind'],
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
return await this.client.sessionTaskCancel(
|
||||
return this.client.sessionTaskCancel(
|
||||
this.sessionId,
|
||||
taskId,
|
||||
kind,
|
||||
|
|
@ -887,12 +873,24 @@ export class DaemonSessionClient {
|
|||
);
|
||||
}
|
||||
|
||||
async clearGoal(): Promise<{ cleared: boolean; condition?: string }> {
|
||||
return await this.client.sessionGoalClear(this.sessionId, this.clientId);
|
||||
clearGoal(): Promise<{ cleared: boolean; condition?: string }> {
|
||||
return this.client.sessionGoalClear(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async stats(): Promise<DaemonSessionStatsStatus> {
|
||||
return await this.client.sessionStats(this.sessionId, this.clientId);
|
||||
goal(): Promise<GoalStateResponse> {
|
||||
return this.client.sessionGoal(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
controlGoal(request: GoalControlRequest): Promise<GoalStateResponse> {
|
||||
return this.client.sessionGoalControl(
|
||||
this.sessionId,
|
||||
request,
|
||||
this.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
stats(): Promise<DaemonSessionStatsStatus> {
|
||||
return this.client.sessionStats(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
|
|
|
|||
|
|
@ -344,6 +344,14 @@ export type {
|
|||
KnownDaemonEvent,
|
||||
} from './events.js';
|
||||
export type {
|
||||
GoalActivity,
|
||||
GoalControlRequest,
|
||||
GoalLimitKind,
|
||||
GoalRecord,
|
||||
GoalSnapshotV2,
|
||||
GoalStateResponse,
|
||||
GoalStatus,
|
||||
TranscriptCursor,
|
||||
DaemonAgentLevel,
|
||||
DaemonAgentMutationResult,
|
||||
DaemonGeneratedAgentContent,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,70 @@
|
|||
|
||||
export type DaemonMode = 'http-bridge' | 'native';
|
||||
|
||||
/** Goal v2 wire types, duplicated here to keep the SDK independent of Core. */
|
||||
export type GoalStatus =
|
||||
| 'active'
|
||||
| 'paused'
|
||||
| 'blocked'
|
||||
| 'usage_limited'
|
||||
| 'complete';
|
||||
|
||||
export type GoalActivity = 'idle' | 'running' | 'verifying';
|
||||
|
||||
export interface TranscriptCursor {
|
||||
recordId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the runtime stopped a Goal at one of its enumerated bounds. Set alongside
|
||||
* `lastReason` — that stays the human-readable half, this is the half a client
|
||||
* may key behavior off (an evidence-limited Goal cannot be resumed).
|
||||
*/
|
||||
export type GoalLimitKind = 'evidence_catalog' | 'checkpoint_request';
|
||||
|
||||
export interface GoalRecord {
|
||||
goalId: string;
|
||||
revision: number;
|
||||
objective: string;
|
||||
status: GoalStatus;
|
||||
evidenceCursor: TranscriptCursor;
|
||||
turnCount: number;
|
||||
activeTimeMs: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastReason?: string;
|
||||
limitKind?: GoalLimitKind;
|
||||
}
|
||||
|
||||
export interface GoalSnapshotV2 {
|
||||
v: 2;
|
||||
goal: GoalRecord | null;
|
||||
activity: GoalActivity;
|
||||
clearedGoal?: {
|
||||
goalId: string;
|
||||
revision: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type GoalControlRequest =
|
||||
| { action: 'create'; objective: string }
|
||||
| {
|
||||
action: 'replace' | 'edit';
|
||||
objective: string;
|
||||
expectedGoalId: string;
|
||||
expectedRevision: number;
|
||||
}
|
||||
| {
|
||||
action: 'pause' | 'resume' | 'clear';
|
||||
expectedGoalId: string;
|
||||
expectedRevision: number;
|
||||
};
|
||||
|
||||
export interface GoalStateResponse {
|
||||
snapshot: GoalSnapshotV2;
|
||||
}
|
||||
|
||||
export interface DaemonProtocolVersions {
|
||||
current: string;
|
||||
supported: string[];
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue