mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-11 09:46:05 +00:00
Merge branch 'main' into ci/verify-npm-cache
This commit is contained in:
commit
b59161fd0b
46 changed files with 4310 additions and 277 deletions
132
.github/scripts/cap-release-notes.mjs
vendored
Normal file
132
.github/scripts/cap-release-notes.mjs
vendored
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Prepares the body handed to `gh release create`. GitHub rejects release
|
||||
// bodies over 125000 characters, and that rejection lands *after* the npm
|
||||
// packages have been published, so an oversized or empty changelog has to
|
||||
// degrade the notes rather than the release.
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// GitHub's limit is 125000; the margin absorbs the finalize step's rewrite and
|
||||
// any counting difference between code points and UTF-16 units.
|
||||
export const MAX_BODY_CHARS = 120000;
|
||||
|
||||
const TRUNCATION_NOTE = '_Release notes were truncated._';
|
||||
|
||||
/**
|
||||
* @returns {{body: string, truncated: boolean, fallback: boolean}} a body that
|
||||
* is non-empty and at most `maxChars` code points long.
|
||||
*/
|
||||
export function prepareReleaseNotes({
|
||||
body = '',
|
||||
tag,
|
||||
previousTag = '',
|
||||
repo = '',
|
||||
serverUrl = 'https://github.com',
|
||||
maxChars = MAX_BODY_CHARS,
|
||||
}) {
|
||||
if (!tag) {
|
||||
throw new Error('prepareReleaseNotes requires a tag');
|
||||
}
|
||||
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return { body: `Release ${tag}`, truncated: false, fallback: true };
|
||||
}
|
||||
|
||||
// Split on code points so a cut never lands inside a surrogate pair and
|
||||
// leaves the body invalid.
|
||||
const chars = Array.from(trimmed);
|
||||
if (chars.length <= maxChars) {
|
||||
return { body: trimmed, truncated: false, fallback: false };
|
||||
}
|
||||
|
||||
const compare =
|
||||
previousTag && repo
|
||||
? ` Full changelog: ${serverUrl}/${repo}/compare/${previousTag}...${tag}`
|
||||
: '';
|
||||
const footer = `\n\n${TRUNCATION_NOTE}${compare}`;
|
||||
const keep = maxChars - Array.from(footer).length;
|
||||
if (keep <= 0) {
|
||||
return { body: `Release ${tag}`, truncated: true, fallback: true };
|
||||
}
|
||||
|
||||
return {
|
||||
body: `${chars.slice(0, keep).join('')}${footer}`,
|
||||
truncated: true,
|
||||
fallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
file: '',
|
||||
tag: '',
|
||||
previousTag: '',
|
||||
repo: '',
|
||||
serverUrl: 'https://github.com',
|
||||
maxChars: MAX_BODY_CHARS,
|
||||
};
|
||||
const options = {
|
||||
'--file': 'file',
|
||||
'--tag': 'tag',
|
||||
'--previous-tag': 'previousTag',
|
||||
'--repo': 'repo',
|
||||
'--server-url': 'serverUrl',
|
||||
'--max-chars': 'maxChars',
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const key = options[argv[index]];
|
||||
if (!key) {
|
||||
throw new Error(`Unknown option: ${argv[index]}`);
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing value for ${argv[index]}`);
|
||||
}
|
||||
args[key] = key === 'maxChars' ? Number(value) : value;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (!args.file || !args.tag) {
|
||||
throw new Error('--file and --tag are required');
|
||||
}
|
||||
if (!Number.isInteger(args.maxChars) || args.maxChars <= 0) {
|
||||
throw new Error(`--max-chars must be a positive integer`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const args = parseArgs(argv);
|
||||
let body = '';
|
||||
try {
|
||||
body = readFileSync(args.file, 'utf8');
|
||||
} catch {
|
||||
// A failed generate-notes call leaves no file; the fallback body covers it.
|
||||
}
|
||||
|
||||
const result = prepareReleaseNotes({ ...args, body });
|
||||
writeFileSync(args.file, `${result.body}\n`);
|
||||
|
||||
if (result.truncated) {
|
||||
process.stdout.write(
|
||||
`::warning::Release notes exceeded ${args.maxChars} characters; truncated\n`,
|
||||
);
|
||||
}
|
||||
if (result.fallback) {
|
||||
process.stdout.write(
|
||||
`::warning::No release notes were generated; using a minimal body\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
main(process.argv.slice(2));
|
||||
}
|
||||
179
.github/scripts/cap-release-notes.test.mjs
vendored
Normal file
179
.github/scripts/cap-release-notes.test.mjs
vendored
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { MAX_BODY_CHARS, prepareReleaseNotes } from './cap-release-notes.mjs';
|
||||
|
||||
const SCRIPT = fileURLToPath(
|
||||
new URL('./cap-release-notes.mjs', import.meta.url),
|
||||
);
|
||||
|
||||
const context = {
|
||||
tag: 'v0.21.2',
|
||||
previousTag: 'v0.21.1',
|
||||
repo: 'QwenLM/qwen-code',
|
||||
serverUrl: 'https://github.com',
|
||||
};
|
||||
|
||||
describe('release notes body preparation', () => {
|
||||
it('leaves a body that fits untouched', () => {
|
||||
const body = "## What's Changed\n* one thing\n";
|
||||
|
||||
const result = prepareReleaseNotes({ ...context, body });
|
||||
|
||||
assert.equal(result.body, body.trim());
|
||||
assert.equal(result.truncated, false);
|
||||
assert.equal(result.fallback, false);
|
||||
});
|
||||
|
||||
it('keeps the truncated body plus footer within the limit', () => {
|
||||
const maxChars = 500;
|
||||
const body = 'x'.repeat(5000);
|
||||
|
||||
const result = prepareReleaseNotes({ ...context, body, maxChars });
|
||||
|
||||
assert.equal(result.truncated, true);
|
||||
assert.ok(Array.from(result.body).length <= maxChars);
|
||||
assert.ok(
|
||||
result.body.endsWith(
|
||||
'https://github.com/QwenLM/qwen-code/compare/v0.21.1...v0.21.2',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// The whole point of the cap is that the release still publishes, so the
|
||||
// default must leave room for the footer under GitHub's 125000 limit.
|
||||
it('stays under the API limit at the default cap', () => {
|
||||
const result = prepareReleaseNotes({
|
||||
...context,
|
||||
body: 'x'.repeat(MAX_BODY_CHARS * 2),
|
||||
});
|
||||
|
||||
assert.ok(Array.from(result.body).length <= MAX_BODY_CHARS);
|
||||
assert.ok(MAX_BODY_CHARS < 125000);
|
||||
});
|
||||
|
||||
it('omits the compare link when there is no previous tag', () => {
|
||||
const result = prepareReleaseNotes({
|
||||
...context,
|
||||
previousTag: '',
|
||||
body: 'x'.repeat(5000),
|
||||
maxChars: 500,
|
||||
});
|
||||
|
||||
assert.ok(result.body.endsWith('_Release notes were truncated._'));
|
||||
assert.ok(!result.body.includes('compare'));
|
||||
});
|
||||
|
||||
it('never splits a multi-byte character', () => {
|
||||
// Cut lands inside the emoji run: a code-unit slice would leave a lone
|
||||
// surrogate and an invalid body.
|
||||
const maxChars = 200;
|
||||
const body = `${'a'.repeat(50)}${'😀'.repeat(200)}`;
|
||||
|
||||
const result = prepareReleaseNotes({ ...context, body, maxChars });
|
||||
|
||||
assert.equal(
|
||||
Buffer.from(result.body, 'utf8').toString('utf8'),
|
||||
result.body,
|
||||
);
|
||||
assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(result.body));
|
||||
});
|
||||
|
||||
it('falls back to a minimal body when the footer alone exceeds the cap', () => {
|
||||
const result = prepareReleaseNotes({
|
||||
...context,
|
||||
body: 'x'.repeat(100),
|
||||
maxChars: 10,
|
||||
});
|
||||
|
||||
assert.equal(result.body, 'Release v0.21.2');
|
||||
assert.equal(result.truncated, true);
|
||||
assert.equal(result.fallback, true);
|
||||
});
|
||||
|
||||
it('falls back to a minimal body when nothing was generated', () => {
|
||||
for (const body of ['', ' \n ']) {
|
||||
const result = prepareReleaseNotes({ ...context, body });
|
||||
|
||||
assert.equal(result.body, 'Release v0.21.2');
|
||||
assert.equal(result.fallback, true);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a tag', () => {
|
||||
assert.throws(
|
||||
() => prepareReleaseNotes({ body: 'notes', tag: '' }),
|
||||
/requires a tag/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('release notes body preparation (cli)', () => {
|
||||
it('rewrites the notes file in place and warns on truncation', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cap-release-notes-'));
|
||||
try {
|
||||
const file = join(dir, 'release-notes.md');
|
||||
writeFileSync(file, 'y'.repeat(5000));
|
||||
|
||||
const run = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
SCRIPT,
|
||||
'--file',
|
||||
file,
|
||||
'--tag',
|
||||
'v0.21.2',
|
||||
'--previous-tag',
|
||||
'v0.21.1',
|
||||
'--repo',
|
||||
'QwenLM/qwen-code',
|
||||
'--max-chars',
|
||||
'500',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
|
||||
assert.equal(run.status, 0);
|
||||
assert.match(run.stdout, /::warning::Release notes exceeded 500/);
|
||||
const written = readFileSync(file, 'utf8');
|
||||
assert.ok(Array.from(written.trimEnd()).length <= 500);
|
||||
assert.ok(written.includes('_Release notes were truncated._'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes a minimal body when generate-notes produced no file', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cap-release-notes-'));
|
||||
try {
|
||||
const file = join(dir, 'missing.md');
|
||||
|
||||
const run = spawnSync(
|
||||
process.execPath,
|
||||
[SCRIPT, '--file', file, '--tag', 'v0.21.2'],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
|
||||
assert.equal(run.status, 0);
|
||||
assert.match(run.stdout, /::warning::No release notes were generated/);
|
||||
assert.equal(readFileSync(file, 'utf8'), 'Release v0.21.2\n');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown options', () => {
|
||||
const run = spawnSync(
|
||||
process.execPath,
|
||||
[SCRIPT, '--file', 'notes.md', '--tag', 'v1.0.0', '--nope', '1'],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
|
||||
assert.notEqual(run.status, 0);
|
||||
assert.match(run.stderr, /Unknown option: --nope/);
|
||||
});
|
||||
});
|
||||
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/ci/classify-profile.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/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/auto-minimize-spam.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/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-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/auto-minimize-spam.test.mjs'
|
||||
|
||||
jobs:
|
||||
classify_pr:
|
||||
|
|
|
|||
51
.github/workflows/release.yml
vendored
51
.github/workflows/release.yml
vendored
|
|
@ -562,26 +562,53 @@ jobs:
|
|||
PRERELEASE_FLAG="--prerelease"
|
||||
fi
|
||||
|
||||
# Only anchor notes to the previous release when it is an ancestor of
|
||||
# this release's target. A preview or nightly cut can lag behind (or
|
||||
# diverge from) the latest stable, and a divergent --notes-start-tag
|
||||
# makes `gh release create --generate-notes` fail, aborting the
|
||||
# publish after the npm packages have already been published.
|
||||
NOTES_START_TAG_FLAG=()
|
||||
if [[ -n "${PREVIOUS_RELEASE_TAG}" ]] && git merge-base --is-ancestor "${PREVIOUS_RELEASE_TAG}" HEAD; then
|
||||
NOTES_START_TAG_FLAG+=(--notes-start-tag "${PREVIOUS_RELEASE_TAG}")
|
||||
elif [[ -n "${PREVIOUS_RELEASE_TAG}" ]]; then
|
||||
echo "::warning::PREVIOUS_RELEASE_TAG (${PREVIOUS_RELEASE_TAG}) is not an ancestor of HEAD; omitting --notes-start-tag"
|
||||
# Always anchor the notes to the previous release. Every stable tag
|
||||
# lives on its own release/* branch that is tagged before merging back
|
||||
# to main, so the previous tag is normally NOT an ancestor of the
|
||||
# branch being released; GitHub still diffs it correctly through the
|
||||
# merge base. Dropping the anchor is what hurts: GitHub then falls
|
||||
# back to the entire branch history, and the generated body blows past
|
||||
# the 125000 character limit, failing the release after the npm
|
||||
# packages have already been published.
|
||||
NOTES_ARGS=()
|
||||
if [[ -n "${PREVIOUS_RELEASE_TAG}" ]]; then
|
||||
NOTES_ARGS+=(-f "previous_tag_name=${PREVIOUS_RELEASE_TAG}")
|
||||
fi
|
||||
|
||||
# Generate the body up front so an unusable one can be repaired here
|
||||
# instead of aborting `gh release create`. A failed call still prints
|
||||
# the API error payload on stdout, so its output is discarded rather
|
||||
# than published as release notes.
|
||||
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
|
||||
generate_notes() {
|
||||
gh api --method POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
|
||||
-f "tag_name=${RELEASE_TAG}" \
|
||||
-f "target_commitish=${RELEASE_BRANCH}" \
|
||||
"$@" \
|
||||
--jq '.body'
|
||||
}
|
||||
if ! generate_notes "${NOTES_ARGS[@]}" > "${NOTES_FILE}"; then
|
||||
echo "::warning::Could not generate notes anchored at ${PREVIOUS_RELEASE_TAG:-<none>}; retrying without an anchor"
|
||||
generate_notes > "${NOTES_FILE}" || : > "${NOTES_FILE}"
|
||||
fi
|
||||
|
||||
# Caps the body below the 125000 character API limit and substitutes a
|
||||
# minimal body when nothing was generated, so an oversized or missing
|
||||
# changelog degrades the notes instead of the release.
|
||||
node .github/scripts/cap-release-notes.mjs \
|
||||
--file "${NOTES_FILE}" \
|
||||
--tag "${RELEASE_TAG}" \
|
||||
--previous-tag "${PREVIOUS_RELEASE_TAG}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--server-url "${GITHUB_SERVER_URL}"
|
||||
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
dist/cli.js \
|
||||
dist/standalone/qwen-code-* \
|
||||
dist/standalone/SHA256SUMS \
|
||||
--target "${RELEASE_BRANCH}" \
|
||||
--title "Release ${RELEASE_TAG}" \
|
||||
"${NOTES_START_TAG_FLAG[@]}" \
|
||||
--generate-notes \
|
||||
--notes-file "${NOTES_FILE}" \
|
||||
${PRERELEASE_FLAG}
|
||||
|
||||
notify_failure:
|
||||
|
|
|
|||
|
|
@ -65,12 +65,14 @@ single-flight across managed shutdown and concurrent request-failure cleanup.
|
|||
The full `shutdown(options)` call is not single-flight because writer and
|
||||
telemetry options remain call-specific.
|
||||
|
||||
An incompletely initialized Config fast-closes its writer before joining
|
||||
initialization. A successfully initialized Config retains the normal
|
||||
finalize, flush, and close order. Initialization join has no local timeout:
|
||||
timing out the wait would leave the underlying initialization running and
|
||||
reintroduce late resource creation. The daemon process deadline remains the
|
||||
hard bound, after writer release has already completed or failed explicitly.
|
||||
An incompletely initialized Config starts exact-owner release as soon as its
|
||||
pending lease is exposed, before joining initialization. Transcript snapshot
|
||||
reads observe that release between chunks and stop without publishing a late
|
||||
recorder. A successfully initialized Config retains the normal finalize,
|
||||
flush, and close order. Initialization join has no local timeout: timing out
|
||||
the wait would leave the underlying initialization running and reintroduce
|
||||
late resource creation. The daemon process deadline remains the hard bound,
|
||||
after pending-writer release has already completed or failed explicitly.
|
||||
|
||||
## Parent process lifecycle
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ The protocol is gated by `experimental.sessionWriterLease` and is disabled by de
|
|||
1. At most one cooperating ACP process owns a session writer lease under a runtime base.
|
||||
2. A leased ACP recorder is inactive until it owns the lease and has reloaded the transcript while holding it.
|
||||
3. Preview data loaded before the lease is never the recorder's authoritative tail.
|
||||
4. Every leased ACP append verifies the owner token and the expected transcript file identity, metadata, and byte length.
|
||||
4. Every leased ACP append verifies the owner token, hard transcript state, and byte length. Timestamp-only drift is accepted only after stable content verification.
|
||||
5. An ownership or transcript-integrity failure permanently rejects later top-level turns in that leased ACP Config.
|
||||
6. A daemon never constructs a second writable Config for a session already live in that daemon.
|
||||
7. A live entry is removed only after its recorder has drained and released the lease.
|
||||
|
|
@ -46,13 +46,17 @@ Its immutable record contains a random owner token, PID, host, process kind, acq
|
|||
|
||||
Acquisition creates a fully written temporary record and links it into the lock name atomically. A valid live owner returns `session_writer_conflict`. A valid dead local owner can be renamed, rechecked, and reclaimed. Reclaim guards form bounded owner generations so another process can recover if a reclaimer itself crashes. A malformed, symlink, or non-regular lock returns `session_writer_unavailable` rather than being guessed stale.
|
||||
|
||||
The lease snapshots whether the transcript exists, its file identity and metadata, and its byte length. `appendJsonLine` checks the immutable owner record and snapshot immediately before writing through the same file handle, then advances the expected state only after a successful durable append and post-write path verification. New transcript creation uses exclusive creation.
|
||||
The lease snapshots whether the transcript exists, its file identity, security metadata, byte length, and an in-memory incremental SHA-256 state. Existence, length, device/inode, mode, owner/group, and link-count changes fail closed. Birth, change, and modification timestamps are advisory: timestamp-only drift triggers a stable full-content check through one file handle and is accepted only when the digest is unchanged. Extended attributes and ACL entries that do not change the mode are not fingerprinted separately. When such an operation surfaces as timestamp drift, it is accepted after the same content check if all hard state remains unchanged; if the filesystem exposes no observed timestamp difference, the operation is not detected. `appendJsonLine` applies the same check after opening its append handle, advances a candidate digest with the known bytes, and commits the digest and expected state only after a successful durable append, post-write path verification, and final owner check. New transcript creation uses exclusive creation.
|
||||
|
||||
Acquiring an existing transcript performs one O(n) streaming read to establish the digest baseline, using a buffer bounded to 1 MiB; ordinary appends remain incremental. A reconciliation scan requires timestamps to remain stable from its pre-read state through its post-read state and retries timestamp-only instability at most three times. This stability interval is necessary because a sequential digest can match the expected content even when a non-cooperating writer changes an already-read offset behind the read cursor. If timestamps continue changing, the lease returns `session_writer_unavailable` instead of accepting a potentially torn snapshot.
|
||||
|
||||
The incremental digest is a live-process compatibility check, not a persisted proof for certified handoff. A non-cooperating writer can still overwrite an equal-length prefix during an append without leaving a timestamp difference visible at one of this process's state observations. Closing that existing boundary would require an unconditional O(n) post-write scan, make repeated appends quadratic, and is outside P0a.
|
||||
|
||||
## Activation and close
|
||||
|
||||
When the feature gate is enabled, an ACP `Config.initialize()` acquires the lease before extension, hook, tool, model, or scheduler initialization. While holding the lease it resolves active/archive state, reloads the active transcript when one exists, verifies that the transcript did not change during the reload, replaces any pre-lock preview, and activates the recorder. ACP Configs without the opt-in and all non-ACP Configs continue through the legacy recorder path without acquiring this P0a lease.
|
||||
|
||||
Any later initialization failure closes the recorder and releases the lease. Normal shutdown and ACP session close finalize pending metadata, drain the recorder queue, release the owner token, and only then remove the live session entry. Cleanup is identity-checked so a failed older initialization cannot close a newer same-ID entry, and an unreturned Config whose first release fails is retried before the daemon creates another fresh session. A definitive child refusal leaves the session live so close can be retried. Close draining is bounded; a timeout or transport failure has an unknown result, so the bridge terminates the shared ACP channel and its process-owned leases become recoverable as stale. Other sessions on that channel are also reaped by that recovery action.
|
||||
Any later initialization failure closes the recorder and releases the lease. Normal shutdown and ACP session close finalize pending metadata, drain the recorder queue, release the owner token, and only then remove the live session entry. Cleanup is identity-checked so a failed older initialization cannot close a newer same-ID entry. Acquisition cleanup uses the lease's single-flight exact-record release; a terminal failure retains the primary lock, later release calls observe the same failure instead of attempting a second rename, and another writer remains fenced until process-exit recovery. A definitive child refusal leaves the session live so close can be retried. Close draining is bounded; a timeout or transport failure has an unknown result, so the bridge terminates the shared ACP channel and its process-owned leases become recoverable as stale. Other sessions on that channel are also reaped by that recovery action.
|
||||
|
||||
## Error contract
|
||||
|
||||
|
|
@ -65,6 +69,8 @@ Any later initialization failure closes the recorder and releases the lease. Nor
|
|||
|
||||
External responses use fixed messages and `errorKind`; they do not expose PID, host, owner token, lock path, or transcript path.
|
||||
|
||||
A symlink or non-regular transcript path with no prior regular-file baseline is `session_writer_unavailable`. Once a lease has established a regular-file baseline, replacing that path with a symlink or another non-regular file is an external transcript replacement and is classified as `session_transcript_changed`.
|
||||
|
||||
## Compatibility and rollout
|
||||
|
||||
The protocol only coordinates ACP writers that have the feature enabled. Deployment and rollback must drain old ACP/daemon writer processes before enabling or disabling the setting. Mixed-version or mixed-configuration ACP operation is not safe because a legacy writer ignores the lock. Concurrent interactive or headless access to the same persisted session remains outside P0a and is unsupported until P0b.
|
||||
|
|
@ -75,7 +81,7 @@ Existing branched transcripts are not automatically repaired. P0a prevents a new
|
|||
|
||||
## Verification
|
||||
|
||||
Unit coverage exercises the default-off and explicit-opt-in gates, lock contention, dead-owner and crashed-reclaimer recovery, malformed and non-regular locks, concurrent and retryable owner-token release, truncated and externally changed transcripts, equal-length file replacement, UTF-8 byte accounting, recorder activation/fencing/close, authoritative reload, initialization cleanup, runtime-root pinning, turn admission, same-daemon replay reuse, disabled-recording compatibility, legacy interactive recorder behavior, and error sanitization. Darwin coverage also verifies that processes with different time zones derive the same owner identity. PID-reuse handling is implemented but is not claimed as test evidence because process-start probing is platform dependent.
|
||||
Unit coverage exercises the default-off and explicit-opt-in gates, lock contention, dead-owner and crashed-reclaimer recovery, malformed and non-regular locks, concurrent owner-token release, bounded release prechecks, terminal cleanup failures, truncated and externally changed transcripts, timestamp-only reconciliation, equal-length in-place and atomic replacement, security-metadata changes, UTF-8 byte accounting, recorder activation/fencing/close, authoritative reload, initialization cleanup, runtime-root pinning, turn admission, same-daemon replay reuse, disabled-recording compatibility, legacy interactive recorder behavior, and error sanitization. Darwin coverage also verifies that processes with different time zones derive the same owner identity. PID-reuse handling is implemented but is not claimed as test evidence because process-start probing is platform dependent.
|
||||
|
||||
With the feature gate enabled, a real two-process regression recreates the incident timing: process A holds the writer after a tool-result tail, process B is rejected before loading as a writer, A appends its final answer and closes, and B then acquires, reloads that final answer, and appends the next user record with the final answer as its parent.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import type { ArgumentsCamelCase, Argv, Options } from 'yargs';
|
|||
import { normalizeServeFastPathArgv } from './serve/fast-path-argv.js';
|
||||
import { initStartupProfiler } from './utils/startupProfiler.js';
|
||||
import { initCpuProfiler } from './utils/cpuProfiler.js';
|
||||
import {
|
||||
handleUncaughtException,
|
||||
isExpectedPtyRaceError,
|
||||
} from './utils/uncaught-exception-handler.js';
|
||||
|
||||
// Preserve the old entrypoint's profiling baseline before route-specific
|
||||
// dynamic imports or command handling shift startup measurements.
|
||||
|
|
@ -390,42 +394,6 @@ export async function runCliEntry(
|
|||
await main();
|
||||
}
|
||||
|
||||
function getErrnoCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
export function isExpectedPtyRaceError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message;
|
||||
const code = getErrnoCode(error);
|
||||
|
||||
if (
|
||||
(code === 'EIO' && message.includes('read')) ||
|
||||
message.includes('read EIO')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(code === 'EAGAIN' && message.includes('read')) ||
|
||||
message.includes('read EAGAIN')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
message.includes('ioctl(2) failed, EBADF') ||
|
||||
message.includes('Cannot resize a pty that has already exited')
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleCriticalError(error: unknown): Promise<void> {
|
||||
const [{ FatalError }, { AlreadyReportedError }] = await Promise.all([
|
||||
import('./utils/deferred-core-runtime.js'),
|
||||
|
|
@ -533,24 +501,21 @@ export function stampCliEntryEnv(entryPath?: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// handleUncaughtException and isExpectedPtyRaceError live in
|
||||
// ./utils/uncaught-exception-handler.js and are re-exported here for existing
|
||||
// importers (cli.test.ts). gemini.tsx must import them from that leaf module
|
||||
// directly: a static import of this entry file from a module the bundle loads
|
||||
// lazily makes esbuild hoist this entry into a shared chunk, which silently
|
||||
// disables the bootstrap guard at the bottom.
|
||||
export { handleUncaughtException, isExpectedPtyRaceError };
|
||||
|
||||
export async function runCliEntryPoint(
|
||||
run: () => Promise<void> = runCliEntry,
|
||||
handleError: (error: unknown) => Promise<void> = handleCriticalError,
|
||||
): Promise<void> {
|
||||
stampCliEntryEnv();
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (isExpectedPtyRaceError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
writeStderrLine(error.stack ?? error.message);
|
||||
} else {
|
||||
writeStderrLine(String(error));
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('uncaughtException', handleUncaughtException);
|
||||
|
||||
try {
|
||||
await run();
|
||||
|
|
|
|||
|
|
@ -192,11 +192,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
|||
// moving the caret in the editable input buffer (plain arrows only switch
|
||||
// tabs in modal dialogs, which have no text buffer). Alt/Option+arrows still
|
||||
// perform word movement.
|
||||
// Ctrl+←/→ is the primary binding but many terminals intercept it for
|
||||
// word-jump. Ctrl+Tab / Ctrl+Shift+Tab are alternatives that are less
|
||||
// commonly intercepted (#8069).
|
||||
[Command.COMPLETION_TAB_LEFT]: [
|
||||
{ key: 'left', shift: false, ctrl: true, command: false },
|
||||
{ key: 'tab', shift: true, ctrl: true, command: false },
|
||||
],
|
||||
[Command.COMPLETION_TAB_RIGHT]: [
|
||||
{ key: 'right', shift: false, ctrl: true, command: false },
|
||||
{ key: 'tab', shift: false, ctrl: true, command: false },
|
||||
],
|
||||
|
||||
// Text input
|
||||
|
|
|
|||
|
|
@ -1607,6 +1607,45 @@ const SETTINGS_SCHEMA = {
|
|||
parentKey: 'generationConfig',
|
||||
showInDialog: false,
|
||||
},
|
||||
cacheRetention: {
|
||||
type: 'enum',
|
||||
label: 'Anthropic Cache Retention',
|
||||
category: 'Generation Configuration',
|
||||
requiresRestart: false,
|
||||
default: undefined as 'ephemeral' | '1h' | undefined,
|
||||
description:
|
||||
"Default Anthropic cache_control retention. 'ephemeral' uses the spec 5-minute default (no ttl on the wire). '1h' requests the extended cache tier (ttl: '1h') -- note the 1h tier writes at 2x base input token cost (vs 1.25x for the 5-minute default; cached reads stay 0.1x for both), so it only pays off when a prefix survives long enough between requests to outlast several 5-minute windows.",
|
||||
parentKey: 'generationConfig',
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ value: 'ephemeral', label: 'Ephemeral (5m, Default)' },
|
||||
{ value: '1h', label: 'Extended (1h)' },
|
||||
],
|
||||
},
|
||||
cacheRetentionByBlock: {
|
||||
type: 'object',
|
||||
label: 'Anthropic Cache Retention By Block',
|
||||
category: 'Generation Configuration',
|
||||
requiresRestart: false,
|
||||
default: undefined as
|
||||
| Partial<
|
||||
Record<'system' | 'tool' | 'user.last', 'ephemeral' | '1h'>
|
||||
>
|
||||
| undefined,
|
||||
description:
|
||||
"Optional per-anchor override for Anthropic cache retention. Keys (system, tool, user.last) override generationConfig.cacheRetention when present. Resolution is normalized so retention is monotonically non-increasing in wire order (tool -> system -> user.last, per Anthropic's 'longer TTL must precede shorter TTL' rule): setting one anchor to '1h' promotes every anchor before it on the wire to '1h' as well, so any combination here is valid.",
|
||||
parentKey: 'generationConfig',
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
system: { type: 'string', enum: ['ephemeral', '1h'] },
|
||||
tool: { type: 'string', enum: ['ephemeral', '1h'] },
|
||||
'user.last': { type: 'string', enum: ['ephemeral', '1h'] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
splitToolMedia: {
|
||||
type: 'boolean',
|
||||
label: 'Split Tool Result Media',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type { Config } from '@qwen-code/qwen-code-core';
|
|||
import { ApprovalMode, OutputFormat } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
|
||||
const mockConsumeLastRenderError = vi.hoisted(() => vi.fn());
|
||||
const mockHandleListExtensions = vi.hoisted(() => vi.fn());
|
||||
const mockStartEarlyStartupPrefetches = vi.hoisted(() => vi.fn());
|
||||
const mockStartPostRenderPrefetches = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -2056,6 +2057,55 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('still exits on SIGHUP with code 129', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const cleanupModule = await import('./utils/cleanup.js');
|
||||
const signalHandlers = new Map<string, (...args: unknown[]) => void>();
|
||||
const realProcessOn = process.on.bind(process);
|
||||
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(((
|
||||
eventName: string | symbol,
|
||||
listener: (...args: unknown[]) => void,
|
||||
) => {
|
||||
if (
|
||||
eventName === 'SIGTERM' ||
|
||||
eventName === 'SIGINT' ||
|
||||
eventName === 'SIGHUP'
|
||||
) {
|
||||
if (!signalHandlers.has(eventName as string)) {
|
||||
signalHandlers.set(eventName as string, listener);
|
||||
}
|
||||
return process;
|
||||
}
|
||||
return realProcessOn(
|
||||
eventName as string,
|
||||
listener as (...args: unknown[]) => void,
|
||||
);
|
||||
}) as typeof process.on);
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as unknown as typeof process.exit);
|
||||
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
|
||||
runExitCleanupMock.mockResolvedValue(undefined);
|
||||
applyInteractiveSigintConfigMocks(loadCliConfig, loadSettings);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
extensions: undefined,
|
||||
} as never);
|
||||
|
||||
await main();
|
||||
signalHandlers.get('SIGHUP')?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(129);
|
||||
|
||||
processOnSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects --json-schema when running in interactive (TUI) mode', async () => {
|
||||
// The synthetic structured_output tool only terminates the run inside
|
||||
// runNonInteractive. In TUI mode it's an inert tool that prints
|
||||
|
|
@ -2222,6 +2272,17 @@ describe('startInteractiveUI', () => {
|
|||
render: vi.fn().mockReturnValue({ unmount: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('./ui/components/shared/ErrorBoundary.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<
|
||||
typeof import('./ui/components/shared/ErrorBoundary.js')
|
||||
>();
|
||||
return {
|
||||
...original,
|
||||
consumeLastRenderError: mockConsumeLastRenderError,
|
||||
};
|
||||
});
|
||||
|
||||
let initialExitListeners: NodeJS.ExitListener[] = [];
|
||||
let originalStdoutIsTTY: boolean | undefined;
|
||||
let restoreCiEnv = () => {};
|
||||
|
|
@ -2607,6 +2668,70 @@ describe('startInteractiveUI', () => {
|
|||
).toBeGreaterThan(unmount.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('echoes a stored render error to stderr on cleanup (VP exit-time echo)', async () => {
|
||||
const unmount = vi.fn();
|
||||
const { render } = await import('ink');
|
||||
vi.mocked(render).mockReturnValue({ unmount } as never);
|
||||
mockConsumeLastRenderError.mockReturnValue(new Error('render boom'));
|
||||
mockWriteStderrLine.mockClear();
|
||||
|
||||
await startInteractiveUI(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
{
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
const cleanupFn = vi.mocked(registerCleanup).mock.calls.at(-1)?.[0] as
|
||||
| (() => Promise<void> | void)
|
||||
| undefined;
|
||||
expect(cleanupFn).toBeTypeOf('function');
|
||||
await cleanupFn?.();
|
||||
|
||||
expect(unmount).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
'\nRendering error: render boom',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not echo when no render error was stored', async () => {
|
||||
const unmount = vi.fn();
|
||||
const { render } = await import('ink');
|
||||
vi.mocked(render).mockReturnValue({ unmount } as never);
|
||||
mockConsumeLastRenderError.mockReturnValue(undefined);
|
||||
mockWriteStderrLine.mockClear();
|
||||
|
||||
await startInteractiveUI(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
{
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
const cleanupFn = vi.mocked(registerCleanup).mock.calls.at(-1)?.[0] as
|
||||
| (() => Promise<void> | void)
|
||||
| undefined;
|
||||
await cleanupFn?.();
|
||||
|
||||
expect(mockWriteStderrLine).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Rendering error'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('periodic memory-pressure check', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
uiTelemetryService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import dns from 'node:dns';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
|
|
@ -81,7 +82,8 @@ import { start_sandbox } from './utils/sandbox.js';
|
|||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { initializeWarningHandler } from './utils/warningHandler.js';
|
||||
import { writeStderrLine } from './utils/stdioHelpers.js';
|
||||
import { writeStderrLine, writeStderrLineSafe } from './utils/stdioHelpers.js';
|
||||
import { sanitizeTerminalText } from './ui/utils/textUtils.js';
|
||||
import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js';
|
||||
import { initializeLlmOutputLanguage } from './utils/languageUtils.js';
|
||||
import {
|
||||
|
|
@ -166,6 +168,62 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
|||
}
|
||||
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import {
|
||||
handleUncaughtException,
|
||||
isExpectedPtyRaceError,
|
||||
} from './utils/uncaught-exception-handler.js';
|
||||
|
||||
let uncaughtExceptionHandler: ((error: unknown) => void) | undefined;
|
||||
|
||||
export function setupUncaughtExceptionHandler(config: Config) {
|
||||
// runCliEntryPoint() registered the basic handleUncaughtException at startup,
|
||||
// before the session ID existed. Replace it now: two listeners conflict — the
|
||||
// first calls process.exit(1) so the second never runs — and the basic one
|
||||
// lacks the debug-log write and the alternate-screen handling below. Also drop
|
||||
// any handler a previous call installed so exactly one listener is ever active.
|
||||
process.removeListener('uncaughtException', handleUncaughtException);
|
||||
if (uncaughtExceptionHandler) {
|
||||
process.removeListener('uncaughtException', uncaughtExceptionHandler);
|
||||
}
|
||||
uncaughtExceptionHandler = (rawError) => {
|
||||
if (isExpectedPtyRaceError(rawError)) {
|
||||
return;
|
||||
}
|
||||
const error =
|
||||
rawError instanceof Error ? rawError : new Error(String(rawError));
|
||||
const timestamp = new Date().toISOString();
|
||||
const line = `${timestamp} [ERROR] [STARTUP] [UNCAUGHT_EXCEPTION] ${error.message}\n${error.stack ?? ''}\n`;
|
||||
// debugLogger.error() uses async fs.appendFile — the write would be
|
||||
// abandoned by the process.exit() below. Write synchronously instead.
|
||||
let logged = false;
|
||||
try {
|
||||
const logPath = Storage.getDebugLogPath(config.getSessionId());
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
fs.appendFileSync(logPath, line, 'utf8');
|
||||
logged = true;
|
||||
} catch {
|
||||
// Best-effort: if the debug dir doesn't exist yet or the disk is
|
||||
// full, the stderr output below is the fallback record.
|
||||
}
|
||||
// In VP / alternate-screen mode, stderr is written to the alternate
|
||||
// buffer which is discarded on teardown. Leave the alternate screen
|
||||
// *before* writing the error so the user actually sees it. Guard on
|
||||
// isTTY: with stdout redirected to a file the escapes would corrupt it.
|
||||
if (process.stdout.isTTY) {
|
||||
try {
|
||||
process.stdout.write('\x1b[?1049l'); // leave alternate screen
|
||||
process.stdout.write('\x1b[?25h'); // show cursor
|
||||
} catch {
|
||||
// stdout may be broken; the debug log above is the primary record.
|
||||
}
|
||||
}
|
||||
writeStderrLineSafe(
|
||||
`\nFatal: uncaught exception${logged ? ' (logged to debug file)' : ''}\n${sanitizeTerminalText(error.stack ?? error.message)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
};
|
||||
process.on('uncaughtException', uncaughtExceptionHandler);
|
||||
}
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
let unhandledRejectionOccurred = false;
|
||||
|
|
@ -191,7 +249,9 @@ ${reason.stack}`
|
|||
}
|
||||
|
||||
function getSignalExitCode(signal: NodeJS.Signals): number {
|
||||
return signal === 'SIGINT' ? 130 : 143;
|
||||
if (signal === 'SIGINT') return 130;
|
||||
if (signal === 'SIGHUP') return 129;
|
||||
return 143;
|
||||
}
|
||||
|
||||
// A real SIGINT only reaches the process-level handler while raw mode is
|
||||
|
|
@ -241,6 +301,9 @@ function installInteractiveSignalHandlers(wasRaw: boolean): () => void {
|
|||
const handleSigterm = () => {
|
||||
beginExit('SIGTERM');
|
||||
};
|
||||
const handleSighup = () => {
|
||||
beginExit('SIGHUP');
|
||||
};
|
||||
const handleSigint = () => {
|
||||
if (cleanupStarted) {
|
||||
return;
|
||||
|
|
@ -260,10 +323,12 @@ function installInteractiveSignalHandlers(wasRaw: boolean): () => void {
|
|||
|
||||
process.on('SIGTERM', handleSigterm);
|
||||
process.on('SIGINT', handleSigint);
|
||||
process.on('SIGHUP', handleSighup);
|
||||
|
||||
return () => {
|
||||
process.removeListener('SIGTERM', handleSigterm);
|
||||
process.removeListener('SIGINT', handleSigint);
|
||||
process.removeListener('SIGHUP', handleSighup);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -859,6 +924,11 @@ export async function main() {
|
|||
// This ensures MCP server subprocesses are properly terminated on exit
|
||||
registerCleanup(() => config.shutdown());
|
||||
|
||||
// Install the uncaughtException handler once the session ID is known.
|
||||
// Before this point VP mode is not active, so Node's default stderr
|
||||
// output is visible and sufficient.
|
||||
setupUncaughtExceptionHandler(config);
|
||||
|
||||
startEarlyStartupPrefetches(config);
|
||||
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
|
|
|
|||
|
|
@ -187,9 +187,11 @@ export function SuggestionsDisplay({
|
|||
</Box>
|
||||
);
|
||||
})}
|
||||
{/* Mention Ctrl+Tab as an alternative since many terminals
|
||||
intercept Ctrl+←/→ for word-jump (#8069). */}
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('(Ctrl+←/→ to switch)')}
|
||||
{t('(Ctrl+Tab / Ctrl+Shift+Tab or Ctrl+←/→ to switch)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@
|
|||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AgentStatus } from '@qwen-code/qwen-code-core';
|
||||
import { AgentStatus, ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
useAgentViewActions,
|
||||
useAgentViewState,
|
||||
} from '../../contexts/AgentViewContext.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { usePreferredEditor } from '../../hooks/usePreferredEditor.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { StreamingState } from '../../types.js';
|
||||
|
|
@ -34,13 +34,17 @@ vi.mock('../QueuedMessageDisplay.js', () => ({
|
|||
}));
|
||||
vi.mock('./AgentFooter.js', () => ({ AgentFooter: () => null }));
|
||||
|
||||
type KeypressHandler = (key: Key) => void;
|
||||
|
||||
describe('AgentComposer', () => {
|
||||
const setAgentInputBufferText = vi.fn();
|
||||
const setAgentTabBarFocused = vi.fn();
|
||||
const setAgentApprovalMode = vi.fn();
|
||||
let capturedKeypressHandlers: KeypressHandler[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedKeypressHandlers = [];
|
||||
|
||||
vi.mocked(useAgentViewState).mockReturnValue({
|
||||
activeView: 'agent-1',
|
||||
|
|
@ -74,7 +78,11 @@ describe('AgentComposer', () => {
|
|||
} as never);
|
||||
vi.mocked(usePreferredEditor).mockReturnValue(undefined);
|
||||
vi.mocked(useTerminalSize).mockReturnValue({ columns: 80, rows: 24 });
|
||||
vi.mocked(useKeypress).mockImplementation(() => {});
|
||||
vi.mocked(useKeypress).mockImplementation(
|
||||
(handler: KeypressHandler, _options) => {
|
||||
capturedKeypressHandlers.push(handler);
|
||||
},
|
||||
);
|
||||
vi.mocked(useAgentStreamingState).mockReturnValue({
|
||||
status: AgentStatus.IDLE,
|
||||
streamingState: StreamingState.Idle,
|
||||
|
|
@ -99,4 +107,29 @@ describe('AgentComposer', () => {
|
|||
|
||||
expect(setAgentInputBufferText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The second useKeypress call is the Shift+Tab approval-mode cycler.
|
||||
const getShiftTabHandler = (): KeypressHandler => {
|
||||
render(<AgentComposer agentId="agent-1" />);
|
||||
return capturedKeypressHandlers[1]!;
|
||||
};
|
||||
|
||||
it('cycles approval mode on Shift+Tab', () => {
|
||||
const handler = getShiftTabHandler();
|
||||
|
||||
handler({ name: 'tab', shift: true, ctrl: false } as Key);
|
||||
|
||||
expect(setAgentApprovalMode).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cycle approval mode on Ctrl+Shift+Tab', () => {
|
||||
const handler = getShiftTabHandler();
|
||||
|
||||
handler({ name: 'tab', shift: true, ctrl: true } as Key);
|
||||
|
||||
expect(setAgentApprovalMode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
|
|||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
const isShiftTab = key.shift && key.name === 'tab';
|
||||
const isShiftTab = key.shift && key.name === 'tab' && !key.ctrl;
|
||||
const isWindowsTab =
|
||||
process.platform === 'win32' &&
|
||||
key.name === 'tab' &&
|
||||
|
|
|
|||
|
|
@ -18,10 +18,17 @@ import {
|
|||
SCREEN_READER_USER_PREFIX,
|
||||
} from '../../textConstants.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { ErrorBoundary } from '../shared/ErrorBoundary.js';
|
||||
import { ICON } from '../../constants.js';
|
||||
import { wrapToVisualLines } from '../../utils/textUtils.js';
|
||||
import {
|
||||
wrapToVisualLines,
|
||||
sanitizeTerminalText,
|
||||
} from '../../utils/textUtils.js';
|
||||
import { formatDuration } from '../../utils/displayUtils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('THINK_RENDER');
|
||||
|
||||
export const THINKING_ICON = `${ICON.THEREFORE} `;
|
||||
export const THINKING_ICON_PENDING = `${ICON.BECAUSE} `;
|
||||
|
||||
|
|
@ -341,13 +348,26 @@ const ThinkBody: React.FC<{
|
|||
|
||||
return (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
contentWidth={contentWidth - 2}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
<ErrorBoundary
|
||||
fallback={(err) => (
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
{sanitizeTerminalText(err.message)}
|
||||
</Text>
|
||||
)}
|
||||
onError={(error, info) => {
|
||||
debugLogger.error(
|
||||
`[THINK_RENDER_ERROR] ${error.message}\n${info.componentStack ?? ''}\n${error.stack ?? ''}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
contentWidth={contentWidth - 2}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||
import { render } from 'ink-testing-library';
|
||||
import { act } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { ErrorBoundary } from './ErrorBoundary.js';
|
||||
import { ErrorBoundary, consumeLastRenderError } from './ErrorBoundary.js';
|
||||
|
||||
// A child that throws during render to trip the boundary.
|
||||
const Thrower = ({ message }: { message: string }) => {
|
||||
|
|
@ -124,4 +124,40 @@ describe('ErrorBoundary', () => {
|
|||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('string error');
|
||||
});
|
||||
|
||||
it('stores the error for consumeLastRenderError (VP main-screen echo)', () => {
|
||||
// Drain any leftover state from prior tests.
|
||||
consumeLastRenderError();
|
||||
|
||||
const onError = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary recordForExitEcho onError={onError}>
|
||||
<Thrower message="vp crash" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
const err = consumeLastRenderError();
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err?.message).toBe('vp crash');
|
||||
// Second call returns undefined (consumed).
|
||||
expect(consumeLastRenderError()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not store the error without recordForExitEcho (non-fatal boundary)', () => {
|
||||
// A boundary that handles the error itself (e.g. the transcript view)
|
||||
// must not feed the exit-time echo: the app continues normally, so a
|
||||
// later /quit should not print a spurious "Rendering error".
|
||||
consumeLastRenderError();
|
||||
|
||||
const onError = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<Thrower message="handled inline" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(consumeLastRenderError()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,21 @@ function normalizeError(error: unknown): Error {
|
|||
return new Error(String(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level store for the last rendering error. The cleanup chain in
|
||||
* startInteractiveUI.tsx reads this after `instance.unmount()` leaves the
|
||||
* alternate screen, so the message can be echoed to the *main* screen buffer
|
||||
* where it survives after the process exits. Without this, VP / alternate-
|
||||
* screen mode discards the fallback UI on teardown and the user sees nothing.
|
||||
*/
|
||||
let lastRenderError: Error | undefined;
|
||||
|
||||
export function consumeLastRenderError(): Error | undefined {
|
||||
const err = lastRenderError;
|
||||
lastRenderError = undefined;
|
||||
return err;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
|
|
@ -26,6 +41,14 @@ interface ErrorBoundaryProps {
|
|||
fallback?: (error: Error, reset: () => void) => ReactNode;
|
||||
/** Optional side-effecting hook for logging the error. */
|
||||
onError?: (error: Error, info: ErrorInfo) => void;
|
||||
/**
|
||||
* When true, the caught error is stored in the module-level
|
||||
* `lastRenderError` so the cleanup chain in startInteractiveUI.tsx can
|
||||
* echo it to stderr after leaving the alternate screen. Only the fatal
|
||||
* top-level boundary should set this; non-fatal boundaries (e.g. the
|
||||
* transcript view) recover and the app continues.
|
||||
*/
|
||||
recordForExitEcho?: boolean;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
|
|
@ -50,7 +73,11 @@ export class ErrorBoundary extends Component<
|
|||
}
|
||||
|
||||
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
||||
this.props.onError?.(normalizeError(error), info);
|
||||
const normalized = normalizeError(error);
|
||||
if (this.props.recordForExitEcho) {
|
||||
lastRenderError = normalized;
|
||||
}
|
||||
this.props.onError?.(normalized, info);
|
||||
}
|
||||
|
||||
private readonly reset = () => {
|
||||
|
|
|
|||
|
|
@ -248,6 +248,29 @@ describe('useAutoAcceptIndicator', () => {
|
|||
expect(result.current).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should not cycle approval modes when Ctrl+Shift+Tab is pressed', () => {
|
||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
|
||||
const { result } = renderHook(() =>
|
||||
useAutoAcceptIndicator({
|
||||
config: mockConfigInstance as unknown as ActualConfigType,
|
||||
addItem: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Ctrl+Shift+Tab is a completion-category navigation binding (#8069); it
|
||||
// must not also cycle the approval mode in Kitty-protocol terminals that
|
||||
// report the ctrl modifier on Shift+Tab.
|
||||
act(() => {
|
||||
capturedUseKeypressHandler({
|
||||
name: 'tab',
|
||||
shift: true,
|
||||
ctrl: true,
|
||||
} as Key);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
|
||||
expect(result.current).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should not toggle if only one key or other keys combinations are pressed', () => {
|
||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
|
||||
renderHook(() =>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export function useAutoAcceptIndicator({
|
|||
// Handle Shift+Tab to cycle through all modes
|
||||
// On Windows, Shift+Tab is indistinguishable from Tab (\t) in some terminals,
|
||||
// so we allow Tab to switch modes as well to support the shortcut.
|
||||
const isShiftTab = key.shift && key.name === 'tab';
|
||||
const isShiftTab = key.shift && key.name === 'tab' && !key.ctrl;
|
||||
const isWindowsTab =
|
||||
process.platform === 'win32' &&
|
||||
key.name === 'tab' &&
|
||||
|
|
|
|||
|
|
@ -516,6 +516,46 @@ describe('keyMatchers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// The Ctrl+Tab / Ctrl+Shift+Tab alternatives intentionally diverge from the
|
||||
// original hard-coded matchers (which only knew Ctrl+←/→), so they are
|
||||
// asserted against the data-driven matchers here rather than in the
|
||||
// comparison block above (#8069).
|
||||
describe('Completion tab-switching alternative bindings (#8069)', () => {
|
||||
it('should match Ctrl+Tab as COMPLETION_TAB_RIGHT', () => {
|
||||
expect(
|
||||
keyMatchers[Command.COMPLETION_TAB_RIGHT](
|
||||
createKey('tab', { ctrl: true }),
|
||||
),
|
||||
).toBe(true);
|
||||
// Bare Tab accepts the suggestion; Ctrl+Shift+Tab switches left.
|
||||
expect(keyMatchers[Command.COMPLETION_TAB_RIGHT](createKey('tab'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
keyMatchers[Command.COMPLETION_TAB_RIGHT](
|
||||
createKey('tab', { ctrl: true, shift: true }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should match Ctrl+Shift+Tab as COMPLETION_TAB_LEFT', () => {
|
||||
expect(
|
||||
keyMatchers[Command.COMPLETION_TAB_LEFT](
|
||||
createKey('tab', { ctrl: true, shift: true }),
|
||||
),
|
||||
).toBe(true);
|
||||
// Bare Tab accepts the suggestion; Ctrl+Tab switches right.
|
||||
expect(keyMatchers[Command.COMPLETION_TAB_LEFT](createKey('tab'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
keyMatchers[Command.COMPLETION_TAB_LEFT](
|
||||
createKey('tab', { ctrl: true }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom key bindings', () => {
|
||||
it('should work with custom configuration', () => {
|
||||
const customConfig: KeyBindingConfig = {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { render } from 'ink';
|
|||
import React from 'react';
|
||||
import {
|
||||
createDebugLogger,
|
||||
isDebugLogFileEnabled,
|
||||
type Config,
|
||||
writeRuntimeStatus,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -37,11 +38,15 @@ import {
|
|||
isInteractiveTerminal,
|
||||
shouldUseVirtualViewport,
|
||||
} from './utils/terminal-buffer.js';
|
||||
import { ErrorBoundary } from './components/shared/ErrorBoundary.js';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
consumeLastRenderError,
|
||||
} from './components/shared/ErrorBoundary.js';
|
||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
import { stopAndGetCapturedInput } from '../utils/earlyInputCapture.js';
|
||||
import { profileCheckpoint } from '../utils/startupProfiler.js';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
import { sanitizeTerminalText } from './utils/textUtils.js';
|
||||
import { startPostRenderPrefetches } from '../startup/startup-prefetch.js';
|
||||
import {
|
||||
computeWindowTitle,
|
||||
|
|
@ -208,6 +213,7 @@ export async function startInteractiveUI(
|
|||
}
|
||||
const appTree = (
|
||||
<ErrorBoundary
|
||||
recordForExitEcho
|
||||
onError={(error, info) => {
|
||||
debugLogger.error(
|
||||
`[FATAL_RENDER_ERROR] ${error.message}\n${info.componentStack ?? ''}\n${error.stack ?? ''}`,
|
||||
|
|
@ -306,6 +312,18 @@ export async function startInteractiveUI(
|
|||
}
|
||||
restoreSynchronizedOutput();
|
||||
restoreTerminalRedrawOptimizer();
|
||||
// If the ErrorBoundary caught a rendering error, echo it to stderr
|
||||
// now that we are back on the main screen buffer. In VP mode the
|
||||
// fallback UI was drawn on the alternate screen and is gone.
|
||||
const renderError = consumeLastRenderError();
|
||||
if (renderError) {
|
||||
const loggedHint = isDebugLogFileEnabled()
|
||||
? ' (logged to debug file)'
|
||||
: '';
|
||||
writeStderrLine(
|
||||
`\nRendering error${loggedHint}: ${sanitizeTerminalText(renderError.message)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,11 +96,14 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
|
|||
protocolSupported = true;
|
||||
enableProtocol();
|
||||
|
||||
// Set up cleanup on exit (exit covers process.exit() calls,
|
||||
// SIGTERM/SIGINT cover signal-based terminations).
|
||||
// Last-resort fallback: if the process exits without running
|
||||
// the async cleanup chain (e.g. direct process.exit() call),
|
||||
// the 'exit' event still fires synchronously and restores the
|
||||
// terminal. Signal-based teardown is handled by the main
|
||||
// installInteractiveSignalHandlers() → runExitCleanup() →
|
||||
// disableKittyProtocol() path, which runs *after* Ink leaves
|
||||
// the alternate screen so the pop lands on the correct buffer.
|
||||
process.on('exit', disableProtocol);
|
||||
process.on('SIGTERM', disableProtocol);
|
||||
process.on('SIGINT', disableProtocol);
|
||||
}
|
||||
|
||||
detectionComplete = true;
|
||||
|
|
|
|||
73
packages/cli/src/utils/uncaught-exception-handler.ts
Normal file
73
packages/cli/src/utils/uncaught-exception-handler.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { writeStderrLine } from './stdioHelpers.js';
|
||||
|
||||
// These helpers live in a leaf module (no import of cli.ts or gemini.tsx) so
|
||||
// both the entry point and the lazily-loaded gemini.tsx can share them. A
|
||||
// static import of cli.ts from gemini.tsx makes esbuild hoist the entry into a
|
||||
// shared chunk under `splitting: true`, which silently disables the bootstrap
|
||||
// guard at the bottom of cli.ts and leaves the bundled CLI dead.
|
||||
|
||||
function getErrnoCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
export function isExpectedPtyRaceError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message;
|
||||
const code = getErrnoCode(error);
|
||||
|
||||
if (
|
||||
(code === 'EIO' && message.includes('read')) ||
|
||||
message.includes('read EIO')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(code === 'EAGAIN' && message.includes('read')) ||
|
||||
message.includes('read EAGAIN')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
message.includes('ioctl(2) failed, EBADF') ||
|
||||
message.includes('Cannot resize a pty that has already exited')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The process-level `uncaughtException` handler registered at the entry point,
|
||||
* before the session ID (and thus the debug-log path) is known. Benign PTY
|
||||
* teardown races are suppressed; anything else is reported to stderr and fatal.
|
||||
*
|
||||
* `setupUncaughtExceptionHandler` in gemini.tsx removes this handler and
|
||||
* installs a session-aware replacement once interactive startup is far enough
|
||||
* along to leave the alternate screen and write the debug file. Exactly one
|
||||
* listener must be active: two would conflict (the first calls `process.exit`
|
||||
* before the second runs) and this basic one lacks the visibility behavior.
|
||||
*/
|
||||
export function handleUncaughtException(error: unknown): void {
|
||||
if (isExpectedPtyRaceError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
writeStderrLine(error.stack ?? error.message);
|
||||
} else {
|
||||
writeStderrLine(String(error));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, open, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import type { ConfigParameters, SandboxConfig } from './config.js';
|
||||
import {
|
||||
Config,
|
||||
|
|
@ -96,8 +96,10 @@ import {
|
|||
type GoalTurnHost,
|
||||
} from '../goals/goal-runtime.js';
|
||||
import {
|
||||
getSessionWriterLockPath,
|
||||
SessionTranscriptChangedError,
|
||||
SessionWriterLease,
|
||||
SessionWriterUnavailableError,
|
||||
} from '../services/session-writer-lease.js';
|
||||
import * as jsonl from '../utils/jsonl-utils.js';
|
||||
import { checkPriorRead } from '../tools/priorReadEnforcement.js';
|
||||
|
|
@ -2859,6 +2861,92 @@ describe('Server Config (config.ts)', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('releases a pending lease while a real baseline read is gated', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'qwen-config-writer-'));
|
||||
const runtimeBaseDir = path.join(root, 'runtime');
|
||||
const projectDir = path.join(root, 'project');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
Storage.setRuntimeBaseDir(runtimeBaseDir);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
sessionId: 'pending-baseline',
|
||||
cwd: projectDir,
|
||||
targetDir: projectDir,
|
||||
chatRecording: true,
|
||||
experimentalZedIntegration: true,
|
||||
sessionWriterLeaseEnabled: true,
|
||||
});
|
||||
const transcriptPath = config.getTranscriptPath();
|
||||
await mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
const transcript = Buffer.alloc(2 * 1024 * 1024, 0x20);
|
||||
transcript[transcript.byteLength - 1] = 0x0a;
|
||||
await writeFile(transcriptPath, transcript);
|
||||
const lockPath = getSessionWriterLockPath(
|
||||
runtimeBaseDir,
|
||||
'pending-baseline',
|
||||
);
|
||||
const probe = await open(transcriptPath, 'r');
|
||||
const fileHandlePrototype = Object.getPrototypeOf(probe) as {
|
||||
read: typeof probe.read;
|
||||
};
|
||||
await probe.close();
|
||||
const originalRead = fileHandlePrototype.read;
|
||||
let releaseRead!: () => void;
|
||||
const readGate = new Promise<void>((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
let notifyReadStarted!: () => void;
|
||||
const readStarted = new Promise<void>((resolve) => {
|
||||
notifyReadStarted = resolve;
|
||||
});
|
||||
let gated = false;
|
||||
const read = vi
|
||||
.spyOn(fileHandlePrototype, 'read')
|
||||
.mockImplementation(async function (
|
||||
this: fs.promises.FileHandle,
|
||||
...args
|
||||
) {
|
||||
const result = await originalRead.apply(this, args);
|
||||
const values = args as readonly unknown[];
|
||||
if (!gated && values[2] === 1024 * 1024) {
|
||||
gated = true;
|
||||
notifyReadStarted();
|
||||
await readGate;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
try {
|
||||
const initialize = config.initialize();
|
||||
await readStarted;
|
||||
await expect(stat(lockPath)).resolves.toBeDefined();
|
||||
const close = config.closeSessionWriter();
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(stat(lockPath)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
}),
|
||||
{ timeout: 1_000 },
|
||||
);
|
||||
expect(gated).toBe(true);
|
||||
releaseRead();
|
||||
await expect(close).resolves.toBeUndefined();
|
||||
await expect(initialize).rejects.toMatchObject({
|
||||
name: 'SessionWriterUnavailableError',
|
||||
});
|
||||
expect(config.hasSessionWriteOwnership()).toBe(false);
|
||||
expect(config.getChatRecordingService()?.hasWriteOwnership()).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
releaseRead();
|
||||
read.mockRestore();
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('treats managed shutdown during writer acquisition as a clean terminal', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
|
|
@ -2921,11 +3009,7 @@ describe('Server Config (config.ts)', () => {
|
|||
'getSessionLocation',
|
||||
).mockRejectedValue(activationError);
|
||||
|
||||
const result = await (
|
||||
config as unknown as { activateChatRecording(): Promise<void> }
|
||||
)
|
||||
.activateChatRecording()
|
||||
.catch((error: unknown) => error);
|
||||
const result = await config.initialize().catch((error: unknown) => error);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: 'SessionWriterUnavailableError',
|
||||
|
|
@ -2941,6 +3025,67 @@ describe('Server Config (config.ts)', () => {
|
|||
acquire.mockRestore();
|
||||
});
|
||||
|
||||
it('does not report the same acquisition release failure twice', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
chatRecording: true,
|
||||
experimentalZedIntegration: true,
|
||||
sessionWriterLeaseEnabled: true,
|
||||
});
|
||||
const activationError = new SessionTranscriptChangedError();
|
||||
const releaseError = new Error('lease release failed');
|
||||
const acquisitionFailure = new SessionWriterUnavailableError({
|
||||
cause: new AggregateError([activationError, releaseError]),
|
||||
});
|
||||
const release = vi.fn().mockRejectedValue(releaseError);
|
||||
const lease = {
|
||||
release,
|
||||
isReleased: false,
|
||||
} as unknown as SessionWriterLease;
|
||||
const acquire = vi
|
||||
.spyOn(SessionWriterLease, 'acquire')
|
||||
.mockImplementation(async (options) => {
|
||||
options.onOwnershipAcquired?.(lease);
|
||||
throw acquisitionFailure;
|
||||
});
|
||||
|
||||
const result = await config.initialize().catch((error: unknown) => error);
|
||||
|
||||
expect(result).toBe(acquisitionFailure);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
acquire.mockRestore();
|
||||
});
|
||||
|
||||
it('does not duplicate a concurrent activation and close failure', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
chatRecording: true,
|
||||
experimentalZedIntegration: true,
|
||||
sessionWriterLeaseEnabled: true,
|
||||
});
|
||||
const activationError = new SessionTranscriptChangedError();
|
||||
let rejectAcquire!: (error: Error) => void;
|
||||
const acquireGate = new Promise<SessionWriterLease>(
|
||||
(_resolve, reject) => {
|
||||
rejectAcquire = reject;
|
||||
},
|
||||
);
|
||||
const acquire = vi
|
||||
.spyOn(SessionWriterLease, 'acquire')
|
||||
.mockReturnValue(acquireGate);
|
||||
|
||||
const initialize = config.initialize().catch((error: unknown) => error);
|
||||
await vi.waitFor(() => expect(acquire).toHaveBeenCalledOnce());
|
||||
const close = config
|
||||
.closeSessionWriter()
|
||||
.catch((error: unknown) => error);
|
||||
rejectAcquire(activationError);
|
||||
|
||||
expect(await close).toBe(activationError);
|
||||
expect(await initialize).toBe(activationError);
|
||||
acquire.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves initialization and recording close failures', async () => {
|
||||
const config = new Config(baseParams);
|
||||
const initializationError = new Error('initialization failed');
|
||||
|
|
|
|||
|
|
@ -1667,6 +1667,15 @@ export type SubSessionSpawner = (
|
|||
|
||||
class SessionWriterShutdownError extends SessionWriterUnavailableError {}
|
||||
|
||||
function containsErrorByIdentity(error: unknown, candidate: unknown): boolean {
|
||||
return (
|
||||
error === candidate ||
|
||||
(error instanceof Error &&
|
||||
error.cause instanceof AggregateError &&
|
||||
error.cause.errors.includes(candidate))
|
||||
);
|
||||
}
|
||||
|
||||
export class Config {
|
||||
private sessionId: string;
|
||||
private sessionSourceType?: string;
|
||||
|
|
@ -1675,6 +1684,9 @@ export class Config {
|
|||
private readonly sessionRuntimeBaseDir: string;
|
||||
private sessionProjectDirRegistered = false;
|
||||
private pendingSessionWriterLease?: SessionWriterLease;
|
||||
private pendingSessionWriterRelease:
|
||||
| { lease: SessionWriterLease; promise: Promise<void> }
|
||||
| undefined;
|
||||
private sessionWriterReclaimPolicy: 'local' | 'never' = 'local';
|
||||
private sessionWriterTakeoverPolicy: 'never' | 'certified' = 'never';
|
||||
private sessionWriterShutdownRequested = false;
|
||||
|
|
@ -2530,6 +2542,9 @@ export class Config {
|
|||
try {
|
||||
await this.closeSessionWriter();
|
||||
} catch (closeError) {
|
||||
if (containsErrorByIdentity(error, closeError)) {
|
||||
throw error;
|
||||
}
|
||||
throw new SessionWriterUnavailableError({
|
||||
cause: new AggregateError(
|
||||
[error, closeError],
|
||||
|
|
@ -3048,6 +3063,9 @@ export class Config {
|
|||
onOwnershipAcquired: (acquiredLease) => {
|
||||
lease = acquiredLease;
|
||||
this.pendingSessionWriterLease = acquiredLease;
|
||||
if (this.sessionWriterShutdownRequested) {
|
||||
this.startPendingSessionWriterRelease(acquiredLease);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (this.sessionWriterShutdownRequested) {
|
||||
|
|
@ -3094,20 +3112,27 @@ export class Config {
|
|||
}
|
||||
try {
|
||||
const ownedLease = lease ?? this.pendingSessionWriterLease;
|
||||
await ownedLease?.release();
|
||||
await this.startPendingSessionWriterRelease(ownedLease);
|
||||
if (
|
||||
this.pendingSessionWriterLease === ownedLease &&
|
||||
(ownedLease?.isReleased ?? true)
|
||||
) {
|
||||
this.pendingSessionWriterLease = undefined;
|
||||
}
|
||||
if (
|
||||
this.sessionWriterShutdownRequested &&
|
||||
failure instanceof SessionWriterLostError &&
|
||||
ownedLease?.isReleased
|
||||
) {
|
||||
failure = new SessionWriterShutdownError();
|
||||
}
|
||||
} catch (releaseError) {
|
||||
if (
|
||||
releaseError instanceof SessionWriterLostError ||
|
||||
(lease ?? this.pendingSessionWriterLease)?.isReleased
|
||||
) {
|
||||
this.pendingSessionWriterLease = undefined;
|
||||
} else {
|
||||
} else if (!containsErrorByIdentity(failure, releaseError)) {
|
||||
failure = new SessionWriterUnavailableError({
|
||||
cause: new AggregateError(
|
||||
[failure, releaseError],
|
||||
|
|
@ -4293,6 +4318,9 @@ export class Config {
|
|||
config.enableCacheControl;
|
||||
this.contentGeneratorConfig.forceGlobalCacheScope =
|
||||
config.forceGlobalCacheScope;
|
||||
this.contentGeneratorConfig.cacheRetention = config.cacheRetention;
|
||||
this.contentGeneratorConfig.cacheRetentionByBlock =
|
||||
config.cacheRetentionByBlock;
|
||||
this.contentGeneratorConfig.splitToolMedia = config.splitToolMedia;
|
||||
this.contentGeneratorConfig.toolResultContentFormat =
|
||||
config.toolResultContentFormat;
|
||||
|
|
@ -4320,6 +4348,14 @@ export class Config {
|
|||
this.contentGeneratorConfigSources['forceGlobalCacheScope'] =
|
||||
sources['forceGlobalCacheScope'];
|
||||
}
|
||||
if ('cacheRetention' in sources) {
|
||||
this.contentGeneratorConfigSources['cacheRetention'] =
|
||||
sources['cacheRetention'];
|
||||
}
|
||||
if ('cacheRetentionByBlock' in sources) {
|
||||
this.contentGeneratorConfigSources['cacheRetentionByBlock'] =
|
||||
sources['cacheRetentionByBlock'];
|
||||
}
|
||||
if ('contextWindowSize' in sources) {
|
||||
this.contentGeneratorConfigSources['contextWindowSize'] =
|
||||
sources['contextWindowSize'];
|
||||
|
|
@ -7302,14 +7338,16 @@ export class Config {
|
|||
this.chatRecordingService?.beginClose({
|
||||
handoff: this.sessionWriterHandoffRequested,
|
||||
});
|
||||
this.startPendingSessionWriterRelease();
|
||||
this.sessionWriterClosePromise ??= this.closeSessionWriterOnce();
|
||||
return this.sessionWriterClosePromise;
|
||||
}
|
||||
|
||||
private async closeSessionWriterOnce(): Promise<void> {
|
||||
const failures: unknown[] = [];
|
||||
const activation = this.sessionWriterActivationPromise;
|
||||
try {
|
||||
await this.sessionWriterActivationPromise;
|
||||
await activation;
|
||||
} catch (error) {
|
||||
if (!(error instanceof SessionWriterShutdownError)) {
|
||||
failures.push(error);
|
||||
|
|
@ -7322,10 +7360,12 @@ export class Config {
|
|||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
const pendingLease = this.pendingSessionWriterLease;
|
||||
const pendingLease = activation
|
||||
? undefined
|
||||
: this.pendingSessionWriterLease;
|
||||
if (pendingLease) {
|
||||
try {
|
||||
await pendingLease.release();
|
||||
await this.startPendingSessionWriterRelease(pendingLease);
|
||||
if (
|
||||
this.pendingSessionWriterLease === pendingLease &&
|
||||
pendingLease.isReleased
|
||||
|
|
@ -7350,6 +7390,18 @@ export class Config {
|
|||
}
|
||||
}
|
||||
|
||||
private startPendingSessionWriterRelease(
|
||||
lease = this.pendingSessionWriterLease,
|
||||
): Promise<void> | undefined {
|
||||
if (!lease) return undefined;
|
||||
const existing = this.pendingSessionWriterRelease;
|
||||
if (existing?.lease === lease) return existing.promise;
|
||||
const promise = lease.release();
|
||||
this.pendingSessionWriterRelease = { lease, promise };
|
||||
void promise.catch(() => undefined);
|
||||
return promise;
|
||||
}
|
||||
|
||||
getSessionRuntimeBaseDir(): string {
|
||||
return this.sessionRuntimeBaseDir;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1285,6 +1285,27 @@ describe('AnthropicContentGenerator', () => {
|
|||
'prompt-caching-scope-2026-01-05',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends extended-cache-ttl-2025-04-11 when cacheRetention is "1h"', async () => {
|
||||
const headers = await callOnce({
|
||||
...baseConfig,
|
||||
reasoning: false,
|
||||
cacheRetention: '1h',
|
||||
});
|
||||
expect(headers['anthropic-beta']).toContain(
|
||||
'extended-cache-ttl-2025-04-11',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits extended-cache-ttl-2025-04-11 when cacheRetention is unset (ephemeral default)', async () => {
|
||||
const headers = await callOnce({
|
||||
...baseConfig,
|
||||
reasoning: false,
|
||||
});
|
||||
expect(headers['anthropic-beta']).not.toContain(
|
||||
'extended-cache-ttl-2025-04-11',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContent', () => {
|
||||
|
|
@ -1696,7 +1717,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
expect.objectContaining({
|
||||
output_config: { effort: 'max' },
|
||||
// 4.6+ uses adaptive thinking; the server controls the budget.
|
||||
thinking: { type: 'adaptive' },
|
||||
thinking: { type: 'adaptive', display: 'summarized' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -1733,7 +1754,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
expect(anthropicRequest).toEqual(
|
||||
expect.objectContaining({
|
||||
output_config: { effort: 'xhigh' },
|
||||
thinking: { type: 'adaptive' },
|
||||
thinking: { type: 'adaptive', display: 'summarized' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -2169,12 +2190,15 @@ describe('AnthropicContentGenerator', () => {
|
|||
it('selects adaptive for claude-opus-4-6 / sonnet-4-6 / opus-4-7', async () => {
|
||||
expect(await thinkingFor('claude-opus-4-6')).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
expect(await thinkingFor('claude-sonnet-4-6')).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
expect(await thinkingFor('claude-opus-4-7')).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2182,6 +2206,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
// Single-digit character-class regex would have missed haiku entirely.
|
||||
expect(await thinkingFor('claude-haiku-4-6')).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2190,12 +2215,14 @@ describe('AnthropicContentGenerator', () => {
|
|||
// invalid `{ type: 'enabled', budget_tokens: ... }` body.
|
||||
expect(await thinkingFor('claude-opus-4-10')).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
});
|
||||
|
||||
it('selects adaptive for a future major like claude-opus-5-1', async () => {
|
||||
expect(await thinkingFor('claude-opus-5-1')).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2206,6 +2233,24 @@ describe('AnthropicContentGenerator', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('never sets display on the budget_tokens shape (pre-4.6 models and the explicit-override escape hatch)', async () => {
|
||||
// display is a field on the 'enabled'/'adaptive' Anthropic thinking
|
||||
// shapes, but the summarized-default-changed-to-omitted problem
|
||||
// documented by Anthropic is scoped to adaptive thinking only
|
||||
// (Opus 4.7+ / every 5.x family). Pre-4.6 models on the manual
|
||||
// budget path, and the explicit reasoning.budget_tokens escape
|
||||
// hatch on models that still accept it, must not carry `display`.
|
||||
expect(await thinkingFor('claude-opus-4-5')).not.toHaveProperty(
|
||||
'display',
|
||||
);
|
||||
expect(
|
||||
await thinkingFor('claude-opus-4-6', {
|
||||
effort: 'medium',
|
||||
budget_tokens: 42_000,
|
||||
}),
|
||||
).not.toHaveProperty('display');
|
||||
});
|
||||
|
||||
it('keeps the budget path for dated Opus 4.0 (claude-opus-4-20250514, date suffix is not a minor)', async () => {
|
||||
// Regression: the 8-digit date suffix must not be parsed as the minor
|
||||
// version. Opus 4.0 lacks adaptive thinking, so it must fall to the
|
||||
|
|
@ -2241,7 +2286,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
effort: 'medium',
|
||||
budget_tokens: 42_000,
|
||||
}),
|
||||
).toEqual({ type: 'adaptive' });
|
||||
).toEqual({ type: 'adaptive', display: 'summarized' });
|
||||
});
|
||||
|
||||
it('still ships adaptive (no output_config, no effort beta) when reasoning is undefined on a 4.6+ model', async () => {
|
||||
|
|
@ -2288,6 +2333,7 @@ describe('AnthropicContentGenerator', () => {
|
|||
anthropicState.lastCreateArgs as AnthropicCreateArgs;
|
||||
expect((req as { thinking?: unknown }).thinking).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
expect(req).toEqual(
|
||||
expect.not.objectContaining({ output_config: expect.anything() }),
|
||||
|
|
@ -2304,6 +2350,99 @@ describe('AnthropicContentGenerator', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('assistant-turn prefill stripping (generator wiring)', () => {
|
||||
// stripTrailingAssistantPrefill is derived from
|
||||
// modelSupportsAdaptiveThinking() (anthropicContentGenerator.ts),
|
||||
// the same 4.6+ gate used for the thinking shape. These pin that the
|
||||
// generator actually turns the converter option on/off per model,
|
||||
// not just that the converter behaves correctly when told to.
|
||||
it('strips a trailing assistant turn and appends a synthetic user turn on claude-opus-4-6', async () => {
|
||||
const { AnthropicContentGenerator } = await importGenerator();
|
||||
anthropicState.createImpl.mockResolvedValue({
|
||||
id: 'anthropic-1',
|
||||
model: 'claude-opus-4-6',
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
|
||||
const generator = new AnthropicContentGenerator(
|
||||
{
|
||||
model: 'claude-opus-4-6',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
timeout: 10_000,
|
||||
maxRetries: 2,
|
||||
samplingParams: { max_tokens: 500 },
|
||||
schemaCompliance: 'auto',
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
await generator.generateContent({
|
||||
model: 'models/ignored',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
|
||||
],
|
||||
} as unknown as GenerateContentParameters);
|
||||
|
||||
const [anthropicRequest] =
|
||||
anthropicState.lastCreateArgs as AnthropicCreateArgs;
|
||||
const messages = (anthropicRequest as { messages: unknown[] }).messages;
|
||||
// enableCacheControl defaults to on at the generator level (unlike
|
||||
// the converter-level tests above, which pass it explicitly), so
|
||||
// the synthetic turn also picks up the same cache_control the
|
||||
// trailing user message would otherwise carry.
|
||||
expect(messages[messages.length - 1]).toEqual({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Continue.',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a trailing assistant turn untouched on claude-opus-4-5 (pre-4.6)', async () => {
|
||||
const { AnthropicContentGenerator } = await importGenerator();
|
||||
anthropicState.createImpl.mockResolvedValue({
|
||||
id: 'anthropic-1',
|
||||
model: 'claude-opus-4-5',
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
|
||||
const generator = new AnthropicContentGenerator(
|
||||
{
|
||||
model: 'claude-opus-4-5',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
timeout: 10_000,
|
||||
maxRetries: 2,
|
||||
samplingParams: { max_tokens: 500 },
|
||||
schemaCompliance: 'auto',
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
await generator.generateContent({
|
||||
model: 'models/ignored',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
|
||||
],
|
||||
} as unknown as GenerateContentParameters);
|
||||
|
||||
const [anthropicRequest] =
|
||||
anthropicState.lastCreateArgs as AnthropicCreateArgs;
|
||||
const messages = (anthropicRequest as { messages: unknown[] }).messages;
|
||||
expect(messages[messages.length - 1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Sure, here you go.' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('omits thinking when request.config.thinkingConfig.includeThoughts is false', async () => {
|
||||
const { AnthropicContentGenerator } = await importGenerator();
|
||||
anthropicState.createImpl.mockResolvedValue({
|
||||
|
|
@ -2624,7 +2763,10 @@ describe('AnthropicContentGenerator', () => {
|
|||
'https://internal-proxy.example/anthropic',
|
||||
);
|
||||
|
||||
expect(request.thinking).toEqual({ type: 'adaptive' });
|
||||
expect(request.thinking).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
expect(request.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Visible answer' }],
|
||||
|
|
|
|||
|
|
@ -261,9 +261,20 @@ type StreamingBlockState = {
|
|||
// and the adaptive shape for 4.6+. Centralized so the message-params type,
|
||||
// the streaming-request override, and `buildThinkingConfig`'s return type
|
||||
// stay in lockstep when a third shape (e.g. `extended`) eventually lands.
|
||||
//
|
||||
// `display` controls whether adaptive thinking is rendered as readable text
|
||||
// ('summarized') or withheld ('omitted', the server default per Anthropic's
|
||||
// own migration docs — Opus 4.7+/Fable 5/etc. all default to 'omitted', so
|
||||
// a caller that surfaces reasoning to users must set 'summarized' or every
|
||||
// thinking block comes back empty).
|
||||
type AnthropicThinkingDisplay = 'summarized' | 'omitted';
|
||||
type AnthropicThinkingParam =
|
||||
| { type: 'enabled'; budget_tokens: number }
|
||||
| { type: 'adaptive' };
|
||||
| {
|
||||
type: 'enabled';
|
||||
budget_tokens: number;
|
||||
display?: AnthropicThinkingDisplay;
|
||||
}
|
||||
| { type: 'adaptive'; display?: AnthropicThinkingDisplay };
|
||||
|
||||
type MessageCreateParamsWithThinking = MessageCreateParamsNonStreaming & {
|
||||
thinking?: AnthropicThinkingParam;
|
||||
|
|
@ -547,6 +558,16 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
betas.push('prompt-caching-scope-2026-01-05');
|
||||
}
|
||||
|
||||
// Sent defensively whenever the body carries `ttl: '1h'`. Live
|
||||
// verification against the Anthropic Messages API (via Vertex AI)
|
||||
// found this header has no observable effect there -- identical
|
||||
// `ephemeral_1h_input_tokens` with and without it across every
|
||||
// currently-active model -- but omitting it risks a hard 400 on any
|
||||
// Anthropic-compatible backend that still enforces the beta gate.
|
||||
if (this.hasExtendedCacheTtlOnWire(anthropicRequest)) {
|
||||
betas.push('extended-cache-ttl-2025-04-11');
|
||||
}
|
||||
|
||||
if (betas.length === 0) return undefined;
|
||||
const unique = Array.from(new Set(betas));
|
||||
return { 'anthropic-beta': unique.join(',') };
|
||||
|
|
@ -615,6 +636,51 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the assembled request body carries any
|
||||
* `cache_control: { ..., ttl: '1h' }` entry. Scans the system block,
|
||||
* tools array, and message content blocks — every place the converter
|
||||
* attaches `cache_control` (system text, last tool, trailing user
|
||||
* message). Used to gate the `extended-cache-ttl-2025-04-11` beta
|
||||
* header defensively: live verification found the header has no
|
||||
* observable effect on this proxy/Vertex backend (identical
|
||||
* `ephemeral_1h_input_tokens` with and without it), but a hard
|
||||
* requirement can't be ruled out for every Anthropic-compatible
|
||||
* backend, so it is sent whenever the body actually requests the 1h
|
||||
* tier -- same single-source-of-truth pattern as
|
||||
* {@link hasGlobalCacheScopeOnWire}.
|
||||
*/
|
||||
private hasExtendedCacheTtlOnWire(
|
||||
req: MessageCreateParamsWithThinking,
|
||||
): boolean {
|
||||
const hasTtl1h = (block: unknown): boolean => {
|
||||
if (!block || typeof block !== 'object') return false;
|
||||
const cc = (block as { cache_control?: unknown }).cache_control;
|
||||
if (!cc || typeof cc !== 'object') return false;
|
||||
return (cc as { ttl?: string }).ttl === '1h';
|
||||
};
|
||||
|
||||
if (Array.isArray(req.system)) {
|
||||
for (const block of req.system) {
|
||||
if (hasTtl1h(block)) return true;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(req.tools)) {
|
||||
for (const tool of req.tools) {
|
||||
if (hasTtl1h(tool)) return true;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(req.messages)) {
|
||||
for (const message of req.messages) {
|
||||
if (!Array.isArray(message.content)) continue;
|
||||
for (const block of message.content) {
|
||||
if (hasTtl1h(block)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every customHeaders entry whose key (case-insensitively) is
|
||||
* `anthropic-beta` and yield the comma-separated flags from each. Multiple
|
||||
|
|
@ -672,6 +738,13 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
!!thinking &&
|
||||
this.modelSupportsAdaptiveThinking() &&
|
||||
!isAnthropicNativeBaseUrl(this.contentGeneratorConfig);
|
||||
// Opus/Sonnet 4.6+ and every 5.x family reject a request whose final
|
||||
// message has role 'assistant' ("assistant message prefill") with a
|
||||
// hard 400 — per Anthropic's own migration guidance this is a
|
||||
// model-generation behavior change, identical on the native API,
|
||||
// Vertex AI, and Bedrock, so (unlike the signature workaround above)
|
||||
// this is NOT gated on baseURL.
|
||||
const stripTrailingAssistantPrefill = this.modelSupportsAdaptiveThinking();
|
||||
|
||||
// Sample the live cache-control flags once per request and forward
|
||||
// them to the converter (body-side `cache_control`). The converter's
|
||||
|
|
@ -692,6 +765,9 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
const enableCacheControl =
|
||||
this.contentGeneratorConfig.enableCacheControl !== false;
|
||||
const useGlobalCacheScope = this.useGlobalCacheScope();
|
||||
const cacheRetention = this.contentGeneratorConfig.cacheRetention;
|
||||
const cacheRetentionByBlock =
|
||||
this.contentGeneratorConfig.cacheRetentionByBlock;
|
||||
|
||||
const { system, messages } = this.converter.convertGeminiRequestToAnthropic(
|
||||
request,
|
||||
|
|
@ -703,8 +779,11 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
injectThinkingOnToolUseTurns: deepseekThinkingOn,
|
||||
dropUnsignedAssistantThinking,
|
||||
stripAssistantThinking,
|
||||
stripTrailingAssistantPrefill,
|
||||
enableCacheControl,
|
||||
useGlobalCacheScope,
|
||||
cacheRetention,
|
||||
cacheRetentionByBlock,
|
||||
// Read per request (not latched at construction): the client
|
||||
// re-records the prefix whenever it rebuilds the system prompt
|
||||
// (memory refresh, model change), and the converter fails open to
|
||||
|
|
@ -717,7 +796,12 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
const tools = request.config?.tools
|
||||
? await this.converter.convertGeminiToolsToAnthropic(
|
||||
request.config.tools,
|
||||
{ enableCacheControl, useGlobalCacheScope },
|
||||
{
|
||||
enableCacheControl,
|
||||
useGlobalCacheScope,
|
||||
cacheRetention,
|
||||
cacheRetentionByBlock,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
|
||||
|
|
@ -1008,8 +1092,18 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
// Models that support adaptive thinking use { type: 'adaptive' } without
|
||||
// a budget_tokens field. The server controls the thinking budget via
|
||||
// output_config.effort instead.
|
||||
//
|
||||
// `display: 'summarized'` is set explicitly rather than relying on the
|
||||
// server default: Sonnet 4.6 defaults adaptive thinking's `display` to
|
||||
// `'summarized'`, but Opus 4.7+ and every 5.x family (Sonnet 5, Fable 5,
|
||||
// Mythos 5, …) silently changed the default to `'omitted'` — with no
|
||||
// error, thinking blocks stream back with empty `thinking` text, which
|
||||
// looks like a long pause before output to anyone rendering reasoning.
|
||||
// Setting it explicitly is a no-op on 4.6 (matches its existing default)
|
||||
// and required on 4.7+ to keep behavior consistent across the whole
|
||||
// adaptive-thinking model population.
|
||||
if (this.modelSupportsAdaptiveThinking()) {
|
||||
return { type: 'adaptive' };
|
||||
return { type: 'adaptive', display: 'summarized' };
|
||||
}
|
||||
|
||||
// Budget path for non-adaptive (pre-4.6) models. resolveEffectiveEffort has
|
||||
|
|
|
|||
|
|
@ -1067,6 +1067,10 @@ describe('AnthropicContentConverter', () => {
|
|||
});
|
||||
|
||||
it('cleans orphaned tool_use blocks without matching tool_result', () => {
|
||||
// A genuine orphan requires a subsequent message that was actually
|
||||
// scanned and found lacking a matching tool_result -- not merely the
|
||||
// absence of any subsequent message (see the "trailing tool_use"
|
||||
// test below for that case).
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
|
|
@ -1078,20 +1082,69 @@ describe('AnthropicContentConverter', () => {
|
|||
{ functionCall: { id: 'orphan', name: 'tool', args: {} } },
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'never mind' }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Hi', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
content: [{ type: 'text', text: 'Hi' }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Let me help' }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'never mind',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not strip a trailing tool_use that has no subsequent message yet (unresolved, not orphaned)', () => {
|
||||
// "History ends on a pending tool_use" is not evidence the call is
|
||||
// orphaned -- the tool may simply not have finished executing yet,
|
||||
// or this conversion may be happening for a reason other than
|
||||
// sending the completed turn to Anthropic (token counting, a
|
||||
// resumed/replayed session snapshot, ...). Regression test for the
|
||||
// bug where this exact shape had its tool_use silently deleted.
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'What is the weather in Paris?' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Let me check the weather.' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'toolu_pending',
|
||||
name: 'get_weather',
|
||||
args: { city: 'Paris' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
expect(lastMsg.role).toBe('assistant');
|
||||
expect(lastMsg.content).toEqual([
|
||||
{ type: 'text', text: 'Let me check the weather.' },
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_pending',
|
||||
name: 'get_weather',
|
||||
input: { city: 'Paris' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -1136,6 +1189,155 @@ describe('AnthropicContentConverter', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
describe('tool_use id sanitization', () => {
|
||||
// Anthropic validates tool_use.id / tool_result.tool_use_id against
|
||||
// ^[a-zA-Z0-9_-]+$ server-side (HTTP 400 otherwise), but the Gemini
|
||||
// lingua-franca's functionCall.id / functionResponse.id has no such
|
||||
// constraint. Verified live: sending an id containing characters
|
||||
// outside that set, or an empty tool_use_id, both 400 with
|
||||
// "String should match pattern '^[a-zA-Z0-9_-]+$'".
|
||||
it('sanitizes a tool_use id containing characters outside [a-zA-Z0-9_-]', () => {
|
||||
const rawId = 'call:abc.def/ghi?jkl';
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: rawId, name: 'tool', args: { a: 1 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: rawId,
|
||||
name: 'tool',
|
||||
response: { output: 'ok' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const assistantBlocks = messages[1]?.content as Array<{
|
||||
type: string;
|
||||
id?: string;
|
||||
}>;
|
||||
const userBlocks = messages[2]?.content as Array<{
|
||||
type: string;
|
||||
tool_use_id?: string;
|
||||
}>;
|
||||
const toolUse = assistantBlocks.find((b) => b.type === 'tool_use');
|
||||
const toolResult = userBlocks.find((b) => b.type === 'tool_result');
|
||||
|
||||
expect(toolUse?.id).toMatch(/^[a-zA-Z0-9_-]+$/);
|
||||
expect(toolUse?.id).not.toBe(rawId);
|
||||
// The sanitized id links the pair back up.
|
||||
expect(toolResult?.tool_use_id).toBe(toolUse?.id);
|
||||
});
|
||||
|
||||
it('generates a non-empty fallback id when functionCall.id is missing (not an empty string)', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'tool', args: {} } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const assistantBlocks = messages[1]?.content as Array<{
|
||||
type: string;
|
||||
id?: string;
|
||||
}>;
|
||||
const toolUse = assistantBlocks.find((b) => b.type === 'tool_use');
|
||||
expect(toolUse?.id).toBeTruthy();
|
||||
expect(toolUse?.id).toMatch(/^[a-zA-Z0-9_-]+$/);
|
||||
});
|
||||
|
||||
// Note: there is no analogous standalone test for "functionResponse.id
|
||||
// missing" here -- a tool_result with no id can't be linked to any
|
||||
// tool_use by definition (which call is it responding to?), so it is
|
||||
// always a genuine orphan and gets cleaned up by cleanOrphanedToolCalls
|
||||
// regardless of this fix. tool_result.tool_use_id goes through the
|
||||
// exact same resolveToolUseId/nextGeneratedToolId path exercised by
|
||||
// the tool_use-side tests above, so the never-empty-string guarantee
|
||||
// is already covered.
|
||||
|
||||
it('does not collide fallback ids generated for two different missing-id tool calls in the same request', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { name: 'tool_a', args: {} } },
|
||||
{ functionCall: { name: 'tool_b', args: {} } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const assistantBlocks = messages[1]?.content as Array<{
|
||||
type: string;
|
||||
id?: string;
|
||||
}>;
|
||||
const ids = assistantBlocks
|
||||
.filter((b) => b.type === 'tool_use')
|
||||
.map((b) => b.id);
|
||||
expect(ids).toHaveLength(2);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves the same source id to the same sanitized id across tool_use and tool_result in different messages', () => {
|
||||
const rawId = 'weird/id:1';
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { id: rawId, name: 'tool', args: {} } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: rawId,
|
||||
name: 'tool',
|
||||
response: { output: 'ok' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const toolUseId = (
|
||||
messages[1]?.content as Array<{ type: string; id?: string }>
|
||||
).find((b) => b.type === 'tool_use')?.id;
|
||||
const toolResultId = (
|
||||
messages[2]?.content as Array<{
|
||||
type: string;
|
||||
tool_use_id?: string;
|
||||
}>
|
||||
).find((b) => b.type === 'tool_result')?.tool_use_id;
|
||||
|
||||
expect(toolUseId).toBeDefined();
|
||||
expect(toolUseId).toBe(toolResultId);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps tool results split across consecutive user messages', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
|
|
@ -1268,7 +1470,15 @@ describe('AnthropicContentConverter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('drops tool results that do not lead user content', () => {
|
||||
it('reorders a tool_result ahead of other content in the same message rather than dropping it', () => {
|
||||
// Anthropic requires tool_result to be the first content in a user
|
||||
// message replying to a tool_use. A text part preceding the
|
||||
// functionResponse part within the same Gemini Content used to be
|
||||
// treated by cleanOrphanedToolCalls's own "seenNonToolResult" gate as
|
||||
// if the tool_result never showed up at all -- silently discarding
|
||||
// both the tool_result AND its paired tool_use, rather than fixing
|
||||
// the order. Now the blocks are reordered before that gate runs, so
|
||||
// the pairing is recognized and everything survives.
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
|
|
@ -1294,10 +1504,15 @@ describe('AnthropicContentConverter', () => {
|
|||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 't1', name: 'tool', input: {} }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Hi' },
|
||||
{ type: 'tool_result', tool_use_id: 't1', content: 'late' },
|
||||
{
|
||||
type: 'text',
|
||||
text: 'preface',
|
||||
|
|
@ -1308,6 +1523,53 @@ describe('AnthropicContentConverter', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('preserves relative order among multiple tool_result blocks when reordering ahead of text', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { id: 't1', name: 'tool', args: {} } },
|
||||
{ functionCall: { id: 't2', name: 'tool', args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'preface' },
|
||||
{
|
||||
functionResponse: {
|
||||
id: 't1',
|
||||
name: 'tool',
|
||||
response: { output: 'first' },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: 't2',
|
||||
name: 'tool',
|
||||
response: { output: 'second' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
expect(lastMsg.content).toEqual([
|
||||
{ type: 'tool_result', tool_use_id: 't1', content: 'first' },
|
||||
{ type: 'tool_result', tool_use_id: 't2', content: 'second' },
|
||||
{
|
||||
type: 'text',
|
||||
text: 'preface',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates tool_use blocks by id during merge', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
|
|
@ -2099,6 +2361,134 @@ describe('AnthropicContentConverter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('assistant-turn prefill stripping', () => {
|
||||
it('drops a trailing empty assistant message when stripTrailingAssistantPrefill is set', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
// Whitespace-only, not empty: processContent only emits a text
|
||||
// block when part.text is truthy, so an actually-empty string
|
||||
// never reaches this pass at all (the fixture would be
|
||||
// vacuous). isEmptyAssistantMessage's `.trim()` check is what
|
||||
// this test needs to exercise.
|
||||
{ role: 'model', parts: [{ text: ' ' }] },
|
||||
],
|
||||
},
|
||||
{ stripTrailingAssistantPrefill: true, enableCacheControl: false },
|
||||
);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends a synthetic user turn when a trailing assistant message has real content', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
|
||||
],
|
||||
},
|
||||
{ stripTrailingAssistantPrefill: true, enableCacheControl: false },
|
||||
);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Sure, here you go.' }],
|
||||
},
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Continue.' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a trailing user message untouched when stripTrailingAssistantPrefill is set', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hello!' }] },
|
||||
{ role: 'user', parts: [{ text: 'How are you?' }] },
|
||||
],
|
||||
},
|
||||
{ stripTrailingAssistantPrefill: true, enableCacheControl: false },
|
||||
);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'Hello!' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not strip a trailing assistant message when the option is unset', () => {
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
|
||||
],
|
||||
},
|
||||
{ enableCacheControl: false },
|
||||
);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Sure, here you go.' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a trailing thinking-only assistant message and appends a synthetic user turn', () => {
|
||||
// A thinking block is real content (not text/whitespace-only), so it
|
||||
// must be preserved rather than dropped as an "empty prefill" —
|
||||
// unlike an unanswered tool_use, thinking blocks are never treated
|
||||
// as orphans by the earlier merge/clean passes.
|
||||
const { messages } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'Hi' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 'pondering the answer',
|
||||
thought: true,
|
||||
thoughtSignature: 'sig',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ stripTrailingAssistantPrefill: true, enableCacheControl: false },
|
||||
);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'pondering the answer',
|
||||
signature: 'sig',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Continue.' }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertGeminiToolsToAnthropic', () => {
|
||||
it('converts Tool.functionDeclarations to Anthropic tools and runs schema conversion', async () => {
|
||||
const tools = [
|
||||
|
|
@ -2637,5 +3027,243 @@ describe('AnthropicContentConverter', () => {
|
|||
expect(result[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheRetention', () => {
|
||||
it('omits ttl on the system block when cacheRetention is unset (ephemeral default)', () => {
|
||||
const { system } = converter.convertGeminiRequestToAnthropic({
|
||||
model: 'models/test',
|
||||
contents: 'hi',
|
||||
config: { systemInstruction: 'sys' },
|
||||
});
|
||||
expect(system).toEqual([
|
||||
{ type: 'text', text: 'sys', cache_control: { type: 'ephemeral' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets ttl:'1h' on system, last tool, and trailing user message when cacheRetention is '1h'", async () => {
|
||||
const { system, messages } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: 'hi',
|
||||
config: { systemInstruction: 'sys' },
|
||||
},
|
||||
{ cacheRetention: '1h' },
|
||||
);
|
||||
expect(system).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'sys',
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
},
|
||||
]);
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const content = Array.isArray(lastMsg.content) ? lastMsg.content : [];
|
||||
expect(content[content.length - 1]).toEqual({
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
});
|
||||
|
||||
const tools = await converter.convertGeminiToolsToAnthropic(
|
||||
[
|
||||
{
|
||||
functionDeclarations: [
|
||||
{ name: 'get_weather', description: 'Get weather' },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ cacheRetention: '1h' },
|
||||
);
|
||||
expect(tools[0]?.cache_control).toEqual({
|
||||
type: 'ephemeral',
|
||||
ttl: '1h',
|
||||
});
|
||||
});
|
||||
|
||||
it('composes ttl with scope:"global" on the same cache_control entry', () => {
|
||||
const { system } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: 'hi',
|
||||
config: { systemInstruction: 'sys' },
|
||||
},
|
||||
{ cacheRetention: '1h', useGlobalCacheScope: true },
|
||||
);
|
||||
expect(system).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'sys',
|
||||
cache_control: {
|
||||
type: 'ephemeral',
|
||||
scope: 'global',
|
||||
ttl: '1h',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors a per-anchor cacheRetentionByBlock override, promoting the earlier tool anchor to keep wire order legal', async () => {
|
||||
// Anthropic requires cache entries with a longer TTL to appear
|
||||
// before shorter ones on the wire (tools -> system -> messages).
|
||||
// { system: '1h' } alone would otherwise leave a 5m-default tool
|
||||
// anchor ahead of a 1h system anchor -- an ordering violation.
|
||||
// resolveCacheRetention promotes every anchor before a '1h' one,
|
||||
// so the tool anchor here also resolves to '1h'.
|
||||
const { system } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: 'hi',
|
||||
config: { systemInstruction: 'sys' },
|
||||
},
|
||||
{
|
||||
cacheRetention: 'ephemeral',
|
||||
cacheRetentionByBlock: { system: '1h' },
|
||||
},
|
||||
);
|
||||
expect(system).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'sys',
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
},
|
||||
]);
|
||||
|
||||
const tools = await converter.convertGeminiToolsToAnthropic(
|
||||
[
|
||||
{
|
||||
functionDeclarations: [
|
||||
{ name: 'get_weather', description: 'Get weather' },
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
cacheRetention: 'ephemeral',
|
||||
cacheRetentionByBlock: { system: '1h' },
|
||||
},
|
||||
);
|
||||
expect(tools[0]?.cache_control).toEqual({
|
||||
type: 'ephemeral',
|
||||
ttl: '1h',
|
||||
});
|
||||
});
|
||||
|
||||
it("does not promote anchors after the overridden one -- { tool: '1h' } alone leaves system/user.last at the default", async () => {
|
||||
// tool -> system -> user.last is already longest-to-shortest here,
|
||||
// so nothing needs promoting; this is the one override shape that
|
||||
// was always legal even before the ordering fix.
|
||||
const { system, messages } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: 'hi',
|
||||
config: { systemInstruction: 'sys' },
|
||||
},
|
||||
{
|
||||
cacheRetention: 'ephemeral',
|
||||
cacheRetentionByBlock: { tool: '1h' },
|
||||
},
|
||||
);
|
||||
expect(system).toEqual([
|
||||
{ type: 'text', text: 'sys', cache_control: { type: 'ephemeral' } },
|
||||
]);
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const content = Array.isArray(lastMsg.content) ? lastMsg.content : [];
|
||||
expect(content[content.length - 1]).toEqual({
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
|
||||
const tools = await converter.convertGeminiToolsToAnthropic(
|
||||
[
|
||||
{
|
||||
functionDeclarations: [
|
||||
{ name: 'get_weather', description: 'Get weather' },
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
cacheRetention: 'ephemeral',
|
||||
cacheRetentionByBlock: { tool: '1h' },
|
||||
},
|
||||
);
|
||||
expect(tools[0]?.cache_control).toEqual({
|
||||
type: 'ephemeral',
|
||||
ttl: '1h',
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes both tool and system when only 'user.last' is overridden to '1h'", async () => {
|
||||
// { 'user.last': '1h' } alone would otherwise leave both the tool
|
||||
// and system anchors at the 5m default ahead of a 1h trailing
|
||||
// user message -- also an ordering violation, and one the
|
||||
// reviewer's case analysis called out explicitly (case E).
|
||||
const { system, messages } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: 'hi',
|
||||
config: { systemInstruction: 'sys' },
|
||||
},
|
||||
{
|
||||
cacheRetention: 'ephemeral',
|
||||
cacheRetentionByBlock: { 'user.last': '1h' },
|
||||
},
|
||||
);
|
||||
expect(system).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'sys',
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
},
|
||||
]);
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const content = Array.isArray(lastMsg.content) ? lastMsg.content : [];
|
||||
expect(content[content.length - 1]).toEqual({
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
});
|
||||
|
||||
const tools = await converter.convertGeminiToolsToAnthropic(
|
||||
[
|
||||
{
|
||||
functionDeclarations: [
|
||||
{ name: 'get_weather', description: 'Get weather' },
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
cacheRetention: 'ephemeral',
|
||||
cacheRetentionByBlock: { 'user.last': '1h' },
|
||||
},
|
||||
);
|
||||
expect(tools[0]?.cache_control).toEqual({
|
||||
type: 'ephemeral',
|
||||
ttl: '1h',
|
||||
});
|
||||
});
|
||||
|
||||
it('carries ttl on both halves of a split system prompt (staticSystemPrefix)', () => {
|
||||
const { system } = converter.convertGeminiRequestToAnthropic(
|
||||
{
|
||||
model: 'models/test',
|
||||
contents: 'hi',
|
||||
config: { systemInstruction: 'stable prefixvolatile suffix' },
|
||||
},
|
||||
{ cacheRetention: '1h', staticSystemPrefix: 'stable prefix' },
|
||||
);
|
||||
expect(system).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'stable prefix',
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'volatile suffix',
|
||||
cache_control: { type: 'ephemeral', ttl: '1h' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,7 +35,20 @@ type AnthropicMessageParam = Anthropic.MessageParam;
|
|||
// model `cache_control` as `{ type: 'ephemeral' }` only, so we widen the
|
||||
// shape here for the fields where we actually attach it (tool params and
|
||||
// the system text block).
|
||||
type AnthropicCacheControl = { type: 'ephemeral'; scope?: 'global' };
|
||||
//
|
||||
// `ttl` is the Anthropic spec's extended-cache-tier field
|
||||
// (`ttl?: '5m' | '1h'`). Anthropic's current docs describe the 1h tier as
|
||||
// GA with no beta requirement; `extended-cache-ttl-2025-04-11` is sent
|
||||
// defensively for older Anthropic-compatible backends that may still gate
|
||||
// the field on it (see `hasExtendedCacheTtlOnWire` in
|
||||
// anthropicContentGenerator.ts). Omitting `ttl` means the spec default
|
||||
// (5m). It composes freely with `scope`: the two are independent and
|
||||
// Anthropic accepts both on the same cache_control entry.
|
||||
type AnthropicCacheControl = {
|
||||
type: 'ephemeral';
|
||||
scope?: 'global';
|
||||
ttl?: '5m' | '1h';
|
||||
};
|
||||
type AnthropicToolParam = Anthropic.Tool & {
|
||||
cache_control?: AnthropicCacheControl;
|
||||
};
|
||||
|
|
@ -46,6 +59,41 @@ type AnthropicContentBlockParam = Anthropic.ContentBlockParam;
|
|||
|
||||
const debugLogger = createDebugLogger('AnthropicConverter');
|
||||
|
||||
/**
|
||||
* Internal token for "how long should a cache anchor live?", resolved from
|
||||
* `ContentGeneratorConfig.cacheRetention` (settings.json:
|
||||
* `model.generationConfig.cacheRetention`), threaded into the converter
|
||||
* per-call alongside `enableCacheControl`/`useGlobalCacheScope`.
|
||||
*
|
||||
* `'ephemeral'` (default) omits `ttl` on the wire — spec default is 5m.
|
||||
* `'1h'` requests the extended cache tier (`ttl: '1h'`) unconditionally --
|
||||
* live verification against the Anthropic Messages API found every
|
||||
* currently-active model (Haiku 4.5 through Opus 4.8 and Sonnet 5) accepts
|
||||
* it, so there is no known model-specific allowlist to gate on. If a future
|
||||
* model rejects it, the 400 from Anthropic surfaces directly to the caller
|
||||
* rather than being silently masked by an incomplete allowlist.
|
||||
*/
|
||||
export type CacheRetention = 'ephemeral' | '1h';
|
||||
|
||||
/**
|
||||
* Per-anchor override of {@link CacheRetention}. Keys are the three cache
|
||||
* anchors this converter places `cache_control` on — the system text
|
||||
* block, the last tool definition, and the trailing user message (a
|
||||
* single anchor; this converter marks only one trailing user message with
|
||||
* cache_control, not a sliding multi-turn window). Missing keys inherit
|
||||
* the top-level retention.
|
||||
*
|
||||
* These render on the wire in a fixed order — `tool` -> `system` ->
|
||||
* `user.last` — and Anthropic requires cache entries with a longer TTL to
|
||||
* appear before shorter ones. Resolution (see `resolveCacheRetention`)
|
||||
* normalizes for this automatically: setting one anchor to `'1h'`
|
||||
* promotes every anchor before it on the wire to `'1h'` as well, so any
|
||||
* combination of overrides here produces a legal request body.
|
||||
*/
|
||||
export type CacheRetentionByBlock = Partial<
|
||||
Record<'system' | 'tool' | 'user.last', CacheRetention>
|
||||
>;
|
||||
|
||||
export interface ConvertGeminiRequestToAnthropicOptions {
|
||||
/**
|
||||
* On every assistant turn, fill in `signature: ''` on any `thinking` block
|
||||
|
|
@ -89,6 +137,35 @@ export interface ConvertGeminiRequestToAnthropicOptions {
|
|||
* spawned with `thinkingConfig.includeThoughts: false`).
|
||||
*/
|
||||
stripAssistantThinking?: boolean;
|
||||
/**
|
||||
* Strip a trailing assistant message that would otherwise be sent as an
|
||||
* "assistant-turn prefill" (a request whose final message has
|
||||
* `role: 'assistant'`). Anthropic Opus/Sonnet 4.6+ (and every 5.x
|
||||
* family — Fable 5, Mythos 5, …) reject prefill outright:
|
||||
*
|
||||
* "This model does not support assistant message prefill. The
|
||||
* conversation must end with a user message."
|
||||
*
|
||||
* Per Anthropic's own migration guidance this is a model-generation
|
||||
* change, not a backend quirk — it 400s identically on the native API,
|
||||
* Vertex AI, and Bedrock for every 4.6+ model.
|
||||
*
|
||||
* A trailing assistant message reaches the converter when Gemini history
|
||||
* ends on a model turn with no follow-up (e.g. context-window trimming
|
||||
* drops the next user turn, or a subagent's transcript is replayed
|
||||
* mid-turn). Two cases are handled:
|
||||
* - The trailing assistant message is empty/whitespace-only (a
|
||||
* leftover prefill artifact with no real content) — drop it.
|
||||
* - The trailing assistant message carries real content (text,
|
||||
* tool_use, thinking) — keep it in history but append a synthetic
|
||||
* user turn so the request satisfies "must end with a user message"
|
||||
* without discarding anything the model already said.
|
||||
*
|
||||
* Only meaningful when the active model requires adaptive thinking
|
||||
* (Claude 4.6+); older models accept prefill on every backend, so this
|
||||
* should be gated on `modelSupportsAdaptiveThinking()` in the caller.
|
||||
*/
|
||||
stripTrailingAssistantPrefill?: boolean;
|
||||
/**
|
||||
* Per-call override for `enableCacheControl`. Falls back to the value
|
||||
* captured at construction. The generator passes the live
|
||||
|
|
@ -122,11 +199,33 @@ export interface ConvertGeminiRequestToAnthropicOptions {
|
|||
* than before. Only meaningful when `enableCacheControl` is on.
|
||||
*/
|
||||
staticSystemPrefix?: string;
|
||||
/**
|
||||
* Default Anthropic `cache_control` retention for every cache anchor
|
||||
* (system text, last tool, trailing user message) unless overridden
|
||||
* per-anchor by {@link cacheRetentionByBlock}. `'ephemeral'` (default)
|
||||
* omits `ttl` on the wire (spec default is 5m); `'1h'` requests the
|
||||
* extended cache tier. See {@link CacheRetention}.
|
||||
*/
|
||||
cacheRetention?: CacheRetention;
|
||||
/**
|
||||
* Per-anchor override of {@link cacheRetention}. See
|
||||
* {@link CacheRetentionByBlock}.
|
||||
*/
|
||||
cacheRetentionByBlock?: CacheRetentionByBlock;
|
||||
}
|
||||
|
||||
export class AnthropicContentConverter {
|
||||
private schemaCompliance: SchemaComplianceMode;
|
||||
private enableCacheControl: boolean;
|
||||
/**
|
||||
* Per-request tool ID sanitization state (see {@link resolveToolUseId}).
|
||||
* The converter instance is long-lived across requests (constructed once
|
||||
* per generator), so this state is reset at the top of every
|
||||
* `convertGeminiRequestToAnthropic` call rather than at construction.
|
||||
*/
|
||||
private readonly toolIdMap = new Map<string, string>();
|
||||
private readonly usedToolIds = new Set<string>();
|
||||
private generatedToolIdCounter = 0;
|
||||
|
||||
constructor(
|
||||
_model: string,
|
||||
|
|
@ -144,6 +243,7 @@ export class AnthropicContentConverter {
|
|||
system?: AnthropicTextBlockParam[] | string;
|
||||
messages: AnthropicMessageParam[];
|
||||
} {
|
||||
this.resetToolIdState();
|
||||
let messages: AnthropicMessageParam[] = [];
|
||||
|
||||
const systemText = this.extractTextFromContentUnion(
|
||||
|
|
@ -183,6 +283,9 @@ export class AnthropicContentConverter {
|
|||
this.stripThinkingFromAssistantMessages(messages);
|
||||
}
|
||||
messages = mergeConsecutiveUserMessages(messages);
|
||||
if (options.stripTrailingAssistantPrefill) {
|
||||
this.stripTrailingAssistantPrefill(messages);
|
||||
}
|
||||
|
||||
// Add cache_control to enable prompt caching (if enabled). Prefer the
|
||||
// per-call override when the caller (typically the generator) passes
|
||||
|
|
@ -196,15 +299,29 @@ export class AnthropicContentConverter {
|
|||
const enableCacheControl =
|
||||
options.enableCacheControl ?? this.enableCacheControl;
|
||||
const useGlobalCacheScope = options.useGlobalCacheScope ?? false;
|
||||
const cacheRetention = options.cacheRetention ?? 'ephemeral';
|
||||
const cacheRetentionByBlock = options.cacheRetentionByBlock ?? {};
|
||||
const system = enableCacheControl
|
||||
? this.buildSystemWithCacheControl(
|
||||
systemText,
|
||||
useGlobalCacheScope,
|
||||
options.staticSystemPrefix,
|
||||
this.resolveCacheRetention(
|
||||
'system',
|
||||
cacheRetention,
|
||||
cacheRetentionByBlock,
|
||||
),
|
||||
)
|
||||
: systemText;
|
||||
if (enableCacheControl) {
|
||||
this.addCacheControlToMessages(messages);
|
||||
this.addCacheControlToMessages(
|
||||
messages,
|
||||
this.resolveCacheRetention(
|
||||
'user.last',
|
||||
cacheRetention,
|
||||
cacheRetentionByBlock,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -218,6 +335,8 @@ export class AnthropicContentConverter {
|
|||
options: {
|
||||
enableCacheControl?: boolean;
|
||||
useGlobalCacheScope?: boolean;
|
||||
cacheRetention?: CacheRetention;
|
||||
cacheRetentionByBlock?: CacheRetentionByBlock;
|
||||
} = {},
|
||||
): Promise<AnthropicToolParam[]> {
|
||||
const tools: AnthropicToolParam[] = [];
|
||||
|
|
@ -285,11 +404,18 @@ export class AnthropicContentConverter {
|
|||
const useGlobalCacheScope = options.useGlobalCacheScope ?? false;
|
||||
if (enableCacheControl && tools.length > 0) {
|
||||
const lastToolIndex = tools.length - 1;
|
||||
const resolvedRetention = this.resolveCacheRetention(
|
||||
'tool',
|
||||
options.cacheRetention ?? 'ephemeral',
|
||||
options.cacheRetentionByBlock ?? {},
|
||||
);
|
||||
tools[lastToolIndex] = {
|
||||
...tools[lastToolIndex],
|
||||
cache_control: useGlobalCacheScope
|
||||
? { type: 'ephemeral', scope: 'global' }
|
||||
: { type: 'ephemeral' },
|
||||
cache_control: {
|
||||
type: 'ephemeral',
|
||||
...(useGlobalCacheScope ? { scope: 'global' as const } : {}),
|
||||
...(resolvedRetention === '1h' ? { ttl: '1h' as const } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -414,7 +540,6 @@ export class AnthropicContentConverter {
|
|||
const parts = content.parts || [];
|
||||
const role = content.role === 'model' ? 'assistant' : 'user';
|
||||
const contentBlocks: AnthropicContentBlockParam[] = [];
|
||||
let toolCallIndex = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
|
|
@ -452,11 +577,10 @@ export class AnthropicContentConverter {
|
|||
if (role === 'assistant') {
|
||||
contentBlocks.push({
|
||||
type: 'tool_use',
|
||||
id: part.functionCall.id || `tool_${toolCallIndex}`,
|
||||
id: this.resolveToolUseId(part.functionCall.id),
|
||||
name: normalizeMcpToolName(part.functionCall.name || ''),
|
||||
input: (part.functionCall.args as Record<string, unknown>) || {},
|
||||
});
|
||||
toolCallIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -471,6 +595,24 @@ export class AnthropicContentConverter {
|
|||
}
|
||||
|
||||
if (contentBlocks.length > 0) {
|
||||
// Anthropic requires tool_result to be the first content in a user
|
||||
// message replying to a tool_use -- it doesn't scan past a leading
|
||||
// non-tool_result block to find the result later in the same
|
||||
// message. The source Gemini parts can arrive in any order (e.g. a
|
||||
// text part preceding the functionResponse part within the same
|
||||
// Content), so move tool_result blocks to the front of a user
|
||||
// message whenever any are present. A stable sort preserves the
|
||||
// relative order of multiple tool_result blocks against each other.
|
||||
if (
|
||||
role === 'user' &&
|
||||
contentBlocks.some((b) => b.type === 'tool_result')
|
||||
) {
|
||||
contentBlocks.sort((a, b) => {
|
||||
if (a.type === 'tool_result' && b.type !== 'tool_result') return -1;
|
||||
if (a.type !== 'tool_result' && b.type === 'tool_result') return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
messages.push({ role, content: contentBlocks });
|
||||
}
|
||||
}
|
||||
|
|
@ -504,7 +646,7 @@ export class AnthropicContentConverter {
|
|||
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_use_id: response.id || '',
|
||||
tool_use_id: this.resolveToolUseId(response.id),
|
||||
content,
|
||||
...(response.response &&
|
||||
Object.prototype.hasOwnProperty.call(response.response, 'error')
|
||||
|
|
@ -513,6 +655,75 @@ export class AnthropicContentConverter {
|
|||
};
|
||||
}
|
||||
|
||||
private resetToolIdState(): void {
|
||||
this.toolIdMap.clear();
|
||||
this.usedToolIds.clear();
|
||||
this.generatedToolIdCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `functionCall.id` / `functionResponse.id` into a wire-safe
|
||||
* `tool_use.id` / `tool_result.tool_use_id`. Anthropic validates both
|
||||
* fields against `^[a-zA-Z0-9_-]+$` server-side (HTTP 400 otherwise) and
|
||||
* rejects the empty string the same way, since `+` requires at least one
|
||||
* character. The Gemini lingua-franca's `id` field has no such
|
||||
* constraint -- it can carry another provider's ID scheme, a
|
||||
* composite/namespaced ID, or be entirely absent.
|
||||
*
|
||||
* The same source ID always resolves to the same wire ID within a
|
||||
* request (memoized in `toolIdMap`), so a `tool_use`/`tool_result` pair
|
||||
* that shares a source ID still links up correctly after sanitization.
|
||||
* State is scoped to a single `convertGeminiRequestToAnthropic` call
|
||||
* (reset via {@link resetToolIdState}), since the converter instance
|
||||
* itself is long-lived across requests.
|
||||
*/
|
||||
private resolveToolUseId(rawId?: string): string {
|
||||
const sourceId = typeof rawId === 'string' ? rawId.trim() : '';
|
||||
const existingId = sourceId ? this.toolIdMap.get(sourceId) : undefined;
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
const baseId = sourceId
|
||||
? this.sanitizeToolUseId(sourceId)
|
||||
: this.nextGeneratedToolId();
|
||||
const uniqueId = this.makeUniqueToolUseId(baseId);
|
||||
|
||||
if (sourceId) {
|
||||
this.toolIdMap.set(sourceId, uniqueId);
|
||||
}
|
||||
|
||||
return uniqueId;
|
||||
}
|
||||
|
||||
private sanitizeToolUseId(id: string): string {
|
||||
const cleaned = id.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
return cleaned || this.nextGeneratedToolId();
|
||||
}
|
||||
|
||||
private nextGeneratedToolId(): string {
|
||||
const id = `tool_${this.generatedToolIdCounter}`;
|
||||
this.generatedToolIdCounter += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
private makeUniqueToolUseId(baseId: string): string {
|
||||
if (!this.usedToolIds.has(baseId)) {
|
||||
this.usedToolIds.add(baseId);
|
||||
return baseId;
|
||||
}
|
||||
|
||||
let suffix = 1;
|
||||
let candidate = `${baseId}_${suffix}`;
|
||||
while (this.usedToolIds.has(candidate)) {
|
||||
suffix += 1;
|
||||
candidate = `${baseId}_${suffix}`;
|
||||
}
|
||||
|
||||
this.usedToolIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private createMediaBlockFromPart(
|
||||
part: Part,
|
||||
): AnthropicContentBlockParam | null {
|
||||
|
|
@ -695,6 +906,49 @@ export class AnthropicContentConverter {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective {@link CacheRetention} for one cache anchor,
|
||||
* normalized so retention is monotonically non-increasing in wire order.
|
||||
*
|
||||
* Render order is `tools` -> `system` -> `messages`, so this converter's
|
||||
* three anchors sit on the wire in exactly that order: `tool` -> `system`
|
||||
* -> `user.last`. Anthropic requires "cache entries with longer TTL must
|
||||
* appear before shorter TTLs" — an anchor at the spec's 5-minute default
|
||||
* (no `ttl`) is a short-TTL entry for this rule's purposes, so a raw
|
||||
* per-anchor override like `cacheRetentionByBlock: { system: '1h' }`
|
||||
* would otherwise leave the (still 5m-default) `tool` anchor ahead of a
|
||||
* 1h `system` anchor on the wire — an ordering violation Anthropic 400s
|
||||
* on.
|
||||
*
|
||||
* Resolving with a scan instead of a straight per-anchor lookup avoids
|
||||
* that: `anchor` resolves to `'1h'` if `anchor` itself OR any anchor
|
||||
* later on the wire resolves to `'1h'`. That makes every
|
||||
* `cacheRetentionByBlock` configuration legal — anchors before a `'1h'`
|
||||
* anchor are promoted to `'1h'` too — without adding a new error surface
|
||||
* or rejecting any input. `{ tool: '1h' }` alone is unaffected (nothing
|
||||
* follows it that needs promoting); `{ system: '1h' }` alone now also
|
||||
* promotes `tool` to `'1h'`, which is exactly the "cache my big system
|
||||
* prompt for an hour" usage the per-anchor override exists for.
|
||||
*/
|
||||
private resolveCacheRetention(
|
||||
anchor: 'system' | 'tool' | 'user.last',
|
||||
cacheRetention: CacheRetention,
|
||||
cacheRetentionByBlock: CacheRetentionByBlock,
|
||||
): CacheRetention {
|
||||
const wireOrder: ReadonlyArray<'tool' | 'system' | 'user.last'> = [
|
||||
'tool',
|
||||
'system',
|
||||
'user.last',
|
||||
];
|
||||
const anchorIndex = wireOrder.indexOf(anchor);
|
||||
for (let i = wireOrder.length - 1; i >= anchorIndex; i--) {
|
||||
if ((cacheRetentionByBlock[wireOrder[i]] ?? cacheRetention) === '1h') {
|
||||
return '1h';
|
||||
}
|
||||
}
|
||||
return 'ephemeral';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system content blocks with cache_control.
|
||||
* Anthropic prompt caching requires cache_control on system content.
|
||||
|
|
@ -724,14 +978,17 @@ export class AnthropicContentConverter {
|
|||
systemText: string,
|
||||
useGlobalCacheScope: boolean,
|
||||
staticSystemPrefix?: string,
|
||||
cacheRetention: CacheRetention = 'ephemeral',
|
||||
): AnthropicTextBlockParam[] | string {
|
||||
if (!systemText) {
|
||||
return systemText;
|
||||
}
|
||||
|
||||
const scopedCacheControl: AnthropicCacheControl = useGlobalCacheScope
|
||||
? { type: 'ephemeral', scope: 'global' }
|
||||
: { type: 'ephemeral' };
|
||||
const scopedCacheControl: AnthropicCacheControl = {
|
||||
type: 'ephemeral',
|
||||
...(useGlobalCacheScope ? { scope: 'global' as const } : {}),
|
||||
...(cacheRetention === '1h' ? { ttl: '1h' as const } : {}),
|
||||
};
|
||||
|
||||
if (
|
||||
staticSystemPrefix &&
|
||||
|
|
@ -747,7 +1004,16 @@ export class AnthropicContentConverter {
|
|||
{
|
||||
type: 'text',
|
||||
text: systemText.slice(staticSystemPrefix.length),
|
||||
cache_control: { type: 'ephemeral' },
|
||||
// Deliberately never carries `scope: 'global'` (see class doc
|
||||
// above — the suffix varies per session, cross-session reuse
|
||||
// has ~zero hit rate). `cacheRetention` still applies: the
|
||||
// suffix is cached within a session, and a caller that asked
|
||||
// for the 1h tier benefits from it surviving longer gaps
|
||||
// between turns even on this volatile block.
|
||||
cache_control: {
|
||||
type: 'ephemeral',
|
||||
...(cacheRetention === '1h' ? { ttl: '1h' as const } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -955,6 +1221,58 @@ export class AnthropicContentConverter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a trailing empty-content assistant message, or append a
|
||||
* synthetic user turn to satisfy Anthropic's "must end with a user
|
||||
* message" requirement (Opus/Sonnet 4.6+, every 5.x family) when the
|
||||
* conversation would otherwise end on a non-empty assistant message.
|
||||
* See {@link ConvertGeminiRequestToAnthropicOptions.stripTrailingAssistantPrefill}.
|
||||
*/
|
||||
private stripTrailingAssistantPrefill(
|
||||
messages: AnthropicMessageParam[],
|
||||
): void {
|
||||
// Phase 1: drop genuinely empty trailing assistant messages (no real
|
||||
// content — a leftover prefill artifact from history trimming/replay).
|
||||
while (messages.length > 0) {
|
||||
const last = messages[messages.length - 1]!;
|
||||
if (last.role !== 'assistant') return;
|
||||
if (!this.isEmptyAssistantMessage(last)) break;
|
||||
messages.pop();
|
||||
}
|
||||
|
||||
// Phase 2: a real-content assistant message is still trailing — keep
|
||||
// it in history (it may carry tool_use/thinking the model needs to see
|
||||
// again) and append a synthetic user turn instead of dropping it.
|
||||
if (
|
||||
messages.length > 0 &&
|
||||
messages[messages.length - 1]!.role === 'assistant'
|
||||
) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Continue.' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isEmptyAssistantMessage(message: AnthropicMessageParam): boolean {
|
||||
const content = message.content;
|
||||
if (!content) return true;
|
||||
if (typeof content === 'string') return content.trim().length === 0;
|
||||
if (!Array.isArray(content) || content.length === 0) return true;
|
||||
|
||||
for (const block of content) {
|
||||
const type = (block as { type?: string }).type;
|
||||
if (type === 'text') {
|
||||
const text = (block as { text?: string }).text;
|
||||
if (typeof text === 'string' && text.trim().length > 0) return false;
|
||||
} else {
|
||||
// Any non-text block (tool_use, thinking, etc.) is real content.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add cache_control to the last user message's content.
|
||||
* This enables prompt caching for the conversation context.
|
||||
|
|
@ -967,7 +1285,10 @@ export class AnthropicContentConverter {
|
|||
* system prefix and tool prefixes (which DO repeat across sessions) carry
|
||||
* `scope: 'global'` instead.
|
||||
*/
|
||||
private addCacheControlToMessages(messages: Anthropic.MessageParam[]): void {
|
||||
private addCacheControlToMessages(
|
||||
messages: Anthropic.MessageParam[],
|
||||
cacheRetention: CacheRetention = 'ephemeral',
|
||||
): void {
|
||||
// Find the last user message to add cache_control. The Anthropic docs
|
||||
// (https://docs.claude.com/en/docs/build-with-claude/prompt-caching)
|
||||
// explicitly list both `text` and `tool_result` blocks as cacheable in
|
||||
|
|
@ -994,6 +1315,7 @@ export class AnthropicContentConverter {
|
|||
if ((type === 'text' || type === 'tool_result') && !isEmptyText) {
|
||||
lastContent.cache_control = {
|
||||
type: 'ephemeral',
|
||||
...(cacheRetention === '1h' ? { ttl: '1h' as const } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1079,6 +1401,15 @@ function mergeConsecutiveAssistantMessages(
|
|||
* immediately following user message, and remove tool_result blocks that
|
||||
* have no matching tool_use in the immediately preceding assistant message.
|
||||
*
|
||||
* A `tool_use` in the very last message (no message follows it at all) is
|
||||
* never condemned as orphaned here -- "no result yet" isn't the same as
|
||||
* "no result ever": the tool may simply not have finished executing yet,
|
||||
* or this conversion may not be building the completed turn to send to
|
||||
* Anthropic at all (token counting, a resumed/replayed session snapshot,
|
||||
* a retry issued before tool execution completes, ...). Only a `tool_use`
|
||||
* whose subsequent message was actually scanned and found lacking a
|
||||
* matching `tool_result` is a genuine orphan.
|
||||
*
|
||||
* Empty messages produced by the cleanup are dropped entirely. A subsequent
|
||||
* mergeConsecutiveAssistantMessages call fixes any alternation issues
|
||||
* created by dropped messages.
|
||||
|
|
@ -1107,6 +1438,20 @@ function cleanOrphanedToolCalls(
|
|||
}
|
||||
if (toolUseBlocks.size === 0) continue;
|
||||
|
||||
// No message follows this assistant turn at all -- these tool_use
|
||||
// blocks are unresolved (the tool hasn't finished executing yet, or
|
||||
// this conversion isn't building the completed turn for Anthropic at
|
||||
// all, e.g. a token-count pass or a mid-tool-call snapshot), not
|
||||
// orphaned. Protect them from the filter below. A genuine orphan
|
||||
// requires a subsequent message that was actually scanned and found
|
||||
// to lack a matching tool_result -- "history ends here" is not that.
|
||||
if (i === messages.length - 1) {
|
||||
for (const block of toolUseBlocks.values()) {
|
||||
validToolUseBlocks.add(block as object);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let j = i + 1; j < messages.length; j++) {
|
||||
const nextMessage = messages[j];
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -97,6 +97,22 @@ export type ContentGeneratorConfig = {
|
|||
// Routify, OpenRouter). Requires the proxy to forward `cache_control` fields
|
||||
// and the `prompt-caching-scope-2026-01-05` beta. See issue #6642.
|
||||
forceGlobalCacheScope?: boolean;
|
||||
/**
|
||||
* Default Anthropic `cache_control` retention for every cache anchor
|
||||
* (system text, last tool, trailing user message) unless overridden
|
||||
* per-anchor by {@link cacheRetentionByBlock}. `'ephemeral'` (default)
|
||||
* omits `ttl` on the wire (spec default is 5m); `'1h'` requests the
|
||||
* extended cache tier (`ttl: '1h'`).
|
||||
*/
|
||||
cacheRetention?: 'ephemeral' | '1h';
|
||||
/**
|
||||
* Per-anchor override of {@link cacheRetention}. Keys are the three
|
||||
* cache anchors the Anthropic converter places `cache_control` on;
|
||||
* missing keys inherit the top-level `cacheRetention`.
|
||||
*/
|
||||
cacheRetentionByBlock?: Partial<
|
||||
Record<'system' | 'tool' | 'user.last', 'ephemeral' | '1h'>
|
||||
>;
|
||||
samplingParams?: {
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [
|
|||
'retryErrorCodes',
|
||||
'enableCacheControl',
|
||||
'forceGlobalCacheScope',
|
||||
'cacheRetention',
|
||||
'cacheRetentionByBlock',
|
||||
'schemaCompliance',
|
||||
'reasoning',
|
||||
'contextWindowSize',
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ export type ModelGenerationConfig = Pick<
|
|||
| 'retryErrorCodes'
|
||||
| 'enableCacheControl'
|
||||
| 'forceGlobalCacheScope'
|
||||
| 'cacheRetention'
|
||||
| 'cacheRetentionByBlock'
|
||||
| 'schemaCompliance'
|
||||
| 'reasoning'
|
||||
| 'customHeaders'
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,8 @@
|
|||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import * as nodeConstants from 'node:constants';
|
||||
import { createHash, randomUUID, type Hash } from 'node:crypto';
|
||||
import type { Stats } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
|
|
@ -20,6 +21,17 @@ const CLAIMED_PRIMARY_WAIT_ATTEMPTS = 20;
|
|||
const RELEASE_PRECHECK_ATTEMPTS = 3;
|
||||
const RELEASE_PRECHECK_RETRY_DELAY_MS = 50;
|
||||
const ACQUIRE_ATTEMPTS = 8;
|
||||
const TRANSCRIPT_SNAPSHOT_ATTEMPTS = 3;
|
||||
const TRANSCRIPT_HASH_BUFFER_BYTES = 1024 * 1024;
|
||||
const TRANSCRIPT_NO_FOLLOW_FLAG = nodeConstants.O_NOFOLLOW ?? 0;
|
||||
const TRANSCRIPT_NONBLOCK_FLAG = nodeConstants.O_NONBLOCK ?? 0;
|
||||
const TRANSCRIPT_READ_FLAGS =
|
||||
nodeConstants.O_RDONLY | TRANSCRIPT_NO_FOLLOW_FLAG | TRANSCRIPT_NONBLOCK_FLAG;
|
||||
const TRANSCRIPT_APPEND_FLAGS =
|
||||
nodeConstants.O_APPEND |
|
||||
nodeConstants.O_RDWR |
|
||||
TRANSCRIPT_NO_FOLLOW_FLAG |
|
||||
TRANSCRIPT_NONBLOCK_FLAG;
|
||||
const debugLogger = createDebugLogger('SESSION_WRITER_LEASE');
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
|
|
@ -168,6 +180,10 @@ type ExistingLockState =
|
|||
interface TranscriptFingerprint {
|
||||
dev: number;
|
||||
ino: number;
|
||||
mode: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
nlink: number;
|
||||
birthtimeMs: number;
|
||||
ctimeMs: number;
|
||||
mtimeMs: number;
|
||||
|
|
@ -181,6 +197,12 @@ type TranscriptState =
|
|||
fingerprint: TranscriptFingerprint;
|
||||
};
|
||||
|
||||
interface TranscriptSnapshot {
|
||||
state: TranscriptState;
|
||||
hasher: Hash;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
interface OpenTranscriptProof {
|
||||
readonly state: TranscriptState;
|
||||
readonly sha256: string;
|
||||
|
|
@ -366,6 +388,10 @@ function transcriptFingerprint(stat: Stats): TranscriptFingerprint {
|
|||
return {
|
||||
dev: stat.dev,
|
||||
ino: stat.ino,
|
||||
mode: stat.mode,
|
||||
uid: stat.uid,
|
||||
gid: stat.gid,
|
||||
nlink: stat.nlink,
|
||||
birthtimeMs: stat.birthtimeMs,
|
||||
ctimeMs: stat.ctimeMs,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
|
|
@ -375,11 +401,32 @@ function transcriptFingerprint(stat: Stats): TranscriptFingerprint {
|
|||
function sameFileIdentity(
|
||||
left: TranscriptFingerprint,
|
||||
right: TranscriptFingerprint,
|
||||
): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino;
|
||||
}
|
||||
|
||||
function sameFileSecurityMetadata(
|
||||
left: TranscriptFingerprint,
|
||||
right: TranscriptFingerprint,
|
||||
): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.birthtimeMs === right.birthtimeMs
|
||||
left.mode === right.mode &&
|
||||
left.uid === right.uid &&
|
||||
left.gid === right.gid &&
|
||||
left.nlink === right.nlink
|
||||
);
|
||||
}
|
||||
|
||||
function sameHardTranscriptState(
|
||||
left: TranscriptState,
|
||||
right: TranscriptState,
|
||||
): boolean {
|
||||
if (left.exists !== right.exists) return false;
|
||||
if (!left.exists || !right.exists) return true;
|
||||
return (
|
||||
left.byteLength === right.byteLength &&
|
||||
sameFileIdentity(left.fingerprint, right.fingerprint) &&
|
||||
sameFileSecurityMetadata(left.fingerprint, right.fingerprint)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -390,13 +437,54 @@ function sameTranscriptState(
|
|||
if (left.exists !== right.exists) return false;
|
||||
if (!left.exists || !right.exists) return true;
|
||||
return (
|
||||
left.byteLength === right.byteLength &&
|
||||
sameFileIdentity(left.fingerprint, right.fingerprint) &&
|
||||
sameHardTranscriptState(left, right) &&
|
||||
left.fingerprint.birthtimeMs === right.fingerprint.birthtimeMs &&
|
||||
left.fingerprint.ctimeMs === right.fingerprint.ctimeMs &&
|
||||
left.fingerprint.mtimeMs === right.fingerprint.mtimeMs
|
||||
);
|
||||
}
|
||||
|
||||
function transcriptStateFromStat(
|
||||
stat: Stats,
|
||||
): Extract<TranscriptState, { exists: true }> {
|
||||
return {
|
||||
exists: true,
|
||||
byteLength: stat.size,
|
||||
fingerprint: transcriptFingerprint(stat),
|
||||
};
|
||||
}
|
||||
|
||||
function transcriptStateChangedFields(
|
||||
left: TranscriptState,
|
||||
right: TranscriptState,
|
||||
): string[] {
|
||||
if (left.exists !== right.exists) return ['exists'];
|
||||
if (!left.exists || !right.exists) return [];
|
||||
const fields: string[] = [];
|
||||
if (left.byteLength !== right.byteLength) fields.push('byteLength');
|
||||
const fingerprintFields = [
|
||||
'dev',
|
||||
'ino',
|
||||
'mode',
|
||||
'uid',
|
||||
'gid',
|
||||
'nlink',
|
||||
'birthtimeMs',
|
||||
'ctimeMs',
|
||||
'mtimeMs',
|
||||
] as const;
|
||||
for (const field of fingerprintFields) {
|
||||
if (left.fingerprint[field] !== right.fingerprint[field]) {
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function transcriptHashesEqual(left: Hash, right: Hash): boolean {
|
||||
return left.copy().digest().equals(right.copy().digest());
|
||||
}
|
||||
|
||||
async function assertTranscriptPathMissing(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.lstat(filePath);
|
||||
|
|
@ -409,51 +497,263 @@ async function assertTranscriptPathMissing(filePath: string): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
async function getTranscriptState(filePath: string): Promise<TranscriptState> {
|
||||
let handle: fs.FileHandle | undefined;
|
||||
async function getOpenTranscriptState(
|
||||
filePath: string,
|
||||
handle: fs.FileHandle,
|
||||
invalidPathIsChange: boolean,
|
||||
): Promise<Extract<TranscriptState, { exists: true }>> {
|
||||
let handleStat: Stats;
|
||||
try {
|
||||
try {
|
||||
handle = await fs.open(filePath, 'r');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
await assertTranscriptPathMissing(filePath);
|
||||
return { exists: false, byteLength: 0 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const [handleStat, pathStat] = await Promise.all([
|
||||
handle.stat(),
|
||||
fs.lstat(filePath),
|
||||
]);
|
||||
handleStat = await handle.stat();
|
||||
} catch (error) {
|
||||
if (
|
||||
!handleStat.isFile() ||
|
||||
!pathStat.isFile() ||
|
||||
pathStat.isSymbolicLink()
|
||||
invalidPathIsChange &&
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
) {
|
||||
throw new SessionWriterUnavailableError();
|
||||
}
|
||||
const handleFingerprint = transcriptFingerprint(handleStat);
|
||||
const pathFingerprint = transcriptFingerprint(pathStat);
|
||||
if (!sameFileIdentity(handleFingerprint, pathFingerprint)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
if (handleStat.size > 0) {
|
||||
const lastByte = Buffer.allocUnsafe(1);
|
||||
const { bytesRead } = await handle.read(
|
||||
lastByte,
|
||||
0,
|
||||
1,
|
||||
handleStat.size - 1,
|
||||
);
|
||||
if (bytesRead !== 1 || lastByte[0] !== 0x0a) {
|
||||
throw error;
|
||||
}
|
||||
if (!handleStat.isFile()) {
|
||||
if (invalidPathIsChange) throw new SessionTranscriptChangedError();
|
||||
throw new SessionWriterUnavailableError();
|
||||
}
|
||||
if (handleStat.size > 0) {
|
||||
const lastByte = Buffer.allocUnsafe(1);
|
||||
const { bytesRead } = await handle.read(
|
||||
lastByte,
|
||||
0,
|
||||
1,
|
||||
handleStat.size - 1,
|
||||
);
|
||||
if (bytesRead !== 1 || lastByte[0] !== 0x0a) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
}
|
||||
let pathStat: Stats;
|
||||
try {
|
||||
pathStat = await fs.lstat(filePath);
|
||||
} catch (error) {
|
||||
if (
|
||||
invalidPathIsChange &&
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
|
||||
if (invalidPathIsChange) throw new SessionTranscriptChangedError();
|
||||
throw new SessionWriterUnavailableError();
|
||||
}
|
||||
const handleState = transcriptStateFromStat(handleStat);
|
||||
const pathState = transcriptStateFromStat(pathStat);
|
||||
if (!sameHardTranscriptState(handleState, pathState)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
return pathState;
|
||||
}
|
||||
|
||||
async function inspectTranscriptPath(
|
||||
filePath: string,
|
||||
invalidPathIsChange: boolean,
|
||||
): Promise<TranscriptState> {
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await fs.lstat(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { exists: false, byteLength: 0 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
if (invalidPathIsChange) throw new SessionTranscriptChangedError();
|
||||
throw new SessionWriterUnavailableError();
|
||||
}
|
||||
return transcriptStateFromStat(stat);
|
||||
}
|
||||
|
||||
async function openTranscriptForRead(
|
||||
filePath: string,
|
||||
expectedState: TranscriptState | undefined,
|
||||
): Promise<fs.FileHandle | undefined> {
|
||||
const pathState = await inspectTranscriptPath(
|
||||
filePath,
|
||||
expectedState !== undefined,
|
||||
);
|
||||
if (expectedState && !sameHardTranscriptState(pathState, expectedState)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
if (!pathState.exists) return undefined;
|
||||
|
||||
try {
|
||||
return await fs.open(filePath, TRANSCRIPT_READ_FLAGS);
|
||||
} catch (error) {
|
||||
if (expectedState !== undefined) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ELOOP') {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
const currentState = await inspectTranscriptPath(filePath, true);
|
||||
if (!sameHardTranscriptState(currentState, expectedState)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
byteLength: handleStat.size,
|
||||
fingerprint: handleFingerprint,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function openTranscriptForAppend(
|
||||
filePath: string,
|
||||
expectedState: TranscriptState,
|
||||
): Promise<fs.FileHandle> {
|
||||
const pathState = await inspectTranscriptPath(filePath, true);
|
||||
if (!sameHardTranscriptState(pathState, expectedState)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const flags = expectedState.exists
|
||||
? TRANSCRIPT_APPEND_FLAGS
|
||||
: TRANSCRIPT_APPEND_FLAGS | nodeConstants.O_CREAT | nodeConstants.O_EXCL;
|
||||
return await fs.open(filePath, flags, 0o600);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'EEXIST' || code === 'ENOENT' || code === 'ELOOP') {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
const currentState = await inspectTranscriptPath(filePath, true);
|
||||
if (!sameHardTranscriptState(currentState, expectedState)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTranscriptState(
|
||||
filePath: string,
|
||||
expectedState: TranscriptState | undefined,
|
||||
): Promise<TranscriptState> {
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await openTranscriptForRead(filePath, expectedState);
|
||||
if (!handle) return { exists: false, byteLength: 0 };
|
||||
return await getOpenTranscriptState(
|
||||
filePath,
|
||||
handle,
|
||||
expectedState !== undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionWriterError) throw error;
|
||||
throw new SessionWriterUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function captureOpenTranscriptSnapshot(
|
||||
filePath: string,
|
||||
handle: fs.FileHandle,
|
||||
expectedState: TranscriptState | undefined,
|
||||
shouldAbort: () => boolean,
|
||||
): Promise<TranscriptSnapshot> {
|
||||
let buffer: Buffer | undefined;
|
||||
for (let attempt = 1; attempt <= TRANSCRIPT_SNAPSHOT_ATTEMPTS; attempt++) {
|
||||
if (shouldAbort()) throw new SessionWriterLostError();
|
||||
const beforeState = await getOpenTranscriptState(
|
||||
filePath,
|
||||
handle,
|
||||
expectedState !== undefined,
|
||||
);
|
||||
if (expectedState && !sameHardTranscriptState(beforeState, expectedState)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
|
||||
const bufferBytes = Math.min(
|
||||
TRANSCRIPT_HASH_BUFFER_BYTES,
|
||||
beforeState.byteLength,
|
||||
);
|
||||
if (!buffer || buffer.byteLength < bufferBytes) {
|
||||
buffer = Buffer.allocUnsafe(bufferBytes);
|
||||
}
|
||||
const hasher = createHash('sha256');
|
||||
let position = 0;
|
||||
while (position < beforeState.byteLength) {
|
||||
if (shouldAbort()) throw new SessionWriterLostError();
|
||||
const length = Math.min(
|
||||
buffer.byteLength,
|
||||
beforeState.byteLength - position,
|
||||
);
|
||||
if (length === 0) throw new SessionWriterUnavailableError();
|
||||
let chunkBytesRead = 0;
|
||||
while (chunkBytesRead < length) {
|
||||
if (shouldAbort()) throw new SessionWriterLostError();
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
chunkBytesRead,
|
||||
length - chunkBytesRead,
|
||||
position + chunkBytesRead,
|
||||
);
|
||||
if (bytesRead === 0) throw new SessionTranscriptChangedError();
|
||||
chunkBytesRead += bytesRead;
|
||||
}
|
||||
hasher.update(buffer.subarray(0, chunkBytesRead));
|
||||
position += chunkBytesRead;
|
||||
}
|
||||
|
||||
if (shouldAbort()) throw new SessionWriterLostError();
|
||||
const afterState = await getOpenTranscriptState(
|
||||
filePath,
|
||||
handle,
|
||||
expectedState !== undefined,
|
||||
);
|
||||
if (!sameHardTranscriptState(beforeState, afterState)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
if (sameTranscriptState(beforeState, afterState)) {
|
||||
return { state: afterState, hasher, attempts: attempt };
|
||||
}
|
||||
debugLogger.debug(
|
||||
`Session transcript snapshot retry attempt=${attempt} ` +
|
||||
`changedFields=${transcriptStateChangedFields(beforeState, afterState).join(',')}`,
|
||||
);
|
||||
}
|
||||
throw new SessionWriterUnavailableError({
|
||||
cause: new Error('Session transcript metadata did not stabilize'),
|
||||
});
|
||||
}
|
||||
|
||||
async function captureTranscriptSnapshot(
|
||||
filePath: string,
|
||||
expectedState: TranscriptState | undefined,
|
||||
shouldAbort: () => boolean,
|
||||
): Promise<TranscriptSnapshot> {
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await openTranscriptForRead(filePath, expectedState);
|
||||
if (!handle) {
|
||||
const missingState: TranscriptState = { exists: false, byteLength: 0 };
|
||||
if (
|
||||
expectedState &&
|
||||
!sameHardTranscriptState(missingState, expectedState)
|
||||
) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
return {
|
||||
state: missingState,
|
||||
hasher: createHash('sha256'),
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
return await captureOpenTranscriptSnapshot(
|
||||
filePath,
|
||||
handle,
|
||||
expectedState,
|
||||
shouldAbort,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionWriterError) throw error;
|
||||
throw new SessionWriterUnavailableError({
|
||||
|
|
@ -595,7 +895,7 @@ async function validateOpenTranscriptProof(
|
|||
proof: OpenTranscriptProof,
|
||||
): Promise<void> {
|
||||
if (!proof.state.exists) {
|
||||
const current = await getTranscriptState(filePath);
|
||||
const current = await getTranscriptState(filePath, undefined);
|
||||
if (!sameTranscriptState(current, proof.state)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
|
|
@ -1123,6 +1423,7 @@ export class SessionWriterLease {
|
|||
readonly runtimeBaseDir: string;
|
||||
readonly transcriptPath: string;
|
||||
private expectedTranscriptState: TranscriptState | undefined;
|
||||
private expectedTranscriptHasher: Hash | undefined;
|
||||
private released = false;
|
||||
private terminalPromise: Promise<void> | undefined;
|
||||
private operationTail: Promise<void> = Promise.resolve();
|
||||
|
|
@ -1310,11 +1611,14 @@ export class SessionWriterLease {
|
|||
}
|
||||
primaryInstalled = true;
|
||||
await assertPathMissing(claimPath);
|
||||
const lease = await SessionWriterLease.finishAcquisition(
|
||||
const finishingLease = SessionWriterLease.finishAcquisition(
|
||||
lockPath,
|
||||
lockRecord,
|
||||
normalizedOptions,
|
||||
);
|
||||
// finishAcquisition now owns exact-record cleanup for this primary lock.
|
||||
primaryInstalled = false;
|
||||
const lease = await finishingLease;
|
||||
await removeOwnedLock(reclaimPath, lockRecord.owner_id).catch(() => {});
|
||||
return lease;
|
||||
} catch (error) {
|
||||
|
|
@ -1478,9 +1782,14 @@ export class SessionWriterLease {
|
|||
const lease = new SessionWriterLease(lockPath, lockRecord, options);
|
||||
try {
|
||||
options.onOwnershipAcquired?.(lease);
|
||||
lease.expectedTranscriptState = await getTranscriptState(
|
||||
const snapshot = await captureTranscriptSnapshot(
|
||||
options.transcriptPath,
|
||||
undefined,
|
||||
() => lease.released,
|
||||
);
|
||||
await lease.readOwnedLock();
|
||||
lease.expectedTranscriptState = snapshot.state;
|
||||
lease.expectedTranscriptHasher = snapshot.hasher;
|
||||
if (
|
||||
requiredTranscriptState &&
|
||||
!sameTranscriptState(
|
||||
|
|
@ -1493,9 +1802,14 @@ export class SessionWriterLease {
|
|||
return lease;
|
||||
} catch (error) {
|
||||
try {
|
||||
await removeOwnedLock(lockPath, lockRecord.owner_id);
|
||||
} catch {
|
||||
throw new SessionWriterUnavailableError();
|
||||
await lease.release();
|
||||
} catch (releaseError) {
|
||||
throw new SessionWriterUnavailableError({
|
||||
cause: new AggregateError(
|
||||
[error, releaseError],
|
||||
'Session writer acquisition cleanup failed',
|
||||
),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -1594,13 +1908,25 @@ export class SessionWriterLease {
|
|||
|
||||
private async assertOwnedAndUnchangedOnce(): Promise<void> {
|
||||
await this.readOwnedLock();
|
||||
if (!this.expectedTranscriptState) {
|
||||
const expectedState = this.expectedTranscriptState;
|
||||
if (!expectedState || !this.expectedTranscriptHasher) {
|
||||
throw new SessionWriterUnavailableError();
|
||||
}
|
||||
const transcriptState = await getTranscriptState(this.transcriptPath);
|
||||
if (!sameTranscriptState(transcriptState, this.expectedTranscriptState)) {
|
||||
const transcriptState = await getTranscriptState(
|
||||
this.transcriptPath,
|
||||
expectedState,
|
||||
);
|
||||
if (sameTranscriptState(transcriptState, expectedState)) {
|
||||
debugLogger.debug('Session transcript verified path=fast');
|
||||
return;
|
||||
}
|
||||
if (!sameHardTranscriptState(transcriptState, expectedState)) {
|
||||
debugLogger.debug(
|
||||
`Session transcript hard state changed changedFields=${transcriptStateChangedFields(expectedState, transcriptState).join(',')}`,
|
||||
);
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
await this.reconcileTranscriptMetadata(transcriptState);
|
||||
}
|
||||
|
||||
appendJsonLine(value: unknown): Promise<void> {
|
||||
|
|
@ -1619,53 +1945,128 @@ export class SessionWriterLease {
|
|||
if (serialized === undefined) throw new SessionWriterUnavailableError();
|
||||
const bytes = Buffer.from(`${serialized}\n`, 'utf8');
|
||||
await this.assertOwnedAndUnchangedOnce();
|
||||
const expectedBefore = this.expectedTranscriptState;
|
||||
if (!expectedBefore) throw new SessionWriterUnavailableError();
|
||||
const nextByteLength = expectedBefore.byteLength + bytes.byteLength;
|
||||
let expectedBefore = this.expectedTranscriptState;
|
||||
if (!expectedBefore || !this.expectedTranscriptHasher) {
|
||||
throw new SessionWriterUnavailableError();
|
||||
}
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
await fs.mkdir(path.dirname(this.transcriptPath), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
handle = await fs.open(
|
||||
handle = await openTranscriptForAppend(
|
||||
this.transcriptPath,
|
||||
expectedBefore.exists ? 'a+' : 'ax+',
|
||||
0o600,
|
||||
expectedBefore,
|
||||
);
|
||||
const beforeStat = await handle.stat();
|
||||
const beforeState: TranscriptState = {
|
||||
exists: true,
|
||||
byteLength: beforeStat.size,
|
||||
fingerprint: transcriptFingerprint(beforeStat),
|
||||
};
|
||||
if (
|
||||
expectedBefore.exists
|
||||
? !sameTranscriptState(beforeState, expectedBefore)
|
||||
: beforeStat.size !== 0
|
||||
) {
|
||||
let beforeState = await getOpenTranscriptState(
|
||||
this.transcriptPath,
|
||||
handle,
|
||||
true,
|
||||
);
|
||||
if (expectedBefore.exists) {
|
||||
if (!sameTranscriptState(beforeState, expectedBefore)) {
|
||||
if (!sameHardTranscriptState(beforeState, expectedBefore)) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
await this.reconcileTranscriptMetadata(beforeState, handle);
|
||||
expectedBefore = this.expectedTranscriptState;
|
||||
if (!expectedBefore?.exists) {
|
||||
throw new SessionWriterUnavailableError();
|
||||
}
|
||||
beforeState = expectedBefore;
|
||||
}
|
||||
} else if (beforeState.byteLength !== 0) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
const expectedHasher = this.expectedTranscriptHasher;
|
||||
if (!expectedHasher) throw new SessionWriterUnavailableError();
|
||||
const candidateHasher = expectedHasher.copy();
|
||||
candidateHasher.update(bytes);
|
||||
const nextByteLength = expectedBefore.byteLength + bytes.byteLength;
|
||||
await this.readOwnedLock();
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
const afterStat = await handle.stat();
|
||||
if (afterStat.size !== nextByteLength) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
const writtenFingerprint = transcriptFingerprint(afterStat);
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
const transcriptState = await getTranscriptState(this.transcriptPath);
|
||||
const afterState = transcriptStateFromStat(afterStat);
|
||||
if (
|
||||
!transcriptState.exists ||
|
||||
transcriptState.byteLength !== nextByteLength ||
|
||||
!sameFileIdentity(transcriptState.fingerprint, writtenFingerprint)
|
||||
afterState.byteLength !== nextByteLength ||
|
||||
!sameFileIdentity(afterState.fingerprint, beforeState.fingerprint) ||
|
||||
!sameFileSecurityMetadata(
|
||||
afterState.fingerprint,
|
||||
beforeState.fingerprint,
|
||||
)
|
||||
) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
const transcriptState = await getTranscriptState(
|
||||
this.transcriptPath,
|
||||
afterState,
|
||||
);
|
||||
if (
|
||||
!transcriptState.exists ||
|
||||
transcriptState.byteLength !== nextByteLength ||
|
||||
!sameFileIdentity(
|
||||
transcriptState.fingerprint,
|
||||
afterState.fingerprint,
|
||||
) ||
|
||||
!sameFileSecurityMetadata(
|
||||
transcriptState.fingerprint,
|
||||
afterState.fingerprint,
|
||||
)
|
||||
) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
let committedState: TranscriptState = transcriptState;
|
||||
let committedHasher = candidateHasher;
|
||||
let appendReconciliation:
|
||||
| {
|
||||
changedFields: string[];
|
||||
attempts: number;
|
||||
startedAt: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!sameTranscriptState(transcriptState, afterState)) {
|
||||
const changedFields = transcriptStateChangedFields(
|
||||
afterState,
|
||||
transcriptState,
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
await this.readOwnedLock();
|
||||
const snapshot = await captureTranscriptSnapshot(
|
||||
this.transcriptPath,
|
||||
afterState,
|
||||
() => this.released,
|
||||
);
|
||||
if (!transcriptHashesEqual(snapshot.hasher, candidateHasher)) {
|
||||
debugLogger.debug(
|
||||
`Session transcript content changed after append metadata signal ` +
|
||||
`path=slow changedFields=${changedFields.join(',')} ` +
|
||||
`attempts=${snapshot.attempts} durationMs=${Date.now() - startedAt}`,
|
||||
);
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
committedState = snapshot.state;
|
||||
committedHasher = snapshot.hasher;
|
||||
appendReconciliation = {
|
||||
changedFields,
|
||||
attempts: snapshot.attempts,
|
||||
startedAt,
|
||||
};
|
||||
}
|
||||
await this.readOwnedLock();
|
||||
this.expectedTranscriptState = transcriptState;
|
||||
this.expectedTranscriptHasher = committedHasher;
|
||||
this.expectedTranscriptState = committedState;
|
||||
if (appendReconciliation) {
|
||||
debugLogger.debug(
|
||||
`Session transcript append metadata reconciled path=slow ` +
|
||||
`changedFields=${appendReconciliation.changedFields.join(',')} ` +
|
||||
`attempts=${appendReconciliation.attempts} ` +
|
||||
`durationMs=${Date.now() - appendReconciliation.startedAt}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'EEXIST' || code === 'ENOENT') {
|
||||
|
|
@ -1680,6 +2081,56 @@ export class SessionWriterLease {
|
|||
}
|
||||
}
|
||||
|
||||
private async reconcileTranscriptMetadata(
|
||||
observedState: TranscriptState,
|
||||
handle?: fs.FileHandle,
|
||||
): Promise<void> {
|
||||
const expectedState = this.expectedTranscriptState;
|
||||
const expectedHasher = this.expectedTranscriptHasher;
|
||||
if (
|
||||
!expectedState ||
|
||||
!expectedHasher ||
|
||||
!sameHardTranscriptState(observedState, expectedState)
|
||||
) {
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
|
||||
const changedFields = transcriptStateChangedFields(
|
||||
expectedState,
|
||||
observedState,
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
await this.readOwnedLock();
|
||||
const snapshot = handle
|
||||
? await captureOpenTranscriptSnapshot(
|
||||
this.transcriptPath,
|
||||
handle,
|
||||
expectedState,
|
||||
() => this.released,
|
||||
)
|
||||
: await captureTranscriptSnapshot(
|
||||
this.transcriptPath,
|
||||
expectedState,
|
||||
() => this.released,
|
||||
);
|
||||
if (!transcriptHashesEqual(snapshot.hasher, expectedHasher)) {
|
||||
debugLogger.debug(
|
||||
`Session transcript content changed after metadata signal ` +
|
||||
`path=slow changedFields=${changedFields.join(',')} ` +
|
||||
`attempts=${snapshot.attempts} durationMs=${Date.now() - startedAt}`,
|
||||
);
|
||||
throw new SessionTranscriptChangedError();
|
||||
}
|
||||
await this.readOwnedLock();
|
||||
this.expectedTranscriptHasher = snapshot.hasher;
|
||||
this.expectedTranscriptState = snapshot.state;
|
||||
debugLogger.debug(
|
||||
`Session transcript metadata reconciled path=slow ` +
|
||||
`changedFields=${changedFields.join(',')} attempts=${snapshot.attempts} ` +
|
||||
`durationMs=${Date.now() - startedAt}`,
|
||||
);
|
||||
}
|
||||
|
||||
release(): Promise<void> {
|
||||
this.terminalPromise ??= this.runExclusive(() => this.releaseOnce());
|
||||
return this.terminalPromise;
|
||||
|
|
|
|||
|
|
@ -381,3 +381,82 @@ describe('toolResultDisplayCompaction', () => {
|
|||
expect(compactedTeam.teamName).toContain('truncated from');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactString limit', () => {
|
||||
// The compaction marker embeds the original length, so it is 60-80
|
||||
// characters on its own. It used to be appended whatever the limit was,
|
||||
// which meant a small caller-supplied limit got back more than it asked
|
||||
// for -- and sometimes more than the string it was given.
|
||||
it.each([
|
||||
['recording' as const, 70, 60],
|
||||
['history' as const, 64, 63],
|
||||
['history' as const, 100, 50],
|
||||
['recording' as const, 200, 10],
|
||||
['history' as const, 40, 0],
|
||||
])(
|
||||
'keeps %s output within bounds for input %d at limit %d',
|
||||
(purpose, inputLength, limit) => {
|
||||
const value = 'x'.repeat(inputLength);
|
||||
const compact =
|
||||
purpose === 'recording'
|
||||
? compactStringForRecording(value, limit)
|
||||
: compactStringForHistory(value, limit);
|
||||
|
||||
expect(compact.length).toBeLessThanOrEqual(limit);
|
||||
// Compacting must never hand back more characters than it was given.
|
||||
expect(compact.length).toBeLessThanOrEqual(value.length);
|
||||
},
|
||||
);
|
||||
|
||||
// Guards against over-correcting: when the limit does leave room for the
|
||||
// marker, the marker must still be there. These pass before and after.
|
||||
it.each([
|
||||
['recording' as const, 5000, 500],
|
||||
['history' as const, 5000, 200],
|
||||
['history' as const, 5000, 120],
|
||||
])(
|
||||
'still explains the truncation for %s at input %d, limit %d',
|
||||
(purpose, inputLength, limit) => {
|
||||
const value = 'x'.repeat(inputLength);
|
||||
const compact =
|
||||
purpose === 'recording'
|
||||
? compactStringForRecording(value, limit)
|
||||
: compactStringForHistory(value, limit);
|
||||
|
||||
expect(compact.length).toBeLessThanOrEqual(limit);
|
||||
expect(compact).toContain('truncated');
|
||||
},
|
||||
);
|
||||
|
||||
// The `marker.length >= limit` path slices without a marker, so it has a
|
||||
// boundary of its own to get right. The two surrogate-aware tests above both
|
||||
// run at the default limit and take the head+marker+tail path, so neither
|
||||
// reaches this one.
|
||||
it.each([
|
||||
['history' as const, 9],
|
||||
['history' as const, 8],
|
||||
['recording' as const, 9],
|
||||
['recording' as const, 8],
|
||||
])(
|
||||
'does not split a surrogate pair when the marker does not fit, for %s at limit %d',
|
||||
(purpose, limit) => {
|
||||
const value = '😀'.repeat(40);
|
||||
const compact =
|
||||
purpose === 'recording'
|
||||
? compactStringForRecording(value, limit)
|
||||
: compactStringForHistory(value, limit);
|
||||
|
||||
expect(compact.length).toBeLessThanOrEqual(limit);
|
||||
expect(hasUnpairedSurrogate(compact)).toBe(false);
|
||||
// A whole number of pairs survived, so the cut backed off to a boundary
|
||||
// rather than landing between a high and low surrogate.
|
||||
expect(compact.length % 2).toBe(0);
|
||||
// Confirms this really is the marker-does-not-fit path.
|
||||
expect(compact).not.toContain('truncated');
|
||||
},
|
||||
);
|
||||
|
||||
it('returns a short string untouched regardless of the marker length', () => {
|
||||
expect(compactStringForHistory('short', 1000)).toBe('short');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,6 +84,18 @@ function compactString(
|
|||
}
|
||||
|
||||
const marker = buildStringCompactionMarker(value, purpose);
|
||||
|
||||
// The marker is 60-80 characters and was appended whatever the limit was,
|
||||
// so a caller-supplied limit below that got back more than it asked for --
|
||||
// and for a limit under the input length, more characters than it passed
|
||||
// in. `compactStringForRecording('x'.repeat(70), 60)` returned 79. When the
|
||||
// marker cannot fit alongside any content there is nothing to announce, so
|
||||
// hard-truncate instead of explaining the truncation at greater length than
|
||||
// the truncated text.
|
||||
if (marker.length >= limit) {
|
||||
return copyString(value.slice(0, safeHeadEnd(value, Math.max(0, limit))));
|
||||
}
|
||||
|
||||
const contentBudget = Math.max(0, limit - marker.length);
|
||||
const headLength = Math.ceil(contentBudget * 0.6);
|
||||
const tailLength = contentBudget - headLength;
|
||||
|
|
|
|||
|
|
@ -705,6 +705,41 @@
|
|||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"cacheRetention": {
|
||||
"description": "Default Anthropic cache_control retention. 'ephemeral' uses the spec 5-minute default (no ttl on the wire). '1h' requests the extended cache tier (ttl: '1h') -- note the 1h tier writes at 2x base input token cost (vs 1.25x for the 5-minute default; cached reads stay 0.1x for both), so it only pays off when a prefix survives long enough between requests to outlast several 5-minute windows. Options: ephemeral, 1h",
|
||||
"enum": [
|
||||
"ephemeral",
|
||||
"1h"
|
||||
]
|
||||
},
|
||||
"cacheRetentionByBlock": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"system": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ephemeral",
|
||||
"1h"
|
||||
]
|
||||
},
|
||||
"tool": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ephemeral",
|
||||
"1h"
|
||||
]
|
||||
},
|
||||
"user.last": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ephemeral",
|
||||
"1h"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Optional per-anchor override for Anthropic cache retention. Keys (system, tool, user.last) override generationConfig.cacheRetention when present. Resolution is normalized so retention is monotonically non-increasing in wire order (tool -> system -> user.last, per Anthropic's 'longer TTL must precede shorter TTL' rule): setting one anchor to '1h' promotes every anchor before it on the wire to '1h' as well, so any combination here is valid."
|
||||
},
|
||||
"splitToolMedia": {
|
||||
"description": "When true, media (images / audio / video / files) returned by tool calls — including the built-in read_file and MCP tools — is split into a follow-up user message instead of being embedded in the `role: \"tool\"` message. The OpenAI Chat Completions spec only permits text on tool messages, so strict OpenAI-compatible servers (e.g., doubao / new-api / LM Studio) silently drop or reject embedded media and the model never sees an image read via read_file (QwenLM/qwen-code#4876, #3616). Default true is spec-compliant and safe for permissive providers; set false only to restore the legacy embed-in-tool-message behavior.",
|
||||
"type": "boolean",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { useI18n } from '../../i18n';
|
|||
import { formatRuntime } from '../../utils/formatRuntime';
|
||||
import { createSentinelSerializer } from '../../utils/sentinelMessage';
|
||||
import {
|
||||
localizeAgentTypeName,
|
||||
localizeToolDisplayName,
|
||||
sanitizeControlChars,
|
||||
} from './toolFormatting';
|
||||
|
|
@ -750,7 +751,7 @@ function detailTitle(
|
|||
): string {
|
||||
switch (task.kind) {
|
||||
case 'agent':
|
||||
return `${task.subagentType ?? t('common.agent')} › ${task.label}`;
|
||||
return `${task.subagentType ? localizeAgentTypeName(task.subagentType, t) : t('common.agent')} › ${task.label}`;
|
||||
case 'shell':
|
||||
return `${t('tasks.kind.shell')} › ${task.command}`;
|
||||
case 'monitor':
|
||||
|
|
@ -1234,7 +1235,10 @@ function TaskDetail({
|
|||
)}
|
||||
|
||||
{task.kind === 'agent' && task.subagentType && (
|
||||
<DetailField label={t('tasks.detail.type')} value={task.subagentType} />
|
||||
<DetailField
|
||||
label={t('tasks.detail.type')}
|
||||
value={localizeAgentTypeName(task.subagentType, t)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{task.kind === 'agent' && (task.depth ?? 0) > 0 && (
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import {
|
|||
isAskUserQuestionToolName,
|
||||
isSkillToolName,
|
||||
isShellToolName,
|
||||
localizeAgentTypeName,
|
||||
toolContainsCallId,
|
||||
} from './toolFormatting';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
|
@ -1199,7 +1200,7 @@ export const ToolLine = memo(function ToolLine({
|
|||
if (isAgent) {
|
||||
const info = getAgentDisplayInfo(tool, now);
|
||||
const displayName = info.explicitAgentType
|
||||
? `${t('agent.label')} (${info.explicitAgentType})`
|
||||
? `${t('agent.label')} (${localizeAgentTypeName(info.explicitAgentType, t)})`
|
||||
: t('agent.label');
|
||||
const isComplete = tool.status === 'completed' || tool.status === 'failed';
|
||||
const isBackground = isBackgroundSubAgentToolCall(tool);
|
||||
|
|
|
|||
|
|
@ -511,6 +511,26 @@ export function getAgentType(agent: ACPToolCall): string {
|
|||
return agent.toolName === 'task' ? 'task' : DEFAULT_SUBAGENT_TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware agent type display name. Looks up `agentType.<name>`
|
||||
* (case-insensitive) via the translator; falls back to the raw name
|
||||
* for user-defined agents that have no i18n entry.
|
||||
*/
|
||||
export function localizeAgentTypeName(
|
||||
agentType: string,
|
||||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const keys = [
|
||||
`agentType.${agentType}`,
|
||||
`agentType.${agentType.toLowerCase()}`,
|
||||
];
|
||||
for (const key of keys) {
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
}
|
||||
return agentType;
|
||||
}
|
||||
|
||||
export function getAgentDescription(agent: ACPToolCall): string {
|
||||
if (agent.title) {
|
||||
const colonIdx = agent.title.indexOf(': ');
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
formatTokenCount,
|
||||
getAgentCancellationReason,
|
||||
getAgentDisplayStatus,
|
||||
localizeAgentTypeName,
|
||||
toolContainsCallId,
|
||||
} from '../toolFormatting';
|
||||
import { SubAgentPanel } from './SubAgentPanel';
|
||||
|
|
@ -261,7 +262,10 @@ export function ParallelAgentsGroup({
|
|||
>
|
||||
<StatusIcon status={status} />
|
||||
<span className={styles.rowDesc}>
|
||||
{truncateText(desc || agentType, 50)}
|
||||
{truncateText(
|
||||
desc || localizeAgentTypeName(agentType, t),
|
||||
50,
|
||||
)}
|
||||
{toolHint && (
|
||||
<span
|
||||
className={styles.rowTool}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
formatTokenCount,
|
||||
getAgentType,
|
||||
getAgentDescription,
|
||||
localizeAgentTypeName,
|
||||
localizeToolDisplayName,
|
||||
} from '../toolFormatting';
|
||||
import chromeStyles from './ToolChrome.module.css';
|
||||
|
|
@ -302,7 +303,9 @@ export function SubAgentPanel({
|
|||
{!hideHeader && (
|
||||
<div className={styles.header} onClick={() => setExpanded(!expanded)}>
|
||||
<StatusIcon status={displayStatus} />
|
||||
<span className={chromeStyles.lineName}>{agentType}:</span>
|
||||
<span className={chromeStyles.lineName}>
|
||||
{localizeAgentTypeName(agentType, t)}:
|
||||
</span>
|
||||
{description && (
|
||||
<span className={styles.desc}>{truncateText(description, 50)}</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -361,6 +361,11 @@ const EN: Messages = {
|
|||
'subagent.paused': 'paused',
|
||||
'subagent.detailsLoading': 'Loading agent details…',
|
||||
'subagent.detailsLoadFailed': 'Failed to load agent details.',
|
||||
'agentType.general-purpose': 'General-purpose',
|
||||
'agentType.explore': 'Explore',
|
||||
'agentType.statusline-setup': 'Status Line Setup',
|
||||
'agentType.test-engineer': 'Test Engineer',
|
||||
'agentType.fork': 'Fork',
|
||||
'timeline.parallelAgents': 'Parallel agents',
|
||||
'timeline.thinking': 'Thinking',
|
||||
'timeline.assistantUpdate': 'Assistant update',
|
||||
|
|
@ -2941,6 +2946,11 @@ const ZH: Messages = {
|
|||
'subagent.paused': '已暂停',
|
||||
'subagent.detailsLoading': '正在加载子智能体详情…',
|
||||
'subagent.detailsLoadFailed': '子智能体详情加载失败。',
|
||||
'agentType.general-purpose': '通用',
|
||||
'agentType.explore': '探索',
|
||||
'agentType.statusline-setup': '状态栏设置',
|
||||
'agentType.test-engineer': '测试工程师',
|
||||
'agentType.fork': '分支',
|
||||
'timeline.parallelAgents': '并行智能体',
|
||||
'timeline.thinking': '思考',
|
||||
'timeline.assistantUpdate': '助手更新',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { fileURLToPath } from 'node:url';
|
|||
const DEFAULT_METAFILE_PATH = resolve('dist/esbuild.json');
|
||||
const METAFILE_BUILD_COMMAND =
|
||||
'node scripts/clean-package-build-artifacts.js && npm run build -- --cli-only && cross-env DEV=true npm run bundle';
|
||||
const ENTRY_OUTPUT = 'dist/cli.js';
|
||||
const ENTRY_INPUT = 'packages/cli/src/cli.ts';
|
||||
const SERVE_PRE_LISTEN_ROOTS = [
|
||||
{
|
||||
label: 'serve fast path entry',
|
||||
|
|
@ -506,6 +508,32 @@ export function checkSdkImplProtocolBoundary({
|
|||
return { ok: offenders.length === 0, offenders };
|
||||
}
|
||||
|
||||
/**
|
||||
* `cli.ts` bootstraps only when it is the main module, comparing
|
||||
* `import.meta.url` against `process.argv[1]`. The bundle is built with
|
||||
* `splitting: true`, so a *static* `import ... from './cli.js'` in any module
|
||||
* the entry loads lazily (e.g. `gemini.tsx`) makes esbuild move the entry's
|
||||
* body into a shared chunk and leave `dist/cli.js` as a re-export stub. Inside
|
||||
* a chunk that comparison can never hold, so the bundled CLI exits 0 without
|
||||
* running anything — with `tsc`, eslint and every src-based unit test still
|
||||
* green. Assert the entry module still compiles into the entry output.
|
||||
*/
|
||||
export function checkEntryBootstrapIntact({
|
||||
metafilePath = DEFAULT_METAFILE_PATH,
|
||||
} = {}) {
|
||||
const metafile = readMetafile(metafilePath);
|
||||
const output = metafile?.outputs?.[ENTRY_OUTPUT];
|
||||
if (!output) {
|
||||
throw new Error(
|
||||
`Missing ${ENTRY_OUTPUT} in the esbuild metafile at ${metafilePath}. ` +
|
||||
`Run \`${METAFILE_BUILD_COMMAND}\` to regenerate it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const inputs = Object.keys(output.inputs ?? {});
|
||||
return { ok: inputs.includes(ENTRY_INPUT), inputs };
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const serveResult = checkServeFastPathBundle();
|
||||
|
|
@ -535,7 +563,22 @@ function main() {
|
|||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (serveResult.ok && acpResult.ok && sdkImplResult.ok) {
|
||||
const entryResult = checkEntryBootstrapIntact();
|
||||
if (!entryResult.ok) {
|
||||
console.error(
|
||||
`${ENTRY_OUTPUT} no longer contains ${ENTRY_INPUT} — esbuild code ` +
|
||||
'splitting hoisted the entry into a shared chunk, so its\n' +
|
||||
'`import.meta.url === pathToFileURL(process.argv[1]).href` guard ' +
|
||||
'can never match and the bundled CLI\nwould exit 0 without running. ' +
|
||||
'Cause: a module the entry loads lazily now statically imports ' +
|
||||
"'./cli.js'.\nMove the shared helper into a leaf module and import " +
|
||||
`that from both sides instead.\nCurrent ${ENTRY_OUTPUT} inputs: ` +
|
||||
`${entryResult.inputs.length === 0 ? '(none)' : entryResult.inputs.join(', ')}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (serveResult.ok && acpResult.ok && sdkImplResult.ok && entryResult.ok) {
|
||||
console.log('Startup bundle closure checks passed.');
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -31,16 +31,25 @@ describe('stable release notes workflow', () => {
|
|||
it('publishes immediately with GitHub-generated notes', () => {
|
||||
const step = getStep(releaseWorkflow, 'Create GitHub Release and Tag');
|
||||
|
||||
expect(step).toContain('--notes-start-tag "${PREVIOUS_RELEASE_TAG}"');
|
||||
expect(step).toContain('--generate-notes');
|
||||
expect(step).toContain(
|
||||
'git merge-base --is-ancestor "${PREVIOUS_RELEASE_TAG}" HEAD',
|
||||
'repos/${GITHUB_REPOSITORY}/releases/generate-notes',
|
||||
);
|
||||
expect(step).toContain('NOTES_START_TAG_FLAG=()');
|
||||
expect(step).toContain('-f "previous_tag_name=${PREVIOUS_RELEASE_TAG}"');
|
||||
expect(step).toContain('"${NOTES_ARGS[@]}"');
|
||||
expect(step).toContain('--notes-file "${NOTES_FILE}"');
|
||||
// Stable tags live on their own release/* branch and are merged back to
|
||||
// main only afterwards, so the previous tag is never an ancestor of the
|
||||
// branch being released. Anchoring on ancestry dropped the anchor on every
|
||||
// stable release, and unanchored notes span the whole branch history and
|
||||
// overrun the 125000 character body limit.
|
||||
expect(step).not.toContain('git merge-base --is-ancestor');
|
||||
expect(step).toContain('node .github/scripts/cap-release-notes.mjs');
|
||||
expect(step).toContain('--file "${NOTES_FILE}"');
|
||||
// gh prints the API error payload on stdout, so a failed attempt's output
|
||||
// must not survive into the release body.
|
||||
expect(step).toContain(
|
||||
'echo "::warning::PREVIOUS_RELEASE_TAG (${PREVIOUS_RELEASE_TAG}) is not an ancestor of HEAD; omitting --notes-start-tag"',
|
||||
'generate_notes > "${NOTES_FILE}" || : > "${NOTES_FILE}"',
|
||||
);
|
||||
expect(step).toContain('"${NOTES_START_TAG_FLAG[@]}"');
|
||||
expect(step).toContain("GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'");
|
||||
expect(releaseWorkflow).not.toContain(
|
||||
"name: 'Generate AI-assisted stable release notes'",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { join } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
checkAcpImportBoundary,
|
||||
checkEntryBootstrapIntact,
|
||||
checkSdkImplProtocolBoundary,
|
||||
checkServeFastPathBundle,
|
||||
findAcpImportBoundaryOffenders,
|
||||
|
|
@ -28,6 +29,8 @@ const checkScriptPath = fileURLToPath(
|
|||
function makeMetafile(outputs) {
|
||||
return {
|
||||
outputs: {
|
||||
// A healthy bundle compiles the entry module into the entry output.
|
||||
'dist/cli.js': output({ inputs: ['packages/cli/src/cli.ts'] }),
|
||||
'dist/chunks/fast-path.js': output({
|
||||
inputs: ['packages/cli/src/serve/fast-path.ts'],
|
||||
}),
|
||||
|
|
@ -754,3 +757,81 @@ describe('telemetry sdk-impl protocol boundary check', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundled entry bootstrap check', () => {
|
||||
it('accepts an entry output that still contains the entry module', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
|
||||
try {
|
||||
const metafilePath = writeMetafile(tempDir, makeMetafile({}));
|
||||
expect(checkEntryBootstrapIntact({ metafilePath }).ok).toBe(true);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an entry hoisted into a shared chunk by code splitting', () => {
|
||||
// What a static `import ... from './cli.js'` inside a lazily-loaded module
|
||||
// does to the bundle: dist/cli.js keeps no inputs of its own and becomes a
|
||||
// re-export stub, so cli.ts's main-module bootstrap guard never fires.
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
|
||||
try {
|
||||
const metafilePath = writeMetafile(
|
||||
tempDir,
|
||||
makeMetafile({
|
||||
'dist/cli.js': output({
|
||||
imports: [staticImport('dist/chunks/cli-entry.js')],
|
||||
}),
|
||||
'dist/chunks/cli-entry.js': output({
|
||||
inputs: ['packages/cli/src/cli.ts'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(checkEntryBootstrapIntact({ metafilePath }).ok).toBe(false);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when the entry output is absent from the metafile', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
|
||||
try {
|
||||
const metafile = makeMetafile({});
|
||||
delete metafile.outputs['dist/cli.js'];
|
||||
const metafilePath = writeMetafile(tempDir, metafile);
|
||||
|
||||
expect(() => checkEntryBootstrapIntact({ metafilePath })).toThrow(
|
||||
/Missing dist\/cli\.js in the esbuild metafile/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('exits non-zero with CLI diagnostics for a hoisted entry', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
|
||||
try {
|
||||
writeMetafile(
|
||||
tempDir,
|
||||
makeMetafile({
|
||||
'dist/cli.js': output({
|
||||
imports: [staticImport('dist/chunks/cli-entry.js')],
|
||||
}),
|
||||
'dist/chunks/cli-entry.js': output({
|
||||
inputs: ['packages/cli/src/cli.ts'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
execFileSync(process.execPath, [checkScriptPath], {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
}),
|
||||
).toThrow(/no longer contains packages\/cli\/src\/cli\.ts/);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue