mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 04:29:24 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eac11fa2c | ||
|
|
4321aecea3 | ||
|
|
d0da1d2c5e | ||
|
|
199ab3179d | ||
|
|
094747fb59 | ||
|
|
b59c9ab725 | ||
|
|
6ba531722b | ||
|
|
74ee30e6da |
No files matched your search
@@ -32,6 +32,7 @@ function namedStep(source, name) {
|
||||
}
|
||||
|
||||
const ci = read(".github/workflows/ci.yml");
|
||||
const ciSuite = read(".github/workflows/ci-test-suite.yml");
|
||||
const nightly = read(".github/workflows/nightly.yml");
|
||||
const candidate = read(".github/workflows/release-candidate.yml");
|
||||
const artifacts = read(".github/workflows/release-artifacts.yml");
|
||||
@@ -46,28 +47,212 @@ const archiveInstaller = read("scripts/release/install.sh");
|
||||
const cliDispatcher = read("crates/cli/src/lib.rs");
|
||||
const runbook = read("docs/RELEASE_RUNBOOK.md");
|
||||
|
||||
const ciTestJob = ci.slice(ci.indexOf("\n test:\n"));
|
||||
assert.match(
|
||||
ciTestJob,
|
||||
/matrix\.os == 'ubuntu-latest'.*github\.event_name == 'pull_request'/,
|
||||
"heavy pull requests must select the real Ubuntu test lane",
|
||||
);
|
||||
assert.match(
|
||||
ciTestJob,
|
||||
/cargo nextest run --workspace --all-features --locked --profile ci/,
|
||||
"the Ubuntu pull-request lane must run workspace nextest",
|
||||
);
|
||||
assert.match(
|
||||
ciTestJob,
|
||||
/name: Linux test location \(CNB\)/,
|
||||
"the non-PR CNB fallback must be named explicitly",
|
||||
);
|
||||
function jobBlock(source, job) {
|
||||
const match = source.match(new RegExp(`^ ${job}:\\n([\\s\\S]*?)(?=^ \\S|(?![\\s\\S]))`, "m"));
|
||||
assert.ok(match, `missing job: ${job}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function jobIf(source, job) {
|
||||
const lines = jobBlock(source, job).split("\n");
|
||||
const at = lines.findIndex((line) => /^ if:/.test(line));
|
||||
assert.notEqual(at, -1, `${job} must declare an if`);
|
||||
const first = lines[at].replace(/^ if:\s*/, "");
|
||||
if (first !== ">-") return first.replace(/^'(.*)'$/, "$1");
|
||||
const folded = [];
|
||||
for (let i = at + 1; i < lines.length && /^ \S/.test(lines[i]); i += 1) folded.push(lines[i].trim());
|
||||
return folded.join(" ");
|
||||
}
|
||||
|
||||
function evaluate(expression, context) {
|
||||
return Boolean(vm.runInNewContext(expression, context));
|
||||
}
|
||||
|
||||
// The sharded suite: one archive per OS, partitions of it in parallel shards,
|
||||
// doctests once, and a check that the shards add up to the archive's list.
|
||||
assert.match(
|
||||
namedStep(ciTestJob, "Build canonical executable for acceptance tests"),
|
||||
namedStep(ciSuite, "Archive test binaries"),
|
||||
/cargo nextest archive --workspace --all-features --locked --profile ci/,
|
||||
"the suite must archive the same workspace, features and profile the old single run used",
|
||||
);
|
||||
assert.match(
|
||||
namedStep(ciSuite, "Build canonical executable for acceptance tests"),
|
||||
/cargo build -p codewhale-cli --bin codewhale --all-features --locked/,
|
||||
"Engine acceptance must build the canonical executable first",
|
||||
);
|
||||
assert.match(
|
||||
namedStep(ciSuite, "Run doctests"),
|
||||
/cargo test --workspace --all-features --locked --doc/,
|
||||
"nextest does not run doctests; the suite must run them once per OS",
|
||||
);
|
||||
const shardRun = namedStep(ciSuite, "Run tests");
|
||||
assert.match(shardRun, /--partition "count:\$\{\{ matrix\.shard \}\}\/\$\{\{ inputs\.shards \}\}"/);
|
||||
assert.match(shardRun, /-E "not \(\$NATIVE_FILTER\)"/, "every shard must exclude the native tests that shard 1 runs apart");
|
||||
assert.match(namedStep(ciSuite, "Run native containment tests"), /-E "\$NATIVE_FILTER"/);
|
||||
assert.match(ciSuite, /nextest-shard-count\.py check counts/, "the suite must prove the shards add up to the archive's list");
|
||||
for (const name of ["Run tests", "Run native containment tests", "Run doctests"]) {
|
||||
const step = namedStep(ciSuite, name);
|
||||
assert.match(step, /shell: bash/, `${name} must invoke the POSIX helper on Windows too`);
|
||||
}
|
||||
assert.match(ciSuite, /RUST_MIN_STACK: '16777216'/);
|
||||
|
||||
// The native receipt script requires its tests in one JUnit report, so the
|
||||
// shard that runs them must select exactly the script's list.
|
||||
const receiptScript = read("scripts/compiled-host-native-receipt.py");
|
||||
const receiptNames = [
|
||||
receiptScript.match(/^COMPILED = "([^"]+)"$/m)[1],
|
||||
receiptScript.match(/^MEMORY = "([^"]+)"$/m)[1],
|
||||
...[...receiptScript.match(/^WINDOWS = \[([\s\S]*?)^\]/m)[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]),
|
||||
];
|
||||
const nativeFilter = ciSuite.match(/NATIVE_FILTER: >-\n([\s\S]*?)\n steps:/)[1];
|
||||
assert.deepEqual(
|
||||
[...nativeFilter.matchAll(/test\(=([^)]+)\)/g)].map((m) => m[1]).sort(),
|
||||
receiptNames.sort(),
|
||||
"NATIVE_FILTER must list exactly the tests scripts/compiled-host-native-receipt.py requires",
|
||||
);
|
||||
|
||||
// Shard counts: each caller's shards equals the length of its shard_list, the
|
||||
// suite's own list agrees with it, and every OS keeps a pipeline.
|
||||
const suiteCallers = {};
|
||||
for (const job of ["test-ubuntu", "test-macos", "test-windows"]) {
|
||||
const block = jobBlock(ci, job);
|
||||
const os = block.match(/^ os: (\S+)$/m)[1];
|
||||
const shards = Number(block.match(/^ shards: (\d+)$/m)[1]);
|
||||
const list = JSON.parse(block.match(/^ shard_list: '(.+)'$/m)[1]);
|
||||
assert.deepEqual(list, Array.from({ length: shards }, (_, i) => i + 1), `${job} shard_list must be 1..shards`);
|
||||
assert.match(block, /uses: \.\/\.github\/workflows\/ci-test-suite\.yml/);
|
||||
assert.equal(jobIf(ci, job), jobIf(ci, "test-ubuntu"), `${job} must share the suite condition`);
|
||||
suiteCallers[os] = shards;
|
||||
}
|
||||
assert.deepEqual(Object.keys(suiteCallers).sort(), ["macos-latest", "ubuntu-latest", "windows-latest"]);
|
||||
|
||||
// Required contexts. The ruleset requires exactly "Lint" and "Test
|
||||
// (windows-latest)". Only Lint and the compile-only test-windows-pr job may
|
||||
// produce them; a shard, build or suite check must never share a name.
|
||||
const jobNames = (source) => [...source.matchAll(/^ [A-Za-z0-9_-]+:\n name: (.+)$/gm)].map((m) => m[1]);
|
||||
assert.deepEqual(
|
||||
[...jobNames(ci), ...jobNames(ciSuite)].filter((name) => /^Test \(/.test(name) || name === "Lint"),
|
||||
["Lint", "Test (windows-latest)"],
|
||||
"only Lint and the compile-only Windows leg may use the required context names",
|
||||
);
|
||||
assert.match(jobBlock(ci, "test-windows-pr"), /^ name: Test \(windows-latest\)$/m);
|
||||
assert.equal(jobIf(ci, "test-windows-pr"), "github.event_name == 'pull_request'");
|
||||
assert.match(jobBlock(ci, "test-windows-pr"), /cargo check --workspace --all-features --all-targets|cargo check --workspace --all-targets --all-features --locked/);
|
||||
|
||||
// Each gate that pull requests lost on 2026-11-07 (99cb5a0f4f) runs on a heavy
|
||||
// pull request again, plus on dispatch; a docs-only pull request keeps skipping
|
||||
// them; a scoped dispatch skips all of them.
|
||||
const gates = ["safety-gate", "msrv", "test-ubuntu", "test-macos", "test-windows", "portable-boundaries", "npm-wrapper-smoke", "mobile-smoke"];
|
||||
for (const job of gates) {
|
||||
const guard = jobIf(ci, job);
|
||||
const base = { inputs: { workspace_test_mode: "nextest" } };
|
||||
const run = (event, heavy, extra = {}) =>
|
||||
evaluate(guard, {
|
||||
github: { event_name: event },
|
||||
needs: { changes: { outputs: { heavy: String(heavy), mobile: "true", scoped: "false", ...extra } } },
|
||||
...base,
|
||||
});
|
||||
assert.equal(run("pull_request", true), true, `${job} must run on a heavy pull request`);
|
||||
if (job !== "npm-wrapper-smoke") {
|
||||
assert.equal(run("pull_request", false), false, `${job} must skip a docs-only pull request`);
|
||||
}
|
||||
assert.equal(run("workflow_dispatch", true), true, `${job} must run on a full dispatch`);
|
||||
assert.equal(run("workflow_dispatch", true, { scoped: "true" }), false, `${job} must skip a scoped dispatch`);
|
||||
}
|
||||
assert.equal(
|
||||
evaluate(jobIf(ci, "mobile-smoke"), {
|
||||
github: { event_name: "pull_request" },
|
||||
needs: { changes: { outputs: { heavy: "true", mobile: "false", scoped: "false" } } },
|
||||
}),
|
||||
false,
|
||||
"mobile smoke keeps its path filter on pull requests",
|
||||
);
|
||||
for (const job of ["test-ubuntu", "test-macos", "test-windows"]) {
|
||||
assert.equal(
|
||||
evaluate(jobIf(ci, job), {
|
||||
github: { event_name: "workflow_dispatch" },
|
||||
inputs: { workspace_test_mode: "shared-process-twice" },
|
||||
needs: { changes: { outputs: { heavy: "true", scoped: "false" } } },
|
||||
}),
|
||||
false,
|
||||
`${job} must yield to the shared-process qualification`,
|
||||
);
|
||||
}
|
||||
const sharedGuard = jobIf(ci, "test");
|
||||
for (const [event, mode, scoped, expected] of [
|
||||
["workflow_dispatch", "shared-process-twice", "false", true],
|
||||
["workflow_dispatch", "nextest", "false", false],
|
||||
["workflow_dispatch", "shared-process-twice", "true", false],
|
||||
["pull_request", "nextest", "false", false],
|
||||
]) {
|
||||
assert.equal(
|
||||
evaluate(sharedGuard, {
|
||||
github: { event_name: event },
|
||||
inputs: { workspace_test_mode: mode },
|
||||
needs: { changes: { outputs: { heavy: "true", scoped } } },
|
||||
}),
|
||||
expected,
|
||||
`shared-process job: ${event} ${mode} scoped=${scoped}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
evaluate(jobIf(ci, "scoped-test"), { needs: { changes: { outputs: { scoped: "true" } } } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
evaluate(jobIf(ci, "scoped-test"), { needs: { changes: { outputs: { scoped: "false" } } } }),
|
||||
false,
|
||||
);
|
||||
assert.match(ci, /^ expected_sha:\n description: .*\n required: true\n type: string$/m, "expected_sha stays required");
|
||||
for (const input of ["crate", "filter", "os"]) {
|
||||
assert.match(ci, new RegExp(`^ ${input}:\\n description: .*\\n required: false\\n`, "m"), `scoped dispatch input ${input}`);
|
||||
}
|
||||
|
||||
// Concurrency: a new push to the same pull request or non-main branch cancels
|
||||
// the older run; runs on main and scoped dispatches never cancel anything.
|
||||
const concurrency = ci.match(/^concurrency:\n group: (.+)\n cancel-in-progress: (.+)$/m);
|
||||
assert.ok(concurrency, "ci.yml must declare its concurrency");
|
||||
const strip = (value) => value.replace(/^\$\{\{ /, "").replace(/ \}\}$/, "");
|
||||
const format = (template, ...args) => template.replace(/\{(\d)\}/g, (_, i) => args[Number(i)]);
|
||||
const groupOf = (context) =>
|
||||
vm.runInNewContext(strip(concurrency[1]), { ...context, format });
|
||||
const cancelOf = (context) => vm.runInNewContext(strip(concurrency[2]), context);
|
||||
const dispatch = (ref, sha, inputs = {}) => ({
|
||||
github: { event_name: "workflow_dispatch", ref, sha, workflow: "CI", run_id: `run-${sha}` },
|
||||
inputs: { crate: "", filter: "", ...inputs },
|
||||
});
|
||||
const pr = (number, sha) => ({
|
||||
github: { event_name: "pull_request", ref: `refs/pull/${number}/merge`, sha, workflow: "CI", event: { pull_request: { number } }, run_id: `run-${sha}` },
|
||||
inputs: {},
|
||||
});
|
||||
assert.equal(groupOf(pr(7, "a")), groupOf(pr(7, "b")), "a new push to a pull request shares its group");
|
||||
assert.notEqual(groupOf(pr(7, "a")), groupOf(pr(8, "a")));
|
||||
assert.equal(cancelOf(pr(7, "a")), true);
|
||||
assert.equal(groupOf(dispatch("refs/heads/ci/x", "a")), groupOf(dispatch("refs/heads/ci/x", "b")), "a new dispatch on the same ci branch shares its group");
|
||||
assert.notEqual(groupOf(dispatch("refs/heads/ci/x", "a")), groupOf(dispatch("refs/heads/ci/y", "a")));
|
||||
assert.equal(cancelOf(dispatch("refs/heads/ci/x", "a")), true);
|
||||
assert.notEqual(groupOf(dispatch("refs/heads/main", "a")), groupOf(dispatch("refs/heads/main", "b")), "runs on main never share a group");
|
||||
assert.equal(cancelOf(dispatch("refs/heads/main", "a")), false, "runs on main never cancel");
|
||||
assert.notEqual(
|
||||
groupOf(dispatch("refs/heads/ci/x", "a", { crate: "codewhale-tui" })),
|
||||
groupOf(dispatch("refs/heads/ci/x", "a")),
|
||||
"a scoped dispatch never shares the full run's group",
|
||||
);
|
||||
assert.equal(cancelOf(dispatch("refs/heads/ci/x", "a", { filter: "test(x)" })), false, "a scoped dispatch cancels nothing");
|
||||
|
||||
// Security for pull requests from forks: the workflows use pull_request (no
|
||||
// pull_request_target), read-only permissions, no secrets, and write caches
|
||||
// only from main.
|
||||
for (const [name, source] of [["ci.yml", ci], ["ci-test-suite.yml", ciSuite]]) {
|
||||
assert.doesNotMatch(source, /pull_request_target/, `${name} must not use pull_request_target`);
|
||||
assert.doesNotMatch(source, /\$\{\{\s*secrets\./, `${name} must not read secrets`);
|
||||
assert.match(source, /^permissions:\n contents: read\n/m, `${name} must keep read-only default permissions`);
|
||||
assert.doesNotMatch(source.replace(/^permissions:\n contents: read\n/m, ""), /^\s+(?:contents|actions|id-token|pull-requests|packages):\s*write/m, `${name} must not grant write permissions`);
|
||||
for (const match of source.matchAll(/Swatinem\/rust-cache@[^\n]*\n(?:\s+(?:with|cache-bin):[^\n]*\n)*\s+save-if: ([^\n]+)/g)) {
|
||||
assert.match(match[1], /refs\/heads\/main|false/, `${name}: rust-cache must save only on main`);
|
||||
}
|
||||
assert.doesNotMatch(source, /actions\/cache(?:\/save)?@/, `${name} must not write caches outside rust-cache and sccache`);
|
||||
}
|
||||
const npmSmokeJob = ci.match(/^ npm-wrapper-smoke:\n([\s\S]*?)(?=^ \S)/m)?.[1];
|
||||
assert.ok(npmSmokeJob, "CI must retain the npm-wrapper job");
|
||||
assert.match(
|
||||
@@ -79,13 +264,13 @@ assert.match(
|
||||
/run: node scripts\/release\/npm-wrapper-smoke\.js/,
|
||||
);
|
||||
const npmSmokeSteps = npmSmokeJob.split(/(?=^ - )/m).slice(1);
|
||||
// Exercise the workflow's actual Boolean guards. The smoke is manual-only
|
||||
// (workflow_dispatch); pull requests, pushes and schedules skip the job.
|
||||
// Exercise the workflow's actual Boolean guards. The smoke runs on pull
|
||||
// requests and dispatches (§180); pushes and schedules skip the job.
|
||||
const npmSmokeCases = [
|
||||
// name, event, heavy, OS, trusted, cache success, execute, Linux deps, CNB
|
||||
["own PR", "pull_request", true, "ubuntu-latest", true, true, false, false, false],
|
||||
["fork PR", "pull_request", true, "ubuntu-latest", false, true, false, false, false],
|
||||
["PR cache failure", "pull_request", true, "ubuntu-latest", false, false, false, false, false],
|
||||
["own PR", "pull_request", true, "ubuntu-latest", true, true, true, true, false],
|
||||
["fork PR", "pull_request", true, "ubuntu-latest", false, true, true, true, false],
|
||||
["PR cache failure", "pull_request", true, "ubuntu-latest", false, false, true, true, false],
|
||||
["light PR", "pull_request", false, "ubuntu-latest", true, true, false, false, false],
|
||||
["manual Ubuntu", "workflow_dispatch", true, "ubuntu-latest", true, true, true, true, false],
|
||||
["main Ubuntu", "push", true, "ubuntu-latest", true, true, false, false, false],
|
||||
@@ -95,15 +280,22 @@ const npmSmokeCases = [
|
||||
["light main", "push", false, "ubuntu-latest", true, true, false, false, false],
|
||||
["schedule", "schedule", true, "ubuntu-latest", true, true, false, false, false],
|
||||
];
|
||||
// The sccache GitHub Actions backend is main-only, mirroring rust-cache's
|
||||
// save-if: pull requests never install or enable it (cache bloat, PLAN D).
|
||||
// The sccache GitHub Actions backend is read-only off main: every run reads
|
||||
// main's cache, and only main writes it (SCCACHE_GHA_RW_MODE), mirroring
|
||||
// rust-cache's save-if (cache bloat, PLAN D).
|
||||
for (const [name, source] of [["ci.yml", ci], ["ci-test-suite.yml", ciSuite]]) {
|
||||
const enabled = source.match(/SCCACHE_GHA_ENABLED=true/g) ?? [];
|
||||
const modes = source.match(/if \[ "\$\{GITHUB_REF\}" = refs\/heads\/main \]; then rw=READ_WRITE; else rw=READ_ONLY; fi\n\s+echo "SCCACHE_GHA_RW_MODE=\$\{rw\}"/g) ?? [];
|
||||
assert.ok(enabled.length > 0, `${name} must enable sccache`);
|
||||
assert.equal(modes.length, enabled.length, `${name}: every sccache enable must be read-only off main`);
|
||||
assert.doesNotMatch(source, /sccache-action[^\n]*\n(?:[^\n]*\n){0,4}\s+if: [^\n]*github\.ref == 'refs\/heads\/main'/, `${name}: sccache must install on every run`);
|
||||
}
|
||||
const sccacheInstallStep = "mozilla-actions/[email protected]";
|
||||
for (const [label, event, heavy, os, trusted, cache, execute, linuxDeps, cnb] of npmSmokeCases) {
|
||||
const ref = event === "pull_request" ? "refs/pull/1/merge" : "refs/heads/main";
|
||||
const onMain = ref === "refs/heads/main";
|
||||
const installed = execute && onMain;
|
||||
const installed = execute;
|
||||
const context = {
|
||||
needs: { changes: { outputs: { heavy: String(heavy), trusted: String(trusted) } } },
|
||||
needs: { changes: { outputs: { heavy: String(heavy), trusted: String(trusted), scoped: "false" } } },
|
||||
github: { event_name: event, ref },
|
||||
matrix: { os },
|
||||
steps: { sccache: { outcome: installed ? (cache ? "success" : "failure") : "skipped" } },
|
||||
@@ -688,7 +880,8 @@ assert.doesNotMatch(
|
||||
// itself runs none: its parity job calls release-parity.yml.
|
||||
let hermeticInvocations = 0;
|
||||
for (const [label, workflow, expected] of [
|
||||
["CI", ci, 6],
|
||||
["CI", ci, 5],
|
||||
["CI test suite", ciSuite, 3],
|
||||
["release", release, 0],
|
||||
["release parity", parityWorkflow, 4],
|
||||
["CNB", cnb, 3],
|
||||
@@ -707,11 +900,6 @@ for (const [label, workflow, expected] of [
|
||||
}
|
||||
hermeticInvocations += commands.length;
|
||||
}
|
||||
for (const name of ["Run tests", "Run doctests"]) {
|
||||
const step = namedStep(ciTestJob, name);
|
||||
assert.match(step, /shell: bash/, `${name} must invoke the POSIX helper on Windows too`);
|
||||
assert.match(step, /RUST_MIN_STACK: '16777216'/);
|
||||
}
|
||||
console.log(`Hermetic Rust workflow invocations OK: ${hermeticInvocations} checks passed.`);
|
||||
|
||||
const nextest = read(".config/nextest.toml");
|
||||
@@ -853,6 +1041,7 @@ for (const [name, source] of [
|
||||
["release-parity.yml", parityWorkflow],
|
||||
["release-republish.yml", republish],
|
||||
["ci.yml", ci],
|
||||
["ci-test-suite.yml", ciSuite],
|
||||
["nightly.yml", nightly],
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
name: CI test suite
|
||||
|
||||
# Called by ci.yml, once per operating system, so each OS is its own pipeline:
|
||||
# one build of the whole workspace's test binaries, parallel shards that run
|
||||
# partitions of that one nextest archive, and a check that the shards together
|
||||
# ran exactly the tests the archive lists. Running per OS means Linux reports
|
||||
# as soon as Linux is done instead of waiting for the slowest build.
|
||||
#
|
||||
# Trust boundary: this runs with the caller's event. For a pull request from a
|
||||
# fork that is the read-only token and no secrets; nothing here writes a cache
|
||||
# (rust-cache and sccache save only on main) or uses a write permission. The
|
||||
# artifacts passed between jobs live inside one run.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
os:
|
||||
description: Hosted runner label
|
||||
required: true
|
||||
type: string
|
||||
shards:
|
||||
description: Number of shards, equal to the length of shard_list
|
||||
required: true
|
||||
type: number
|
||||
shard_list:
|
||||
description: JSON array of shard numbers, for example [1,2,3,4]
|
||||
required: true
|
||||
type: string
|
||||
build_jobs:
|
||||
description: CARGO_BUILD_JOBS for the test build
|
||||
required: true
|
||||
type: string
|
||||
debug:
|
||||
description: Debug-table level for the test build (CARGO_PROFILE_*_DEBUG)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUSTFLAGS: -Dwarnings
|
||||
RUST_MIN_STACK: 8388608
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
# A cold hosted Mac compiled for 65 minutes with one Cargo job (run
|
||||
# 43216596942) and the old one-job cap was 210; this job only compiles, so
|
||||
# it keeps real margin without the test run in it.
|
||||
timeout-minutes: 150
|
||||
runs-on: ${{ inputs.os }}
|
||||
env:
|
||||
# Test builds compile all features of the whole workspace. One Cargo job
|
||||
# kept hosted runners inside memory (exit 143 on 2026-10-19 with four);
|
||||
# trimmed debug tables shrink compiler and linker memory so Linux and
|
||||
# Windows can run two. Assertions and every test are unchanged.
|
||||
CARGO_BUILD_JOBS: ${{ inputs.build_jobs }}
|
||||
CARGO_PROFILE_DEV_DEBUG: ${{ inputs.debug }}
|
||||
CARGO_PROFILE_TEST_DEBUG: ${{ inputs.debug }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Test Windows installer PATH helper
|
||||
if: inputs.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: ./scripts/installer/update-user-path.tests.ps1
|
||||
- name: Install NSIS for Windows installer regression
|
||||
if: inputs.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: ./scripts/installer/install-nsis.ps1
|
||||
- name: Test Windows installer PATH regression
|
||||
if: inputs.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install macOS LLVM tools
|
||||
if: inputs.os == 'macos-latest'
|
||||
run: rustup component add llvm-tools --toolchain stable
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
# Every run reads the GitHub Actions cache main wrote; only main writes (see Enable sccache).
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
|
||||
if [ "${GITHUB_REF}" = refs/heads/main ]; then rw=READ_WRITE; else rw=READ_ONLY; fi
|
||||
echo "SCCACHE_GHA_RW_MODE=${rw}" >> "${GITHUB_ENV}"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
|
||||
- name: Install Linux system dependencies
|
||||
if: inputs.os == 'ubuntu-latest'
|
||||
run: |
|
||||
bash scripts/ci-apt-install.sh libdbus-1-dev pkg-config bubblewrap apparmor-profiles
|
||||
sh scripts/prepare-linux-test-sandbox.sh
|
||||
- name: Ubuntu build headroom
|
||||
# Reuse the same fixed hosted-runner provisioning as Safety and Lint.
|
||||
# Exit 143 is a shutdown observation, not proof of OOM or timeout.
|
||||
if: inputs.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: bash scripts/prepare-ubuntu-build-headroom.sh
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-bin: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Build canonical executable for acceptance tests
|
||||
shell: bash
|
||||
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
|
||||
env:
|
||||
RUSTC_WRAPPER: ${{ inputs.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
|
||||
- name: Windows Terminal clipboard-to-draft acceptance
|
||||
if: inputs.os == 'windows-latest'
|
||||
timeout-minutes: 5
|
||||
shell: pwsh
|
||||
run: node scripts/release/windows-terminal-clipboard.mjs target/debug/codewhale.exe "${{ runner.temp }}/windows-clipboard"
|
||||
- name: Retain Windows Terminal clipboard-to-draft evidence
|
||||
if: ${{ !cancelled() && inputs.os == 'windows-latest' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: windows-clipboard-${{ github.sha }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/windows-clipboard/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: Archive test binaries
|
||||
# One compile per OS. Shards run this archive with
|
||||
# `cargo nextest run --archive-file`, so the suite is the same test
|
||||
# binaries, built once. Doctests are not in the archive; they run
|
||||
# below, once, while the library artifacts are still warm.
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/test-archive" "$RUNNER_TEMP/test-expected"
|
||||
cargo nextest archive --workspace --all-features --locked --profile ci \
|
||||
--archive-file "$RUNNER_TEMP/test-archive/nextest-archive.tar.zst"
|
||||
exe=codewhale
|
||||
if [ "$RUNNER_OS" = Windows ]; then exe=codewhale.exe; fi
|
||||
cp "target/debug/$exe" "$RUNNER_TEMP/test-archive/$exe"
|
||||
env:
|
||||
RUSTC_WRAPPER: ${{ inputs.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
|
||||
- name: Count the tests the archive selects
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo nextest list --archive-file "$RUNNER_TEMP/test-archive/nextest-archive.tar.zst" \
|
||||
--workspace-remap . --profile ci --message-format json > "$RUNNER_TEMP/test-listing.json"
|
||||
tests="$(python3 -I scripts/ci/nextest-shard-count.py list "$RUNNER_TEMP/test-listing.json")"
|
||||
printf '{"os": "%s", "tests": %s, "shards": %s}\n' "${{ inputs.os }}" "$tests" "${{ inputs.shards }}" \
|
||||
> "$RUNNER_TEMP/test-expected/expected-${{ inputs.os }}.json"
|
||||
cat "$RUNNER_TEMP/test-expected/expected-${{ inputs.os }}.json"
|
||||
- name: Run doctests
|
||||
# nextest does not run doctests; this keeps them once per OS.
|
||||
shell: bash
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked --doc
|
||||
env:
|
||||
RUSTC_WRAPPER: ${{ inputs.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
|
||||
RUST_MIN_STACK: '16777216'
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
- name: sccache stats
|
||||
if: steps.sccache.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: sccache --show-stats
|
||||
- name: Retain test archive for the shards
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-archive-${{ inputs.os }}
|
||||
path: ${{ runner.temp }}/test-archive/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
- name: Retain expected test count
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-expected-${{ inputs.os }}
|
||||
path: ${{ runner.temp }}/test-expected/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
shard:
|
||||
name: Shard ${{ matrix.shard }}/${{ inputs.shards }}
|
||||
needs: build
|
||||
timeout-minutes: 60
|
||||
runs-on: ${{ inputs.os }}
|
||||
strategy:
|
||||
# A failure in one shard must not erase the others' results; the suite
|
||||
# gate needs every conclusion.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: ${{ fromJSON(inputs.shard_list) }}
|
||||
env:
|
||||
CODEWHALE_EXT_HOST_TESTS: '1'
|
||||
CODEWHALE_EXT_HOST_BUN_TESTS: '1'
|
||||
# Give test threads the stack the product gives itself; the reasoning is
|
||||
# on the shared-process step above and in scripts/dev-cargo.sh.
|
||||
RUST_MIN_STACK: '16777216'
|
||||
# The exact-image Native containment tests (and, on Windows, the LPAC
|
||||
# owner and profile tests) that scripts/compiled-host-native-receipt.py
|
||||
# requires in ONE JUnit report. Shard 1 runs them in their own nextest
|
||||
# invocation and every shard excludes them from its partition, so each
|
||||
# still runs exactly once. release-workflows.test.js keeps this list equal
|
||||
# to the script's.
|
||||
NATIVE_FILTER: >-
|
||||
package(codewhale-engine) & kind(lib) & (
|
||||
test(=extension_host::tests::compiled_native_host_cannot_read_secrets_or_write_outside_its_data_dir) |
|
||||
test(=extension_host::tests::compiled_native_host_memory_cap_is_enforced) |
|
||||
test(=extension_host::windows::tests::windows_native_lpac_node_owner_boundary) |
|
||||
test(=extension_host::windows::tests::windows_native_lpac_bun_owner_boundary) |
|
||||
test(=extension_host::windows::tests::windows_native_lpac_compiled_owner_boundary) |
|
||||
test(=extension_host::windows::tests::windows_native_profiles_are_distinct_and_acl_grant_refuses_junctions) |
|
||||
test(=extension_host::windows::tests::windows_argv_and_environment_keep_exact_values_and_reject_nul) |
|
||||
test(=extension_host::windows::tests::windows_profile_retirement_removes_only_its_grants_and_inherited_data_on_restarts) |
|
||||
test(=extension_host::windows::tests::windows_profile_directory_budget_and_recorded_identity_refuse_before_overwrite))
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Point terminal failure diagnostics at the runner temp directory
|
||||
shell: bash
|
||||
run: echo "QA_PTY_DIAGNOSTICS_DIR=${RUNNER_TEMP}/pty-failures" >> "${GITHUB_ENV}"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux system dependencies
|
||||
if: inputs.os == 'ubuntu-latest'
|
||||
run: |
|
||||
bash scripts/ci-apt-install.sh libdbus-1-dev pkg-config bubblewrap apparmor-profiles
|
||||
sh scripts/prepare-linux-test-sandbox.sh
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- uses: actions/[email protected]
|
||||
# The extension-host integration tests spawn the real bundle under
|
||||
# Node >= 22.19; CODEWHALE_EXT_HOST_TESTS makes a missing Node a
|
||||
# failure instead of a silent skip.
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- name: Build matching compiled-host input for required Native containment receipts
|
||||
shell: bash
|
||||
run: bash scripts/ci/compiled-host-test-input.sh
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
name: test-archive-${{ inputs.os }}
|
||||
path: ${{ runner.temp }}/test-archive
|
||||
- name: Place the canonical executable for acceptance tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p target/debug
|
||||
cp "$RUNNER_TEMP"/test-archive/codewhale* target/debug/
|
||||
if [ "$RUNNER_OS" != Windows ]; then chmod +x target/debug/codewhale; fi
|
||||
- name: Run native containment tests
|
||||
if: matrix.shard == 1
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/test-count" "$RUNNER_TEMP/nextest-extract"
|
||||
sh scripts/with-hermetic-test-home.sh cargo nextest run \
|
||||
--archive-file "$RUNNER_TEMP/test-archive/nextest-archive.tar.zst" \
|
||||
--extract-to "$RUNNER_TEMP/nextest-extract" --extract-overwrite \
|
||||
--workspace-remap . --profile ci -E "$NATIVE_FILTER"
|
||||
cp target/nextest/ci/junit.xml "$RUNNER_TEMP/native-junit.xml"
|
||||
python3 -I scripts/ci/nextest-shard-count.py junit "$RUNNER_TEMP/native-junit.xml" > "$RUNNER_TEMP/native-count.txt"
|
||||
- name: Run tests
|
||||
# Same test binaries as `cargo test`, run by cargo-nextest: one
|
||||
# process per test, all runner cores busy, slow tests named instead
|
||||
# of stalling the binary. `.config/nextest.toml` serializes the PTY
|
||||
# binary and bounds the integration binary that spawns the real
|
||||
# executable; retries are off, so a flake is a red run, not a hidden
|
||||
# one. This shard runs partition k of N of everything except the
|
||||
# native containment tests; test-suite proves the shards add up to the
|
||||
# archive's full list.
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/test-count" "$RUNNER_TEMP/nextest-extract"
|
||||
monitor=
|
||||
if [ "$RUNNER_OS" = Linux ]; then
|
||||
# Headroom trace: if the runner is terminated again, the last lines
|
||||
# say whether memory or disk ran out.
|
||||
( while sleep 30; do
|
||||
echo "[headroom] $(free -m | awk '/^Mem:/{print "mem used " $3 "/" $2 " MiB"} /^Swap:/{print "swap used " $3 "/" $2 " MiB"}' | paste -sd ' ') disk free $(df -h / | awk 'NR==2{print $4}')"
|
||||
done ) &
|
||||
monitor=$!
|
||||
fi
|
||||
status=0
|
||||
sh scripts/with-hermetic-test-home.sh cargo nextest run \
|
||||
--archive-file "$RUNNER_TEMP/test-archive/nextest-archive.tar.zst" \
|
||||
--extract-to "$RUNNER_TEMP/nextest-extract" --extract-overwrite \
|
||||
--workspace-remap . --profile ci \
|
||||
--partition "count:${{ matrix.shard }}/${{ inputs.shards }}" \
|
||||
-E "not ($NATIVE_FILTER)" || status=$?
|
||||
if [ -n "$monitor" ]; then kill "$monitor" 2>/dev/null || true; fi
|
||||
if [ "$status" -ne 0 ]; then exit "$status"; fi
|
||||
tests="$(python3 -I scripts/ci/nextest-shard-count.py junit target/nextest/ci/junit.xml)"
|
||||
if [ -f "$RUNNER_TEMP/native-count.txt" ]; then tests=$((tests + $(cat "$RUNNER_TEMP/native-count.txt"))); fi
|
||||
printf '{"os": "%s", "shard": %s, "tests": %s}\n' "${{ inputs.os }}" "${{ matrix.shard }}" "$tests" \
|
||||
> "$RUNNER_TEMP/test-count/shard-${{ inputs.os }}-${{ matrix.shard }}.json"
|
||||
cat "$RUNNER_TEMP/test-count/shard-${{ inputs.os }}-${{ matrix.shard }}.json"
|
||||
- name: Retain sealed terminal failure diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: terminal-failures-${{ runner.os }}-${{ matrix.shard }}-${{ github.sha }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/pty-failures/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Export exact compiled-image Native containment receipt
|
||||
# JUnit is from the same archive's native invocation: no second
|
||||
# compile. The official entire CI run must be green before release can
|
||||
# consume this artifact. Missing/changed inputs or silent test skips
|
||||
# are red.
|
||||
if: matrix.shard == 1
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
git diff --exit-code
|
||||
git diff --cached --exit-code
|
||||
python3 scripts/compiled-host-native-receipt.py \
|
||||
--junit "$RUNNER_TEMP/native-junit.xml" \
|
||||
--compiled-image "$CODEWHALE_COMPILED_HOST_TEST_BINARY" \
|
||||
--bundle crates/engine/extension-host/dist/codewhale-extension-host.mjs \
|
||||
--bun "$CODEWHALE_COMPILED_HOST_TEST_BUN" --source-sha "$GITHUB_SHA" \
|
||||
--image-platform "$CODEWHALE_COMPILED_HOST_TEST_PLATFORM" --image-arch "$CODEWHALE_COMPILED_HOST_TEST_ARCH" \
|
||||
--host-sha256 "$CODEWHALE_COMPILED_HOST_TEST_SHA256" \
|
||||
--runtime-sha256 "$CODEWHALE_COMPILED_HOST_TEST_RUNTIME_SHA256" \
|
||||
--bundle-sha256 "$CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256" \
|
||||
--output "$RUNNER_TEMP/native-compiled-host"
|
||||
- name: Retain exact compiled image and Native receipt
|
||||
if: matrix.shard == 1
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: native-compiled-host-${{ runner.os }}-${{ runner.arch }}
|
||||
path: ${{ runner.temp }}/native-compiled-host/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
- name: Retain shard test count
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-count-${{ inputs.os }}-${{ matrix.shard }}
|
||||
path: ${{ runner.temp }}/test-count/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
check:
|
||||
# The job that answers "did this OS's whole suite pass": the build and every
|
||||
# shard succeeded, and the shards together ran exactly the tests the archive
|
||||
# lists. Not a required check (§180).
|
||||
name: Suite check
|
||||
needs: [build, shard]
|
||||
if: '!cancelled()'
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: scripts/ci
|
||||
- name: Every build and shard succeeded
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_RESULT: ${{ needs.build.result }}
|
||||
SHARD_RESULT: ${{ needs.shard.result }}
|
||||
run: |
|
||||
echo "build: ${BUILD_RESULT}"
|
||||
echo "shard: ${SHARD_RESULT}"
|
||||
if [ "${BUILD_RESULT}" != success ] || [ "${SHARD_RESULT}" != success ]; then
|
||||
echo "::error::The sharded suite did not complete successfully."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
name: test-expected-${{ inputs.os }}
|
||||
path: counts
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
pattern: test-count-${{ inputs.os }}-*
|
||||
path: counts
|
||||
merge-multiple: true
|
||||
- name: Shards ran every listed test exactly once
|
||||
run: python3 -I scripts/ci/nextest-shard-count.py check counts
|
||||
+317
-316
@@ -16,13 +16,32 @@ on:
|
||||
options:
|
||||
- nextest
|
||||
- shared-process-twice
|
||||
crate:
|
||||
description: Scoped run, in a few minutes. One workspace package (for example codewhale-tui); with crate or filter set only the scoped test runs, not the full suite
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
filter:
|
||||
description: Scoped run, in a few minutes. A nextest filterset (for example 'test(auto_review)'), alone or with crate; with crate or filter set only the scoped test runs
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
os:
|
||||
description: Operating systems for a scoped run, comma separated (ubuntu-latest, macos-latest, windows-latest, or all); ignored by a full run
|
||||
required: false
|
||||
default: ubuntu-latest
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A new push to a pull request, or a new dispatch on the same non-main branch
|
||||
# (an agent's ci/<task> branch), cancels the older run still in progress.
|
||||
# Runs on main are keyed by SHA and never cancel each other, and a scoped
|
||||
# dispatch gets its own group so it cannot cancel a full run on the same branch.
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-{0}-{1}', github.workflow, github.sha) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || (github.event_name == 'workflow_dispatch' && (inputs.crate != '' || inputs.filter != '') && format('ci-scoped-{0}', github.run_id)) || (github.ref != 'refs/heads/main' && format('ci-ref-{0}', github.ref)) || format('ci-{0}-{1}', github.workflow, github.sha) }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && !(github.event_name == 'workflow_dispatch' && (inputs.crate != '' || inputs.filter != '')) }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -45,6 +64,8 @@ jobs:
|
||||
mobile: ${{ steps.detect.outputs.mobile }}
|
||||
actions: ${{ steps.detect.outputs.actions }}
|
||||
trusted: ${{ steps.trust.outputs.trusted }}
|
||||
scoped: ${{ steps.scope.outputs.scoped }}
|
||||
scoped_os: ${{ steps.scope.outputs.scoped_os }}
|
||||
steps:
|
||||
- name: Classify event trust
|
||||
id: trust
|
||||
@@ -64,6 +85,48 @@ jobs:
|
||||
else
|
||||
echo "trusted=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Classify scoped dispatch
|
||||
id: scope
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
SCOPE_CRATE: ${{ inputs.crate }}
|
||||
SCOPE_FILTER: ${{ inputs.filter }}
|
||||
SCOPE_OS: ${{ inputs.os }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# A dispatch with a crate or a filter runs only the scoped test job.
|
||||
# Inputs reach the shell only through env, and the crate and OS
|
||||
# names are validated before anything uses them.
|
||||
scoped=false
|
||||
scoped_os='["ubuntu-latest"]'
|
||||
if [[ "${EVENT_NAME}" == "workflow_dispatch" && ( -n "${SCOPE_CRATE}" || -n "${SCOPE_FILTER}" ) ]]; then
|
||||
scoped=true
|
||||
if [[ -n "${SCOPE_CRATE}" && ! "${SCOPE_CRATE}" =~ ^[A-Za-z0-9_-]+$ ]]; then
|
||||
echo "::error::crate must be a package name (letters, digits, - and _)." >&2
|
||||
exit 1
|
||||
fi
|
||||
oses=()
|
||||
IFS=',' read -r -a requested <<< "${SCOPE_OS:-ubuntu-latest}"
|
||||
for name in "${requested[@]}"; do
|
||||
name="${name//[[:space:]]/}"
|
||||
case "${name}" in
|
||||
ubuntu|ubuntu-latest|linux) oses+=("ubuntu-latest") ;;
|
||||
macos|macos-latest) oses+=("macos-latest") ;;
|
||||
windows|windows-latest) oses+=("windows-latest") ;;
|
||||
all) oses+=("ubuntu-latest" "macos-latest" "windows-latest") ;;
|
||||
"") ;;
|
||||
*)
|
||||
echo "::error::os entries must be ubuntu-latest, macos-latest, windows-latest or all." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [[ "${#oses[@]}" -eq 0 ]]; then oses=("ubuntu-latest"); fi
|
||||
scoped_os="$(printf '%s\n' "${oses[@]}" | sort -u | jq -R . | jq -sc .)"
|
||||
fi
|
||||
echo "scoped=${scoped}" >> "${GITHUB_OUTPUT}"
|
||||
echo "scoped_os=${scoped_os}" >> "${GITHUB_OUTPUT}"
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -197,6 +260,8 @@ jobs:
|
||||
|
||||
versions:
|
||||
name: Version drift
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scoped != 'true'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -274,6 +339,8 @@ jobs:
|
||||
|
||||
plugin-conversion:
|
||||
name: Plugin conversion
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scoped != 'true'
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -293,6 +360,8 @@ jobs:
|
||||
|
||||
integrations:
|
||||
name: Integrations
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scoped != 'true'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -349,7 +418,7 @@ jobs:
|
||||
extension-host-runtimes:
|
||||
name: Extension host runtimes (${{ matrix.os }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.changes.outputs.scoped != 'true'
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -388,6 +457,8 @@ jobs:
|
||||
|
||||
vscode-extension:
|
||||
name: VS Code extension
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scoped != 'true'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -421,7 +492,7 @@ jobs:
|
||||
safety-gate:
|
||||
name: Safety gate
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.changes.outputs.scoped != 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -431,16 +502,18 @@ jobs:
|
||||
toolchain: stable
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
# The GitHub Actions cache backend is main-only, mirroring
|
||||
# rust-cache's save-if: PR runs wrote thousands of refs/pull/N
|
||||
# entries that pushed the repo past its 10 GiB cache cap.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
# Every run reads the cache main wrote; only main writes it
|
||||
# (SCCACHE_GHA_RW_MODE in Enable sccache), mirroring rust-cache's
|
||||
# save-if. PR runs once wrote thousands of refs/pull/N entries that
|
||||
# pushed the repo past its 10 GiB cache cap.
|
||||
continue-on-error: true
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
|
||||
if [ "${GITHUB_REF}" = refs/heads/main ]; then rw=READ_WRITE; else rw=READ_ONLY; fi
|
||||
echo "SCCACHE_GHA_RW_MODE=${rw}" >> "${GITHUB_ENV}"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
|
||||
- name: Install Linux system dependencies
|
||||
@@ -475,6 +548,7 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scoped != 'true'
|
||||
# Measured on 89ee629e with runner headroom: clippy 10 min, runtime-contract
|
||||
# budget 16 min, and persistence-backlog still running at the old 45-minute
|
||||
# cancellation. Commands, filters and budgets are unchanged.
|
||||
@@ -494,13 +568,15 @@ jobs:
|
||||
# Cache bootstrap failures (e.g. GitHub 504s fetching the sccache
|
||||
# binary) degrade to an uncached build instead of failing product CI.
|
||||
continue-on-error: true
|
||||
# Main-only GitHub Actions cache backend; see the Safety gate job.
|
||||
if: needs.changes.outputs.heavy == 'true' && github.ref == 'refs/heads/main'
|
||||
# Every run reads the GitHub Actions cache main wrote; only main writes (see Enable sccache).
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
- name: Enable sccache
|
||||
if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
|
||||
if [ "${GITHUB_REF}" = refs/heads/main ]; then rw=READ_WRITE; else rw=READ_ONLY; fi
|
||||
echo "SCCACHE_GHA_RW_MODE=${rw}" >> "${GITHUB_ENV}"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
|
||||
- name: Install Linux system dependencies
|
||||
@@ -725,7 +801,7 @@ jobs:
|
||||
# fail the build there while stable stays green (#6543).
|
||||
name: MSRV check (1.89)
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.changes.outputs.scoped != 'true'
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -746,7 +822,7 @@ jobs:
|
||||
workflow-rlm-cache:
|
||||
name: Workflow RLM cache
|
||||
needs: changes
|
||||
if: needs.changes.outputs.workflow == 'true'
|
||||
if: needs.changes.outputs.workflow == 'true' && needs.changes.outputs.scoped != 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -754,14 +830,15 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
# Main-only GitHub Actions cache backend; see the Safety gate job.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
# Every run reads the GitHub Actions cache main wrote; only main writes (see Enable sccache).
|
||||
continue-on-error: true
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
|
||||
if [ "${GITHUB_REF}" = refs/heads/main ]; then rw=READ_WRITE; else rw=READ_ONLY; fi
|
||||
echo "SCCACHE_GHA_RW_MODE=${rw}" >> "${GITHUB_ENV}"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -772,261 +849,64 @@ jobs:
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-workflow --locked
|
||||
|
||||
test:
|
||||
name: Test
|
||||
# Manual shared-process qualification only (workspace_test_mode =
|
||||
# shared-process-twice): the GH6698 evidence of two libtest passes at one
|
||||
# exact revision on one hosted Ubuntu runner. Every other run executes the
|
||||
# suite through test-build, test-shard and test-suite below.
|
||||
name: Shared-process tests
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# Contexts "Test (ubuntu-latest)" / "Test (macos-latest)" /
|
||||
# "Test (windows-latest)" derive from job name + matrix.os and are
|
||||
# independent of runs-on. For light changes the macOS/Windows legs only
|
||||
# echo a skip line, so run them on ubuntu instead of queueing for scarce
|
||||
# macOS/Windows runners. Pull requests run only the Linux leg here; the
|
||||
# required "Test (windows-latest)" context comes from test-windows-pr.
|
||||
# The ternary is safe: matrix.os is always a non-empty literal, so
|
||||
# runs-on can never evaluate to empty.
|
||||
# A cold GitHub-hosted Mac spent 77-80 minutes in Test on 2026-09-23
|
||||
# (fork PRs #6431, #6417), too close to the old 90-minute limit.
|
||||
# On 2026-10-04 a PR leg spent 111 minutes compiling with no cache and
|
||||
# was cancelled at 120 with 12,356 tests passed and none failed. The
|
||||
# limit stays a literal (release-workflows.test.js, #5496); 210 bounds
|
||||
# cold compilation and the complete suite without disabling test deadlines.
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice' && needs.changes.outputs.heavy == 'true' && needs.changes.outputs.scoped != 'true'
|
||||
# A cold GitHub-hosted runner spent 111 minutes compiling with no cache on
|
||||
# 2026-10-04. The limit stays a literal (release-workflows.test.js, #5496);
|
||||
# 210 bounds cold compilation and both complete passes.
|
||||
timeout-minutes: 210
|
||||
# macOS legs go to the self-hosted Mac ONLY when all three hold: the
|
||||
# change is heavy, the event is trusted (not a fork PR), and the
|
||||
# CW_SELF_HOSTED_MAC repo variable is 'true'. That variable is the kill
|
||||
# switch: unset it and every leg falls back to GitHub-hosted runners
|
||||
# immediately, with no commit — important because an offline
|
||||
# self-hosted runner queues jobs forever, which is worse than a slow one.
|
||||
runs-on: ${{ needs.changes.outputs.heavy != 'true' && 'ubuntu-latest' || (matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true' && fromJSON('["self-hosted","macOS","ARM64","codewhale-mac"]')) || matrix.os }}
|
||||
strategy:
|
||||
# A failure on one desktop platform must not erase evidence from the
|
||||
# other one. We need both conclusions to diagnose and release safely.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Pull requests run the Linux workspace suite only; the macOS and
|
||||
# Windows suites run in final validation. The explicit manual
|
||||
# libtest qualification uses one hosted Ubuntu job, never a fork PR or
|
||||
# self-hosted runner; normal dispatches keep all three legs.
|
||||
os: ${{ fromJSON((github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')) && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Skip tests for light change
|
||||
if: needs.changes.outputs.heavy != 'true'
|
||||
run: echo "No executable Rust changes detected; preserving required Test context."
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
- name: Test Windows installer PATH helper
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: ./scripts/installer/update-user-path.tests.ps1
|
||||
- name: Install NSIS for Windows installer regression
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: ./scripts/installer/install-nsis.ps1
|
||||
- name: Test Windows installer PATH regression
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
- name: Install macOS LLVM tools
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
|
||||
run: rustup component add llvm-tools --toolchain stable
|
||||
- name: Omit macOS build debug metadata
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
# Match the existing full-workspace CNB gate. Keep debug assertions and
|
||||
# every test; only disposable build debug tables change on this lane.
|
||||
run: printf '%s\n' 'CARGO_PROFILE_DEV_DEBUG=0' 'CARGO_PROFILE_TEST_DEBUG=0' >> "$GITHUB_ENV"
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
# Main-only GitHub Actions cache backend; see the Safety gate job.
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && github.ref == 'refs/heads/main'
|
||||
# Every run reads the GitHub Actions cache main wrote; only main writes (see Enable sccache).
|
||||
- name: Enable sccache
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
|
||||
if [ "${GITHUB_REF}" = refs/heads/main ]; then rw=READ_WRITE; else rw=READ_ONLY; fi
|
||||
echo "SCCACHE_GHA_RW_MODE=${rw}" >> "${GITHUB_ENV}"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
|
||||
- name: Install Linux system dependencies
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
run: |
|
||||
bash scripts/ci-apt-install.sh libdbus-1-dev pkg-config bubblewrap apparmor-profiles
|
||||
sh scripts/prepare-linux-test-sandbox.sh
|
||||
- name: Ubuntu build headroom
|
||||
# Reuse the same fixed hosted-runner provisioning as Safety and Lint.
|
||||
# Exit 143 is a shutdown observation, not proof of OOM or timeout.
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
shell: bash
|
||||
run: bash scripts/prepare-ubuntu-build-headroom.sh
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
with:
|
||||
cache-bin: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- uses: taiki-e/install-action@nextest
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
- name: Check portable config policy boundary
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -B scripts/test_check_command_config_proof.py
|
||||
sh scripts/check-portable-config.sh
|
||||
- name: Check portable core navigation boundary
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -B scripts/test_check_command_core_navigation_proof.py
|
||||
sh scripts/check-portable-core-navigation.sh
|
||||
- uses: actions/[email protected]
|
||||
# The extension-host integration tests spawn the real bundle under
|
||||
# Node >= 22.19; CODEWHALE_EXT_HOST_TESTS below makes a missing Node a
|
||||
# failure instead of a silent skip.
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- name: Build matching compiled-host input for required Native containment receipts
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bun_binary="$(bun -p 'process.execPath')"
|
||||
host_binary="$(node -p "require('path').join(process.env.RUNNER_TEMP, 'codewhale-extension-host' + (process.platform === 'win32' ? '.exe' : ''))")"
|
||||
compiled_info="$(node crates/engine/extension-host/compile-host.mjs --bun "$bun_binary" --output "$host_binary")"
|
||||
printf 'CODEWHALE_COMPILED_HOST_TEST_BINARY=%s\n' "$host_binary" >> "$GITHUB_ENV"
|
||||
node - "$host_binary" "$bun_binary" "$compiled_info" >> "$GITHUB_ENV" <<'JS'
|
||||
const fs = require('node:fs'), crypto = require('node:crypto');
|
||||
const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const info = JSON.parse(process.argv[4]);
|
||||
if (!info.platform || !info.arch || info.platform !== info.compiler?.platform || info.arch !== info.compiler?.arch) throw new Error('image/compiler identity mismatch');
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_PLATFORM=' + info.platform);
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_ARCH=' + info.arch);
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_SHA256=' + digest(process.argv[2]));
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_BUN=' + process.argv[3]);
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_RUNTIME_SHA256=' + digest(process.argv[3]));
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256=' + digest('crates/engine/extension-host/dist/codewhale-extension-host.mjs'));
|
||||
JS
|
||||
# The required hermetic full invocation executes the shared actual
|
||||
# compiled Native secret/history/write scenario on each OS; Windows
|
||||
# additionally requires Node/Bun/compiled LPAC owner/reparse probes.
|
||||
# Missing requested compiled input or a failed sandbox is red.
|
||||
- name: Hold this machine's build lock (self-hosted)
|
||||
# The self-hosted Mac is also a developer machine: local agents build
|
||||
# through scripts/dev-cargo.sh, which holds this lock, and two Cargo
|
||||
# builds at once exhaust its memory. Opt-in: the runner's `.env` names
|
||||
# the same file as CODEWHALE_BUILD_LOCK_FILE. The holder is a
|
||||
# background process; the runner kills orphans when the job ends, so a
|
||||
# cancelled or crashed job cannot leave the lock held.
|
||||
# (`env.*` in `if:` cannot see the runner's `.env`, so the opt-in is
|
||||
# checked in the script.)
|
||||
if: needs.changes.outputs.heavy == 'true' && runner.environment == 'self-hosted'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "${CODEWHALE_BUILD_LOCK_FILE:-}" ]; then
|
||||
echo "CODEWHALE_BUILD_LOCK_FILE is not set on this runner; building without the machine lock."
|
||||
exit 0
|
||||
fi
|
||||
hold="$RUNNER_TEMP/cw-build-lock.hold"
|
||||
ready="$RUNNER_TEMP/cw-build-lock.ready"
|
||||
log="$RUNNER_TEMP/cw-build-lock.log"
|
||||
touch "$hold"
|
||||
rm -f "$ready"
|
||||
# The single quotes are deliberate: the inner sh expands $1/$2.
|
||||
# shellcheck disable=SC2016
|
||||
nohup python3 scripts/build-lock.py "$CODEWHALE_BUILD_LOCK_FILE" -- \
|
||||
sh -c 'touch "$1"; while [ -e "$2" ]; do sleep 2; done' _ "$ready" "$hold" \
|
||||
>"$log" 2>&1 &
|
||||
shown=0
|
||||
until [ -e "$ready" ]; do
|
||||
if [ "$shown" -eq 0 ] && [ -s "$log" ]; then cat "$log"; shown=1; fi
|
||||
sleep 2
|
||||
done
|
||||
cat "$log"
|
||||
# Scripts inside this job already run under the lock.
|
||||
echo "CODEWHALE_BUILD_LOCK_HELD=1" >> "$GITHUB_ENV"
|
||||
run: bash scripts/ci/compiled-host-test-input.sh
|
||||
- name: Build canonical executable for acceptance tests
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
shell: bash
|
||||
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
|
||||
env:
|
||||
RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
|
||||
- name: Windows Terminal clipboard-to-draft acceptance
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
|
||||
timeout-minutes: 5
|
||||
shell: pwsh
|
||||
run: node scripts/release/windows-terminal-clipboard.mjs target/debug/codewhale.exe "${{ runner.temp }}/windows-clipboard"
|
||||
- name: Retain Windows Terminal clipboard-to-draft evidence
|
||||
if: ${{ !cancelled() && matrix.os == 'windows-latest' && needs.changes.outputs.heavy == 'true' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: windows-clipboard-${{ github.sha }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/windows-clipboard/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: Run tests
|
||||
# Same test binaries as `cargo test`, run by cargo-nextest: one
|
||||
# process per test, all runner cores busy, slow tests named instead
|
||||
# of stalling the binary. `.config/nextest.toml` serializes the PTY
|
||||
# binary and bounds the integration binary that spawns the real
|
||||
# executable; retries are off, so a flake is a red run, not a hidden
|
||||
# one. nextest does not run doctests — the next step keeps them.
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
|
||||
shell: bash
|
||||
run: |
|
||||
monitor=
|
||||
if [ "$RUNNER_OS" = Linux ]; then
|
||||
# Headroom trace: if the runner is terminated again, the last lines
|
||||
# say whether memory or disk ran out.
|
||||
( while sleep 30; do
|
||||
echo "[headroom] $(free -m | awk '/^Mem:/{print "mem used " $3 "/" $2 " MiB"} /^Swap:/{print "swap used " $3 "/" $2 " MiB"}' | paste -sd ' ') disk free $(df -h / | awk 'NR==2{print $4}')"
|
||||
done ) &
|
||||
monitor=$!
|
||||
fi
|
||||
status=0
|
||||
sh scripts/with-hermetic-test-home.sh cargo nextest run --workspace --all-features --locked --profile ci || status=$?
|
||||
if [ -n "$monitor" ]; then kill "$monitor" 2>/dev/null || true; fi
|
||||
exit "$status"
|
||||
env:
|
||||
CARGO_BUILD_JOBS: '1'
|
||||
QA_PTY_DIAGNOSTICS_DIR: ${{ runner.temp }}/pty-failures
|
||||
CODEWHALE_EXT_HOST_TESTS: '1'
|
||||
CODEWHALE_EXT_HOST_BUN_TESTS: '1'
|
||||
# sccache 0.17 panics resolving its config directory under the
|
||||
# isolated Windows home before Cargo can compile or run any test.
|
||||
# Bypass only that optional cache; keep the full suite and isolation.
|
||||
RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
|
||||
# Give test threads the stack the product gives itself. main.rs runs
|
||||
# the owner thread and every tokio worker at
|
||||
# CODEWHALE_MAIN_STACK_BYTES (32 MiB) because the engine and
|
||||
# runtime-thread futures are genuinely deep. `#[tokio::test]` builds
|
||||
# its own runtime and never sees that, so tests ran the same code on
|
||||
# ~2 MiB (~1 MiB on Windows) — a configuration that never ships.
|
||||
# That gap is what aborted the whole Windows test binary with
|
||||
# STATUS_STACK_OVERFLOW in start_turn_accepts_dynamic_tools_and_
|
||||
# environment_id, masking every other Windows result (78afd8d3d4
|
||||
# Box::pin'd that one frame; the mismatch itself remained). std reads
|
||||
# this for any thread spawned without an explicit size, which covers
|
||||
# both libtest's per-test threads and tokio's workers. Test threads
|
||||
# hold 16 MiB: the engine-only chains they run measured a ~2.5 MiB
|
||||
# debug high-water, while the full ~16.5 MiB UI-loop chain that
|
||||
# forced 32 MiB lives in spawned binaries, which size their own
|
||||
# stacks explicitly and never read this variable.
|
||||
RUST_MIN_STACK: '16777216'
|
||||
- name: Retain sealed terminal failure diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: terminal-failures-${{ runner.os }}-${{ github.sha }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/pty-failures/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Shared-process workspace qualification
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice' && matrix.os == 'ubuntu-latest'
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice'
|
||||
shell: bash
|
||||
env:
|
||||
CARGO_BUILD_JOBS: '1'
|
||||
@@ -1128,83 +1008,193 @@ jobs:
|
||||
cat "$evidence/results.txt"
|
||||
exit "$failed"
|
||||
- name: Retain shared-process qualification evidence
|
||||
if: always() && github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice' && matrix.os == 'ubuntu-latest'
|
||||
if: always() && github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: shared-process-workspace-${{ github.sha }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/shared-process-workspace/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Export exact compiled-image Native containment receipt
|
||||
# JUnit is from the same full invocation: no second compile/test run.
|
||||
# The official entire CI run must be green before release can consume
|
||||
# this artifact. Missing/changed inputs or silent test skips are red.
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
|
||||
|
||||
# One sharded pipeline per OS (ci-test-suite.yml). Shard counts follow the
|
||||
# measured serial test time of each OS (2026-11-12, run 43216596942: Linux 19
|
||||
# min, macOS 24 min, Windows 25 min of test execution, on top of 30 to 65
|
||||
# minutes of single-job compilation), so a shard is five to eight minutes of
|
||||
# tests plus setup. Hosted macOS is the scarce pool, so it gets three shards.
|
||||
# The caller names ("Sharded tests (...)") never equal the required
|
||||
# "Test (windows-latest)" context, which test-windows-pr still produces on
|
||||
# every pull request.
|
||||
test-ubuntu:
|
||||
name: Sharded tests (ubuntu-latest)
|
||||
needs: changes
|
||||
if: >-
|
||||
needs.changes.outputs.heavy == 'true' &&
|
||||
needs.changes.outputs.scoped != 'true' &&
|
||||
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
|
||||
uses: ./.github/workflows/ci-test-suite.yml
|
||||
with:
|
||||
os: ubuntu-latest
|
||||
shards: 4
|
||||
shard_list: '[1,2,3,4]'
|
||||
build_jobs: '2'
|
||||
debug: line-tables-only
|
||||
|
||||
test-macos:
|
||||
name: Sharded tests (macos-latest)
|
||||
needs: changes
|
||||
if: >-
|
||||
needs.changes.outputs.heavy == 'true' &&
|
||||
needs.changes.outputs.scoped != 'true' &&
|
||||
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
|
||||
uses: ./.github/workflows/ci-test-suite.yml
|
||||
with:
|
||||
os: macos-latest
|
||||
shards: 3
|
||||
shard_list: '[1,2,3]'
|
||||
build_jobs: '1'
|
||||
debug: '0'
|
||||
|
||||
test-windows:
|
||||
name: Sharded tests (windows-latest)
|
||||
needs: changes
|
||||
if: >-
|
||||
needs.changes.outputs.heavy == 'true' &&
|
||||
needs.changes.outputs.scoped != 'true' &&
|
||||
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
|
||||
uses: ./.github/workflows/ci-test-suite.yml
|
||||
with:
|
||||
os: windows-latest
|
||||
shards: 4
|
||||
shard_list: '[1,2,3,4]'
|
||||
build_jobs: '2'
|
||||
debug: '0'
|
||||
|
||||
portable-boundaries:
|
||||
name: Portable boundaries
|
||||
needs: changes
|
||||
if: >-
|
||||
needs.changes.outputs.heavy == 'true' &&
|
||||
needs.changes.outputs.scoped != 'true' &&
|
||||
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch')
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
# Every run reads the GitHub Actions cache main wrote; only main writes (see Enable sccache).
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
|
||||
if [ "${GITHUB_REF}" = refs/heads/main ]; then rw=READ_WRITE; else rw=READ_ONLY; fi
|
||||
echo "SCCACHE_GHA_RW_MODE=${rw}" >> "${GITHUB_ENV}"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
bash scripts/ci-apt-install.sh libdbus-1-dev pkg-config bubblewrap apparmor-profiles
|
||||
sh scripts/prepare-linux-test-sandbox.sh
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-bin: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- name: Check portable config policy boundary
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -B scripts/test_check_command_config_proof.py
|
||||
sh scripts/check-portable-config.sh
|
||||
- name: Check portable core navigation boundary
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -B scripts/test_check_command_core_navigation_proof.py
|
||||
sh scripts/check-portable-core-navigation.sh
|
||||
|
||||
scoped-test:
|
||||
# workflow_dispatch with `crate` and/or `filter`: one package or one nextest
|
||||
# filterset on the chosen OSes (default ubuntu-latest) instead of the full
|
||||
# suite. `changes` skips every other job for such a run. The build uses the
|
||||
# same flags as the full build; only `-p` and `-E` narrow what runs.
|
||||
name: Scoped test (${{ matrix.os }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scoped == 'true'
|
||||
timeout-minutes: 90
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJSON(needs.changes.outputs.scoped_os) }}
|
||||
env:
|
||||
CARGO_BUILD_JOBS: ${{ matrix.os == 'macos-latest' && '1' || '2' }}
|
||||
CARGO_PROFILE_DEV_DEBUG: ${{ matrix.os == 'ubuntu-latest' && 'line-tables-only' || '0' }}
|
||||
CARGO_PROFILE_TEST_DEBUG: ${{ matrix.os == 'ubuntu-latest' && 'line-tables-only' || '0' }}
|
||||
CODEWHALE_EXT_HOST_TESTS: '1'
|
||||
CODEWHALE_EXT_HOST_BUN_TESTS: '1'
|
||||
RUST_MIN_STACK: '16777216'
|
||||
SCOPE_CRATE: ${{ inputs.crate }}
|
||||
SCOPE_FILTER: ${{ inputs.filter }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Point terminal failure diagnostics at the runner temp directory
|
||||
shell: bash
|
||||
run: echo "QA_PTY_DIAGNOSTICS_DIR=${RUNNER_TEMP}/pty-failures" >> "${GITHUB_ENV}"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux system dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
bash scripts/ci-apt-install.sh libdbus-1-dev pkg-config bubblewrap apparmor-profiles
|
||||
sh scripts/prepare-linux-test-sandbox.sh
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-bin: false
|
||||
save-if: false
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- name: Build matching compiled-host input for required Native containment receipts
|
||||
shell: bash
|
||||
run: bash scripts/ci/compiled-host-test-input.sh
|
||||
- name: Build canonical executable for acceptance tests
|
||||
# Acceptance tests in the TUI, engine and CLI crates, and any run that
|
||||
# names no crate, look for the codewhale binary next to the test
|
||||
# binaries; a narrow run on another crate does not need it.
|
||||
if: env.SCOPE_CRATE == '' || env.SCOPE_CRATE == 'codewhale-tui' || env.SCOPE_CRATE == 'codewhale-engine' || env.SCOPE_CRATE == 'codewhale-cli'
|
||||
shell: bash
|
||||
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
|
||||
- name: Run the scoped tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
git diff --exit-code
|
||||
git diff --cached --exit-code
|
||||
python3 scripts/compiled-host-native-receipt.py \
|
||||
--junit target/nextest/ci/junit.xml \
|
||||
--compiled-image "$CODEWHALE_COMPILED_HOST_TEST_BINARY" \
|
||||
--bundle crates/engine/extension-host/dist/codewhale-extension-host.mjs \
|
||||
--bun "$CODEWHALE_COMPILED_HOST_TEST_BUN" --source-sha "$GITHUB_SHA" \
|
||||
--image-platform "$CODEWHALE_COMPILED_HOST_TEST_PLATFORM" --image-arch "$CODEWHALE_COMPILED_HOST_TEST_ARCH" \
|
||||
--host-sha256 "$CODEWHALE_COMPILED_HOST_TEST_SHA256" \
|
||||
--runtime-sha256 "$CODEWHALE_COMPILED_HOST_TEST_RUNTIME_SHA256" \
|
||||
--bundle-sha256 "$CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256" \
|
||||
--output "$RUNNER_TEMP/native-compiled-host"
|
||||
- name: Retain exact compiled image and Native receipt
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
|
||||
args=(--all-features --locked --profile ci --no-tests=fail)
|
||||
if [ -n "${SCOPE_CRATE}" ]; then args+=(-p "${SCOPE_CRATE}"); else args+=(--workspace); fi
|
||||
if [ -n "${SCOPE_FILTER}" ]; then args+=(-E "${SCOPE_FILTER}"); fi
|
||||
sh scripts/with-hermetic-test-home.sh cargo nextest run "${args[@]}"
|
||||
- name: Retain sealed terminal failure diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: native-compiled-host-${{ runner.os }}-${{ runner.arch }}
|
||||
path: ${{ runner.temp }}/native-compiled-host/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Run doctests
|
||||
# The shared-process command already includes every workspace doctest.
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
|
||||
shell: bash
|
||||
run: sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked --doc
|
||||
env:
|
||||
RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
|
||||
RUST_MIN_STACK: '16777216'
|
||||
# The Ubuntu lint lane validates non-RSS backlog fields. Run the same
|
||||
# source-bound measurement on macOS so loss or growth of RSS evidence
|
||||
# fails closed instead of becoming an unsupported-field skip.
|
||||
# Only on the self-hosted Mac: there it reuses the warm build. Every
|
||||
# hosted Mac leg (fork PRs, or any PR with CW_SELF_HOSTED_MAC off) runs
|
||||
# it in the separate `macos-budget` job with its own timeout, because
|
||||
# on a cold hosted Mac it pushed Test past 90 minutes.
|
||||
- name: Check persistence-backlog RSS budget
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true'
|
||||
run: python3 scripts/check-persistence-backlog-budget.py
|
||||
- name: Lockfile drift guard
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
- name: Run Offline Eval Harness
|
||||
# The eval harness is OS-independent prompt/composition checking;
|
||||
# running it once (on the faster macOS leg, warm from the test build)
|
||||
# instead of once per desktop OS keeps the coverage while taking
|
||||
# ~2min off the Windows critical path. Self-hosted Mac only; hosted
|
||||
# Mac legs run it in `macos-budget` (see the RSS step above).
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true'
|
||||
run: cargo run -p codewhale-cli --bin codewhale --all-features -- eval
|
||||
- name: sccache stats
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: sccache --show-stats
|
||||
- name: Linux test location (CNB)
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request'
|
||||
run: echo "Linux workspace tests run on CNB for non-PR release/main pushes; pull requests run directly on Ubuntu."
|
||||
- name: Release this machine's build lock (self-hosted)
|
||||
if: always() && runner.environment == 'self-hosted'
|
||||
shell: bash
|
||||
run: rm -f "$RUNNER_TEMP/cw-build-lock.hold"
|
||||
name: terminal-failures-scoped-${{ runner.os }}-${{ github.sha }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/pty-failures/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
test-windows-pr:
|
||||
name: Test (windows-latest)
|
||||
@@ -1239,7 +1229,7 @@ jobs:
|
||||
echo "## Test (windows-latest) on a pull request"
|
||||
echo
|
||||
echo "Compiled every workspace target for Windows with warnings denied and ran the installer PATH helper tests."
|
||||
echo "The Windows and macOS test suites run in final validation, before a release, not on pull requests."
|
||||
echo "The full Windows test suite runs beside this check as Sharded tests (windows-latest); it is not a required check."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos-budget:
|
||||
@@ -1252,15 +1242,15 @@ jobs:
|
||||
# macOS leg runs on the self-hosted Mac (trusted event and
|
||||
# CW_SELF_HOSTED_MAC == 'true'), it runs these two steps on the warm
|
||||
# build instead. The name keeps "(fork PR)" so check contexts stay stable.
|
||||
# Hosted macOS allows only five concurrent jobs per account, and this job
|
||||
# doubled every pull request's claim on them, queueing the required Test
|
||||
# (macos-latest) leg for hours. So pull requests skip it and every push to
|
||||
# main still runs it; a regression shows up on the merge that caused it.
|
||||
# Hosted macOS allows only five concurrent jobs per account, and the macOS
|
||||
# test build and shards already take most of them, so pull requests skip
|
||||
# this job and every dispatch (main-landing issues one per push to main)
|
||||
# still runs it; a regression shows up on the merge that caused it. The
|
||||
# sharded macOS suite never runs on the self-hosted Mac, so these gates no
|
||||
# longer depend on CW_SELF_HOSTED_MAC.
|
||||
name: macOS budget and eval (fork PR)
|
||||
needs: changes
|
||||
# The explicit Ubuntu-only qualification has no warm Test macOS leg, so
|
||||
# retain these gates on the hosted fallback even when self-hosting is on.
|
||||
if: needs.changes.outputs.heavy == 'true' && github.event_name != 'pull_request' && ((github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice') || !(needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true'))
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.changes.outputs.scoped != 'true' && github.event_name != 'pull_request'
|
||||
# Cold hosted builds consumed 73 minutes before eval could start (F run
|
||||
# 37497498829). Match Test's compile allowance; RSS/eval limits stay intact.
|
||||
timeout-minutes: 210
|
||||
@@ -1282,9 +1272,10 @@ jobs:
|
||||
npm-wrapper-smoke:
|
||||
name: npm wrapper smoke
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# Same ternary rationale as the Test job: light legs only echo, so keep
|
||||
# them off macOS/Windows runners. On pull_request the matrix is
|
||||
if: needs.changes.outputs.scoped != 'true' && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch')
|
||||
# Light legs only echo, so keep them off macOS/Windows runners. The
|
||||
# ternary is safe: matrix.os is always a non-empty literal. On
|
||||
# pull_request the matrix is
|
||||
# ubuntu-only, so the required "npm wrapper smoke (ubuntu-latest)"
|
||||
# context is unaffected. Heavy pull requests execute the Ubuntu smoke
|
||||
# here; their branches may not be mirrored to CNB.
|
||||
@@ -1310,13 +1301,15 @@ jobs:
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
# Main-only GitHub Actions cache backend; see the Safety gate job.
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && github.ref == 'refs/heads/main'
|
||||
# Every run reads the GitHub Actions cache main wrote; only main writes (see Enable sccache).
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
- name: Enable sccache
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success' && matrix.os != 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
|
||||
if [ "${GITHUB_REF}" = refs/heads/main ]; then rw=READ_WRITE; else rw=READ_ONLY; fi
|
||||
echo "SCCACHE_GHA_RW_MODE=${rw}" >> "${GITHUB_ENV}"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
|
||||
- uses: actions/[email protected]
|
||||
@@ -1358,8 +1351,14 @@ jobs:
|
||||
mobile-smoke:
|
||||
name: Mobile runtime smoke
|
||||
needs: changes
|
||||
# Manual (workflow_dispatch) only; the release candidate builds android-arm64.
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
|
||||
# Not a required PR context. Pull requests run it only when the mobile
|
||||
# runtime surface changed (see the `mobile` filter above); every dispatch,
|
||||
# which main-landing issues for each push to main, runs it unconditionally
|
||||
# as the pre-release safety net.
|
||||
if: >-
|
||||
needs.changes.outputs.scoped != 'true' &&
|
||||
needs.changes.outputs.heavy == 'true' &&
|
||||
(github.event_name != 'pull_request' || needs.changes.outputs.mobile == 'true')
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1367,14 +1366,15 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
# Main-only GitHub Actions cache backend; see the Safety gate job.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
# Every run reads the GitHub Actions cache main wrote; only main writes (see Enable sccache).
|
||||
continue-on-error: true
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
|
||||
if [ "${GITHUB_REF}" = refs/heads/main ]; then rw=READ_WRITE; else rw=READ_ONLY; fi
|
||||
echo "SCCACHE_GHA_RW_MODE=${rw}" >> "${GITHUB_ENV}"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
|
||||
- name: Install Linux system dependencies
|
||||
@@ -1401,7 +1401,7 @@ jobs:
|
||||
actionlint:
|
||||
name: Workflow lint
|
||||
needs: changes
|
||||
if: needs.changes.outputs.actions == 'true'
|
||||
if: needs.changes.outputs.actions == 'true' && needs.changes.outputs.scoped != 'true'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1418,7 +1418,8 @@ jobs:
|
||||
# Check documentation builds without warnings
|
||||
docs:
|
||||
name: Documentation
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scoped != 'true' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1439,17 +1440,17 @@ jobs:
|
||||
cargo-deny:
|
||||
name: cargo-deny
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.scoped != 'true'
|
||||
uses: ./.github/workflows/cargo-deny.yml
|
||||
|
||||
security-audit:
|
||||
name: Security audit
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.scoped != 'true'
|
||||
uses: ./.github/workflows/security-audit.yml
|
||||
|
||||
ohos:
|
||||
name: OpenHarmony
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.scoped != 'true'
|
||||
uses: ./.github/workflows/ohos.yml
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
if: steps.sccache.outcome == 'success' && runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
if: steps.sccache.outcome == 'success' && runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
|
||||
@@ -11,11 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
|
||||
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
|
||||
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
|
||||
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
|
||||
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
|
||||
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
|
||||
|
||||
+16
-9
@@ -165,7 +165,7 @@ before starting a change; preserve any local work when updating your branch.
|
||||
|
||||
- Write tests for new functionality
|
||||
- Run the tests near your change (see [Fast local loop](#fast-local-loop));
|
||||
your pull request runs the Linux workspace suite and a Windows build for
|
||||
your pull request runs the full test suite on Linux, macOS and Windows for
|
||||
you (see [What happens after you open a pull request](#what-happens-after-you-open-a-pull-request))
|
||||
- Colocate unit tests beside the code they cover (standard Rust `#[cfg(test)]`
|
||||
modules), and add integration tests under the owning crate's `tests/`
|
||||
@@ -174,8 +174,8 @@ before starting a change; preserve any local work when updating your branch.
|
||||
|
||||
### Pre-push verification
|
||||
|
||||
These are the Rust commands your pull request runs on Linux (the `Lint` and
|
||||
`Test (ubuntu-latest)` checks). You do not need all of them before every
|
||||
These are the Rust commands your pull request runs on Linux (the `Lint` check
|
||||
and the Linux shards of `Sharded tests (ubuntu-latest)`). You do not need all of them before every
|
||||
push: run `cargo fmt`, then check and test the crates you touched (see
|
||||
[Fast local loop](#fast-local-loop)). Run the full set locally when a change
|
||||
spans many crates, or let the pull request run it for you:
|
||||
@@ -215,7 +215,7 @@ cargo install cargo-nextest --locked # once
|
||||
scripts/dev-test.sh tui
|
||||
scripts/dev-cargo.sh nextest run --workspace --all-features --locked
|
||||
|
||||
# 4. The Linux suite your PR runs:
|
||||
# 4. The suite your PR runs on Linux (hosted CI runs it as parallel shards):
|
||||
cargo test --workspace --all-features --locked
|
||||
```
|
||||
|
||||
@@ -340,6 +340,9 @@ maintainer to approve the first run for a brand-new GitHub account.
|
||||
| --- | --- | --- |
|
||||
| `Lint` (required) | `cargo fmt --check`, clippy on every target, and the fast drift checks (locales, READMEs, provider registry, dead-code and blocking-call budgets) | Run `cargo fmt --all` and the clippy command in [Pre-push verification](#pre-push-verification); the log names the file and the check |
|
||||
| `Test (windows-latest)` (required) | Compiles every workspace target for Windows with warnings denied, and the installer PATH helper tests | Usually a `cfg(windows)` path or an import only Windows sees; the log has the compiler error |
|
||||
| `Sharded tests (ubuntu-latest)`, `(macos-latest)`, `(windows-latest)` | The full workspace test suite, built once per OS and run as parallel shards of one nextest archive, plus the doctests; each OS ends with a `Suite check` that fails if the shards did not run every test exactly once | Open the failing `Shard k/N` job; the log names the test. The same test reproduces locally with `cargo nextest run -E 'test(name)'` |
|
||||
| `Safety gate`, `MSRV check (1.89)`, `npm wrapper smoke`, `Portable boundaries` | The hermetic safety and authorization tests, a build on the minimum Rust version, the npm wrapper install smoke and the portable-core boundary checks | The log names the failing test or the compiler error |
|
||||
| `Mobile runtime smoke` | The `codewhale serve --mobile` HTTP and event-stream smoke, only when the mobile runtime surface changed | The log shows the failing request |
|
||||
| `Version drift` | Version files, the `feat:` release-note rule, release helper tests and contributor credit | The credit step only reports: a maintainer adds your credit when the PR merges |
|
||||
|
||||
Smaller checks cover the VS Code extension, integrations, the extension host
|
||||
@@ -347,11 +350,15 @@ and plugin conversion, plus workflow lint when you change `.github/workflows`.
|
||||
A documentation-only change skips the Rust builds but still reports the
|
||||
required checks, so it can merge.
|
||||
|
||||
Your pull request does not run the test suites, the safety gate, MSRV, the npm
|
||||
wrapper smoke, the slow runtime-contract and persistence-backlog measurements,
|
||||
the dependency audits or the OpenHarmony build. Maintainers run those once per
|
||||
release, on the exact commit being released. If that run finds a problem your
|
||||
change introduced, we fix it on `main`; we will not ask you to chase it.
|
||||
Only `Lint` and `Test (windows-latest)` are required. Every other job above is
|
||||
informational for a contributor's pull request: a red one tells you and the
|
||||
maintainers something, and never blocks the merge. Your pull request does not
|
||||
run the slow runtime-contract and persistence-backlog measurements, the macOS
|
||||
budget and eval job, the dependency audits or the OpenHarmony build; those run
|
||||
on `main` and for each release, on the exact commit. If one of them finds a
|
||||
problem your change introduced, we fix it on `main`; we will not ask you to
|
||||
chase it. A newer push to your branch cancels the older run of the same pull
|
||||
request, so the runners always work on your latest commit.
|
||||
|
||||
A red check that your change did not cause is not your job to fix. We rerun it
|
||||
or fix it on `main`, and we never close a PR because CI is slow or flaky. Your
|
||||
|
||||
Generated
-1
@@ -1179,7 +1179,6 @@ dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-schema",
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum",
|
||||
|
||||
@@ -180,7 +180,6 @@ async fn persist_exec_checkpoint(
|
||||
Some(write_request.as_ref()),
|
||||
&captured_source,
|
||||
&captured_owner,
|
||||
snapshot.agent_preset.as_deref(),
|
||||
)?;
|
||||
Ok(publication)
|
||||
},
|
||||
@@ -240,7 +239,6 @@ pub async fn run_exec_agent(
|
||||
// allowlist; this also skips workspace snapshots, LSP and the automation
|
||||
// store, and writes the one-shot `--json` receipt shape.
|
||||
one_shot: bool,
|
||||
conversation_mode: Option<String>,
|
||||
) -> Result<()> {
|
||||
use codewhale_config::AppMode;
|
||||
use codewhale_engine::compaction::CompactionConfig;
|
||||
@@ -310,10 +308,6 @@ pub async fn run_exec_agent(
|
||||
let mut exec_write_receipt = resume_session
|
||||
.as_ref()
|
||||
.and_then(|saved| saved.write_receipt.clone());
|
||||
let exec_agent_preset = resume_session.as_ref().map_or_else(
|
||||
|| conversation_mode.clone(),
|
||||
|saved| saved.agent_preset.clone(),
|
||||
);
|
||||
let exec_is_fresh = resume_session.is_none();
|
||||
|
||||
let exec_session_id = fleet_capture
|
||||
@@ -790,7 +784,6 @@ pub async fn run_exec_agent(
|
||||
model: saved.metadata.model,
|
||||
workspace: saved.metadata.workspace,
|
||||
mode,
|
||||
agent_preset: saved.agent_preset,
|
||||
})
|
||||
.await?;
|
||||
loaded_session_id = Some(saved_id.clone());
|
||||
@@ -799,24 +792,6 @@ pub async fn run_exec_agent(
|
||||
}
|
||||
}
|
||||
|
||||
if loaded_session_id.is_none() && exec_agent_preset.is_some() {
|
||||
let snapshot = engine_handle.get_session_snapshot().await?;
|
||||
engine_handle
|
||||
.send(Op::SyncSession {
|
||||
expected: None,
|
||||
control: codewhale_engine::core::ops::SessionControlId::new(),
|
||||
session_id: Some(snapshot.session_id),
|
||||
messages: Vec::new(),
|
||||
system_prompt: None,
|
||||
system_prompt_override: false,
|
||||
model: snapshot.model,
|
||||
workspace: snapshot.workspace,
|
||||
mode,
|
||||
agent_preset: exec_agent_preset.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Lifecycle outbox (`[lifecycle_outbox]`): headless `codewhale exec`
|
||||
// gets the same turn boundaries as the interactive TUI. Disabled
|
||||
// (all emits no-op) when the config has no path.
|
||||
@@ -1465,7 +1440,6 @@ pub async fn run_exec_agent(
|
||||
None,
|
||||
source,
|
||||
owner,
|
||||
exec_agent_preset.as_deref(),
|
||||
)
|
||||
});
|
||||
match saved {
|
||||
@@ -2153,7 +2127,6 @@ mod tests {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "exec".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let caller = SessionCheckpointCaller {
|
||||
session_id: snapshot.session_id.clone(),
|
||||
@@ -2222,7 +2195,6 @@ mod tests {
|
||||
None,
|
||||
source.as_ref().unwrap(),
|
||||
snapshot.source.as_ref().unwrap(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
source = Some(publication.write_receipt());
|
||||
@@ -2346,58 +2318,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_session_file_keeps_the_conversation_mode_it_was_started_in() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let manager =
|
||||
codewhale_engine::session_manager::SessionManager::new(tmp.path().join("exec-mode"))
|
||||
.unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let messages: Vec<codewhale_models::Message> = serde_json::from_value(serde_json::json!([
|
||||
{"role":"user","content":[{"type":"text","text":"hello"}]}
|
||||
]))
|
||||
.unwrap();
|
||||
let owner =
|
||||
codewhale_engine::core::ops::SessionSnapshotSource::turn("exec-turn".into(), None);
|
||||
let save = |source: &codewhale_engine::session_manager::SessionWriteReceipt,
|
||||
preset: Option<&str>| {
|
||||
crate::exec_agent::persist_exec_session(
|
||||
&messages,
|
||||
"model",
|
||||
crate::exec_agent::PersistedProviderRoute {
|
||||
kind: "custom",
|
||||
id: None,
|
||||
},
|
||||
&workspace,
|
||||
&None,
|
||||
Some("exec-mode-session"),
|
||||
1,
|
||||
None,
|
||||
&manager,
|
||||
&[],
|
||||
None,
|
||||
source,
|
||||
&owner,
|
||||
preset,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let first = save(
|
||||
&manager.expect_absent_session("exec-mode-session").unwrap(),
|
||||
Some("creator"),
|
||||
);
|
||||
let saved = manager.load_session_snapshot("exec-mode-session").unwrap();
|
||||
assert_eq!(saved.agent_preset.as_deref(), Some("creator"));
|
||||
|
||||
save(&first.write_receipt(), None);
|
||||
let saved = manager.load_session_snapshot("exec-mode-session").unwrap();
|
||||
assert_eq!(
|
||||
saved.agent_preset.as_deref(),
|
||||
Some("creator"),
|
||||
"a later save without the mode does not turn the conversation into Standard"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_child_settlement_deadline_bounds_a_stalled_engine_probe() {
|
||||
let mut engine = mock_engine_handle();
|
||||
@@ -3457,7 +3377,6 @@ fn persist_exec_session(
|
||||
checkpoint: Option<&codewhale_engine::core::events::SessionCheckpointRequest>,
|
||||
source: &session_manager::SessionWriteReceipt,
|
||||
owner: &codewhale_engine::core::ops::SessionSnapshotSource,
|
||||
agent_preset: Option<&str>,
|
||||
) -> Result<session_manager::SessionPublication> {
|
||||
let captured = source.capture(session_manager::SessionWriteOwner::Engine(owner.clone()))?;
|
||||
if checkpoint.is_some() {
|
||||
@@ -3503,9 +3422,6 @@ fn persist_exec_session(
|
||||
Some("exec"),
|
||||
)
|
||||
};
|
||||
if saved.agent_preset.is_none() {
|
||||
saved.agent_preset = agent_preset.map(str::to_string);
|
||||
}
|
||||
saved.write_receipt = Some(captured);
|
||||
stamp_exec_session_metadata(
|
||||
&mut saved,
|
||||
|
||||
@@ -7649,7 +7649,6 @@ async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapsho
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".to_string(),
|
||||
agent_preset: None,
|
||||
};
|
||||
if let Some(tx) = tx.lock().ok().and_then(|mut guard| guard.take()) {
|
||||
let _ = tx.send(snapshot);
|
||||
|
||||
@@ -156,10 +156,6 @@ struct Cli {
|
||||
session_id: Option<String>,
|
||||
#[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
|
||||
prompt_flag: Option<String>,
|
||||
/// Conversation mode for a new interactive session (for example
|
||||
/// `creator`). With `--resume` or `--continue` it must match the saved mode.
|
||||
#[arg(long = "conversation-mode", value_name = "MODE")]
|
||||
conversation_mode: Option<String>,
|
||||
/// Per-run config override (`KEY=VALUE`), repeatable, never saved.
|
||||
/// Runtime keys: provider, model/default_text_model, verbosity,
|
||||
/// approval_policy, sandbox_mode, telemetry. Dedicated flags win;
|
||||
@@ -2968,10 +2964,6 @@ fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
|
||||
forwarded.push("--resume".to_string());
|
||||
forwarded.push(session_id.to_string());
|
||||
}
|
||||
if let Some(mode) = cli.conversation_mode.as_deref() {
|
||||
forwarded.push("--conversation-mode".to_string());
|
||||
forwarded.push(mode.to_string());
|
||||
}
|
||||
|
||||
let prompt =
|
||||
cli.prompt_flag
|
||||
@@ -13340,24 +13332,6 @@ verbosity = "concise"
|
||||
assert!(root_tui_passthrough(&cli).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_conversation_mode_is_forwarded_and_is_not_prompt_text() {
|
||||
let cli = parse_ok(&["codewhale", "--conversation-mode", "creator"]);
|
||||
|
||||
assert_eq!(cli.conversation_mode.as_deref(), Some("creator"));
|
||||
assert!(cli.prompt.is_empty());
|
||||
assert_eq!(
|
||||
root_tui_passthrough(&cli).unwrap(),
|
||||
vec!["--conversation-mode", "creator"]
|
||||
);
|
||||
|
||||
let cli = parse_ok(&["codewhale", "--conversation-mode", "creator", "hello"]);
|
||||
assert_eq!(
|
||||
root_tui_passthrough(&cli).unwrap(),
|
||||
vec!["--conversation-mode", "creator", "--prompt", "hello"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_top_level_continue_for_interactive_resume() {
|
||||
let cli = parse_ok(&["codewhale", "--continue"]);
|
||||
|
||||
@@ -1361,7 +1361,6 @@ pub struct SessionSyncPayload {
|
||||
pub model: String,
|
||||
pub workspace: PathBuf,
|
||||
pub mode: CommandMode,
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
/// `/branch` success projection (`session/branch.rs`). The handler composes
|
||||
@@ -1487,15 +1486,10 @@ pub trait CommandSessionLifecycleContext {
|
||||
/// `/fork <session_id|prefix>`: explicit-source fork.
|
||||
fn fork_from(&mut self, session_id_or_prefix: &str) -> Result<SessionForkFromReceipt, String>;
|
||||
|
||||
/// `/new [<mode>] [--force]`: fresh-session transition. The caller has
|
||||
/// already parsed the arguments and applied the transition-blocked gate;
|
||||
/// `conversation_mode` is the id the person named, absent for Standard.
|
||||
/// The host validates it against the Engine's mode table.
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> Result<SessionNewReceipt, String>;
|
||||
/// `/new [--force]`: fresh-session transition. The caller has already
|
||||
/// parsed the argument and applied the transition-blocked gate; blocker,
|
||||
/// busy-work-state, and success handling match the baseline.
|
||||
fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String>;
|
||||
|
||||
/// `/load <path>`: resolve the path (separator-bearing direct vs
|
||||
/// workspace-relative) and validate the saved-session shape without
|
||||
|
||||
@@ -141,9 +141,6 @@ pub enum ConfigAction {
|
||||
// Views and pickers.
|
||||
OpenConfigView,
|
||||
OpenModePicker,
|
||||
SetConversationMode {
|
||||
id: String,
|
||||
},
|
||||
OpenThemePicker,
|
||||
OpenStatusPicker,
|
||||
OpenProviderPicker,
|
||||
|
||||
@@ -2333,14 +2333,10 @@ impl CommandSessionLifecycleContext for FakeLifecycle {
|
||||
.clone()
|
||||
.ok_or_else(|| format!("unexpected fork_from({id}) on empty fake"))?
|
||||
}
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> Result<SessionNewReceipt, String> {
|
||||
self.fresh.clone().ok_or_else(|| {
|
||||
format!("unexpected fresh_session({force}, {conversation_mode:?}) on empty fake")
|
||||
})?
|
||||
fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
|
||||
self.fresh
|
||||
.clone()
|
||||
.ok_or_else(|| format!("unexpected fresh_session({force}) on empty fake"))?
|
||||
}
|
||||
fn load_session(&mut self, path: &str) -> Result<PathBuf, String> {
|
||||
self.load
|
||||
@@ -2380,7 +2376,6 @@ fn lifecycle_sync_payload(session_id: Option<&str>) -> SessionSyncPayload {
|
||||
model: "lifecycle-model".to_string(),
|
||||
workspace: PathBuf::from("/workspace/lifecycle"),
|
||||
mode: CommandMode::Plan,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2460,7 +2455,7 @@ fn lifecycle_facet_is_object_safe_and_transports_every_outcome() {
|
||||
explicit.sync.workspace,
|
||||
PathBuf::from("/workspace/lifecycle")
|
||||
);
|
||||
let fresh = fake.fresh_session(true, None).expect("fresh ok");
|
||||
let fresh = fake.fresh_session(true).expect("fresh ok");
|
||||
assert_eq!(fresh.truncated_id, "new-id");
|
||||
assert_eq!(fresh.sync.messages.len(), 1);
|
||||
let loaded = fake.load_session("loaded.json").expect("load ok");
|
||||
|
||||
@@ -417,7 +417,6 @@ fn synced_conversation() -> SessionSyncPayload {
|
||||
model: "model".to_string(),
|
||||
workspace: std::path::PathBuf::from("/tmp/undo-workspace"),
|
||||
mode: codewhale_command_contract::CommandMode::Agent,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
struct UndoFacet {
|
||||
|
||||
@@ -219,9 +219,6 @@ libc = "0.2"
|
||||
windows-sys = { version = "0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_System_IO", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Threading", "Win32_System_SystemServices", "Win32_System_WindowsProgramming"] }
|
||||
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies]
|
||||
arboard = { version = "3.4", features = ["wayland-data-control"] }
|
||||
|
||||
[target.'cfg(not(target_env = "ohos"))'.dependencies]
|
||||
portable-pty = "0.9"
|
||||
|
||||
|
||||
@@ -3504,7 +3504,6 @@ impl Engine {
|
||||
.as_deref()
|
||||
.map(crate::project_context::InstructionAssembly::receipt_json),
|
||||
mode: self.current_mode.as_setting().to_string(),
|
||||
agent_preset: self.session.agent_preset.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4504,7 +4503,6 @@ impl Engine {
|
||||
model,
|
||||
workspace,
|
||||
mode,
|
||||
agent_preset,
|
||||
} => {
|
||||
if expected
|
||||
.as_deref()
|
||||
@@ -4525,9 +4523,6 @@ impl Engine {
|
||||
} else {
|
||||
previous_session_id.clone()
|
||||
};
|
||||
let keep_conversation_mode = next_session_id == previous_session_id
|
||||
&& !self.session.messages.is_empty()
|
||||
&& self.session.agent_preset != agent_preset;
|
||||
let closed_session_id = (next_session_id != previous_session_id)
|
||||
.then_some(previous_session_id);
|
||||
// SyncSession installs a conversation's identity; an id
|
||||
@@ -4594,14 +4589,6 @@ impl Engine {
|
||||
self.session.model = model;
|
||||
self.session.workspace = workspace.clone();
|
||||
self.current_mode = mode;
|
||||
if keep_conversation_mode {
|
||||
tracing::warn!(
|
||||
target: "conversation_mode",
|
||||
"ignored a conversation mode change on a conversation that already has messages"
|
||||
);
|
||||
} else {
|
||||
self.session.agent_preset = agent_preset;
|
||||
}
|
||||
self.config.model.clone_from(&self.session.model);
|
||||
self.config.workspace = workspace.clone();
|
||||
if plugin_workspace_changed {
|
||||
|
||||
@@ -643,7 +643,6 @@ async fn edit_last_turn_restores_the_exchange_when_the_replacement_never_starts(
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -665,111 +664,6 @@ async fn edit_last_turn_restores_the_exchange_when_the_replacement_never_starts(
|
||||
run.await.unwrap();
|
||||
}
|
||||
|
||||
async fn sync_with_mode(
|
||||
handle: &EngineHandle,
|
||||
workspace: &std::path::Path,
|
||||
session_id: &str,
|
||||
messages: Vec<Message>,
|
||||
agent_preset: Option<&str>,
|
||||
) {
|
||||
handle
|
||||
.send(Op::SyncSession {
|
||||
expected: None,
|
||||
control: crate::core::ops::SessionControlId::new(),
|
||||
session_id: Some(session_id.to_string()),
|
||||
messages,
|
||||
system_prompt: None,
|
||||
system_prompt_override: false,
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: agent_preset.map(str::to_string),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn user_text(text: &str) -> Message {
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_session_installs_the_conversation_mode_on_the_engine_session() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (engine, handle) = quiet_engine(deterministic_engine_config(tmp.path()));
|
||||
let run = tokio::spawn(engine.run());
|
||||
assert_eq!(session_snapshot(&handle).await.agent_preset, None);
|
||||
sync_with_mode(
|
||||
&handle,
|
||||
tmp.path(),
|
||||
"blank-one",
|
||||
Vec::new(),
|
||||
Some("creator"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset.as_deref(),
|
||||
Some("creator")
|
||||
);
|
||||
sync_with_mode(&handle, tmp.path(), "blank-one", Vec::new(), None).await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset,
|
||||
None,
|
||||
"a still-blank conversation may change its mode"
|
||||
);
|
||||
sync_with_mode(
|
||||
&handle,
|
||||
tmp.path(),
|
||||
"other",
|
||||
vec![user_text("hi")],
|
||||
Some("creator"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset.as_deref(),
|
||||
Some("creator"),
|
||||
"another conversation installs its own mode"
|
||||
);
|
||||
sync_with_mode(&handle, tmp.path(), "fresh", Vec::new(), None).await;
|
||||
assert_eq!(session_snapshot(&handle).await.agent_preset, None);
|
||||
handle.send(Op::Shutdown).await.unwrap();
|
||||
run.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_sync_of_the_same_conversation_with_messages_cannot_change_its_conversation_mode() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (engine, handle) = quiet_engine(deterministic_engine_config(tmp.path()));
|
||||
let run = tokio::spawn(engine.run());
|
||||
let history = vec![user_text("first message")];
|
||||
sync_with_mode(
|
||||
&handle,
|
||||
tmp.path(),
|
||||
"locked",
|
||||
history.clone(),
|
||||
Some("creator"),
|
||||
)
|
||||
.await;
|
||||
sync_with_mode(&handle, tmp.path(), "locked", history.clone(), None).await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset.as_deref(),
|
||||
Some("creator")
|
||||
);
|
||||
sync_with_mode(&handle, tmp.path(), "locked", history, Some("minimal")).await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset.as_deref(),
|
||||
Some("creator")
|
||||
);
|
||||
handle.send(Op::Shutdown).await.unwrap();
|
||||
run.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_mcp_config_is_reported_instead_of_silently_empty() {
|
||||
// C02-18: an unreadable config still yields an empty, reloadable pool,
|
||||
|
||||
@@ -1044,7 +1044,6 @@ async fn unchanged_compaction_config_is_acknowledged_silently() {
|
||||
model: current.model.clone(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync restored session");
|
||||
@@ -1240,7 +1239,6 @@ async fn sync_session_restores_current_mode() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Plan,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -1323,7 +1321,6 @@ async fn sync_session_without_prompt_repins_full_system_prompt_on_next_turn() {
|
||||
model: codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string(),
|
||||
workspace: workspace.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync fresh session without a persisted prompt");
|
||||
@@ -1425,7 +1422,6 @@ async fn sync_session_same_id_does_not_finalize_live_worker() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("install session");
|
||||
@@ -1448,7 +1444,6 @@ async fn sync_session_same_id_does_not_finalize_live_worker() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("re-sync same session");
|
||||
@@ -1492,7 +1487,6 @@ async fn sync_session_different_id_finalizes_live_worker() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("install session-a");
|
||||
@@ -1518,7 +1512,6 @@ async fn sync_session_different_id_finalizes_live_worker() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("switch to session-b");
|
||||
@@ -1577,7 +1570,6 @@ async fn sync_session_migrates_one_checkpoint_and_strips_its_system_carrier() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync compacted session");
|
||||
|
||||
@@ -36,7 +36,6 @@ async fn sync_session_projects_persisted_subagent_handoff_for_headless_restore()
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -177,7 +176,6 @@ async fn edit_last_turn_preserves_current_mode() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -336,7 +334,6 @@ async fn edit_last_turn_cuts_at_user_prompt_before_tool_results() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -454,7 +451,6 @@ async fn edit_last_turn_without_user_prompt_errors_and_sends_nothing() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -573,7 +569,6 @@ async fn edit_last_turn_without_user_prompt_errors_and_sends_nothing() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync unsupported user session");
|
||||
|
||||
@@ -117,8 +117,6 @@ pub struct SessionSnapshot {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instruction_assembly: Option<serde_json::Value>,
|
||||
pub mode: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
/// Live context-window posture for one thread, computed where the session
|
||||
@@ -470,7 +468,6 @@ pub enum Op {
|
||||
model: String,
|
||||
workspace: PathBuf,
|
||||
mode: AppMode,
|
||||
agent_preset: Option<String>,
|
||||
},
|
||||
|
||||
/// Rewind only the exact conversation observed by the caller. The Engine
|
||||
|
||||
@@ -1261,7 +1261,6 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op {
|
||||
model,
|
||||
workspace,
|
||||
mode,
|
||||
agent_preset,
|
||||
..
|
||||
} => wire_op::Op::SyncSession {
|
||||
expected: expected.as_ref().map(to_value),
|
||||
@@ -1272,7 +1271,6 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op {
|
||||
model: model.clone(),
|
||||
workspace: workspace.clone(),
|
||||
mode: app_mode_str(*mode).to_string(),
|
||||
agent_preset: agent_preset.clone(),
|
||||
},
|
||||
Op::RewindConversation {
|
||||
expected, messages, ..
|
||||
@@ -1741,7 +1739,6 @@ mod tests {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
};
|
||||
let (tx, mut receive) = tokio::sync::oneshot::channel();
|
||||
let op = Op::RewindConversation {
|
||||
|
||||
@@ -408,8 +408,6 @@ pub struct Session {
|
||||
/// boundaries so the next send can compact on last-turn pressure
|
||||
/// (#5577). Cleared when history is rewritten (compaction, restore).
|
||||
pub latest_parent_input_tokens: Option<u32>,
|
||||
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
/// Cumulative usage statistics for a session.
|
||||
@@ -483,7 +481,6 @@ impl Session {
|
||||
tool_activation_cache: ToolActivationCache::default(),
|
||||
messages_revision: 0,
|
||||
latest_parent_input_tokens: None,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1295,7 +1295,6 @@ fn the_wire_result_is_the_tools_text_and_json_and_says_when_it_was_cut() {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let hash = SessionCheckpointRequest::prefix_hash(&snapshot.messages).unwrap();
|
||||
let make = |projection| {
|
||||
|
||||
@@ -1669,10 +1669,6 @@ pub trait McpTransport: Send + Sync {
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>>;
|
||||
|
||||
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
self.send(msg).await
|
||||
}
|
||||
|
||||
/// Record the protocol revision negotiated at `initialize`. Only the
|
||||
/// Streamable HTTP transport uses it (the `MCP-Protocol-Version` header on
|
||||
/// subsequent requests); stdio and legacy SSE have no header channel, so
|
||||
@@ -3129,30 +3125,6 @@ impl McpConnection {
|
||||
result
|
||||
}
|
||||
|
||||
async fn answer_server_request(&mut self, reply: &serde_json::Value) {
|
||||
let result = match serde_json::to_vec(reply) {
|
||||
Ok(bytes) => {
|
||||
let cancel_token = self.cancel_token.clone();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => {
|
||||
Err(anyhow::anyhow!("MCP connection was cancelled"))
|
||||
}
|
||||
result = self.transport.send_reply(bytes) => result,
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
};
|
||||
if let Err(err) = result {
|
||||
tracing::debug!(
|
||||
target: "mcp",
|
||||
server = %self.name,
|
||||
error = %err,
|
||||
"could not answer an MCP server request"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handshake and discovery receive: each frame wait is bounded by the read
|
||||
/// knob, and a server that stays silent past it is treated as dead.
|
||||
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
|
||||
@@ -3230,22 +3202,6 @@ impl McpConnection {
|
||||
{
|
||||
self.catalog_stale = true;
|
||||
}
|
||||
if let Some(method) = value.get("method") {
|
||||
if let Some(id) = value.get("id") {
|
||||
let method = method.as_str().unwrap_or_default();
|
||||
let reply = if method == "ping" {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {"code": -32601, "message": format!("Method not found: {method}")}
|
||||
})
|
||||
};
|
||||
self.answer_server_request(&reply).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if response_id_matches(value.get("id"), &expected_id) {
|
||||
// Marks the connection stale so it is rebuilt, but this is a
|
||||
// reply to the request, so it never qualifies for a replay.
|
||||
|
||||
@@ -177,21 +177,6 @@ impl McpTransport for HttpTransport {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
match &mut self.mode {
|
||||
HttpTransportMode::Streamable(transport) => {
|
||||
transport.send(msg).await.map_err(|err| match err {
|
||||
StreamableSendError::Incompatible(detail)
|
||||
| StreamableSendError::StaleSession(detail) => anyhow::anyhow!(
|
||||
"MCP Streamable HTTP refused a reply to a server request: {detail}"
|
||||
),
|
||||
StreamableSendError::Other(err) => err,
|
||||
})
|
||||
}
|
||||
HttpTransportMode::Sse(transport) => transport.send(msg).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
match &mut self.mode {
|
||||
HttpTransportMode::Streamable(transport) => transport.recv().await,
|
||||
|
||||
@@ -3314,80 +3314,6 @@ async fn call_method_skips_notifications_and_unmatched_responses() {
|
||||
assert_eq!(sent[0]["method"], "tools/call");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_answers_server_requests_instead_of_taking_them_as_the_reply() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let transport = ScriptedValueTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
responses: VecDeque::from([
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": "1", "method": "roots/list"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
|
||||
]),
|
||||
};
|
||||
let mut conn = test_connection(Box::new(transport));
|
||||
|
||||
let result = conn
|
||||
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, serde_json::json!({"ok": true}));
|
||||
let sent = sent.lock().unwrap();
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert_eq!(
|
||||
sent[1],
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}})
|
||||
);
|
||||
assert_eq!(sent[2]["id"], "1");
|
||||
assert_eq!(sent[2]["error"]["code"], -32601);
|
||||
}
|
||||
|
||||
struct ReplyRefusingTransport {
|
||||
sent: Arc<Mutex<Vec<serde_json::Value>>>,
|
||||
responses: VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpTransport for ReplyRefusingTransport {
|
||||
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
let msg: serde_json::Value = serde_json::from_slice(&msg)?;
|
||||
anyhow::ensure!(msg.get("method").is_some(), "status=405 Method Not Allowed");
|
||||
self.sent.lock().unwrap().push(msg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
self.responses
|
||||
.pop_front()
|
||||
.context("scripted transport exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_keeps_its_reply_when_answering_a_server_request_fails() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let transport = ReplyRefusingTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
responses: VecDeque::from([
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
|
||||
]),
|
||||
};
|
||||
let mut conn = test_connection(Box::new(transport));
|
||||
|
||||
let result = conn
|
||||
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, serde_json::json!({"ok": true}));
|
||||
assert_eq!(conn.state, ConnectionState::Ready);
|
||||
let sent = sent.lock().unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0]["method"], "tools/call");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_invalid_json_includes_server_output_preview() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -6844,60 +6770,6 @@ async fn custom_headers_applied_to_get_preflight() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_reply_to_a_server_request_does_not_fall_back_to_sse() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _lock = lock_mcp_loopback_tests().await;
|
||||
let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*,127.0.0.1");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let requests_srv = Arc::clone(&requests);
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = socket.read(&mut buf).await.unwrap();
|
||||
let request_line = String::from_utf8_lossy(&buf[..n])
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
requests_srv.lock().unwrap().push(request_line);
|
||||
socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!("http://{addr}/mcp");
|
||||
let mut transport = HttpTransport::new(
|
||||
test_mcp_http_client(&url),
|
||||
url,
|
||||
tokio_util::sync::CancellationToken::new(),
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
let result = transport
|
||||
.send_reply(json_frame(
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}}),
|
||||
))
|
||||
.await;
|
||||
|
||||
server.abort();
|
||||
assert!(result.is_err(), "a refused reply reports the refusal");
|
||||
let requests = requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1, "requests seen: {requests:?}");
|
||||
assert!(requests[0].starts_with("POST "), "{requests:?}");
|
||||
}
|
||||
|
||||
// === add_runtime_server_config conflict tests ===
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9161,7 +9161,6 @@ impl RuntimeThreadManager {
|
||||
thread.auto_approve,
|
||||
)
|
||||
.mode,
|
||||
agent_preset: thread.agent_preset.clone(),
|
||||
})
|
||||
.await?;
|
||||
let observed = engine.get_session_snapshot().await?;
|
||||
@@ -14917,7 +14916,6 @@ source_usage_records: Default::default(),
|
||||
thread.auto_approve,
|
||||
)
|
||||
.mode,
|
||||
agent_preset: thread.agent_preset.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to sync thread session: {e}"))?;
|
||||
|
||||
@@ -19625,7 +19625,6 @@ fn answer_session_snapshot(
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: String::new(),
|
||||
agent_preset: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19705,7 +19704,6 @@ async fn compaction_lifecycle_emits_item_events_with_compaction_counts() -> Resu
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let (reply, received) = oneshot::channel();
|
||||
let withdraw = CancellationToken::new();
|
||||
@@ -22039,7 +22037,6 @@ mod runtime_image_inputs {
|
||||
model: stored_thread.model.clone(),
|
||||
workspace: stored_thread.workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await?;
|
||||
let followup = reopened
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use super::*;
|
||||
use crate::conversation_modes::{CREATOR, CreatorAvailabilityGuard, ModeRefusal, STANDARD};
|
||||
use crate::core::engine::Engine;
|
||||
use crate::llm_client::mock::MockLlmClient;
|
||||
|
||||
fn refusal_code(error: &anyhow::Error) -> &'static str {
|
||||
error
|
||||
@@ -134,58 +132,3 @@ async fn creator_is_listed_unavailable_until_it_loads_its_tools() -> Result<()>
|
||||
assert_eq!(refusal_code(&refused), ModeRefusal::MODE_UNAVAILABLE);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_runtime_thread_sync_keeps_its_conversation_mode() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let manager = test_manager(tmp.path().to_path_buf())?;
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let thread = manager.create_thread(create_request(Some(CREATOR))).await?;
|
||||
let mock = Arc::new(MockLlmClient::new(Vec::new()));
|
||||
let (engine, handle) = Engine::new_with_model_client(
|
||||
EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
model: thread.model.clone(),
|
||||
subagents_enabled: false,
|
||||
snapshots_enabled: false,
|
||||
memory_enabled: false,
|
||||
terminal_chrome_enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
&Config::default(),
|
||||
mock,
|
||||
);
|
||||
manager
|
||||
.install_test_engine(&thread.id, handle.clone())
|
||||
.await?;
|
||||
let run = tokio::spawn(engine.run());
|
||||
let messages = vec![Message {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "first".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}];
|
||||
let session = crate::session_manager::create_saved_session(
|
||||
&messages,
|
||||
&thread.model,
|
||||
&thread.workspace,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
manager
|
||||
.synchronize_history_operation(&thread.id, &session)
|
||||
.await?;
|
||||
let observed = handle.get_session_snapshot().await?;
|
||||
assert_eq!(observed.agent_preset.as_deref(), Some(CREATOR));
|
||||
manager
|
||||
.synchronize_history_operation(&thread.id, &session)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
handle.get_session_snapshot().await?.agent_preset.as_deref(),
|
||||
Some(CREATOR)
|
||||
);
|
||||
handle.send(Op::Shutdown).await?;
|
||||
tokio::time::timeout(Duration::from_secs(10), run).await??;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2090,27 +2090,9 @@ pub struct SavedSession {
|
||||
/// record resume, export, and the Runtime API read back.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub turn_outcomes: Vec<SavedTurnOutcome>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "read_saved_agent_preset"
|
||||
)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
fn read_saved_agent_preset<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(
|
||||
match <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)? {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(serde_json::Value::String(id)) => Some(id),
|
||||
Some(other) => Some(other.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
impl SavedSession {
|
||||
pub fn capture_publication(
|
||||
&mut self,
|
||||
|
||||
@@ -362,25 +362,3 @@
|
||||
let loaded = manager.load_session(&saved.metadata.id).expect("load");
|
||||
assert_eq!(loaded.agent_preset.as_deref(), Some("creator"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_edited_agent_preset_still_loads_and_never_becomes_standard() {
|
||||
let session = create_saved_session(&[], "test-model", std::path::Path::new("."), 0, None);
|
||||
let base = serde_json::to_value(&session).expect("serialize");
|
||||
let big = "x".repeat(1_000_000);
|
||||
for (edited, expected) in [
|
||||
(serde_json::Value::Null, None),
|
||||
(serde_json::json!("creator"), Some("creator".to_string())),
|
||||
(serde_json::json!(""), Some(String::new())),
|
||||
(serde_json::json!("../x"), Some("../x".to_string())),
|
||||
(serde_json::json!(5), Some("5".to_string())),
|
||||
(serde_json::json!(true), Some("true".to_string())),
|
||||
(serde_json::json!({"a": 1}), Some("{\"a\":1}".to_string())),
|
||||
(serde_json::json!(big.clone()), Some(big)),
|
||||
] {
|
||||
let mut raw = base.clone();
|
||||
raw["agent_preset"] = edited;
|
||||
let loaded: SavedSession = serde_json::from_value(raw).expect("loads");
|
||||
assert_eq!(loaded.agent_preset, expected);
|
||||
}
|
||||
}
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "endormiscat",
|
||||
"PetWatchUnavailable": "La telemetria de la mascota està en pausa. /pet on ho torna a intentar.",
|
||||
"ConversationChangedBeforeUndo": "La conversa ha canviat abans de completar l’acció de desfer. No s’ha desfet res ni s’ha enviat cap nou intent. Revisa la conversa actual abans de tornar-ho a provar.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Mode de conversa",
|
||||
"ConversationModeGroupPermissions": "Permisos",
|
||||
"ConversationModeStandardName": "Mode estàndard",
|
||||
"ConversationModeStandardDescription": "L'agent de cada dia amb les seves eines i habilitats habituals.",
|
||||
"ConversationModeCreatorName": "Mode creador",
|
||||
"ConversationModeCreatorDescription": "Crea i prova connectors en aquesta conversa.",
|
||||
"ConversationModeNotReady": "Encara no està a punt en aquesta versió.",
|
||||
"ConversationModeLockedNote": "Fix després del primer missatge. Comença'n una altra amb {command}.",
|
||||
"ConversationModeLockedRefusal": "El mode de conversa queda fix quan s'envia el primer missatge. Comença una conversa nova amb {command}.",
|
||||
"ConversationModeUnknown": "Mode de conversa desconegut «{id}». Disponibles: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} no està disponible: {reason}",
|
||||
"ConversationModeSet": "Mode de conversa: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (no disponible)",
|
||||
"ConversationModeSavedUnavailableNotice": "Aquesta conversa es va desar en {name}, que no està disponible aquí ({reason}). Se'n mostra l'historial.",
|
||||
"ConversationModeResumeUnreadable": "No s'ha pogut llegir la conversa desada per comprovar-ne el mode; no s'ha iniciat ni canviat res.",
|
||||
"SessionArchiveExported": "Sessió exportada",
|
||||
"SessionArchiveSizes": "elements / mida sense comprimir / arxiu",
|
||||
"SessionArchiveNoArtifacts": "No s’han trobat fitxers d’artefactes regulars en aquesta sessió.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "dösend",
|
||||
"PetWatchUnavailable": "Die Haustier-Telemetrie pausiert. /pet on versucht es erneut.",
|
||||
"ConversationChangedBeforeUndo": "Die Unterhaltung wurde geändert, bevor das Rückgängigmachen abgeschlossen war. Es wurde nichts rückgängig gemacht und kein erneuter Versuch gesendet. Prüfe die aktuelle Unterhaltung, bevor du es erneut versuchst.",
|
||||
"ConversationModePickerTitle": "Modus",
|
||||
"ConversationModeGroupConversation": "Gesprächsmodus",
|
||||
"ConversationModeGroupPermissions": "Berechtigungen",
|
||||
"ConversationModeStandardName": "Standardmodus",
|
||||
"ConversationModeStandardDescription": "Der Alltagsagent mit seinen üblichen Werkzeugen und Skills.",
|
||||
"ConversationModeCreatorName": "Creator-Modus",
|
||||
"ConversationModeCreatorDescription": "Plugins in diesem Gespräch bauen und testen.",
|
||||
"ConversationModeNotReady": "In dieser Version noch nicht bereit.",
|
||||
"ConversationModeLockedNote": "Nach der ersten Nachricht festgelegt. Ein anderes Gespräch starten mit {command}.",
|
||||
"ConversationModeLockedRefusal": "Der Gesprächsmodus ist festgelegt, sobald die erste Nachricht gesendet wurde. Starte ein neues Gespräch mit {command}.",
|
||||
"ConversationModeUnknown": "Unbekannter Gesprächsmodus „{id}“. Verfügbar: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} ist nicht verfügbar: {reason}",
|
||||
"ConversationModeSet": "Gesprächsmodus: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (nicht verfügbar)",
|
||||
"ConversationModeSavedUnavailableNotice": "Dieses Gespräch wurde im {name} gespeichert, der hier nicht verfügbar ist ({reason}). Sein Verlauf wird angezeigt.",
|
||||
"ConversationModeResumeUnreadable": "Das gespeicherte Gespräch konnte nicht gelesen werden, um seinen Modus zu prüfen. Es wurde nichts gestartet oder geändert.",
|
||||
"SessionArchiveExported": "Sitzung exportiert",
|
||||
"SessionArchiveSizes": "Einträge / unkomprimierte Größe / Archivgröße",
|
||||
"SessionArchiveNoArtifacts": "Keine regulären Artefaktdateien für diese Sitzung gefunden.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "dozing",
|
||||
"PetWatchUnavailable": "Pet telemetry paused. /pet on retries.",
|
||||
"ConversationChangedBeforeUndo": "The conversation changed before undo could finish. Nothing was undone and no retry was sent. Review the current conversation before trying again.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Conversation mode",
|
||||
"ConversationModeGroupPermissions": "Permissions",
|
||||
"ConversationModeStandardName": "Standard mode",
|
||||
"ConversationModeStandardDescription": "The everyday agent with its usual tools and skills.",
|
||||
"ConversationModeCreatorName": "Creator mode",
|
||||
"ConversationModeCreatorDescription": "Build and test plugins in this conversation.",
|
||||
"ConversationModeNotReady": "Not ready yet in this version.",
|
||||
"ConversationModeLockedNote": "Fixed after the first message. Start another with {command}.",
|
||||
"ConversationModeLockedRefusal": "The conversation mode is fixed once the first message is sent. Start a new conversation with {command}.",
|
||||
"ConversationModeUnknown": "Unknown conversation mode \"{id}\". Available: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} is not available: {reason}",
|
||||
"ConversationModeSet": "Conversation mode: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (unavailable)",
|
||||
"ConversationModeSavedUnavailableNotice": "This conversation was saved in {name}, which is not available here ({reason}). Its history is shown.",
|
||||
"ConversationModeResumeUnreadable": "Could not read the saved conversation to check its mode, so nothing was started or changed.",
|
||||
"SessionArchiveExported": "Exported session",
|
||||
"SessionArchiveSizes": "members / uncompressed / archive",
|
||||
"SessionArchiveNoArtifacts": "No regular artifacts found for this session.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "dormitando",
|
||||
"PetWatchUnavailable": "La telemetría de la mascota está en pausa. /pet on reintenta.",
|
||||
"ConversationChangedBeforeUndo": "La conversación cambió antes de completar la acción de deshacer. No se deshizo nada ni se envió un nuevo intento. Revisa la conversación actual antes de volver a intentarlo.",
|
||||
"ConversationModePickerTitle": "Modo",
|
||||
"ConversationModeGroupConversation": "Modo de conversación",
|
||||
"ConversationModeGroupPermissions": "Permisos",
|
||||
"ConversationModeStandardName": "Modo estándar",
|
||||
"ConversationModeStandardDescription": "El agente de todos los días con sus herramientas y habilidades habituales.",
|
||||
"ConversationModeCreatorName": "Modo creador",
|
||||
"ConversationModeCreatorDescription": "Crea y prueba plugins en esta conversación.",
|
||||
"ConversationModeNotReady": "Aún no está listo en esta versión.",
|
||||
"ConversationModeLockedNote": "Fijo tras el primer mensaje. Inicia otra con {command}.",
|
||||
"ConversationModeLockedRefusal": "El modo de conversación queda fijo al enviar el primer mensaje. Inicia una conversación nueva con {command}.",
|
||||
"ConversationModeUnknown": "Modo de conversación desconocido «{id}». Disponibles: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} no está disponible: {reason}",
|
||||
"ConversationModeSet": "Modo de conversación: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (no disponible)",
|
||||
"ConversationModeSavedUnavailableNotice": "Esta conversación se guardó en {name}, que no está disponible aquí ({reason}). Se muestra su historial.",
|
||||
"ConversationModeResumeUnreadable": "No se pudo leer la conversación guardada para comprobar su modo, así que no se inició ni se cambió nada.",
|
||||
"SessionArchiveExported": "Sesión exportada",
|
||||
"SessionArchiveSizes": "elementos / tamaño sin comprimir / archivo",
|
||||
"SessionArchiveNoArtifacts": "No se encontraron archivos de artefactos regulares en esta sesión.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "assoupi",
|
||||
"PetWatchUnavailable": "La télémétrie de la mascotte est en pause. /pet on réessaie.",
|
||||
"ConversationChangedBeforeUndo": "La conversation a changé avant la fin de l’annulation. Rien n’a été annulé et aucune nouvelle tentative n’a été envoyée. Vérifiez la conversation actuelle avant de réessayer.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Mode de conversation",
|
||||
"ConversationModeGroupPermissions": "Autorisations",
|
||||
"ConversationModeStandardName": "Mode standard",
|
||||
"ConversationModeStandardDescription": "L'agent du quotidien avec ses outils et compétences habituels.",
|
||||
"ConversationModeCreatorName": "Mode créateur",
|
||||
"ConversationModeCreatorDescription": "Créer et tester des plugins dans cette conversation.",
|
||||
"ConversationModeNotReady": "Pas encore prêt dans cette version.",
|
||||
"ConversationModeLockedNote": "Fixé après le premier message. Pour en démarrer une autre : {command}.",
|
||||
"ConversationModeLockedRefusal": "Le mode de conversation est fixé dès l'envoi du premier message. Démarrez une nouvelle conversation avec {command}.",
|
||||
"ConversationModeUnknown": "Mode de conversation inconnu « {id} ». Disponibles : {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} n'est pas disponible : {reason}",
|
||||
"ConversationModeSet": "Mode de conversation : {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (indisponible)",
|
||||
"ConversationModeSavedUnavailableNotice": "Cette conversation a été enregistrée en {name}, indisponible ici ({reason}). Son historique est affiché.",
|
||||
"ConversationModeResumeUnreadable": "Impossible de lire la conversation enregistrée pour vérifier son mode ; rien n'a été démarré ni modifié.",
|
||||
"SessionArchiveExported": "Session exportée",
|
||||
"SessionArchiveSizes": "éléments / taille non compressée / archive",
|
||||
"SessionArchiveNoArtifacts": "Aucun fichier d’artefact ordinaire trouvé pour cette session.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "ऊँघ रहा है",
|
||||
"PetWatchUnavailable": "पालतू की टेलीमेट्री रुकी हुई है। /pet on से फिर कोशिश करें।",
|
||||
"ConversationChangedBeforeUndo": "पूर्ववत करने की प्रक्रिया पूरी होने से पहले बातचीत बदल गई। कुछ भी पूर्ववत नहीं किया गया और दोबारा अनुरोध नहीं भेजा गया। फिर से कोशिश करने से पहले मौजूदा बातचीत की समीक्षा करें।",
|
||||
"ConversationModePickerTitle": "मोड",
|
||||
"ConversationModeGroupConversation": "बातचीत मोड",
|
||||
"ConversationModeGroupPermissions": "अनुमतियाँ",
|
||||
"ConversationModeStandardName": "मानक मोड",
|
||||
"ConversationModeStandardDescription": "रोज़ के उपयोग वाला एजेंट, अपने सामान्य टूल और स्किल के साथ।",
|
||||
"ConversationModeCreatorName": "क्रिएटर मोड",
|
||||
"ConversationModeCreatorDescription": "इस बातचीत में प्लगइन बनाएँ और परखें।",
|
||||
"ConversationModeNotReady": "इस संस्करण में अभी तैयार नहीं है।",
|
||||
"ConversationModeLockedNote": "पहले संदेश के बाद तय हो जाता है। दूसरी शुरू करने के लिए {command} चलाएँ।",
|
||||
"ConversationModeLockedRefusal": "पहला संदेश भेजते ही बातचीत का मोड तय हो जाता है। {command} से नई बातचीत शुरू करें।",
|
||||
"ConversationModeUnknown": "अज्ञात बातचीत मोड \"{id}\"। उपलब्ध: {modes}।",
|
||||
"ConversationModeUnavailableRefusal": "{name} उपलब्ध नहीं है: {reason}",
|
||||
"ConversationModeSet": "बातचीत मोड: {name}।",
|
||||
"ConversationModeLabelUnavailable": "{name} (उपलब्ध नहीं)",
|
||||
"ConversationModeSavedUnavailableNotice": "यह बातचीत {name} में सहेजी गई थी, जो यहाँ उपलब्ध नहीं है ({reason})। इसका इतिहास दिखाया गया है।",
|
||||
"ConversationModeResumeUnreadable": "इसका मोड जाँचने के लिए सहेजी गई बातचीत नहीं पढ़ी जा सकी, इसलिए कुछ शुरू या बदला नहीं गया।",
|
||||
"SessionArchiveExported": "सत्र निर्यात किया गया",
|
||||
"SessionArchiveSizes": "प्रविष्टियाँ / असंपीड़ित आकार / संग्रह का आकार",
|
||||
"SessionArchiveNoArtifacts": "इस सत्र में कोई सामान्य आर्टिफ़ैक्ट फ़ाइल नहीं मिली।",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "terlelap",
|
||||
"PetWatchUnavailable": "Telemetri hewan peliharaan dijeda. /pet on mencoba lagi.",
|
||||
"ConversationChangedBeforeUndo": "Percakapan berubah sebelum pembatalan selesai. Tidak ada yang dibatalkan dan tidak ada permintaan coba lagi yang dikirim. Periksa percakapan saat ini sebelum mencoba lagi.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Mode percakapan",
|
||||
"ConversationModeGroupPermissions": "Izin",
|
||||
"ConversationModeStandardName": "Mode standar",
|
||||
"ConversationModeStandardDescription": "Agen sehari-hari dengan alat dan skill yang biasa.",
|
||||
"ConversationModeCreatorName": "Mode kreator",
|
||||
"ConversationModeCreatorDescription": "Bangun dan uji plugin dalam percakapan ini.",
|
||||
"ConversationModeNotReady": "Belum siap di versi ini.",
|
||||
"ConversationModeLockedNote": "Tetap setelah pesan pertama. Mulai yang lain dengan {command}.",
|
||||
"ConversationModeLockedRefusal": "Mode percakapan tetap setelah pesan pertama dikirim. Mulai percakapan baru dengan {command}.",
|
||||
"ConversationModeUnknown": "Mode percakapan \"{id}\" tidak dikenal. Tersedia: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} tidak tersedia: {reason}",
|
||||
"ConversationModeSet": "Mode percakapan: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (tidak tersedia)",
|
||||
"ConversationModeSavedUnavailableNotice": "Percakapan ini disimpan dalam {name}, yang tidak tersedia di sini ({reason}). Riwayatnya ditampilkan.",
|
||||
"ConversationModeResumeUnreadable": "Percakapan tersimpan tidak dapat dibaca untuk memeriksa modenya, jadi tidak ada yang dimulai atau diubah.",
|
||||
"SessionArchiveExported": "Sesi diekspor",
|
||||
"SessionArchiveSizes": "jumlah entri / ukuran tanpa kompresi / arsip",
|
||||
"SessionArchiveNoArtifacts": "Tidak ditemukan berkas artefak biasa untuk sesi ini.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "うたた寝",
|
||||
"PetWatchUnavailable": "ペットの計測が一時停止しました。/pet on で再試行します。",
|
||||
"ConversationChangedBeforeUndo": "取り消しが完了する前に会話が変更されました。取り消しも再試行の送信も行っていません。現在の会話を確認してから、もう一度お試しください。",
|
||||
"ConversationModePickerTitle": "モード",
|
||||
"ConversationModeGroupConversation": "会話モード",
|
||||
"ConversationModeGroupPermissions": "権限",
|
||||
"ConversationModeStandardName": "標準モード",
|
||||
"ConversationModeStandardDescription": "いつもの道具とスキルを備えた日常用のエージェントです。",
|
||||
"ConversationModeCreatorName": "クリエイターモード",
|
||||
"ConversationModeCreatorDescription": "この会話でプラグインを作成・テストします。",
|
||||
"ConversationModeNotReady": "このバージョンではまだ使えません。",
|
||||
"ConversationModeLockedNote": "最初のメッセージ後は固定されます。別の会話は {command} で始めます。",
|
||||
"ConversationModeLockedRefusal": "会話モードは最初のメッセージを送ると固定されます。{command} で新しい会話を始めてください。",
|
||||
"ConversationModeUnknown": "会話モード「{id}」は不明です。使用可能: {modes}。",
|
||||
"ConversationModeUnavailableRefusal": "{name} は使えません: {reason}",
|
||||
"ConversationModeSet": "会話モード: {name}。",
|
||||
"ConversationModeLabelUnavailable": "{name}(利用不可)",
|
||||
"ConversationModeSavedUnavailableNotice": "この会話は {name} で保存されましたが、ここでは使えません({reason})。履歴は表示されています。",
|
||||
"ConversationModeResumeUnreadable": "保存された会話を読み取れず、モードを確認できませんでした。何も開始・変更していません。",
|
||||
"SessionArchiveExported": "セッションをエクスポートしました",
|
||||
"SessionArchiveSizes": "項目数 / 非圧縮サイズ / アーカイブサイズ",
|
||||
"SessionArchiveNoArtifacts": "このセッションに通常のアーティファクトファイルはありません。",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "졸고 있음",
|
||||
"PetWatchUnavailable": "펫 원격 측정이 일시 중지되었습니다. /pet on 으로 재시도합니다.",
|
||||
"ConversationChangedBeforeUndo": "실행 취소가 완료되기 전에 대화가 변경되었습니다. 취소된 내용이 없으며 재시도 요청도 보내지 않았습니다. 현재 대화를 확인한 후 다시 시도하세요.",
|
||||
"ConversationModePickerTitle": "모드",
|
||||
"ConversationModeGroupConversation": "대화 모드",
|
||||
"ConversationModeGroupPermissions": "권한",
|
||||
"ConversationModeStandardName": "표준 모드",
|
||||
"ConversationModeStandardDescription": "평소 도구와 스킬을 갖춘 일상용 에이전트입니다.",
|
||||
"ConversationModeCreatorName": "크리에이터 모드",
|
||||
"ConversationModeCreatorDescription": "이 대화에서 플러그인을 만들고 테스트합니다.",
|
||||
"ConversationModeNotReady": "이 버전에서는 아직 준비되지 않았습니다.",
|
||||
"ConversationModeLockedNote": "첫 메시지 이후에는 고정됩니다. 다른 대화는 {command} 로 시작하세요.",
|
||||
"ConversationModeLockedRefusal": "대화 모드는 첫 메시지를 보내면 고정됩니다. {command} 로 새 대화를 시작하세요.",
|
||||
"ConversationModeUnknown": "알 수 없는 대화 모드 \"{id}\". 사용 가능: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} 사용 불가: {reason}",
|
||||
"ConversationModeSet": "대화 모드: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (사용 불가)",
|
||||
"ConversationModeSavedUnavailableNotice": "이 대화는 {name}에서 저장되었지만 여기서는 사용할 수 없습니다({reason}). 기록은 표시됩니다.",
|
||||
"ConversationModeResumeUnreadable": "저장된 대화를 읽어 모드를 확인할 수 없어 아무것도 시작하거나 변경하지 않았습니다.",
|
||||
"SessionArchiveExported": "세션을 내보냈습니다",
|
||||
"SessionArchiveSizes": "항목 수 / 압축 전 크기 / 아카이브 크기",
|
||||
"SessionArchiveNoArtifacts": "이 세션에 일반 산출물 파일이 없습니다.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "cochilando",
|
||||
"PetWatchUnavailable": "A telemetria do pet foi pausada. /pet on tenta novamente.",
|
||||
"ConversationChangedBeforeUndo": "A conversa mudou antes de concluir a ação de desfazer. Nada foi desfeito e nenhuma nova tentativa foi enviada. Revise a conversa atual antes de tentar novamente.",
|
||||
"ConversationModePickerTitle": "Modo",
|
||||
"ConversationModeGroupConversation": "Modo de conversa",
|
||||
"ConversationModeGroupPermissions": "Permissões",
|
||||
"ConversationModeStandardName": "Modo padrão",
|
||||
"ConversationModeStandardDescription": "O agente do dia a dia, com suas ferramentas e habilidades habituais.",
|
||||
"ConversationModeCreatorName": "Modo criador",
|
||||
"ConversationModeCreatorDescription": "Crie e teste plugins nesta conversa.",
|
||||
"ConversationModeNotReady": "Ainda não está pronto nesta versão.",
|
||||
"ConversationModeLockedNote": "Fixo após a primeira mensagem. Inicie outra com {command}.",
|
||||
"ConversationModeLockedRefusal": "O modo de conversa fica fixo depois que a primeira mensagem é enviada. Inicie uma nova conversa com {command}.",
|
||||
"ConversationModeUnknown": "Modo de conversa desconhecido \"{id}\". Disponíveis: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} não está disponível: {reason}",
|
||||
"ConversationModeSet": "Modo de conversa: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (indisponível)",
|
||||
"ConversationModeSavedUnavailableNotice": "Esta conversa foi salva no {name}, que não está disponível aqui ({reason}). O histórico dela é exibido.",
|
||||
"ConversationModeResumeUnreadable": "Não foi possível ler a conversa salva para verificar o modo, então nada foi iniciado ou alterado.",
|
||||
"SessionArchiveExported": "Sessão exportada",
|
||||
"SessionArchiveSizes": "itens / tamanho sem compressão / arquivo",
|
||||
"SessionArchiveNoArtifacts": "Nenhum artefato regular encontrado nesta sessão.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "дремлет",
|
||||
"PetWatchUnavailable": "Телеметрия питомца приостановлена. /pet on повторит попытку.",
|
||||
"ConversationChangedBeforeUndo": "Разговор изменился до завершения отмены. Ничего не отменено, повторный запрос не отправлен. Проверьте текущий разговор, прежде чем повторять попытку.",
|
||||
"ConversationModePickerTitle": "Режим",
|
||||
"ConversationModeGroupConversation": "Режим беседы",
|
||||
"ConversationModeGroupPermissions": "Разрешения",
|
||||
"ConversationModeStandardName": "Стандартный режим",
|
||||
"ConversationModeStandardDescription": "Повседневный агент с обычными инструментами и навыками.",
|
||||
"ConversationModeCreatorName": "Режим создателя",
|
||||
"ConversationModeCreatorDescription": "Создавайте и проверяйте плагины в этой беседе.",
|
||||
"ConversationModeNotReady": "В этой версии пока не готово.",
|
||||
"ConversationModeLockedNote": "Фиксируется после первого сообщения. Начните другую командой {command}.",
|
||||
"ConversationModeLockedRefusal": "Режим беседы фиксируется после отправки первого сообщения. Начните новую беседу командой {command}.",
|
||||
"ConversationModeUnknown": "Неизвестный режим беседы «{id}». Доступны: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} недоступен: {reason}",
|
||||
"ConversationModeSet": "Режим беседы: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (недоступен)",
|
||||
"ConversationModeSavedUnavailableNotice": "Эта беседа сохранена в режиме «{name}», недоступном здесь ({reason}). Её история показана.",
|
||||
"ConversationModeResumeUnreadable": "Не удалось прочитать сохранённый разговор, чтобы проверить его режим. Ничего не запущено и не изменено.",
|
||||
"SessionArchiveExported": "Сеанс экспортирован",
|
||||
"SessionArchiveSizes": "записи / размер без сжатия / размер архива",
|
||||
"SessionArchiveNoArtifacts": "Обычные файлы артефактов для этого сеанса не найдены.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "дрімає",
|
||||
"PetWatchUnavailable": "Телеметрію улюбленця призупинено. /pet on повторить спробу.",
|
||||
"ConversationChangedBeforeUndo": "Розмова змінилася до завершення скасування. Нічого не скасовано, повторний запит не надіслано. Перевірте поточну розмову, перш ніж спробувати знову.",
|
||||
"ConversationModePickerTitle": "Режим",
|
||||
"ConversationModeGroupConversation": "Режим розмови",
|
||||
"ConversationModeGroupPermissions": "Дозволи",
|
||||
"ConversationModeStandardName": "Стандартний режим",
|
||||
"ConversationModeStandardDescription": "Щоденний агент зі звичними інструментами та навичками.",
|
||||
"ConversationModeCreatorName": "Режим творця",
|
||||
"ConversationModeCreatorDescription": "Створюйте й перевіряйте плагіни в цій розмові.",
|
||||
"ConversationModeNotReady": "У цій версії ще не готово.",
|
||||
"ConversationModeLockedNote": "Фіксується після першого повідомлення. Почніть іншу командою {command}.",
|
||||
"ConversationModeLockedRefusal": "Режим розмови фіксується після надсилання першого повідомлення. Почніть нову розмову командою {command}.",
|
||||
"ConversationModeUnknown": "Невідомий режим розмови «{id}». Доступні: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} недоступний: {reason}",
|
||||
"ConversationModeSet": "Режим розмови: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (недоступний)",
|
||||
"ConversationModeSavedUnavailableNotice": "Цю розмову збережено в режимі «{name}», недоступному тут ({reason}). Її історію показано.",
|
||||
"ConversationModeResumeUnreadable": "Не вдалося прочитати збережену розмову, щоб перевірити її режим. Нічого не запущено й не змінено.",
|
||||
"SessionArchiveExported": "Сеанс експортовано",
|
||||
"SessionArchiveSizes": "записи / розмір без стиснення / розмір архіву",
|
||||
"SessionArchiveNoArtifacts": "Звичайних файлів артефактів для цього сеансу не знайдено.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "ngủ gật",
|
||||
"PetWatchUnavailable": "Đã tạm dừng dữ liệu thú cưng. /pet on sẽ thử lại.",
|
||||
"ConversationChangedBeforeUndo": "Cuộc trò chuyện đã thay đổi trước khi hoàn tất hoàn tác. Không có nội dung nào bị hoàn tác và không gửi yêu cầu thử lại. Hãy kiểm tra cuộc trò chuyện hiện tại trước khi thử lại.",
|
||||
"ConversationModePickerTitle": "Chế độ",
|
||||
"ConversationModeGroupConversation": "Chế độ cuộc trò chuyện",
|
||||
"ConversationModeGroupPermissions": "Quyền",
|
||||
"ConversationModeStandardName": "Chế độ tiêu chuẩn",
|
||||
"ConversationModeStandardDescription": "Tác nhân hằng ngày với các công cụ và kỹ năng quen thuộc.",
|
||||
"ConversationModeCreatorName": "Chế độ sáng tạo",
|
||||
"ConversationModeCreatorDescription": "Xây dựng và thử plugin trong cuộc trò chuyện này.",
|
||||
"ConversationModeNotReady": "Phiên bản này chưa sẵn sàng.",
|
||||
"ConversationModeLockedNote": "Cố định sau tin nhắn đầu tiên. Bắt đầu cuộc khác bằng {command}.",
|
||||
"ConversationModeLockedRefusal": "Chế độ cuộc trò chuyện được cố định sau khi gửi tin nhắn đầu tiên. Hãy bắt đầu cuộc trò chuyện mới bằng {command}.",
|
||||
"ConversationModeUnknown": "Không rõ chế độ cuộc trò chuyện \"{id}\". Hiện có: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} không khả dụng: {reason}",
|
||||
"ConversationModeSet": "Chế độ cuộc trò chuyện: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (không khả dụng)",
|
||||
"ConversationModeSavedUnavailableNotice": "Cuộc trò chuyện này được lưu ở {name}, hiện không khả dụng ở đây ({reason}). Lịch sử vẫn được hiển thị.",
|
||||
"ConversationModeResumeUnreadable": "Không đọc được cuộc trò chuyện đã lưu để kiểm tra chế độ, nên không có gì được bắt đầu hoặc thay đổi.",
|
||||
"SessionArchiveExported": "Đã xuất phiên",
|
||||
"SessionArchiveSizes": "số mục / dung lượng chưa nén / tệp lưu trữ",
|
||||
"SessionArchiveNoArtifacts": "Không tìm thấy tệp sản phẩm thông thường trong phiên này.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "打盹",
|
||||
"PetWatchUnavailable": "宠物遥测已暂停。/pet on 重试。",
|
||||
"ConversationChangedBeforeUndo": "撤销完成前,会话已发生变化。未撤销任何内容,也未发送重试请求。请先检查当前会话,再重试。",
|
||||
"ConversationModePickerTitle": "模式",
|
||||
"ConversationModeGroupConversation": "对话模式",
|
||||
"ConversationModeGroupPermissions": "权限",
|
||||
"ConversationModeStandardName": "标准模式",
|
||||
"ConversationModeStandardDescription": "日常使用的智能体,带有常用工具和技能。",
|
||||
"ConversationModeCreatorName": "创作者模式",
|
||||
"ConversationModeCreatorDescription": "在此对话中构建并测试插件。",
|
||||
"ConversationModeNotReady": "此版本尚未就绪。",
|
||||
"ConversationModeLockedNote": "发送第一条消息后即固定。用 {command} 开始另一个对话。",
|
||||
"ConversationModeLockedRefusal": "发送第一条消息后,对话模式即固定。请用 {command} 开始新对话。",
|
||||
"ConversationModeUnknown": "未知的对话模式“{id}”。可用:{modes}。",
|
||||
"ConversationModeUnavailableRefusal": "{name}不可用:{reason}",
|
||||
"ConversationModeSet": "对话模式:{name}。",
|
||||
"ConversationModeLabelUnavailable": "{name}(不可用)",
|
||||
"ConversationModeSavedUnavailableNotice": "此对话保存时使用{name},但这里无法使用({reason})。已显示其历史记录。",
|
||||
"ConversationModeResumeUnreadable": "无法读取已保存的对话以检查其模式,因此没有开始或更改任何内容。",
|
||||
"SessionArchiveExported": "会话已导出",
|
||||
"SessionArchiveSizes": "条目数 / 未压缩大小 / 归档大小",
|
||||
"SessionArchiveNoArtifacts": "此会话没有常规产物文件。",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "打盹",
|
||||
"PetWatchUnavailable": "寵物遙測已暫停。/pet on 重試。",
|
||||
"ConversationChangedBeforeUndo": "復原完成前,對話已發生變更。未復原任何內容,也未送出重試請求。請先檢查目前的對話,再重試。",
|
||||
"ConversationModePickerTitle": "模式",
|
||||
"ConversationModeGroupConversation": "對話模式",
|
||||
"ConversationModeGroupPermissions": "權限",
|
||||
"ConversationModeStandardName": "標準模式",
|
||||
"ConversationModeStandardDescription": "日常使用的代理,具備常用工具與技能。",
|
||||
"ConversationModeCreatorName": "創作者模式",
|
||||
"ConversationModeCreatorDescription": "在此對話中建置並測試外掛。",
|
||||
"ConversationModeNotReady": "此版本尚未就緒。",
|
||||
"ConversationModeLockedNote": "傳送第一則訊息後即固定。用 {command} 開始另一個對話。",
|
||||
"ConversationModeLockedRefusal": "傳送第一則訊息後,對話模式即固定。請用 {command} 開始新對話。",
|
||||
"ConversationModeUnknown": "未知的對話模式「{id}」。可用:{modes}。",
|
||||
"ConversationModeUnavailableRefusal": "{name}無法使用:{reason}",
|
||||
"ConversationModeSet": "對話模式:{name}。",
|
||||
"ConversationModeLabelUnavailable": "{name}(無法使用)",
|
||||
"ConversationModeSavedUnavailableNotice": "此對話儲存時使用{name},但這裡無法使用({reason})。已顯示其歷史記錄。",
|
||||
"ConversationModeResumeUnreadable": "無法讀取已儲存的對話以檢查其模式,因此沒有開始或變更任何內容。",
|
||||
"SessionArchiveExported": "工作階段已匯出",
|
||||
"SessionArchiveSizes": "項目數 / 未壓縮大小 / 封存大小",
|
||||
"SessionArchiveNoArtifacts": "此工作階段沒有一般產物檔案。",
|
||||
|
||||
@@ -171,22 +171,6 @@ pub enum MessageId {
|
||||
MobileStreamReconnecting,
|
||||
MobileStreamConnected,
|
||||
ConversationChangedBeforeUndo,
|
||||
ConversationModePickerTitle,
|
||||
ConversationModeGroupConversation,
|
||||
ConversationModeGroupPermissions,
|
||||
ConversationModeStandardName,
|
||||
ConversationModeStandardDescription,
|
||||
ConversationModeCreatorName,
|
||||
ConversationModeCreatorDescription,
|
||||
ConversationModeNotReady,
|
||||
ConversationModeLockedNote,
|
||||
ConversationModeLockedRefusal,
|
||||
ConversationModeUnknown,
|
||||
ConversationModeUnavailableRefusal,
|
||||
ConversationModeSet,
|
||||
ConversationModeLabelUnavailable,
|
||||
ConversationModeSavedUnavailableNotice,
|
||||
ConversationModeResumeUnreadable,
|
||||
SessionArchiveExported,
|
||||
SessionArchiveSizes,
|
||||
SessionArchiveNoArtifacts,
|
||||
@@ -3130,22 +3114,6 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::MobileStreamReconnecting,
|
||||
MessageId::MobileStreamConnected,
|
||||
MessageId::ConversationChangedBeforeUndo,
|
||||
MessageId::ConversationModePickerTitle,
|
||||
MessageId::ConversationModeGroupConversation,
|
||||
MessageId::ConversationModeGroupPermissions,
|
||||
MessageId::ConversationModeStandardName,
|
||||
MessageId::ConversationModeStandardDescription,
|
||||
MessageId::ConversationModeCreatorName,
|
||||
MessageId::ConversationModeCreatorDescription,
|
||||
MessageId::ConversationModeNotReady,
|
||||
MessageId::ConversationModeLockedNote,
|
||||
MessageId::ConversationModeLockedRefusal,
|
||||
MessageId::ConversationModeUnknown,
|
||||
MessageId::ConversationModeUnavailableRefusal,
|
||||
MessageId::ConversationModeSet,
|
||||
MessageId::ConversationModeLabelUnavailable,
|
||||
MessageId::ConversationModeSavedUnavailableNotice,
|
||||
MessageId::ConversationModeResumeUnreadable,
|
||||
MessageId::SessionArchiveExported,
|
||||
MessageId::SessionArchiveSizes,
|
||||
MessageId::SessionArchiveNoArtifacts,
|
||||
|
||||
@@ -255,12 +255,11 @@ impl StdioLspTransport {
|
||||
let child = Arc::new(AsyncMutex::new(Some(child)));
|
||||
let dispatcher_child = child.clone();
|
||||
let dispatcher_pending = pending.clone();
|
||||
let dispatcher_outbound = tx_outbound.clone();
|
||||
let dispatcher_task = spawn_supervised(
|
||||
"lsp-dispatcher",
|
||||
std::panic::Location::caller(),
|
||||
async move {
|
||||
dispatcher_task(rx_inbound, tx_diag, dispatcher_outbound, dispatcher_pending).await;
|
||||
dispatcher_task(rx_inbound, tx_diag, dispatcher_pending).await;
|
||||
// EOF, malformed frames, or an overflowing diagnostics queue
|
||||
// terminate the producer too, so its pipes cannot stay stuck.
|
||||
if let Some(mut child) = dispatcher_child.lock().await.take() {
|
||||
@@ -537,19 +536,15 @@ impl LspTransport for StdioLspTransport {
|
||||
|
||||
/// Send a JSON value as one Content-Length-framed JSON-RPC message.
|
||||
async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
|
||||
tx.send(frame_message(value)?)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frame_message(value: &Value) -> Result<Vec<u8>> {
|
||||
let body = serde_json::to_vec(value).context("serialize LSP message")?;
|
||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||
let mut frame = Vec::with_capacity(header.len() + body.len());
|
||||
frame.extend_from_slice(header.as_bytes());
|
||||
frame.extend_from_slice(&body);
|
||||
Ok(frame)
|
||||
tx.send(frame)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Background task that drains the outbound queue and writes each frame to
|
||||
@@ -643,7 +638,6 @@ fn parse_header(buf: &[u8]) -> Result<Option<(usize, usize)>> {
|
||||
async fn dispatcher_task(
|
||||
mut rx: mpsc::Receiver<Value>,
|
||||
tx_diag: mpsc::Sender<DiagnosticMessage>,
|
||||
tx_outbound: mpsc::Sender<Vec<u8>>,
|
||||
pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
|
||||
) {
|
||||
while let Some(value) = rx.recv().await {
|
||||
@@ -660,19 +654,6 @@ async fn dispatcher_task(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(method) = method {
|
||||
if let Some(id) = value.get("id") {
|
||||
let reply = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {"code": -32601, "message": format!("unsupported method {method}")},
|
||||
});
|
||||
if let Ok(frame) = frame_message(&reply) {
|
||||
let _ = tx_outbound.try_send(frame);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Replies have an `id` and a `result` or `error`.
|
||||
if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
|
||||
let mut map = pending.lock().await;
|
||||
@@ -799,31 +780,6 @@ pub(super) mod tests {
|
||||
assert_eq!(diags[0].message, "missing semicolon");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_request_sharing_a_pending_id_is_answered_not_taken_as_the_reply() {
|
||||
let (tx_in, rx_in) = mpsc::channel(4);
|
||||
let (tx_out, mut rx_out) = mpsc::channel::<Vec<u8>>(4);
|
||||
let (tx_diag, _rx_diag) = mpsc::channel(4);
|
||||
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
pending.lock().await.insert(2, reply_tx);
|
||||
let dispatcher = tokio::spawn(dispatcher_task(rx_in, tx_diag, tx_out, pending));
|
||||
tx_in
|
||||
.send(json!({"jsonrpc":"2.0","id":2,"method":"workspace/configuration","params":{"items":[]}}))
|
||||
.await
|
||||
.unwrap();
|
||||
tx_in
|
||||
.send(json!({"jsonrpc":"2.0","id":2,"result":"definition"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reply_rx.await.unwrap()["result"], "definition");
|
||||
let answer = next_document(&mut rx_out).await;
|
||||
assert_eq!(answer["id"], 2);
|
||||
assert_eq!(answer["error"]["code"], -32601);
|
||||
drop(tx_in);
|
||||
dispatcher.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_uri_path() {
|
||||
let path = fixture_path("example/foo.rs");
|
||||
@@ -1353,12 +1309,11 @@ while True:
|
||||
let (mut producer, reader) = tokio::io::duplex(MAX_LSP_HEADER_BYTES * 2);
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let (diag, _diagnostics) = mpsc::channel(8);
|
||||
let (outbound, _outbound_rx) = mpsc::channel(8);
|
||||
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
|
||||
let (reply, receiver) = oneshot::channel();
|
||||
pending.lock().await.insert(1, reply);
|
||||
let read_task = tokio::spawn(reader_task(reader, tx));
|
||||
let dispatch = tokio::spawn(dispatcher_task(rx, diag, outbound, pending.clone()));
|
||||
let dispatch = tokio::spawn(dispatcher_task(rx, diag, pending.clone()));
|
||||
producer.write_all(&frame).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_secs(1), receiver)
|
||||
|
||||
@@ -444,8 +444,6 @@ pub enum Op {
|
||||
workspace: PathBuf,
|
||||
#[serde(default = "default_mode")]
|
||||
mode: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
agent_preset: Option<String>,
|
||||
},
|
||||
|
||||
/// Rewind an unchanged conversation. `expected` is the complete observed
|
||||
@@ -789,7 +787,6 @@ mod tests {
|
||||
model: "m".into(),
|
||||
workspace: PathBuf::from("/ws"),
|
||||
mode: "agent".into(),
|
||||
agent_preset: Some("creator".into()),
|
||||
},
|
||||
Op::RewindConversation {
|
||||
expected: json!({"session_id": "s", "messages": []}),
|
||||
|
||||
@@ -11,11 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
|
||||
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
|
||||
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
|
||||
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
|
||||
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
|
||||
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
|
||||
|
||||
@@ -41,7 +41,6 @@ pub(crate) fn config_action_to_app_action(action: ConfigAction) -> AppAction {
|
||||
ConfigAction::StartOrcarouterRevoke => AppAction::StartOrcarouterRevoke,
|
||||
ConfigAction::OpenConfigView => AppAction::OpenConfigView,
|
||||
ConfigAction::OpenModePicker => AppAction::OpenModePicker,
|
||||
ConfigAction::SetConversationMode { id } => AppAction::SetConversationMode { id },
|
||||
ConfigAction::OpenThemePicker => AppAction::OpenThemePicker,
|
||||
ConfigAction::OpenStatusPicker => AppAction::OpenStatusPicker,
|
||||
ConfigAction::OpenProviderPicker => AppAction::OpenProviderPicker,
|
||||
@@ -101,7 +100,6 @@ pub(crate) fn app_action_to_config_action(action: AppAction) -> Option<ConfigAct
|
||||
AppAction::StartOrcarouterRevoke => ConfigAction::StartOrcarouterRevoke,
|
||||
AppAction::OpenConfigView => ConfigAction::OpenConfigView,
|
||||
AppAction::OpenModePicker => ConfigAction::OpenModePicker,
|
||||
AppAction::SetConversationMode { id } => ConfigAction::SetConversationMode { id },
|
||||
AppAction::OpenThemePicker => ConfigAction::OpenThemePicker,
|
||||
AppAction::OpenStatusPicker => ConfigAction::OpenStatusPicker,
|
||||
AppAction::OpenProviderPicker => ConfigAction::OpenProviderPicker,
|
||||
|
||||
@@ -438,7 +438,6 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: to_command_mode(app.mode),
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -735,7 +734,6 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -798,7 +796,6 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
forked.journal = Some(forked_journal);
|
||||
forked.leaf_id = forked.journal.as_ref().and_then(|j| j.leaf_id.clone());
|
||||
forked.messages = messages;
|
||||
forked.agent_preset = source_session.agent_preset.clone();
|
||||
forked.metadata.spawn_depth = forked.journal.as_ref().map(|j| j.spawn_depth).unwrap_or(0);
|
||||
forked.metadata.parent_session_id = Some(source_session.metadata.id.clone());
|
||||
forked.metadata.forked_from_message_count = Some(source_session.metadata.message_count);
|
||||
@@ -837,36 +834,12 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> Result<SessionNewReceipt, String> {
|
||||
fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
|
||||
let app = self.host.app.borrow();
|
||||
let agent_preset = match conversation_mode {
|
||||
None => None,
|
||||
Some(id) => {
|
||||
let table = crate::tui::conversation_mode::current_table_blocking(self.host.config);
|
||||
match crate::tui::conversation_mode::resolve(app.ui_locale, &table, id) {
|
||||
crate::tui::conversation_mode::Resolution::Unknown(message) => {
|
||||
return Err(message);
|
||||
}
|
||||
crate::tui::conversation_mode::Resolution::Unavailable { name, reason } => {
|
||||
return Err(crate::tui::conversation_mode::unavailable_message(
|
||||
app.ui_locale,
|
||||
&name,
|
||||
&reason,
|
||||
));
|
||||
}
|
||||
crate::tui::conversation_mode::Resolution::Ready { stored, .. } => stored,
|
||||
}
|
||||
}
|
||||
};
|
||||
if !force {
|
||||
let mut blockers: Vec<&'static str> = Vec::new();
|
||||
if !app.input.trim().is_empty() {
|
||||
@@ -898,7 +871,6 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode,
|
||||
agent_preset,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1751,7 +1723,6 @@ fn import_session_container(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: to_command_mode(app.mode),
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -7462,9 +7433,7 @@ mod tests {
|
||||
let mut bundle = app.command_contexts();
|
||||
let mut parts = bundle.parts();
|
||||
let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
|
||||
let err = facet
|
||||
.fresh_session(true, None)
|
||||
.expect_err("busy work state");
|
||||
let err = facet.fresh_session(true).expect_err("busy work state");
|
||||
assert!(err.contains("Work state is busy"), "{err}");
|
||||
}
|
||||
assert_eq!(app.api_messages.len(), 1);
|
||||
@@ -7483,7 +7452,7 @@ mod tests {
|
||||
let mut bundle = app.command_contexts();
|
||||
let mut parts = bundle.parts();
|
||||
let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
|
||||
let err = facet.fresh_session(false, None).expect_err("blocker text");
|
||||
let err = facet.fresh_session(false).expect_err("blocker text");
|
||||
assert!(err.contains("/new --force"), "{err}");
|
||||
}
|
||||
assert_eq!(app.input, "draft text");
|
||||
@@ -7494,7 +7463,7 @@ mod tests {
|
||||
let mut parts = bundle.parts();
|
||||
let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
|
||||
let ok = facet
|
||||
.fresh_session(true, None)
|
||||
.fresh_session(true)
|
||||
.expect("force prepares a new session");
|
||||
assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
|
||||
assert_eq!(app.input, "draft text");
|
||||
|
||||
@@ -215,7 +215,6 @@ fn session_sync_payload(app: &App) -> SessionSyncPayload {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: super::to_command_mode(app.mode),
|
||||
agent_preset: app.agent_preset(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ fn sync_session(sync: codewhale_command_contract::facets::SessionSyncPayload) ->
|
||||
model: sync.model,
|
||||
workspace: sync.workspace,
|
||||
mode: super::contract::from_command_mode(sync.mode),
|
||||
agent_preset: sync.agent_preset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -608,26 +608,12 @@ pub fn mode(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandRe
|
||||
}
|
||||
}
|
||||
}
|
||||
None if is_conversation_mode_word(arg) => {
|
||||
ConfigCommandResult::action(ConfigAction::SetConversationMode {
|
||||
id: arg.trim().to_ascii_lowercase(),
|
||||
})
|
||||
}
|
||||
None => ConfigCommandResult::error(format!("Usage: {MODE_USAGE}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_conversation_mode_word(arg: &str) -> bool {
|
||||
let arg = arg.trim();
|
||||
!arg.is_empty()
|
||||
&& arg.len() <= 64
|
||||
&& arg
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
/// `/mode` usage, shared with the registration metadata.
|
||||
pub(super) const MODE_USAGE: &str = "/mode [work|plan|1|2|<conversation mode>]";
|
||||
pub(super) const MODE_USAGE: &str = "/mode [work|plan|1|2]";
|
||||
|
||||
/// `/pet` usage, shared with the registration metadata.
|
||||
pub const PET_USAGE: &str = "/pet [on|off|inspect|status|appearance|style [smooth|pixel]|language [auto|en|zh-Hans|zh-Hant|ja]|window|source|export|sound [on|off]|avatar [whale|cove|braille|pixels|particles|whale-girl|key]|action [name|live]|view [name|live]]";
|
||||
|
||||
@@ -58,7 +58,6 @@ pub fn clear(app: &mut App) -> CommandResult {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -260,30 +260,16 @@ fn load_composes_exact_baseline_outcomes() {
|
||||
fn new_composes_exact_baseline_outcomes() {
|
||||
// Unknown argument usage.
|
||||
let mut canned = CannedLifecycle::default();
|
||||
let result = super::new::new_portable(&mut canned, Some("--bogus"));
|
||||
let result = super::new::new_portable(&mut canned, Some("bogus"));
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Unknown argument: --bogus"),
|
||||
.contains("Unknown argument: bogus"),
|
||||
"{result:?}"
|
||||
);
|
||||
let result = super::new::new_portable(&mut canned, Some("creator standard"));
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Unknown argument: standard"),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(
|
||||
canned.fresh_modes.is_empty(),
|
||||
"a bad argument asks the host nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
canned.transition_checks.get(),
|
||||
0,
|
||||
@@ -327,21 +313,8 @@ fn new_composes_exact_baseline_outcomes() {
|
||||
Some(SessionAction::NewSession { sync: codewhale_command_contract::facets::SessionSyncPayload { session_id: Some(ref id), .. }, force: true }) if id == "new-123"
|
||||
));
|
||||
assert_eq!(canned.fresh_forces, [true]);
|
||||
assert_eq!(canned.fresh_modes, [None]);
|
||||
assert_eq!(canned.transition_checks.get(), 1);
|
||||
|
||||
let mut canned = CannedLifecycle {
|
||||
fresh: Ok(SessionNewReceipt {
|
||||
truncated_id: "new-456".to_string(),
|
||||
sync: sync_payload("new-456"),
|
||||
}),
|
||||
..CannedLifecycle::default()
|
||||
};
|
||||
let result = super::new::new_portable(&mut canned, Some("--force creator"));
|
||||
assert!(!result.is_error, "{result:?}");
|
||||
assert_eq!(canned.fresh_forces, [true]);
|
||||
assert_eq!(canned.fresh_modes, [Some("creator".to_string())]);
|
||||
|
||||
// Host blocker error passes through.
|
||||
let mut canned = CannedLifecycle {
|
||||
fresh: Err("Cannot start a new session while the composer has unsent text. Run `/new --force` to discard pending work and start a fresh session.".to_string()),
|
||||
|
||||
@@ -32,7 +32,6 @@ pub(crate) struct CannedLifecycle {
|
||||
pub save_paths: Vec<Option<String>>,
|
||||
pub fork_sources: Vec<String>,
|
||||
pub fresh_forces: Vec<bool>,
|
||||
pub fresh_modes: Vec<Option<String>>,
|
||||
pub load_paths: Vec<String>,
|
||||
pub picker_calls: Vec<Option<String>>,
|
||||
pub archive_calls: Vec<(String, bool)>,
|
||||
@@ -59,7 +58,6 @@ impl Default for CannedLifecycle {
|
||||
save_paths: Vec::new(),
|
||||
fork_sources: Vec::new(),
|
||||
fresh_forces: Vec::new(),
|
||||
fresh_modes: Vec::new(),
|
||||
load_paths: Vec::new(),
|
||||
picker_calls: Vec::new(),
|
||||
archive_calls: Vec::new(),
|
||||
@@ -76,7 +74,6 @@ pub(crate) fn sync_payload(session_id: &str) -> SessionSyncPayload {
|
||||
model: "test-model".to_string(),
|
||||
workspace: PathBuf::from("/workspace"),
|
||||
mode: codewhale_command_contract::types::CommandMode::Agent,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,13 +107,8 @@ impl CommandSessionLifecycleContext for CannedLifecycle {
|
||||
self.fork_sources.push(id.to_string());
|
||||
self.fork_from.clone()
|
||||
}
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> Result<SessionNewReceipt, String> {
|
||||
fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
|
||||
self.fresh_forces.push(force);
|
||||
self.fresh_modes.push(conversation_mode.map(str::to_string));
|
||||
self.fresh.clone()
|
||||
}
|
||||
fn load_session(&mut self, path: &str) -> Result<PathBuf, String> {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(in crate::commands) struct NewCmd;
|
||||
pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo {
|
||||
name: "new",
|
||||
aliases: &[],
|
||||
usage: "/new [<mode>] [--force]",
|
||||
usage: "/new [--force]",
|
||||
description_key: "cmd_new_description",
|
||||
};
|
||||
|
||||
@@ -51,21 +51,15 @@ pub(in crate::commands) fn new_portable(
|
||||
lifecycle: &mut dyn CommandSessionLifecycleContext,
|
||||
arg: Option<&str>,
|
||||
) -> CommandResult {
|
||||
let mut force = false;
|
||||
let mut conversation_mode: Option<&str> = None;
|
||||
for token in arg.unwrap_or_default().split_whitespace() {
|
||||
match token {
|
||||
"--force" | "force" => force = true,
|
||||
other if !other.starts_with('-') && conversation_mode.is_none() => {
|
||||
conversation_mode = Some(other);
|
||||
}
|
||||
other => {
|
||||
return CommandResult::error(format!(
|
||||
"Usage: /new [<mode>] [--force]\n\nUnknown argument: {other}"
|
||||
));
|
||||
}
|
||||
let force = match arg.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => false,
|
||||
Some("--force" | "force") => true,
|
||||
Some(other) => {
|
||||
return CommandResult::error(format!(
|
||||
"Usage: /new [--force]\n\nUnknown argument: {other}"
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
if lifecycle.transition_blocked() {
|
||||
// The shared tail names the exits that clear a blocker; `--force` is
|
||||
// not one of them, and a user who has just read it will try exactly
|
||||
@@ -75,7 +69,7 @@ pub(in crate::commands) fn new_portable(
|
||||
message.push_str("\n\n`/new --force` only discards draft or queued input.");
|
||||
return CommandResult::error(message);
|
||||
}
|
||||
match lifecycle.fresh_session(force, conversation_mode) {
|
||||
match lifecycle.fresh_session(force) {
|
||||
Ok(receipt) => CommandResult::action(
|
||||
codewhale_command_contract::outcome::SessionAction::NewSession {
|
||||
sync: receipt.sync,
|
||||
|
||||
@@ -94,7 +94,6 @@ pub(in crate::commands) fn host_result(result: SessionCommandResult) -> CommandR
|
||||
model: sync.model,
|
||||
workspace: sync.workspace,
|
||||
mode: super::contract::from_command_mode(sync.mode),
|
||||
agent_preset: sync.agent_preset,
|
||||
},
|
||||
});
|
||||
CommandResult {
|
||||
|
||||
@@ -77,7 +77,6 @@ pub(crate) async fn acknowledge_new_session(app: &mut App, result: &CommandResul
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await
|
||||
.expect("install current fixture session");
|
||||
@@ -1142,131 +1141,3 @@ fn branch_snapshot_roundtrip_preserves_siblings_ids_stamps_and_engine_projection
|
||||
let snapshot = crate::tui::ui::build_session_snapshot(&mut app, &manager).unwrap();
|
||||
assert_eq!(snapshot.journal.unwrap().entries, journal.entries);
|
||||
}
|
||||
|
||||
fn mode_test_message(text: &str) -> codewhale_models::Message {
|
||||
codewhale_models::Message {
|
||||
role: Role::User,
|
||||
content: vec![codewhale_models::ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn saved_file_value(path: &std::path::Path) -> serde_json::Value {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn new_creator_starts_a_creator_session_and_saves_agent_preset() {
|
||||
let _available = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(None);
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = SealedHome::new();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.current_session_id = Some("old-session".to_string());
|
||||
app.capture_session_fixture_source();
|
||||
|
||||
let result = new_session(&mut app, Some("creator"));
|
||||
assert!(!result.is_error, "{:?}", result.message);
|
||||
match result.action.as_ref() {
|
||||
Some(AppAction::NewSession { sync, .. }) => {
|
||||
assert_eq!(sync.agent_preset.as_deref(), Some("creator"));
|
||||
}
|
||||
other => panic!("expected NewSession, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
app.conversation_mode, None,
|
||||
"nothing changes before the Engine acknowledges"
|
||||
);
|
||||
acknowledge_new_session(&mut app, &result).await;
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
assert_eq!(app.agent_preset().as_deref(), Some("creator"));
|
||||
|
||||
app.push_api_message(mode_test_message("hello"));
|
||||
let creator_path = tmpdir.path().join("creator.json");
|
||||
assert!(!save(&mut app, Some(creator_path.to_str().unwrap())).is_error);
|
||||
assert_eq!(saved_file_value(&creator_path)["agent_preset"], "creator");
|
||||
|
||||
let mut standard = create_test_app_with_tmpdir(&tmpdir);
|
||||
standard.push_api_message(mode_test_message("hello"));
|
||||
let standard_path = tmpdir.path().join("standard.json");
|
||||
assert!(!save(&mut standard, Some(standard_path.to_str().unwrap())).is_error);
|
||||
assert!(
|
||||
saved_file_value(&standard_path)
|
||||
.get("agent_preset")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn new_without_argument_starts_standard_from_a_creator_session() {
|
||||
let _available = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(None);
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = SealedHome::new();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.current_session_id = Some("old-session".to_string());
|
||||
app.capture_session_fixture_source();
|
||||
app.install_conversation_mode(Some("creator".to_string()), None);
|
||||
app.push_api_message(mode_test_message("a creator conversation"));
|
||||
|
||||
let result = new_session(&mut app, None);
|
||||
assert!(!result.is_error, "{:?}", result.message);
|
||||
match result.action.as_ref() {
|
||||
Some(AppAction::NewSession { sync, .. }) => assert_eq!(sync.agent_preset, None),
|
||||
other => panic!("expected NewSession, got {other:?}"),
|
||||
}
|
||||
acknowledge_new_session(&mut app, &result).await;
|
||||
assert_eq!(
|
||||
app.conversation_mode, None,
|
||||
"never silently from the last one"
|
||||
);
|
||||
|
||||
let explicit = new_session(&mut app, Some("standard --force"));
|
||||
match explicit.action.as_ref() {
|
||||
Some(AppAction::NewSession { sync, force: true }) => assert_eq!(sync.agent_preset, None),
|
||||
other => panic!("expected a forced NewSession, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
fn new_with_an_unknown_mode_is_refused_with_the_mode_list_and_changes_nothing() {
|
||||
let _available = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(None);
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = SealedHome::new();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.current_session_id = Some("old-session".to_string());
|
||||
app.capture_session_fixture_source();
|
||||
app.install_conversation_mode(Some("creator".to_string()), None);
|
||||
|
||||
let result = new_session(&mut app, Some("minimal"));
|
||||
assert!(result.is_error);
|
||||
let text = result.message.as_deref().unwrap_or_default();
|
||||
assert!(
|
||||
text.contains("minimal") && text.contains("standard, creator"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(result.action.is_none());
|
||||
assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
assert!(app.pending_fresh_session.is_none());
|
||||
|
||||
drop(_available);
|
||||
let _unavailable = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(Some(
|
||||
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON,
|
||||
));
|
||||
let result = new_session(&mut app, Some("creator"));
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Not ready yet in this version."),
|
||||
"{:?}",
|
||||
result.message
|
||||
);
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
@@ -129,10 +129,6 @@ struct Cli {
|
||||
/// Recover the exact durable Resume/Fork intent without repeating its mutation.
|
||||
#[arg(long, global = true, value_name = "KEY")]
|
||||
operation_key: Option<String>,
|
||||
/// Conversation mode for a new interactive session (for example `creator`).
|
||||
/// With `--resume` or `--continue` it must match the session's saved mode.
|
||||
#[arg(long = "conversation-mode", value_name = "MODE")]
|
||||
conversation_mode: Option<String>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Cli {
|
||||
@@ -477,10 +473,6 @@ struct ExecArgs {
|
||||
/// prompts past the OS per-argument limit (~128 KiB on Linux).
|
||||
#[arg(long, value_name = "PATH", conflicts_with = "prompt")]
|
||||
prompt_file: Option<PathBuf>,
|
||||
/// Conversation mode for a new session (for example `creator`). With a
|
||||
/// resumed session it must match the session's saved mode.
|
||||
#[arg(long = "conversation-mode", value_name = "MODE")]
|
||||
conversation_mode: Option<String>,
|
||||
#[arg(
|
||||
value_name = "PROMPT",
|
||||
trailing_var_arg = true,
|
||||
@@ -3018,19 +3010,6 @@ async fn run_async_main_dispatch(
|
||||
.as_deref()
|
||||
.map(load_exec_resume_session)
|
||||
.transpose()?;
|
||||
let exec_conversation_mode = match tui::conversation_mode::launch_conversation_mode(
|
||||
&config,
|
||||
codewhale_localization::resolve_locale("auto"),
|
||||
args.conversation_mode.as_deref(),
|
||||
resume_session.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mode) => mode,
|
||||
Err(message) => {
|
||||
tui::conversation_mode::exit_refused("codewhale exec", &message)
|
||||
}
|
||||
};
|
||||
let explicit_model = args
|
||||
.model
|
||||
.as_deref()
|
||||
@@ -3162,7 +3141,6 @@ async fn run_async_main_dispatch(
|
||||
args.hooks,
|
||||
std::sync::Arc::clone(&plugin_registry),
|
||||
!tool_surface_requested,
|
||||
exec_conversation_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -3326,7 +3304,6 @@ async fn run_async_main_dispatch(
|
||||
let plugin_registry = policy_current_registry(plugin_registry);
|
||||
let prepared = prepare_interactive_config(&cli, &config, true)?;
|
||||
let source_id = resolve_session_id(session_id, last, &prepared.workspace)?;
|
||||
refuse_resume_mode_mismatch(&cli, &prepared.config, &source_id).await;
|
||||
let (prepared, new_session_id) = prepare_mounted_session(
|
||||
&cli,
|
||||
prepared,
|
||||
@@ -14526,7 +14503,6 @@ async fn run_interactive_with_notice(
|
||||
let plugin_registry = policy_current_registry(plugin_registry);
|
||||
let prepared = prepare_interactive_config(cli, config, resume_session_id.is_some())?;
|
||||
let (prepared, resume_session_id) = if let Some(selector) = resume_session_id {
|
||||
refuse_resume_mode_mismatch(cli, &prepared.config, &selector).await;
|
||||
let (prepared, id) = prepare_mounted_session(
|
||||
cli,
|
||||
prepared,
|
||||
@@ -14551,28 +14527,6 @@ async fn run_interactive_with_notice(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn refuse_resume_mode_mismatch(cli: &Cli, config: &Config, selector: &str) {
|
||||
if cli.conversation_mode.is_none() {
|
||||
return;
|
||||
}
|
||||
let locale = codewhale_localization::resolve_locale("auto");
|
||||
let outcome = match tui::conversation_mode::load_saved_for_resume(locale, selector) {
|
||||
Ok(saved) => {
|
||||
tui::conversation_mode::launch_conversation_mode(
|
||||
config,
|
||||
locale,
|
||||
cli.conversation_mode.as_deref(),
|
||||
Some(&saved),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(message) => Err(message),
|
||||
};
|
||||
if let Err(message) = outcome {
|
||||
tui::conversation_mode::exit_refused("codewhale", &message);
|
||||
}
|
||||
}
|
||||
|
||||
struct PreparedInteractiveConfig {
|
||||
config: Config,
|
||||
workspace: PathBuf,
|
||||
@@ -14631,32 +14585,6 @@ async fn run_interactive_prepared(
|
||||
) -> Result<()> {
|
||||
let PreparedInteractiveConfig { config, workspace } = prepared;
|
||||
let config = &config;
|
||||
let launch_conversation_mode = {
|
||||
let locale = codewhale_localization::resolve_locale("auto");
|
||||
let resumed = match (
|
||||
cli.conversation_mode.as_deref(),
|
||||
resume_session_id.as_deref(),
|
||||
) {
|
||||
(Some(_), Some(id)) => {
|
||||
match tui::conversation_mode::load_saved_for_resume(locale, id) {
|
||||
Ok(saved) => Some(saved),
|
||||
Err(message) => tui::conversation_mode::exit_refused("codewhale", &message),
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match tui::conversation_mode::launch_conversation_mode(
|
||||
config,
|
||||
locale,
|
||||
cli.conversation_mode.as_deref(),
|
||||
resumed.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mode) => mode,
|
||||
Err(message) => tui::conversation_mode::exit_refused("codewhale", &message),
|
||||
}
|
||||
};
|
||||
let initial_input = if cli.remote_control {
|
||||
Some(tui::InitialInput::RemoteControl)
|
||||
} else {
|
||||
@@ -14804,7 +14732,6 @@ async fn run_interactive_prepared(
|
||||
},
|
||||
plugin_registry,
|
||||
pending_telemetry_notice,
|
||||
launch_conversation_mode,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -304,106 +304,6 @@ fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSess
|
||||
saved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_conversation_mode_flag_sets_a_new_session_mode() {
|
||||
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
|
||||
let cli = parse_cli(&["codewhale", "exec", "--conversation-mode", "creator", "hi"]);
|
||||
let Some(Commands::Exec(args)) = cli.command else {
|
||||
panic!("exec command");
|
||||
};
|
||||
assert_eq!(args.conversation_mode.as_deref(), Some("creator"));
|
||||
let none = parse_cli(&["codewhale", "exec", "hi"]);
|
||||
let Some(Commands::Exec(args_none)) = none.command else {
|
||||
panic!("exec command");
|
||||
};
|
||||
assert_eq!(args_none.conversation_mode, None);
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let config = codewhale_runtime::config::Config::default();
|
||||
let locale = codewhale_localization::Locale::En;
|
||||
let resolve = |flag: Option<&str>| {
|
||||
runtime.block_on(tui::conversation_mode::launch_conversation_mode(
|
||||
&config, locale, flag, None,
|
||||
))
|
||||
};
|
||||
{
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
assert_eq!(resolve(Some("creator")), Ok(Some("creator".to_string())));
|
||||
assert_eq!(resolve(Some("standard")), Ok(None));
|
||||
assert_eq!(resolve(None), Ok(None));
|
||||
let unknown = resolve(Some("minimal")).expect_err("unknown id");
|
||||
assert!(unknown.contains("standard, creator"), "{unknown}");
|
||||
}
|
||||
{
|
||||
let _unavailable = CreatorAvailabilityGuard::set(Some(
|
||||
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON,
|
||||
));
|
||||
let refused = resolve(Some("creator")).expect_err("unavailable");
|
||||
assert!(refused.contains("Not ready yet in this version."), "{refused}");
|
||||
}
|
||||
|
||||
let mut saved = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
saved.agent_preset = resolve_stored_for_test(&runtime, &config, "creator");
|
||||
let raw = serde_json::to_value(&saved).unwrap();
|
||||
assert_eq!(raw["agent_preset"], "creator");
|
||||
let standard = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
assert!(serde_json::to_value(&standard).unwrap().get("agent_preset").is_none());
|
||||
}
|
||||
|
||||
fn resolve_stored_for_test(
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
config: &codewhale_runtime::config::Config,
|
||||
flag: &str,
|
||||
) -> Option<String> {
|
||||
let _available = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(None);
|
||||
runtime
|
||||
.block_on(tui::conversation_mode::launch_conversation_mode(
|
||||
config,
|
||||
codewhale_localization::Locale::En,
|
||||
Some(flag),
|
||||
None,
|
||||
))
|
||||
.expect("resolves")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_conversation_mode_flag_that_differs_from_a_resumed_session_is_refused_before_any_model_call() {
|
||||
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let config = codewhale_runtime::config::Config::default();
|
||||
let locale = codewhale_localization::Locale::En;
|
||||
let resolve = |flag: &str, saved: &session_manager::SavedSession| {
|
||||
runtime.block_on(tui::conversation_mode::launch_conversation_mode(
|
||||
&config,
|
||||
locale,
|
||||
Some(flag),
|
||||
Some(saved),
|
||||
))
|
||||
};
|
||||
let _unavailable = CreatorAvailabilityGuard::set(Some(
|
||||
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON,
|
||||
));
|
||||
let standard = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
let mut creator = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
creator.agent_preset = Some("creator".to_string());
|
||||
|
||||
let refused = resolve("creator", &standard).expect_err("Standard session, creator flag");
|
||||
assert!(refused.contains("fixed") && refused.contains("/new creator"), "{refused}");
|
||||
let refused = resolve("standard", &creator).expect_err("creator session, standard flag");
|
||||
assert!(refused.contains("fixed") && refused.contains("/new"), "{refused}");
|
||||
assert!(resolve("minimal", &creator).is_err());
|
||||
assert_eq!(resolve("standard", &standard), Ok(None));
|
||||
assert_eq!(
|
||||
resolve("creator", &creator),
|
||||
Ok(Some("creator".to_string())),
|
||||
"a matching flag resumes even while the mode is unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
|
||||
let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
|
||||
|
||||
@@ -2277,8 +2277,6 @@ pub struct App {
|
||||
pub undo_selection: bool,
|
||||
/// Current session ID for auto-save updates
|
||||
pub current_session_id: Option<String>,
|
||||
pub(crate) conversation_mode: Option<String>,
|
||||
pub(crate) conversation_mode_unavailable: Option<String>,
|
||||
pub(crate) session_write_receipt:
|
||||
Option<codewhale_engine::session_manager::SessionWriteReceipt>,
|
||||
pub(crate) session_write_owner: Option<codewhale_engine::session_manager::SessionWriteOwner>,
|
||||
@@ -5592,37 +5590,6 @@ impl App {
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn agent_preset(&self) -> Option<String> {
|
||||
self.conversation_mode.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn conversation_mode_locked(&self) -> bool {
|
||||
!self.api_messages.is_empty()
|
||||
|| self.is_loading
|
||||
|| !self.queued_messages.is_empty()
|
||||
|| self.queued_draft.is_some()
|
||||
|| self.pending_fresh_session.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn install_conversation_mode(
|
||||
&mut self,
|
||||
stored: Option<String>,
|
||||
unavailable: Option<String>,
|
||||
) {
|
||||
self.conversation_mode = stored;
|
||||
self.conversation_mode_unavailable = unavailable;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub(crate) fn conversation_mode_label(&self) -> Option<String> {
|
||||
crate::tui::conversation_mode::label(
|
||||
self.ui_locale,
|
||||
self.conversation_mode.as_deref(),
|
||||
self.conversation_mode_unavailable.as_deref(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn set_current_session_id(&mut self, session_id: Option<String>) {
|
||||
if self.current_session_id != session_id {
|
||||
self.view_stack.remove_owned_evidence_pagers();
|
||||
|
||||
@@ -1062,8 +1062,6 @@ impl App {
|
||||
backtrack: crate::tui::backtrack::BacktrackState::new(),
|
||||
undo_selection: false,
|
||||
current_session_id: None,
|
||||
conversation_mode: None,
|
||||
conversation_mode_unavailable: None,
|
||||
session_write_receipt: None,
|
||||
session_write_owner: None,
|
||||
offline_queue_lease: None,
|
||||
|
||||
@@ -559,10 +559,6 @@ pub enum AppAction {
|
||||
model: String,
|
||||
workspace: PathBuf,
|
||||
mode: AppMode,
|
||||
agent_preset: Option<String>,
|
||||
},
|
||||
SetConversationMode {
|
||||
id: String,
|
||||
},
|
||||
OpenConfigView,
|
||||
/// Open this workspace's `.codewhale/hooks.toml` in `$EDITOR`, creating
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
use codewhale_engine::conversation_modes::{
|
||||
self, CREATOR, CREATOR_NOT_READY_REASON, ConversationMode, ConversationModeTable, STANDARD,
|
||||
};
|
||||
use codewhale_engine::extension_host::ExtensionHostOptions;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_runtime::config::Config;
|
||||
|
||||
const LABEL_CHARS: usize = 40;
|
||||
|
||||
pub(crate) fn options(config: &Config) -> ExtensionHostOptions {
|
||||
ExtensionHostOptions::from_config(config.extension_host.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) async fn current_table(config: &Config) -> ConversationModeTable {
|
||||
conversation_modes::current_table(options(config)).await
|
||||
}
|
||||
|
||||
pub(crate) fn current_table_blocking(config: Option<&Config>) -> ConversationModeTable {
|
||||
let options = config.map_or_else(ExtensionHostOptions::default, options);
|
||||
let future = conversation_modes::current_table(options);
|
||||
match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
|
||||
tokio::task::block_in_place(|| handle.block_on(future))
|
||||
}
|
||||
_ => std::thread::scope(|scope| {
|
||||
scope
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("conversation mode runtime")
|
||||
.block_on(future)
|
||||
})
|
||||
.join()
|
||||
.expect("conversation mode probe")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn display_name(locale: Locale, mode: &ConversationMode) -> String {
|
||||
match mode.id.as_str() {
|
||||
STANDARD => tr(locale, MessageId::ConversationModeStandardName).into_owned(),
|
||||
CREATOR => tr(locale, MessageId::ConversationModeCreatorName).into_owned(),
|
||||
_ => mode.name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn display_description(locale: Locale, mode: &ConversationMode) -> String {
|
||||
match mode.id.as_str() {
|
||||
STANDARD => tr(locale, MessageId::ConversationModeStandardDescription).into_owned(),
|
||||
CREATOR => tr(locale, MessageId::ConversationModeCreatorDescription).into_owned(),
|
||||
_ => mode.description.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn display_reason(locale: Locale, reason: &str) -> String {
|
||||
if reason == CREATOR_NOT_READY_REASON {
|
||||
tr(locale, MessageId::ConversationModeNotReady).into_owned()
|
||||
} else {
|
||||
reason.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn clean(text: &str) -> String {
|
||||
let mut out: String = text
|
||||
.chars()
|
||||
.filter(|c| {
|
||||
!c.is_control()
|
||||
&& !matches!(*c, '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')
|
||||
})
|
||||
.take(LABEL_CHARS + 1)
|
||||
.collect();
|
||||
if out.chars().count() > LABEL_CHARS {
|
||||
out = out.chars().take(LABEL_CHARS).collect::<String>() + "…";
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn name_for_stored(
|
||||
locale: Locale,
|
||||
stored: Option<&str>,
|
||||
table: Option<&ConversationModeTable>,
|
||||
) -> String {
|
||||
let id = conversation_modes::effective_id(stored);
|
||||
match table.and_then(|table| table.modes.iter().find(|mode| mode.id == id)) {
|
||||
Some(mode) => display_name(locale, mode),
|
||||
None if id == STANDARD => tr(locale, MessageId::ConversationModeStandardName).into_owned(),
|
||||
None if id == CREATOR => tr(locale, MessageId::ConversationModeCreatorName).into_owned(),
|
||||
None => clean(id),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unavailable_reason(
|
||||
stored: Option<&str>,
|
||||
table: &ConversationModeTable,
|
||||
locale: Locale,
|
||||
) -> Option<String> {
|
||||
let id = conversation_modes::effective_id(stored);
|
||||
match table.modes.iter().find(|mode| mode.id == id) {
|
||||
Some(mode) => mode
|
||||
.reason
|
||||
.as_deref()
|
||||
.filter(|_| !mode.available)
|
||||
.map(|reason| display_reason(locale, reason))
|
||||
.or_else(|| (!mode.available).then(|| "unavailable".to_string())),
|
||||
None => Some(
|
||||
tr(locale, MessageId::ConversationModeUnknown)
|
||||
.replace("{id}", &clean(id))
|
||||
.replace(
|
||||
"{modes}",
|
||||
&table
|
||||
.modes
|
||||
.iter()
|
||||
.map(|mode| mode.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn label(
|
||||
locale: Locale,
|
||||
stored: Option<&str>,
|
||||
unavailable: Option<&str>,
|
||||
table: Option<&ConversationModeTable>,
|
||||
) -> Option<String> {
|
||||
stored?;
|
||||
let name = name_for_stored(locale, stored, table);
|
||||
Some(if unavailable.is_some() {
|
||||
tr(locale, MessageId::ConversationModeLabelUnavailable).replace("{name}", &name)
|
||||
} else {
|
||||
name
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn new_command(id: &str) -> String {
|
||||
if id == STANDARD {
|
||||
"/new".to_string()
|
||||
} else {
|
||||
format!("/new {id}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn locked_refusal(locale: Locale, requested_id: &str) -> String {
|
||||
tr(locale, MessageId::ConversationModeLockedRefusal)
|
||||
.replace("{command}", &format!("`{}`", new_command(requested_id)))
|
||||
}
|
||||
|
||||
pub(crate) enum Resolution {
|
||||
Unknown(String),
|
||||
Unavailable {
|
||||
name: String,
|
||||
reason: String,
|
||||
},
|
||||
Ready {
|
||||
stored: Option<String>,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(locale: Locale, table: &ConversationModeTable, id: &str) -> Resolution {
|
||||
let id = id.trim().to_ascii_lowercase();
|
||||
let id = id.as_str();
|
||||
match table.modes.iter().find(|mode| mode.id == id) {
|
||||
None => Resolution::Unknown(
|
||||
tr(locale, MessageId::ConversationModeUnknown)
|
||||
.replace("{id}", &clean(id))
|
||||
.replace(
|
||||
"{modes}",
|
||||
&table
|
||||
.modes
|
||||
.iter()
|
||||
.map(|mode| mode.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
),
|
||||
),
|
||||
Some(mode) if !mode.available => Resolution::Unavailable {
|
||||
name: display_name(locale, mode),
|
||||
reason: display_reason(
|
||||
locale,
|
||||
mode.reason
|
||||
.as_deref()
|
||||
.unwrap_or("This mode is unavailable."),
|
||||
),
|
||||
},
|
||||
Some(mode) => Resolution::Ready {
|
||||
stored: conversation_modes::stored_form(&mode.id),
|
||||
name: display_name(locale, mode),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unavailable_message(locale: Locale, name: &str, reason: &str) -> String {
|
||||
tr(locale, MessageId::ConversationModeUnavailableRefusal)
|
||||
.replace("{name}", name)
|
||||
.replace("{reason}", reason)
|
||||
}
|
||||
|
||||
pub(crate) fn saved_unavailable_notice(locale: Locale, name: &str, reason: &str) -> String {
|
||||
tr(locale, MessageId::ConversationModeSavedUnavailableNotice)
|
||||
.replace("{name}", name)
|
||||
.replace("{reason}", reason)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_in_place(
|
||||
app: &mut crate::tui::app::App,
|
||||
config: &Config,
|
||||
engine: &codewhale_engine::core::engine::EngineHandle,
|
||||
id: &str,
|
||||
) -> Result<String, String> {
|
||||
let locale = app.ui_locale;
|
||||
let table = current_table(config).await;
|
||||
match resolve(locale, &table, id) {
|
||||
Resolution::Unknown(message) => Err(message),
|
||||
Resolution::Unavailable { name, reason } => {
|
||||
Err(unavailable_message(locale, &name, &reason))
|
||||
}
|
||||
Resolution::Ready { stored, name } => {
|
||||
let done = tr(locale, MessageId::ConversationModeSet).replace("{name}", &name);
|
||||
if stored == app.conversation_mode {
|
||||
return Ok(done);
|
||||
}
|
||||
if app.conversation_mode_locked() {
|
||||
return Err(locked_refusal(locale, id.trim()));
|
||||
}
|
||||
app.install_conversation_mode(stored, None);
|
||||
let _ = engine
|
||||
.send(codewhale_engine::core::ops::Op::SyncSession {
|
||||
expected: None,
|
||||
control: app.session_sync_control(),
|
||||
session_id: app.current_session_id.clone(),
|
||||
messages: app.api_messages.as_ref().clone(),
|
||||
system_prompt: app.system_prompt.clone(),
|
||||
system_prompt_override: false,
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
Ok(done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn launch_conversation_mode(
|
||||
config: &Config,
|
||||
locale: Locale,
|
||||
flag: Option<&str>,
|
||||
resumed: Option<&codewhale_engine::session_manager::SavedSession>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(id) = flag.map(str::trim) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(saved) = resumed {
|
||||
let requested = conversation_modes::stored_form(&id.to_ascii_lowercase());
|
||||
return if requested == saved.agent_preset {
|
||||
Ok(requested)
|
||||
} else {
|
||||
Err(locked_refusal(locale, id))
|
||||
};
|
||||
}
|
||||
let table = current_table(config).await;
|
||||
match resolve(locale, &table, id) {
|
||||
Resolution::Unknown(message) => Err(message),
|
||||
Resolution::Unavailable { name, reason } => {
|
||||
Err(unavailable_message(locale, &name, &reason))
|
||||
}
|
||||
Resolution::Ready { stored, .. } => Ok(stored),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_saved_for_resume(
|
||||
locale: Locale,
|
||||
selector: &str,
|
||||
) -> Result<codewhale_engine::session_manager::SavedSession, String> {
|
||||
codewhale_engine::session_manager::SessionManager::default_location()
|
||||
.ok()
|
||||
.and_then(|manager| manager.load_session_by_prefix(selector).ok())
|
||||
.ok_or_else(|| tr(locale, MessageId::ConversationModeResumeUnreadable).into_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn exit_refused(program: &str, message: &str) -> ! {
|
||||
eprintln!("{program}: {message}");
|
||||
std::process::exit(2)
|
||||
}
|
||||
@@ -34,7 +34,6 @@ pub mod composer_ui;
|
||||
pub mod context_inspector;
|
||||
pub mod context_menu;
|
||||
pub(crate) mod control_socket;
|
||||
pub(crate) mod conversation_mode;
|
||||
pub(crate) mod coordination_detail;
|
||||
pub mod display_refresh;
|
||||
pub mod external_editor;
|
||||
|
||||
@@ -582,7 +582,6 @@ async fn spawn_tui_engine_with_session(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -1190,7 +1190,6 @@ pub(crate) async fn apply_provider_fallback_switch(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -1726,7 +1725,6 @@ async fn apply_command_result_inner(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
let _ = engine_handle
|
||||
@@ -1766,7 +1764,6 @@ async fn apply_command_result_inner(
|
||||
model,
|
||||
workspace,
|
||||
mode,
|
||||
agent_preset,
|
||||
} => {
|
||||
let mut session_id = session_id;
|
||||
let is_full_reset = messages.is_empty() && system_prompt.is_none();
|
||||
@@ -1847,7 +1844,6 @@ async fn apply_command_result_inner(
|
||||
model,
|
||||
workspace,
|
||||
mode,
|
||||
agent_preset,
|
||||
})
|
||||
.await;
|
||||
let _ = engine_handle
|
||||
@@ -1967,7 +1963,6 @@ async fn apply_command_result_inner(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -2815,24 +2810,13 @@ async fn apply_command_result_inner(
|
||||
}
|
||||
AppAction::OpenModePicker => {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::ModePicker) {
|
||||
let view =
|
||||
crate::tui::views::mode_picker::ModePickerView::for_app(app, config).await;
|
||||
app.view_stack.push(view);
|
||||
app.view_stack
|
||||
.push(crate::tui::views::mode_picker::ModePickerView::new(
|
||||
app.mode,
|
||||
app.ui_locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
AppAction::SetConversationMode { id } => {
|
||||
let content = match crate::tui::conversation_mode::set_in_place(
|
||||
app,
|
||||
config,
|
||||
engine_handle,
|
||||
&id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(message) | Err(message) => message,
|
||||
};
|
||||
app.add_message(HistoryCell::System { content });
|
||||
}
|
||||
AppAction::OpenStatusPicker => {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) {
|
||||
app.view_stack
|
||||
@@ -3205,7 +3189,6 @@ async fn apply_command_result_inner(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -4820,22 +4803,6 @@ pub(crate) fn apply_loaded_session_with_goal(
|
||||
if let Some(mode) = session.metadata.mode.as_deref().and_then(AppMode::parse) {
|
||||
app.set_mode(mode);
|
||||
}
|
||||
let saved_mode_unavailable = match session.agent_preset.clone() {
|
||||
Some(stored) => {
|
||||
let table = crate::tui::conversation_mode::current_table_blocking(Some(config));
|
||||
let reason = crate::tui::conversation_mode::unavailable_reason(
|
||||
Some(&stored),
|
||||
&table,
|
||||
app.ui_locale,
|
||||
);
|
||||
app.install_conversation_mode(Some(stored), reason.clone());
|
||||
reason
|
||||
}
|
||||
None => {
|
||||
app.install_conversation_mode(None, None);
|
||||
None
|
||||
}
|
||||
};
|
||||
app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
|
||||
app.session.total_conversation_tokens = app.session.total_tokens;
|
||||
app.session.displayed_cost_high_water = 0.0;
|
||||
@@ -4898,20 +4865,6 @@ pub(crate) fn apply_loaded_session_with_goal(
|
||||
app.workspace_context_refreshed_at = None;
|
||||
app.system_prompt = session.system_prompt.map(SystemPrompt::Text);
|
||||
app.retire_event_notices(super::event_loop::STARTUP_NOTICE_TOAST);
|
||||
if let Some(reason) = saved_mode_unavailable {
|
||||
let name = crate::tui::conversation_mode::name_for_stored(
|
||||
app.ui_locale,
|
||||
app.conversation_mode.as_deref(),
|
||||
None,
|
||||
);
|
||||
app.add_message(HistoryCell::System {
|
||||
content: crate::tui::conversation_mode::saved_unavailable_notice(
|
||||
app.ui_locale,
|
||||
&name,
|
||||
&reason,
|
||||
),
|
||||
});
|
||||
}
|
||||
app.scroll_to_bottom();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -930,7 +930,6 @@ pub async fn run_tui(
|
||||
options: TuiOptions,
|
||||
plugin_registry: std::sync::Arc<codewhale_plugins::PluginRegistry>,
|
||||
pending_telemetry_notice: Option<codewhale_engine::telemetry_notice::PendingTelemetryNotice>,
|
||||
launch_conversation_mode: Option<String>,
|
||||
) -> Result<()> {
|
||||
// Install notification, sound, category, and attention policy before any
|
||||
// producer (including the model-facing notify tool) can emit an event.
|
||||
@@ -1106,7 +1105,6 @@ pub async fn run_tui(
|
||||
let mut config = config.clone();
|
||||
let config = &mut config;
|
||||
let mut app = App::new_with_plugin_registry(options.clone(), config, plugin_registry);
|
||||
app.install_conversation_mode(launch_conversation_mode, None);
|
||||
let _cursor_accent_guard = codewhale_tui_kit::cursor_accent::CursorAccentGuard::install(
|
||||
app.low_motion || !app.fancy_animations,
|
||||
app.ui_theme.accent_primary,
|
||||
@@ -5146,7 +5144,6 @@ pub(crate) async fn run_event_loop(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -8904,7 +8901,6 @@ mod launch_session_sync_tests {
|
||||
model,
|
||||
workspace: workspace.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
|
||||
@@ -1361,7 +1361,6 @@ pub(crate) fn build_session_snapshot(
|
||||
app.system_prompt.as_ref(),
|
||||
Some(app.mode.as_setting()),
|
||||
);
|
||||
session.agent_preset = app.agent_preset();
|
||||
session.write_receipt = if app.current_session_id.is_none() {
|
||||
Some(
|
||||
manager
|
||||
|
||||
@@ -875,49 +875,3 @@ fn statusline_full_frame_context_reading_updates_below_and_at_warning() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_mode_label_shows_creator_and_nothing_for_standard() {
|
||||
for (width, height) in [(80u16, 24u16), (120, 32)] {
|
||||
let mut standard = frame_app();
|
||||
let standard_rows = draw(&mut standard, width, height);
|
||||
assert!(
|
||||
standard_rows
|
||||
.iter()
|
||||
.all(|row| !row.contains("Creator mode") && !row.contains("Standard mode")),
|
||||
"{width}x{height}: Standard shows nothing new:\n{}",
|
||||
standard_rows.join("\n")
|
||||
);
|
||||
|
||||
let mut creator = frame_app();
|
||||
creator.install_conversation_mode(Some("creator".to_string()), None);
|
||||
let rows = draw(&mut creator, width, height);
|
||||
println!(
|
||||
"--- footer, Creator mode, {width}x{height}\n{}",
|
||||
rows.iter()
|
||||
.map(|row| row.trim_end())
|
||||
.filter(|row| !row.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
assert_eq!(
|
||||
count_rows_containing(&rows, "Creator mode"),
|
||||
1,
|
||||
"{width}x{height}: the mode name paints once, as text:\n{}",
|
||||
rows.join("\n")
|
||||
);
|
||||
|
||||
let mut unavailable = frame_app();
|
||||
unavailable.install_conversation_mode(
|
||||
Some("creator".to_string()),
|
||||
Some("Not ready yet in this version.".to_string()),
|
||||
);
|
||||
let rows = draw(&mut unavailable, width, height);
|
||||
assert_eq!(
|
||||
count_rows_containing(&rows, "Creator mode (unavailable)"),
|
||||
1,
|
||||
"{width}x{height}:\n{}",
|
||||
rows.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2287,7 +2287,6 @@ pub(crate) async fn handle_view_events(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
let _ = engine_handle
|
||||
@@ -3022,9 +3021,11 @@ pub(crate) async fn handle_view_events(
|
||||
}
|
||||
ViewEvent::SetupOpenModeRequested => {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::ModePicker) {
|
||||
let view =
|
||||
crate::tui::views::mode_picker::ModePickerView::for_app(app, config).await;
|
||||
app.view_stack.push(view);
|
||||
app.view_stack
|
||||
.push(crate::tui::views::mode_picker::ModePickerView::new(
|
||||
app.mode,
|
||||
app.ui_locale,
|
||||
));
|
||||
app.status_message =
|
||||
Some("Work mode picker opened from /setup runtime posture.".to_string());
|
||||
}
|
||||
@@ -3458,19 +3459,6 @@ pub(crate) async fn handle_view_events(
|
||||
}
|
||||
app.add_message(HistoryCell::System { content: msg });
|
||||
}
|
||||
ViewEvent::ConversationModeSelected { id } => {
|
||||
let content = match crate::tui::conversation_mode::set_in_place(
|
||||
app,
|
||||
config,
|
||||
engine_handle,
|
||||
&id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(message) | Err(message) => message,
|
||||
};
|
||||
app.add_message(HistoryCell::System { content });
|
||||
}
|
||||
ViewEvent::BacktrackStep { direction } => {
|
||||
app.backtrack.step(direction);
|
||||
if let Some(idx) = app.backtrack.selected_idx() {
|
||||
|
||||
@@ -936,7 +936,6 @@ async fn switch_provider_inner(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -1116,7 +1116,6 @@ pub(crate) fn begin_launch_session(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1166,7 +1165,6 @@ pub(crate) async fn switch_workspace(
|
||||
model: app.model.clone(),
|
||||
workspace: workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -1875,7 +1873,6 @@ pub(crate) struct PendingFreshSession {
|
||||
model: String,
|
||||
workspace: PathBuf,
|
||||
mode: AppMode,
|
||||
agent_preset: Option<String>,
|
||||
queue_transition: Option<OfflineQueueTransition>,
|
||||
write_receipt: codewhale_engine::session_manager::SessionWriteReceipt,
|
||||
input: String,
|
||||
@@ -1910,7 +1907,6 @@ pub(crate) fn begin_fresh_session(
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let agent_preset = sync.agent_preset.clone();
|
||||
let id = sync
|
||||
.session_id
|
||||
.filter(|id| !id.is_empty())
|
||||
@@ -1944,7 +1940,6 @@ pub(crate) fn begin_fresh_session(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset,
|
||||
queue_transition,
|
||||
write_receipt,
|
||||
input: app.input.clone(),
|
||||
@@ -2030,7 +2025,6 @@ pub(crate) fn poll_fresh_session(app: &mut App, engine: &EngineHandle) -> bool {
|
||||
model: pending.model.clone(),
|
||||
workspace: pending.workspace.clone(),
|
||||
mode: pending.mode,
|
||||
agent_preset: pending.agent_preset.clone(),
|
||||
},
|
||||
);
|
||||
engine.send_reserved_op(
|
||||
@@ -2194,7 +2188,6 @@ fn commit_fresh_session(app: &mut App, mut pending: PendingFreshSession) -> bool
|
||||
app.session_context_references.clear();
|
||||
app.tool_evidence.clear();
|
||||
app.set_current_session_id(Some(pending.id.clone()));
|
||||
app.install_conversation_mode(pending.agent_preset.clone(), None);
|
||||
app.session_write_receipt = Some(pending.write_receipt);
|
||||
app.session_write_owner =
|
||||
Some(codewhale_engine::session_manager::SessionWriteOwner::Control(pending.control));
|
||||
|
||||
@@ -58,7 +58,6 @@ use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
|
||||
use codewhale_models::Role;
|
||||
use tempfile::TempDir;
|
||||
|
||||
mod conversation_mode;
|
||||
mod conversation_undo;
|
||||
mod model_picker_actions;
|
||||
mod plan_handoff;
|
||||
@@ -34185,7 +34184,6 @@ fn stale_session_projection_cannot_rewind_a_host_launch() {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let (reply, mut answer) = tokio::sync::oneshot::channel();
|
||||
let request = codewhale_engine::core::events::SessionCheckpointRequest::new(
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
use super::*;
|
||||
use codewhale_engine::conversation_modes::{CREATOR_NOT_READY_REASON, CreatorAvailabilityGuard};
|
||||
use codewhale_engine::core::engine::Engine;
|
||||
use codewhale_engine::core::ops::Op;
|
||||
|
||||
fn creator_app() -> App {
|
||||
let mut app = create_test_app();
|
||||
app.install_conversation_mode(Some("creator".to_string()), None);
|
||||
app
|
||||
}
|
||||
|
||||
fn first_message(app: &mut App) {
|
||||
app.push_api_message(text_message("user", "first message"));
|
||||
}
|
||||
|
||||
fn sent_presets(rx: &mut tokio::sync::mpsc::Receiver<Op>) -> Vec<Option<String>> {
|
||||
let mut presets = Vec::new();
|
||||
while let Ok(op) = rx.try_recv() {
|
||||
if let Op::SyncSession { agent_preset, .. } = op {
|
||||
presets.push(agent_preset);
|
||||
}
|
||||
}
|
||||
presets
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn mode_change_after_the_first_message_is_refused_as_locked() {
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let mut app = create_test_app();
|
||||
let mut engine = mock_engine_handle();
|
||||
let config = Config::default();
|
||||
|
||||
let done =
|
||||
crate::tui::conversation_mode::set_in_place(&mut app, &config, &engine.handle, "creator")
|
||||
.await
|
||||
.expect("a blank conversation changes in place");
|
||||
assert!(done.contains("Creator mode"), "{done}");
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
assert_eq!(
|
||||
sent_presets(&mut engine.rx_op),
|
||||
vec![Some("creator".to_string())]
|
||||
);
|
||||
|
||||
first_message(&mut app);
|
||||
for requested in ["standard", "creator"] {
|
||||
let result = crate::tui::conversation_mode::set_in_place(
|
||||
&mut app,
|
||||
&config,
|
||||
&engine.handle,
|
||||
requested,
|
||||
)
|
||||
.await;
|
||||
if requested == "creator" {
|
||||
assert!(result.is_ok(), "naming the current mode is not a change");
|
||||
} else {
|
||||
let refusal = result.expect_err("locked after the first message");
|
||||
assert!(refusal.contains("fixed"), "{refusal}");
|
||||
assert!(refusal.contains("/new"), "{refusal}");
|
||||
}
|
||||
}
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
assert!(
|
||||
sent_presets(&mut engine.rx_op).is_empty(),
|
||||
"a refusal sends nothing"
|
||||
);
|
||||
|
||||
let result = commands::execute("/mode standard", &mut app);
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::SetConversationMode { ref id }) if id == "standard"
|
||||
));
|
||||
let mut blank = create_test_app();
|
||||
blank
|
||||
.queued_messages
|
||||
.push_back(crate::tui::app::QueuedMessage::new(
|
||||
"queued".to_string(),
|
||||
None,
|
||||
));
|
||||
assert!(
|
||||
blank.conversation_mode_locked(),
|
||||
"a queued message locks the mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn unknown_and_unavailable_modes_are_refused_with_their_words() {
|
||||
let mut app = create_test_app();
|
||||
let engine = mock_engine_handle();
|
||||
let config = Config::default();
|
||||
{
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let refusal = crate::tui::conversation_mode::set_in_place(
|
||||
&mut app,
|
||||
&config,
|
||||
&engine.handle,
|
||||
"minimal",
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown id");
|
||||
assert!(
|
||||
refusal.contains("minimal") && refusal.contains("standard, creator"),
|
||||
"{refusal}"
|
||||
);
|
||||
}
|
||||
let _missing = CreatorAvailabilityGuard::set(Some(CREATOR_NOT_READY_REASON));
|
||||
let refusal =
|
||||
crate::tui::conversation_mode::set_in_place(&mut app, &config, &engine.handle, "creator")
|
||||
.await
|
||||
.expect_err("unavailable");
|
||||
assert!(
|
||||
refusal.contains("Not ready yet in this version."),
|
||||
"{refusal}"
|
||||
);
|
||||
assert_eq!(app.conversation_mode, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn a_saved_unavailable_mode_loads_its_history_and_says_why() {
|
||||
let _guard = CreatorAvailabilityGuard::set(Some(CREATOR_NOT_READY_REASON));
|
||||
let hostile = [
|
||||
"creator".to_string(),
|
||||
"nonsense".to_string(),
|
||||
"../x".to_string(),
|
||||
String::new(),
|
||||
"x".repeat(1_000_000),
|
||||
"line\nbreak\u{1b}[31m".to_string(),
|
||||
];
|
||||
for stored in hostile {
|
||||
let mut app = create_test_app();
|
||||
let mut session = saved_session_with_messages(vec![text_message("user", "history line")]);
|
||||
session.agent_preset = Some(stored.clone());
|
||||
apply_loaded_session(&mut app, &mut Config::default(), &session)
|
||||
.expect("the history loads");
|
||||
assert_eq!(app.api_messages.len(), 1, "history is shown");
|
||||
assert_eq!(
|
||||
app.conversation_mode.as_deref(),
|
||||
Some(stored.as_str()),
|
||||
"never silently Standard"
|
||||
);
|
||||
assert!(app.conversation_mode_unavailable.is_some());
|
||||
let label = app.conversation_mode_label().expect("a label");
|
||||
assert!(label.contains("unavailable"), "{label}");
|
||||
assert!(label.chars().count() <= 80 && !label.contains('\n') && !label.contains('\u{1b}'));
|
||||
assert!(app.history.iter().any(|cell| matches!(
|
||||
cell,
|
||||
HistoryCell::System { content } if content.contains("which is not available here")
|
||||
)));
|
||||
}
|
||||
let mut app = create_test_app();
|
||||
let mut session = saved_session_with_messages(vec![text_message("user", "history line")]);
|
||||
session.agent_preset = Some("creator".to_string());
|
||||
apply_loaded_session(&mut app, &mut Config::default(), &session).expect("loads");
|
||||
assert!(app.history.iter().any(|cell| matches!(
|
||||
cell,
|
||||
HistoryCell::System { content } if content.contains("Not ready yet in this version.")
|
||||
)));
|
||||
|
||||
let mut standard = create_test_app();
|
||||
let session = saved_session_with_messages(vec![text_message("user", "history line")]);
|
||||
assert_eq!(session.agent_preset, None);
|
||||
apply_loaded_session(&mut standard, &mut Config::default(), &session).expect("loads");
|
||||
assert_eq!(standard.conversation_mode, None);
|
||||
assert_eq!(standard.conversation_mode_label(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn clear_fork_and_resume_keep_the_conversation_mode() {
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let _home = crate::test_support::SealedHome::new();
|
||||
|
||||
let mut app = creator_app();
|
||||
first_message(&mut app);
|
||||
let cleared = commands::execute("/clear", &mut app);
|
||||
assert!(matches!(
|
||||
cleared.action,
|
||||
Some(AppAction::SyncSession { agent_preset: Some(ref preset), .. }) if preset == "creator"
|
||||
));
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
|
||||
let mut resumed = create_test_app();
|
||||
let mut session = saved_session_with_messages(vec![text_message("user", "older")]);
|
||||
session.agent_preset = Some("creator".to_string());
|
||||
apply_loaded_session(&mut resumed, &mut Config::default(), &session).expect("resume");
|
||||
assert_eq!(resumed.conversation_mode.as_deref(), Some("creator"));
|
||||
assert_eq!(resumed.conversation_mode_unavailable, None);
|
||||
assert_eq!(resumed.agent_preset().as_deref(), Some("creator"));
|
||||
|
||||
let manager = SessionManager::default_location().unwrap();
|
||||
let mut snapshot = build_session_snapshot(&mut resumed, &manager).expect("snapshot");
|
||||
assert_eq!(snapshot.agent_preset.as_deref(), Some("creator"));
|
||||
snapshot.make_storage_compatible();
|
||||
let raw = serde_json::to_value(&snapshot).unwrap();
|
||||
assert_eq!(raw["agent_preset"], "creator");
|
||||
|
||||
let mut plain = create_test_app();
|
||||
let snapshot = build_session_snapshot(&mut plain, &manager).expect("snapshot");
|
||||
let raw = serde_json::to_value(&snapshot).unwrap();
|
||||
assert!(raw.get("agent_preset").is_none(), "Standard stores no key");
|
||||
|
||||
let mut parent = creator_app();
|
||||
first_message(&mut parent);
|
||||
parent.current_session_id = Some(uuid::Uuid::new_v4().to_string());
|
||||
parent.capture_session_fixture_source();
|
||||
let forked = commands::execute("/fork", &mut parent);
|
||||
assert!(!forked.is_error, "{:?}", forked.message);
|
||||
assert!(matches!(
|
||||
forked.action,
|
||||
Some(AppAction::SyncSession { agent_preset: Some(ref preset), .. }) if preset == "creator"
|
||||
));
|
||||
let fork_id = parent.current_session_id.clone().unwrap();
|
||||
let on_disk = manager.load_session(&fork_id).expect("fork record");
|
||||
assert_eq!(on_disk.agent_preset.as_deref(), Some("creator"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn a_resync_of_the_open_conversation_keeps_its_mode() {
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let _home = crate::test_support::SealedHome::new();
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let other = TempDir::new().unwrap();
|
||||
let mut config = Config::default().with_legacy_root(
|
||||
Some("mock-credential".into()),
|
||||
Some("http://127.0.0.1:1/v1".into()),
|
||||
);
|
||||
config.set_feature("mcp", false).unwrap();
|
||||
config.set_feature("subagents", false).unwrap();
|
||||
let mut app = App::new(
|
||||
crate::tui::test_support::test_tui_options(workspace.path()),
|
||||
&config,
|
||||
);
|
||||
app.onboarding_needs_api_key = false;
|
||||
app.offline_mode = false;
|
||||
app.install_conversation_mode(Some("creator".to_string()), None);
|
||||
first_message(&mut app);
|
||||
app.set_current_session_id(Some(uuid::Uuid::new_v4().to_string()));
|
||||
|
||||
let (engine, mut handle) = Engine::new_with_model_client(
|
||||
codewhale_engine::core::engine::EngineConfig {
|
||||
workspace: workspace.path().to_path_buf(),
|
||||
model: app.model.clone(),
|
||||
snapshots_enabled: false,
|
||||
subagents_enabled: false,
|
||||
terminal_chrome_enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
&config,
|
||||
Arc::new(codewhale_engine::llm_client::mock::MockLlmClient::new(
|
||||
Vec::new(),
|
||||
)),
|
||||
);
|
||||
let run = tokio::spawn(engine.run());
|
||||
let tasks = {
|
||||
use codewhale_engine::task_manager::{
|
||||
TaskExecutionResult, TaskManagerConfig, TaskStatus, TaskTerminalReason,
|
||||
};
|
||||
struct Idle;
|
||||
#[async_trait::async_trait]
|
||||
impl codewhale_engine::task_manager::TaskExecutor for Idle {
|
||||
async fn execute(
|
||||
&self,
|
||||
_: codewhale_engine::task_manager::ExecutionTask,
|
||||
_: tokio::sync::mpsc::Sender<codewhale_engine::task_manager::TaskExecutionEvent>,
|
||||
_: tokio_util::sync::CancellationToken,
|
||||
) -> TaskExecutionResult {
|
||||
TaskExecutionResult {
|
||||
status: TaskStatus::Completed,
|
||||
result_text: None,
|
||||
error: None,
|
||||
terminal_reason: TaskTerminalReason::Completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
TaskManager::start_with_executor(
|
||||
TaskManagerConfig {
|
||||
data_dir: workspace.path().into(),
|
||||
worker_count: 1,
|
||||
default_workspace: workspace.path().into(),
|
||||
default_model: "fixture".into(),
|
||||
default_mode: "plan".into(),
|
||||
allow_shell: false,
|
||||
trust_mode: false,
|
||||
execution_limits: codewhale_engine::task_manager::TaskExecutionLimits::default(),
|
||||
human_waits_answerable: false,
|
||||
},
|
||||
Arc::new(Idle),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let mut backend = ColorCompatBackend::new(
|
||||
std::io::stdout(),
|
||||
codewhale_palette::ColorDepth::Monochrome,
|
||||
codewhale_palette::PaletteMode::Dark,
|
||||
);
|
||||
backend.set_terminal_size(ratatui::layout::Size::new(80, 24));
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
for action in [
|
||||
AppAction::SwitchWorkspace {
|
||||
workspace: other.path().to_path_buf(),
|
||||
},
|
||||
AppAction::PluginRegistryChanged,
|
||||
] {
|
||||
let result = commands::CommandResult {
|
||||
message: None,
|
||||
action: Some(action),
|
||||
is_error: false,
|
||||
};
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
apply_command_result(
|
||||
&mut terminal,
|
||||
&mut app,
|
||||
&mut handle,
|
||||
&tasks,
|
||||
&mut config,
|
||||
result,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("the re-sync finished")
|
||||
.unwrap();
|
||||
let snapshot = tokio::time::timeout(Duration::from_secs(20), handle.get_session_snapshot())
|
||||
.await
|
||||
.expect("the Engine answered")
|
||||
.expect("snapshot");
|
||||
assert_eq!(snapshot.agent_preset.as_deref(), Some("creator"));
|
||||
assert_eq!(snapshot.messages.len(), 1);
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
}
|
||||
let _ = handle.send(Op::Shutdown).await;
|
||||
run.abort();
|
||||
}
|
||||
@@ -70,7 +70,6 @@ fn sync_snapshot(
|
||||
model: snapshot.model.clone(),
|
||||
workspace: snapshot.workspace.clone(),
|
||||
mode: AppMode::parse(&snapshot.mode).unwrap(),
|
||||
agent_preset: snapshot.agent_preset.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +477,6 @@ fn undo_retry_apply_path_keeps_engine_request_and_reopened_session_consistent()
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
};
|
||||
for action in [restore, AppAction::SendMessage("after reopen".into())] {
|
||||
apply_command_result(
|
||||
|
||||
@@ -722,9 +722,6 @@ pub(crate) fn dial_chip(app: &App) -> PostureChip {
|
||||
if let Some(scope) = filesystem_scope_notice(app) {
|
||||
chip.0 = format!("{} · {scope}", chip.0).into();
|
||||
}
|
||||
if let Some(label) = app.conversation_mode_label() {
|
||||
chip.0 = format!("{label} · {}", chip.0).into();
|
||||
}
|
||||
chip
|
||||
}
|
||||
|
||||
|
||||
@@ -997,9 +997,6 @@ pub enum ViewEvent {
|
||||
ModeSelected {
|
||||
mode: codewhale_config::AppMode,
|
||||
},
|
||||
ConversationModeSelected {
|
||||
id: String,
|
||||
},
|
||||
/// Emitted by the `/statusline` picker every time the user toggles an
|
||||
/// item (live preview) and once more on Enter (final). The handler
|
||||
/// updates `app.status_items` immediately and persists on `final_save`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `/mode` picker: the conversation's mode first, then permissions.
|
||||
//! `/mode` picker for the currently supported interactive modes.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
@@ -12,140 +12,56 @@ use ratatui::{
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::tui::app::{App, AppModeUi};
|
||||
use crate::tui::conversation_mode as modes;
|
||||
use crate::tui::app::AppModeUi;
|
||||
use crate::tui::views::{
|
||||
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
|
||||
render_modal_footer, render_modal_surface,
|
||||
};
|
||||
use codewhale_config::AppMode;
|
||||
use codewhale_engine::conversation_modes::ConversationModeTable;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_localization::Locale;
|
||||
use codewhale_palette as palette;
|
||||
use codewhale_runtime::config::Config;
|
||||
use codewhale_tui_kit::menu_style;
|
||||
|
||||
const VISIBLE_MODES: [AppMode; 2] = [AppMode::Agent, AppMode::Plan];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ConversationRow {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
unavailable: Option<String>,
|
||||
current: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Entry {
|
||||
Conversation(usize),
|
||||
Permission(AppMode),
|
||||
}
|
||||
|
||||
pub struct ModePickerView {
|
||||
cursor: usize,
|
||||
locale: Locale,
|
||||
rows: Vec<ConversationRow>,
|
||||
locked: bool,
|
||||
other_command: String,
|
||||
row_hitboxes: RefCell<Vec<Rect>>,
|
||||
}
|
||||
|
||||
impl ModePickerView {
|
||||
pub async fn for_app(app: &App, config: &Config) -> Self {
|
||||
let table = modes::current_table(config).await;
|
||||
Self::from_table(
|
||||
app.mode,
|
||||
app.ui_locale,
|
||||
&table,
|
||||
app.conversation_mode.as_deref(),
|
||||
app.conversation_mode_locked(),
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_table(
|
||||
current: AppMode,
|
||||
locale: Locale,
|
||||
table: &ConversationModeTable,
|
||||
conversation: Option<&str>,
|
||||
locked: bool,
|
||||
) -> Self {
|
||||
let current_id = codewhale_engine::conversation_modes::effective_id(conversation);
|
||||
let rows = table
|
||||
.modes
|
||||
pub fn new(current: AppMode, locale: Locale) -> Self {
|
||||
let cursor = VISIBLE_MODES
|
||||
.iter()
|
||||
.map(|mode| ConversationRow {
|
||||
id: mode.id.clone(),
|
||||
name: modes::display_name(locale, mode),
|
||||
description: modes::display_description(locale, mode),
|
||||
unavailable: (!mode.available).then(|| {
|
||||
modes::display_reason(
|
||||
locale,
|
||||
mode.reason
|
||||
.as_deref()
|
||||
.unwrap_or("This mode is unavailable."),
|
||||
)
|
||||
}),
|
||||
current: mode.id == current_id,
|
||||
})
|
||||
.collect();
|
||||
let other_command = modes::new_command(
|
||||
table
|
||||
.modes
|
||||
.iter()
|
||||
.find(|mode| mode.id != current_id)
|
||||
.map_or(codewhale_engine::conversation_modes::STANDARD, |mode| {
|
||||
mode.id.as_str()
|
||||
}),
|
||||
);
|
||||
let mut view = Self {
|
||||
cursor: 0,
|
||||
locale,
|
||||
rows,
|
||||
locked,
|
||||
other_command,
|
||||
row_hitboxes: RefCell::new(Vec::new()),
|
||||
};
|
||||
view.cursor = view
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|entry| *entry == Entry::Permission(current))
|
||||
.position(|mode| *mode == current)
|
||||
.unwrap_or(0);
|
||||
view
|
||||
}
|
||||
|
||||
fn entries(&self) -> Vec<Entry> {
|
||||
let mut entries = Vec::new();
|
||||
if !self.locked {
|
||||
entries.extend(
|
||||
self.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.unavailable.is_none())
|
||||
.map(|(index, _)| Entry::Conversation(index)),
|
||||
);
|
||||
}
|
||||
entries.extend(VISIBLE_MODES.iter().copied().map(Entry::Permission));
|
||||
entries
|
||||
}
|
||||
|
||||
fn selected(&self) -> Option<Entry> {
|
||||
self.entries().get(self.cursor).copied()
|
||||
}
|
||||
|
||||
fn event_for(&self, entry: Entry) -> ViewEvent {
|
||||
match entry {
|
||||
Entry::Conversation(index) => ViewEvent::ConversationModeSelected {
|
||||
id: self.rows[index].id.clone(),
|
||||
},
|
||||
Entry::Permission(mode) => ViewEvent::ModeSelected { mode },
|
||||
Self {
|
||||
cursor,
|
||||
locale,
|
||||
row_hitboxes: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_mode(&self) -> AppMode {
|
||||
VISIBLE_MODES
|
||||
.get(self.cursor)
|
||||
.copied()
|
||||
.unwrap_or(AppMode::Agent)
|
||||
}
|
||||
|
||||
/// Apply one [`list_nav`](codewhale_tui_kit::list_nav) motion (#6290), returning
|
||||
/// whether it was consumed. Vertical motions cover the whole list —
|
||||
/// Home/End and the page keys included. The horizontal axis has nowhere
|
||||
/// to go on a single-column surface, so those motions are not consumed.
|
||||
fn apply_motion(&mut self, motion: codewhale_tui_kit::list_nav::Motion) -> bool {
|
||||
let len = self.entries().len();
|
||||
let Some(next) = codewhale_tui_kit::list_nav::apply(self.cursor, len, len, motion) else {
|
||||
let Some(next) = codewhale_tui_kit::list_nav::apply(
|
||||
self.cursor,
|
||||
VISIBLE_MODES.len(),
|
||||
VISIBLE_MODES.len(),
|
||||
motion,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
self.cursor = next;
|
||||
@@ -153,22 +69,13 @@ impl ModePickerView {
|
||||
}
|
||||
|
||||
fn select_by_number(&mut self, number: char) -> Option<ViewAction> {
|
||||
let entries = self.entries();
|
||||
let idx = entries.iter().position(
|
||||
|entry| matches!(entry, Entry::Permission(mode) if mode.number() == number),
|
||||
)?;
|
||||
let idx = VISIBLE_MODES
|
||||
.iter()
|
||||
.position(|mode| mode.number() == number)?;
|
||||
self.cursor = idx;
|
||||
Some(ViewAction::EmitAndClose(self.event_for(entries[idx])))
|
||||
}
|
||||
|
||||
fn line_count(&self) -> u16 {
|
||||
let rows = u16::try_from(self.rows.len()).unwrap_or(u16::MAX);
|
||||
let conversation = if self.rows.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2 + rows + u16::from(self.locked)
|
||||
};
|
||||
conversation + 1 + u16::try_from(VISIBLE_MODES.len()).unwrap_or(2)
|
||||
Some(ViewAction::EmitAndClose(ViewEvent::ModeSelected {
|
||||
mode: self.selected_mode(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +89,10 @@ impl ModalView for ModePickerView {
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
// Movement keys come from the shared vocabulary (#6290), so
|
||||
// `j`/`k`, Home/End and the page keys mean here exactly what they
|
||||
// mean on every other list. This match owns only the keys the
|
||||
// vocabulary does not claim.
|
||||
if let Some(motion) = codewhale_tui_kit::list_nav::motion(&key)
|
||||
&& self.apply_motion(motion)
|
||||
{
|
||||
@@ -189,10 +100,9 @@ impl ModalView for ModePickerView {
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => ViewAction::Close,
|
||||
KeyCode::Enter => match self.selected() {
|
||||
Some(entry) => ViewAction::EmitAndClose(self.event_for(entry)),
|
||||
None => ViewAction::None,
|
||||
},
|
||||
KeyCode::Enter => ViewAction::EmitAndClose(ViewEvent::ModeSelected {
|
||||
mode: self.selected_mode(),
|
||||
}),
|
||||
KeyCode::Char(number) => self.select_by_number(number).unwrap_or(ViewAction::None),
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
@@ -223,18 +133,14 @@ impl ModalView for ModePickerView {
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_height = self.line_count() + 7;
|
||||
let popup_height = u16::try_from(VISIBLE_MODES.len()).unwrap_or(2) + 7;
|
||||
let popup_area = centered_modal_area(area, 68, popup_height, 44, 8);
|
||||
|
||||
render_modal_surface(area, popup_area, buf);
|
||||
|
||||
let title = format!(
|
||||
" {} ",
|
||||
tr(self.locale, MessageId::ConversationModePickerTitle)
|
||||
);
|
||||
let block = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
title,
|
||||
" Mode ",
|
||||
Style::default()
|
||||
.fg(palette::WHALE_ACTION)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -259,105 +165,47 @@ impl ModalView for ModePickerView {
|
||||
|
||||
self.row_hitboxes.borrow_mut().clear();
|
||||
|
||||
let entries = self.entries();
|
||||
let heading_style = Style::default()
|
||||
.fg(palette::TEXT_MUTED)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let muted = Style::default().fg(palette::TEXT_MUTED);
|
||||
let width = usize::from(content.width);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut lines = Vec::with_capacity(VISIBLE_MODES.len());
|
||||
|
||||
let push_row = |lines: &mut Vec<Line<'static>>,
|
||||
entry: Option<Entry>,
|
||||
prefix: String,
|
||||
text: String,
|
||||
disabled: bool| {
|
||||
let cursor_here = entry.is_some_and(|entry| entries.get(self.cursor) == Some(&entry));
|
||||
let row_style = if cursor_here {
|
||||
for (idx, mode) in VISIBLE_MODES.iter().copied().enumerate() {
|
||||
let is_cursor = idx == self.cursor;
|
||||
let row_style = if is_cursor {
|
||||
menu_style::selected_row_style()
|
||||
} else if disabled {
|
||||
muted
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
let hint_style = if cursor_here {
|
||||
let hint_style = if is_cursor {
|
||||
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
|
||||
} else {
|
||||
muted
|
||||
Style::default().fg(palette::TEXT_MUTED)
|
||||
};
|
||||
let room = width.saturating_sub(UnicodeWidthStr::width(prefix.as_str()));
|
||||
let text = codewhale_tui_kit::ui_text::semantic_truncate(&text, room);
|
||||
if entry.is_some() {
|
||||
let y = content
|
||||
.y
|
||||
.saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
|
||||
self.row_hitboxes
|
||||
.borrow_mut()
|
||||
.push(Rect::new(content.x, y, content.width, 1));
|
||||
}
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, row_style),
|
||||
Span::styled(text, hint_style),
|
||||
]));
|
||||
};
|
||||
|
||||
if !self.rows.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
tr(self.locale, MessageId::ConversationModeGroupConversation).into_owned(),
|
||||
heading_style,
|
||||
)));
|
||||
if self.locked {
|
||||
lines.push(Line::from(Span::styled(
|
||||
codewhale_tui_kit::ui_text::semantic_truncate(
|
||||
tr(self.locale, MessageId::ConversationModeLockedNote)
|
||||
.replace("{command}", &format!("`{}`", self.other_command))
|
||||
.as_str(),
|
||||
width,
|
||||
),
|
||||
muted,
|
||||
)));
|
||||
}
|
||||
for (index, row) in self.rows.iter().enumerate() {
|
||||
let selectable = !self.locked && row.unavailable.is_none();
|
||||
let entry = selectable.then_some(Entry::Conversation(index));
|
||||
let cursor_here =
|
||||
entry.is_some_and(|entry| entries.get(self.cursor) == Some(&entry));
|
||||
let pointer = if cursor_here {
|
||||
codewhale_tui_kit::glyphs::selection_marker(true).to_string()
|
||||
} else {
|
||||
" ".to_string()
|
||||
};
|
||||
let check = if row.current { "✓" } else { " " };
|
||||
let pad =
|
||||
" ".repeat(16usize.saturating_sub(UnicodeWidthStr::width(row.name.as_str())));
|
||||
let prefix = format!("{pointer} {check} {}{pad}", row.name);
|
||||
let text = row
|
||||
.unavailable
|
||||
.clone()
|
||||
.unwrap_or_else(|| row.description.clone());
|
||||
push_row(
|
||||
&mut lines,
|
||||
entry,
|
||||
prefix,
|
||||
text,
|
||||
row.unavailable.is_some() || self.locked,
|
||||
);
|
||||
}
|
||||
lines.push(Line::default());
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
tr(self.locale, MessageId::ConversationModeGroupPermissions).into_owned(),
|
||||
heading_style,
|
||||
)));
|
||||
for mode in VISIBLE_MODES {
|
||||
let entry = Entry::Permission(mode);
|
||||
let cursor_here = entries.get(self.cursor) == Some(&entry);
|
||||
let pointer = codewhale_tui_kit::glyphs::selection_marker(cursor_here);
|
||||
let pointer = codewhale_tui_kit::glyphs::selection_marker(is_cursor);
|
||||
let name = mode.display_name_localized(self.locale);
|
||||
let hint = mode.picker_hint_localized(self.locale);
|
||||
// Pad by terminal columns, not scalar count, so wide (CJK) mode
|
||||
// names keep the hint column aligned.
|
||||
let pad = " ".repeat(8usize.saturating_sub(UnicodeWidthStr::width(&*name)));
|
||||
let prefix = format!("{pointer} {}. {name}{pad}", mode.number());
|
||||
push_row(&mut lines, Some(entry), prefix, hint.into_owned(), false);
|
||||
// A hint is prose: the pane edge used to cut it mid-word
|
||||
// (`ask f`, `before ac`). Truncate at a word joint instead —
|
||||
// a clipped clause reads as a sentence, a clipped word reads
|
||||
// as a bug.
|
||||
let hint_width =
|
||||
usize::from(content.width).saturating_sub(UnicodeWidthStr::width(prefix.as_str()));
|
||||
let hint = codewhale_tui_kit::ui_text::semantic_truncate(hint.as_ref(), hint_width);
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, row_style),
|
||||
Span::styled(hint, hint_style),
|
||||
]));
|
||||
self.row_hitboxes.borrow_mut().push(Rect::new(
|
||||
content.x,
|
||||
content
|
||||
.y
|
||||
.saturating_add(u16::try_from(idx).unwrap_or(u16::MAX)),
|
||||
content.width,
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
Paragraph::new(lines).render(content, buf);
|
||||
@@ -367,31 +215,18 @@ impl ModalView for ModePickerView {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_engine::conversation_modes::{
|
||||
self, CREATOR_NOT_READY_REASON, CreatorAvailabilityGuard,
|
||||
};
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
fn picker(current: AppMode, conversation: Option<&str>, locked: bool) -> ModePickerView {
|
||||
ModePickerView::from_table(
|
||||
current,
|
||||
Locale::En,
|
||||
&conversation_modes::table(Some(CREATOR_NOT_READY_REASON.to_string())),
|
||||
conversation,
|
||||
locked,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_on_current_mode() {
|
||||
let view = picker(AppMode::Plan, None, false);
|
||||
assert_eq!(view.selected(), Some(Entry::Permission(AppMode::Plan)));
|
||||
let view = ModePickerView::new(AppMode::Plan, Locale::En);
|
||||
assert_eq!(view.selected_mode(), AppMode::Plan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_emits_selected_mode() {
|
||||
let mut view = picker(AppMode::Agent, None, false);
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match action {
|
||||
@@ -402,27 +237,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
|
||||
/// every overlay to remain readable and fully operable at.
|
||||
const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
|
||||
|
||||
fn render_view(view: ModePickerView, width: u16, height: u16) -> (Buffer, Rect) {
|
||||
fn render_at(width: u16, height: u16) -> (Buffer, Rect) {
|
||||
use crate::tui::views::ViewStack;
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
// Pre-fill with a sentinel so any cell the composited modal fails to
|
||||
// paint (bleed-through) is detectable as a surviving 'X'.
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
buf[(x, y)].set_symbol("X");
|
||||
}
|
||||
}
|
||||
// Render through the ViewStack so the shared opaque backdrop is painted
|
||||
// exactly as it is in production.
|
||||
let mut stack = ViewStack::new();
|
||||
stack.push(view);
|
||||
stack.push(ModePickerView::new(AppMode::Agent, Locale::En));
|
||||
stack.render(area, &mut buf);
|
||||
(buf, area)
|
||||
}
|
||||
|
||||
fn render_at(width: u16, height: u16) -> (Buffer, Rect) {
|
||||
render_view(picker(AppMode::Agent, None, false), width, height)
|
||||
}
|
||||
|
||||
fn rows(buf: &Buffer, area: Rect) -> Vec<String> {
|
||||
(0..area.height)
|
||||
.map(|y| {
|
||||
@@ -433,64 +270,42 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn frame_text(buf: &Buffer, area: Rect) -> String {
|
||||
rows(buf, area)
|
||||
.iter()
|
||||
.map(|row| row.trim_end().to_string())
|
||||
.filter(|row| !row.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_picker_is_usable_and_opaque_at_blocker_sizes() {
|
||||
for (w, h) in BLOCKER_SIZES {
|
||||
for view in [
|
||||
picker(AppMode::Agent, None, false),
|
||||
picker(AppMode::Plan, Some("creator"), true),
|
||||
] {
|
||||
let (buf, area) = render_view(view, w, h);
|
||||
let text = rows(&buf, area).join("\n");
|
||||
let (buf, area) = render_at(w, h);
|
||||
let text = rows(&buf, area).join("\n");
|
||||
|
||||
assert!(text.contains("move"), "{w}x{h}: missing 'move' hint");
|
||||
assert!(text.contains("select"), "{w}x{h}: missing 'select' hint");
|
||||
assert!(text.contains("cancel"), "{w}x{h}: missing 'cancel' hint");
|
||||
// Action labels are present (footer never drops an action).
|
||||
assert!(text.contains("move"), "{w}x{h}: missing 'move' hint");
|
||||
assert!(text.contains("select"), "{w}x{h}: missing 'select' hint");
|
||||
assert!(text.contains("cancel"), "{w}x{h}: missing 'cancel' hint");
|
||||
|
||||
// The cursor row carries the charter selection pointer.
|
||||
assert!(
|
||||
text.contains(codewhale_tui_kit::glyphs::SELECTION),
|
||||
"{w}x{h}: missing charter selection pointer"
|
||||
);
|
||||
|
||||
// Composited frame is fully opaque: no sentinel survives and every
|
||||
// cell carries the modal/backdrop ink background.
|
||||
assert!(
|
||||
!text.contains('X'),
|
||||
"{w}x{h}: background bleed-through into modal surface"
|
||||
);
|
||||
let center = &buf[(w / 2, h / 2)];
|
||||
assert_eq!(
|
||||
center.bg,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
|
||||
// No row exceeds the frame width (no horizontal overflow).
|
||||
for (y, row) in rows(&buf, area).iter().enumerate() {
|
||||
assert!(
|
||||
text.contains("Conversation mode") && text.contains("Permissions"),
|
||||
"{w}x{h}: both groups are labelled"
|
||||
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
|
||||
"{w}x{h}: row {y} overflows width: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(codewhale_tui_kit::glyphs::SELECTION),
|
||||
"{w}x{h}: missing charter selection pointer"
|
||||
);
|
||||
assert!(
|
||||
!text.contains('X'),
|
||||
"{w}x{h}: background bleed-through into modal surface"
|
||||
);
|
||||
let lines = rows(&buf, area);
|
||||
let heading_row = lines
|
||||
.iter()
|
||||
.position(|row| row.contains("Conversation mode"))
|
||||
.expect("heading row");
|
||||
let heading_byte = lines[heading_row]
|
||||
.find("Conversation mode")
|
||||
.expect("heading column");
|
||||
let heading_x = lines[heading_row][..heading_byte].chars().count();
|
||||
let padding = &buf[(
|
||||
u16::try_from(heading_x + "Conversation mode".len() + 3).unwrap(),
|
||||
u16::try_from(heading_row).unwrap(),
|
||||
)];
|
||||
assert_eq!(
|
||||
padding.bg,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
for (y, row) in rows(&buf, area).iter().enumerate() {
|
||||
assert!(
|
||||
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
|
||||
"{w}x{h}: row {y} overflows width: {row:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -504,7 +319,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn number_keys_select_modes() {
|
||||
let mut view = picker(AppMode::Agent, None, false);
|
||||
// Visible roster: 1 Act, 2 Plan.
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||||
assert!(matches!(
|
||||
action,
|
||||
@@ -513,21 +329,30 @@ mod tests {
|
||||
})
|
||||
));
|
||||
|
||||
for key in ['3', '4', '5'] {
|
||||
let mut view = picker(AppMode::Agent, None, false);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None), "{key}");
|
||||
}
|
||||
// Retired Operate number (3) selects nothing.
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None));
|
||||
|
||||
// Legacy YOLO shorthand (4) is not offered by the picker.
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None));
|
||||
|
||||
// Old Operate number (5) is gone — no numeric gaps.
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_renders_and_selects_mode_row() {
|
||||
let mut view = picker(AppMode::Agent, None, false);
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 30)).expect("test terminal");
|
||||
terminal
|
||||
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("render mode picker");
|
||||
let rect = view.row_hitboxes.borrow()[2];
|
||||
let rect = view.row_hitboxes.borrow()[1];
|
||||
let action = view.handle_mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: rect.x,
|
||||
@@ -541,126 +366,4 @@ mod tests {
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn mode_picker_lists_conversation_modes_first_and_locks_after_first_message() {
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let table = conversation_modes::current_table(Default::default()).await;
|
||||
for (w, h) in [(80, 24), (120, 32)] {
|
||||
let open = ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
let (buf, area) = render_view(open, w, h);
|
||||
let text = frame_text(&buf, area);
|
||||
println!("--- /mode picker, blank conversation, creator available, {w}x{h}\n{text}");
|
||||
let order = |needle: &str| {
|
||||
text.find(needle)
|
||||
.unwrap_or_else(|| panic!("{needle}: {text}"))
|
||||
};
|
||||
assert!(order("Conversation mode") < order("Standard mode"));
|
||||
assert!(order("Standard mode") < order("Creator mode"));
|
||||
assert!(order("Creator mode") < order("Permissions"));
|
||||
assert!(order("Permissions") < order("1. Work"));
|
||||
assert!(!text.contains("Fixed after the first message"));
|
||||
|
||||
let mut open =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
open.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
open.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert!(matches!(
|
||||
open.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
ViewAction::EmitAndClose(ViewEvent::ConversationModeSelected { ref id }) if id == "standard"
|
||||
));
|
||||
|
||||
let locked = ModePickerView::from_table(
|
||||
AppMode::Agent,
|
||||
Locale::En,
|
||||
&table,
|
||||
Some("creator"),
|
||||
true,
|
||||
);
|
||||
let (buf, area) = render_view(locked, w, h);
|
||||
let text = frame_text(&buf, area);
|
||||
println!(
|
||||
"--- /mode picker, after the first message, creator conversation, {w}x{h}\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Fixed after the first message. Start another with /new creator.")
|
||||
);
|
||||
assert!(text.contains("Creator mode"), "{text}");
|
||||
}
|
||||
let mut locked =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, Some("creator"), true);
|
||||
assert_eq!(
|
||||
locked.entries().len(),
|
||||
2,
|
||||
"only the permission rows stay selectable"
|
||||
);
|
||||
for key in [KeyCode::Up, KeyCode::Up, KeyCode::Home] {
|
||||
locked.handle_key(KeyEvent::new(key, KeyModifiers::NONE));
|
||||
assert!(matches!(locked.selected(), Some(Entry::Permission(_))));
|
||||
}
|
||||
for number in ['1', '2'] {
|
||||
let mut locked =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, true);
|
||||
assert!(matches!(
|
||||
locked.handle_key(KeyEvent::new(KeyCode::Char(number), KeyModifiers::NONE)),
|
||||
ViewAction::EmitAndClose(ViewEvent::ModeSelected { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn creator_row_is_listed_disabled_with_its_reason_while_unavailable() {
|
||||
let runtime_missing =
|
||||
"Creator mode needs a plugin runtime (Node.js or Bun), and none was found.";
|
||||
for reason in [CREATOR_NOT_READY_REASON, runtime_missing] {
|
||||
let _guard = CreatorAvailabilityGuard::set(Some(reason));
|
||||
let table = conversation_modes::current_table(Default::default()).await;
|
||||
let view = ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
let (buf, area) = render_view(view, 100, 30);
|
||||
let text = frame_text(&buf, area);
|
||||
println!("--- /mode picker, creator unavailable ({reason})\n{text}");
|
||||
assert!(
|
||||
text.contains("Creator mode"),
|
||||
"listed, never hidden: {text}"
|
||||
);
|
||||
let shown = if reason == CREATOR_NOT_READY_REASON {
|
||||
"Not ready yet in this version."
|
||||
} else {
|
||||
"Creator mode needs a plugin runtime"
|
||||
};
|
||||
assert!(text.contains(shown), "{text}");
|
||||
assert!(!text.contains(CREATOR_NOT_READY_REASON));
|
||||
let mut view =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
assert_eq!(view.entries().len(), 3);
|
||||
for key in [
|
||||
KeyCode::Up,
|
||||
KeyCode::Up,
|
||||
KeyCode::Up,
|
||||
KeyCode::Home,
|
||||
KeyCode::End,
|
||||
] {
|
||||
let action = view.handle_key(KeyEvent::new(key, KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None));
|
||||
}
|
||||
let mut view =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(
|
||||
!matches!(action, ViewAction::EmitAndClose(ViewEvent::ConversationModeSelected { ref id }) if id == "creator"),
|
||||
"an unavailable mode is never selectable"
|
||||
);
|
||||
}
|
||||
let _guard = CreatorAvailabilityGuard::set(None);
|
||||
let table = conversation_modes::current_table(Default::default()).await;
|
||||
let mut view = ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert!(matches!(
|
||||
view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
ViewAction::EmitAndClose(ViewEvent::ConversationModeSelected { ref id }) if id == "creator"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use super::qa_harness::{
|
||||
harness::{Harness, make_sealed_workspace},
|
||||
keys,
|
||||
};
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(15);
|
||||
|
||||
fn print_frame(label: &str, tui: &mut Harness) {
|
||||
tui.pump();
|
||||
let text = tui.frame().text();
|
||||
println!("--- {label}");
|
||||
for line in text.lines().filter(|line| !line.trim().is_empty()) {
|
||||
println!("{}", line.trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_binary_lists_creator_disabled_and_refuses_new_creator_with_the_reason() {
|
||||
let workspace = make_sealed_workspace().expect("sealed workspace");
|
||||
std::fs::write(workspace.home().join(".codewhale/.onboarded"), "").unwrap();
|
||||
let trust = workspace.workspace().join(".deepseek");
|
||||
std::fs::create_dir_all(&trust).unwrap();
|
||||
std::fs::write(trust.join("trusted"), "").unwrap();
|
||||
let mut tui = Harness::builder(Harness::codewhale_binary())
|
||||
.cwd(workspace.workspace())
|
||||
.clear_env()
|
||||
.seal_home(workspace.home())
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
|
||||
.env("CODEWHALE_NO_UPDATE_CHECK", "1")
|
||||
.env("CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE", "1")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.args([
|
||||
"--workspace",
|
||||
workspace.workspace().to_str().unwrap(),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
"--fresh",
|
||||
])
|
||||
.size(32, 100)
|
||||
.spawn()
|
||||
.expect("start TUI");
|
||||
tui.wait_for_text("New session", WAIT).unwrap();
|
||||
tui.wait_for_idle(Duration::from_millis(250), Duration::from_secs(5))
|
||||
.unwrap();
|
||||
|
||||
tui.type_line("/new creator").unwrap();
|
||||
tui.wait_for_text("Not ready yet in this version.", WAIT)
|
||||
.unwrap();
|
||||
print_frame("/new creator in the shipped binary", &mut tui);
|
||||
assert!(
|
||||
!tui.frame().contains("Creator mode"),
|
||||
"the conversation stays Standard: {}",
|
||||
tui.diagnostics()
|
||||
);
|
||||
|
||||
tui.type_line("/mode").unwrap();
|
||||
tui.wait_for_text("Conversation mode", WAIT).unwrap();
|
||||
tui.wait_for_text("Permissions", WAIT).unwrap();
|
||||
print_frame("/mode picker in the shipped binary", &mut tui);
|
||||
let frame = tui.frame().text();
|
||||
let creator_row = frame
|
||||
.lines()
|
||||
.find(|line| line.contains("Creator mode"))
|
||||
.expect("Creator is listed");
|
||||
assert!(
|
||||
creator_row.contains("Not ready yet in this version."),
|
||||
"{creator_row}"
|
||||
);
|
||||
let first_group = frame.find("Conversation mode").unwrap();
|
||||
let second_group = frame.find("Permissions").unwrap();
|
||||
assert!(first_group < frame.find("Standard mode").unwrap());
|
||||
assert!(frame.find("Creator mode").unwrap() < second_group);
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
tui.shutdown();
|
||||
}
|
||||
@@ -21,8 +21,6 @@ mod automations_editor_pty;
|
||||
mod config_theme_nav_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod contextual_tips_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod conversation_mode_pty;
|
||||
mod core_session_command_extraction;
|
||||
mod directory_listing_acceptance;
|
||||
mod epic_acceptance_harness;
|
||||
|
||||
@@ -40,23 +40,6 @@ Run `/mode` to open the mode picker, or switch directly with `/mode work` or
|
||||
`/mode plan`. `operate` and its spellings still parse, as Work, and say that
|
||||
Operate was retired, so an older setting or script never errors.
|
||||
|
||||
The picker has two groups. **Conversation mode** lists the modes the Engine
|
||||
offers, Standard mode and Creator mode, each with a one-line description; a
|
||||
mode that cannot be used yet is listed, disabled, with its reason. **Permissions**
|
||||
holds Work and Plan, still on `1` and `2`; the conversation modes have no
|
||||
number keys. A conversation's mode is chosen before its first message and is
|
||||
fixed after it: `/mode creator` or the picker's first group changes a blank
|
||||
conversation in place, and after the first message the picker shows the group
|
||||
as locked and `/mode creator` is refused with a note to start a new
|
||||
conversation. `/new` starts a Standard conversation, `/new creator` starts a
|
||||
Creator mode conversation (`--force` still combines). Resume, `/load`, `/fork`
|
||||
and `/clear` keep the conversation's saved mode, and the mode name appears in the
|
||||
footer when it is not Standard. A mode never changes a permission: Creator
|
||||
mode in Plan is still Plan. `codewhale` and `codewhale exec` take
|
||||
`--conversation-mode <MODE>` to start a new session in a mode; with `--resume`,
|
||||
`--session-id` or `--continue` it must match the session's saved mode, or the
|
||||
run exits with code 2 before any model call.
|
||||
|
||||
- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.
|
||||
- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.
|
||||
- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = "operate"` loads as Work and says so once; a Runtime API request with `mode: "operate"` runs as `agent` with the default posture (Accept Edits) and logs one notice per process, never an error. Agents forked from an older Operate conversation do not inherit its contract. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.
|
||||
|
||||
+24
-1
@@ -244,7 +244,30 @@ The manual `ci.yml` path verifies that the dispatch resolved to
|
||||
`expected_sha`, disables light-change shortcuts, and forces the heavy Rust,
|
||||
workflow, mobile, Actions, Linux, macOS, Windows, npm-wrapper, and documentation
|
||||
gates. A mismatch fails before those gates start; it never silently tests a
|
||||
different head.
|
||||
different head. The Linux, macOS and Windows suites run as `Sharded tests (<os>)`
|
||||
(`ci-test-suite.yml`): one test build per OS, parallel nextest shards of that
|
||||
archive, and a `Suite check` job that fails unless the shards together ran
|
||||
exactly the tests the archive lists; read that job, not only the shard jobs.
|
||||
|
||||
`ci.yml` also takes optional scoped-run inputs for a quick answer from an agent
|
||||
or a maintainer. With `crate` (one workspace package, for example
|
||||
`codewhale-tui`) and/or `filter` (a nextest filterset such as
|
||||
`test(auto_review)`) set, only the `Scoped test` job runs, on the operating
|
||||
systems named by `os` (default `ubuntu-latest`; a comma-separated list or
|
||||
`all`), and the full suite and every other gate are skipped. `expected_sha`
|
||||
stays required and is still verified:
|
||||
|
||||
```bash
|
||||
gh workflow run ci.yml --ref ci/my-task \
|
||||
-f expected_sha="$(git rev-parse HEAD)" \
|
||||
-f crate=codewhale-tui -f filter='test(auto_review)'
|
||||
```
|
||||
|
||||
A scoped run proves a slice, never a release; release evidence is a full
|
||||
dispatch with the scoped inputs empty. A newer dispatch on the same non-main
|
||||
branch, and a newer push to a pull request, cancels the older run of the same
|
||||
workflow; runs on `main` are keyed by SHA and never cancel each other, and a
|
||||
scoped run never cancels a full one.
|
||||
|
||||
Verify beforehand that clean local `HEAD`, `main` and `origin/main` agree.
|
||||
Record both run URLs, resolved SHAs and required job results; inspect executed
|
||||
|
||||
+1
-1
@@ -319,7 +319,7 @@ owner = "crates/engine/src/tools/review_host.rs"
|
||||
[[feature]]
|
||||
id = "modes"
|
||||
name = "Modes"
|
||||
summary = "Plan and Work modes on the Shift+Tab dial; a conversation is Standard or Creator mode, fixed after its first message."
|
||||
summary = "Plan and Work modes; Plan is a stop on the Shift+Tab dial, Operate parses as Work; a finished plan offers work."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable", api = "stable" }
|
||||
docs = "docs/MODES.md"
|
||||
|
||||
@@ -17,7 +17,7 @@ Workflow 通过同一个子智能体运行时执行命名步骤;Fleet 管理
|
||||
|
||||
## TUI 模式
|
||||
|
||||
一个拨盘决定 Codewhale 可以不经询问做多少事。按 `Shift+Tab` 逐档切换:**Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**。Plan 档就是 Plan 模式;其余每一档都是带该权限姿态的 Work。页脚显示当前档位和 `Shift+Tab to change`。未保存权限姿态时,新安装从 Accept Edits 开始。显式保存的选择(包括旧版本中的 Ask)在升级和重启后保持不变。`Tab` 只补全输入框(composer)菜单、命令、文件提及和建议;它不会切换模式,也从不发送或排队输入框中的文本(用 `Enter`)。每次有效权限变化都会在对话记录中写入一行 `Permissions changed to …`。按 `Ctrl+T` 循环切换思考强度。运行 `/mode` 打开模式选择器,或直接用 `/mode work`、`/mode plan` 切换。`operate` 及其写法仍按 Work 解析,并说明 Operate 已退役,所以旧的设置或脚本不会报错。选择器分为两组:**对话模式**列出引擎提供的标准模式与创作者模式(暂不可用的模式会列出并置灰,同时给出原因),**权限**包含 Work 与 Plan(仍为 `1`、`2`)。对话的模式在第一条消息之前选定,之后固定:空白对话可用 `/mode creator` 或选择器就地更改;发送第一条消息后选择器锁定该组,`/mode creator` 会被拒绝并提示开启新对话。`/new` 开始标准对话,`/new creator` 开始创作者模式对话(仍可配合 `--force`)。恢复、`/load`、`/fork` 和 `/clear` 保留已保存的对话模式;非标准模式的名称会显示在页脚。模式不会改变任何权限。`codewhale` 与 `codewhale exec` 接受 `--conversation-mode <MODE>`;与 `--resume`、`--session-id` 或 `--continue` 同用时必须与已保存的模式一致,否则在任何模型调用之前以退出码 2 结束。
|
||||
一个拨盘决定 Codewhale 可以不经询问做多少事。按 `Shift+Tab` 逐档切换:**Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**。Plan 档就是 Plan 模式;其余每一档都是带该权限姿态的 Work。页脚显示当前档位和 `Shift+Tab to change`。未保存权限姿态时,新安装从 Accept Edits 开始。显式保存的选择(包括旧版本中的 Ask)在升级和重启后保持不变。`Tab` 只补全输入框(composer)菜单、命令、文件提及和建议;它不会切换模式,也从不发送或排队输入框中的文本(用 `Enter`)。每次有效权限变化都会在对话记录中写入一行 `Permissions changed to …`。按 `Ctrl+T` 循环切换思考强度。运行 `/mode` 打开模式选择器,或直接用 `/mode work`、`/mode plan` 切换。`operate` 及其写法仍按 Work 解析,并说明 Operate 已退役,所以旧的设置或脚本不会报错。
|
||||
|
||||
- **Plan**:设计优先的提示方式。原语(primitive)名称保持不变,仍是大家熟悉的那些,但运行时会集中拒绝文件修改和 shell 执行。只读检查与策略允许的研究(包括延迟加载的 Web 搜索/抓取)仍然可用。当一个 Plan 回合成功结束并给出非空回复(包括纯文字计划)时,TUI 会询问如何继续:Work(Ask)、Work(Accept Edits)、Work(Auto-Review),或继续规划。选择 Work 会把已批准的原文作为普通后续消息发送,并保留该回合已有的 To-dos,或追加一个待办的计划项而不替换无关的工作。
|
||||
- **Work**(内部为 `agent`):普通的多步执行。第一回合的工具箱包含 `read`、`write`、`edit`、`file_search`、`grep_files`、`bash`、`agent`、`todo_write`、`present` 和 `load_skill`,以及无需先搜索即可使用的目标与后台 shell 控制工具 `create_goal`、`get_goal`、`update_goal` 和 `task_shell_wait`。当请求属于一个持久的目标时,智能体会调用 `create_goal`;宿主从不根据措辞去推断目标,契约也不得告诉模型相反的内容。审批、沙箱、仓库规则(repository law)和托管策略决定什么可以执行。
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
bun_binary="$(bun -p 'process.execPath')"
|
||||
host_binary="$(node -p "require('path').join(process.env.RUNNER_TEMP, 'codewhale-extension-host' + (process.platform === 'win32' ? '.exe' : ''))")"
|
||||
compiled_info="$(node crates/engine/extension-host/compile-host.mjs --bun "$bun_binary" --output "$host_binary")"
|
||||
printf 'CODEWHALE_COMPILED_HOST_TEST_BINARY=%s\n' "$host_binary" >> "$GITHUB_ENV"
|
||||
node - "$host_binary" "$bun_binary" "$compiled_info" >> "$GITHUB_ENV" <<'JS'
|
||||
const fs = require('node:fs'), crypto = require('node:crypto');
|
||||
const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const info = JSON.parse(process.argv[4]);
|
||||
if (!info.platform || !info.arch || info.platform !== info.compiler?.platform || info.arch !== info.compiler?.arch) throw new Error('image/compiler identity mismatch');
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_PLATFORM=' + info.platform);
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_ARCH=' + info.arch);
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_SHA256=' + digest(process.argv[2]));
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_BUN=' + process.argv[3]);
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_RUNTIME_SHA256=' + digest(process.argv[3]));
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256=' + digest('crates/engine/extension-host/dist/codewhale-extension-host.mjs'));
|
||||
JS
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Count nextest tests so a sharded run can prove it ran every test once.
|
||||
|
||||
list LISTING_JSON tests the archive selects (nextest list --message-format json)
|
||||
junit JUNIT_XML tests one nextest invocation ran
|
||||
check DIRECTORY per OS, the shard files must cover the expected total exactly
|
||||
|
||||
The sharded CI suite splits one archive across jobs. A shard that is skipped,
|
||||
runs a different filter, or double-counts a partition changes the sum, so the
|
||||
suite gate fails instead of reporting a smaller run as a pass.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def listed(path: str) -> int:
|
||||
suites = json.loads(Path(path).read_text(encoding="utf-8"))["rust-suites"]
|
||||
return sum(
|
||||
1
|
||||
for suite in suites.values()
|
||||
for case in suite["testcases"].values()
|
||||
if case["filter-match"]["status"] == "matches"
|
||||
)
|
||||
|
||||
|
||||
def ran(path: str) -> int:
|
||||
return int(ET.parse(path).getroot().get("tests", "0"))
|
||||
|
||||
|
||||
def check(directory: str) -> int:
|
||||
files = sorted(Path(directory).rglob("*.json"))
|
||||
expected = {}
|
||||
shards: dict[str, dict[int, int]] = {}
|
||||
for file in files:
|
||||
record = json.loads(file.read_text(encoding="utf-8"))
|
||||
if file.name.startswith("expected-"):
|
||||
expected[record["os"]] = record
|
||||
elif file.name.startswith("shard-"):
|
||||
slot = shards.setdefault(record["os"], {})
|
||||
if record["shard"] in slot:
|
||||
print(f"{record['os']}: shard {record['shard']} reported twice")
|
||||
return 1
|
||||
slot[record["shard"]] = record["tests"]
|
||||
if not expected:
|
||||
print("no expected-<os>.json found")
|
||||
return 1
|
||||
failed = False
|
||||
for os_name, record in sorted(expected.items()):
|
||||
got = shards.get(os_name, {})
|
||||
want = set(range(1, record["shards"] + 1))
|
||||
total = sum(got.values())
|
||||
print(f"{os_name}: archive lists {record['tests']}, shards ran {total} {dict(sorted(got.items()))}")
|
||||
if set(got) != want:
|
||||
print(f"{os_name}: expected shards {sorted(want)}, found {sorted(got)}")
|
||||
failed = True
|
||||
if total != record["tests"]:
|
||||
print(f"{os_name}: shard total {total} differs from the archive's {record['tests']}")
|
||||
failed = True
|
||||
if any(count <= 0 for count in got.values()):
|
||||
print(f"{os_name}: a shard ran no tests")
|
||||
failed = True
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) == 3 and argv[1] == "list":
|
||||
print(listed(argv[2]))
|
||||
return 0
|
||||
if len(argv) == 3 and argv[1] == "junit":
|
||||
print(ran(argv[2]))
|
||||
return 0
|
||||
if len(argv) == 3 and argv[1] == "check":
|
||||
return check(argv[2])
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -273,7 +273,7 @@ class CiWiringTests(unittest.TestCase):
|
||||
block = ci[start:next_step]
|
||||
self.assertIn(
|
||||
"sh scripts/with-hermetic-test-home.sh cargo nextest run "
|
||||
"-p codewhale-tui -p codewhale-runtime --lib --locked --no-tests=fail "
|
||||
"-p codewhale-engine -p codewhale-tui -p codewhale-runtime --lib --locked --no-tests=fail "
|
||||
"-E 'test(auto_review) | test(authority) | test(sandbox)'", block
|
||||
)
|
||||
self.assertIn(
|
||||
@@ -290,27 +290,27 @@ class CiWiringTests(unittest.TestCase):
|
||||
self.assertIn("check-command-migration-manifest.py", ci)
|
||||
self.assertIn("needs.changes.outputs.heavy == 'true'", ci)
|
||||
|
||||
def test_shared_process_mode_is_manual_hosted_and_preserves_default_runner(self) -> None:
|
||||
def test_shared_process_mode_is_manual_hosted_ubuntu_only_and_replaces_the_sharded_suite(self) -> None:
|
||||
ci = load_ci()
|
||||
self.assertIn("default: nextest", ci)
|
||||
matrix = ci.split(" test:\n", 1)[1].split(" steps:\n", 1)[0]
|
||||
self.assertIn(
|
||||
SHARED_PROCESS_MODE + " && '[\"ubuntu-latest\"]' || "
|
||||
"'[\"ubuntu-latest\",\"macos-latest\",\"windows-latest\"]'", matrix,
|
||||
)
|
||||
self.assertIn("needs.changes.outputs.trusted == 'true'", matrix)
|
||||
job = ci.split(" test:\n", 1)[1].split(" steps:\n", 1)[0]
|
||||
self.assertIn("runs-on: ubuntu-latest", job)
|
||||
self.assertIn(SHARED_PROCESS_MODE, job)
|
||||
self.assertNotIn("pull_request", job)
|
||||
for name in ("test-ubuntu", "test-macos", "test-windows"):
|
||||
caller = ci.split(f" {name}:\n", 1)[1].split(" uses:", 1)[0]
|
||||
self.assertIn(f"!({SHARED_PROCESS_MODE})", caller)
|
||||
qualification = named_step(ci, "Shared-process workspace qualification")
|
||||
self.assertIn("if: " + SHARED_PROCESS_MODE + " && matrix.os == 'ubuntu-latest'", qualification)
|
||||
for name, command in (
|
||||
("Run tests", "cargo nextest run --workspace --all-features --locked --profile ci"),
|
||||
("Run doctests", "cargo test --workspace --all-features --locked --doc"),
|
||||
):
|
||||
step = named_step(ci, name)
|
||||
self.assertIn(command, step)
|
||||
self.assertIn(f"!({SHARED_PROCESS_MODE})", step)
|
||||
self.assertIn("if: " + SHARED_PROCESS_MODE, qualification)
|
||||
upload = named_step(ci, "Retain shared-process qualification evidence")
|
||||
self.assertIn("if: always() && " + SHARED_PROCESS_MODE, upload)
|
||||
self.assertIn("if-no-files-found: error", upload)
|
||||
suite = (ROOT / ".github" / "workflows" / "ci-test-suite.yml").read_text(encoding="utf-8")
|
||||
for name, command in (
|
||||
("Run tests", "cargo nextest run"),
|
||||
("Run doctests", "cargo test --workspace --all-features --locked --doc"),
|
||||
):
|
||||
self.assertIn(command, named_step(suite, name))
|
||||
|
||||
def test_shared_process_runs_exact_command_twice_with_fresh_hermetic_homes(self) -> None:
|
||||
proof = run_shared_process_fixture()
|
||||
@@ -332,31 +332,24 @@ class CiWiringTests(unittest.TestCase):
|
||||
for identity in ISSUE_6698_TESTS:
|
||||
self.assertIn(f"test {identity} ... ok", proof["evidence"][f"run-{run}.log"])
|
||||
|
||||
def test_shared_process_keeps_macos_budget_when_self_hosted_test_leg_is_absent(self) -> None:
|
||||
def test_macos_budget_runs_on_every_heavy_dispatch_and_never_on_pull_requests(self) -> None:
|
||||
job = load_ci().split(" macos-budget:\n", 1)[1].split(" steps:\n", 1)[0]
|
||||
condition = next(line.strip()[4:] for line in job.splitlines() if line.strip().startswith("if: "))
|
||||
self.assertIn("runs-on: macos-latest", job)
|
||||
for event in ("pull_request", "push", "schedule", "workflow_dispatch"):
|
||||
for mode in ("nextest", "shared-process-twice"):
|
||||
for heavy, trusted, self_hosted in (
|
||||
(True, True, True), (True, True, False),
|
||||
(True, False, True), (False, True, True),
|
||||
):
|
||||
with self.subTest(event=event, mode=mode, heavy=heavy, trusted=trusted, self_hosted=self_hosted):
|
||||
for heavy in (True, False):
|
||||
for scoped in (True, False):
|
||||
with self.subTest(event=event, heavy=heavy, scoped=scoped):
|
||||
expression = condition
|
||||
for name, value in {
|
||||
"github.event_name": event,
|
||||
"inputs.workspace_test_mode": mode,
|
||||
"needs.changes.outputs.heavy": str(heavy).lower(),
|
||||
"needs.changes.outputs.trusted": str(trusted).lower(),
|
||||
"vars.CW_SELF_HOSTED_MAC": str(self_hosted).lower(),
|
||||
"needs.changes.outputs.scoped": str(scoped).lower(),
|
||||
}.items():
|
||||
expression = expression.replace(name, repr(value))
|
||||
expression = re.sub(r"!(?!=)", "not ", expression.replace("&&", "and").replace("||", "or"))
|
||||
actual = eval(expression, {"__builtins__": {}}, {})
|
||||
previous = heavy and event != "pull_request" and not (trusted and self_hosted)
|
||||
qualification = heavy and event == "workflow_dispatch" and mode == "shared-process-twice"
|
||||
self.assertEqual(actual, previous or qualification)
|
||||
self.assertEqual(actual, heavy and not scoped and event != "pull_request")
|
||||
|
||||
def test_shared_process_retains_failed_first_run_and_still_runs_second_once(self) -> None:
|
||||
proof = run_shared_process_fixture("first_failure")
|
||||
|
||||
-10
@@ -2,16 +2,6 @@
|
||||
|
||||
## Unreleased — 0.2.0
|
||||
|
||||
- Add data displays: `DataSparkline`, `DataBars` and `DataTrend` (a braille
|
||||
line chart). The words carry every value; the ink draws in from a caller
|
||||
progress (`MotionStep::drawn`) and is at its end state under reduced or
|
||||
still motion. Bars glide from their previous values.
|
||||
- Add `DataDisplay`, the same displays as JSON an agent can send:
|
||||
`DataDisplay::from_json` validates it (finite numbers, at most 1024 points
|
||||
or 24 bars, labels up to 120 characters), `describe` says the same facts as
|
||||
plain text, and `DATA_DISPLAY_SCHEMA` is its tool schema. Try
|
||||
`cargo run --example data`.
|
||||
|
||||
- Reexport the shared avatar contract from Codewhale Protocol. Rendering and
|
||||
artwork stay in this library. Protocol 0.10.3 registry proof must precede
|
||||
this package publication.
|
||||
|
||||
-1
@@ -57,7 +57,6 @@ Gallery names are the exact names accepted by the gallery example; the
|
||||
| Engine `agent_roster.rs::render_agent_roster`, `widgets/workflow_panel.rs::row_receipt_text`, `gate_receipts.rs`; GPUI `usage.rs::TurnRow`, `workbar.rs::Readout`, `working_context.rs::ExecutionReceipt` | `Receipt`, `ReceiptTable`, `ReceiptValue`, `Cost`, formatting helpers | Primitive/composite | `receipt-row`, `receipt-table`, `receipt-table-compact`, `receipt-table-minimal`, `receipt-table-clipped` |
|
||||
| Engine `diff_render.rs::BoundedDiffRender`, `render_diff_bounded`, `history/file_mutation.rs`; GPUI `review.rs::DiffRowKind`, `workspace/render/review.rs::ReviewRows` | `Diff`, `DiffLine`, `DiffKind`, `DiffGutter`, `DiffHighlight`, `parse_unified` | Primitive | `diff`, `diff-wrapped`, `diff-no-numbers`, `diff-highlighted`, `review-scene` |
|
||||
| Engine `widgets/workflow_panel.rs::WorkflowPanel`, `WorkflowPanelRow`, `history/checklist.rs`; GPUI `plans.rs`, `workspace/render/workers.rs` | `WorkflowTree`, `TreeNode`, `TreeState`, `CountBar` | Primitive/composite | `workflow-tree`, `workflow-tree-selected`, `workflow-tree-clipped`, `workflow-tree-long`, `count-bars` |
|
||||
| Kit-native; data from the host or an agent's JSON (CW-8202) | `DataSparkline`, `DataBars`, `DataBarItem`, `DataTrend`, `DataDisplay`, `DataDisplayView`, `DataWords`, `DATA_DISPLAY_SCHEMA`, `MotionStep::drawn` | Primitive | `data-sparkline`, `data-bars`, `data-bars-growing`, `data-trend`, `data-trend-drawing`, `data-from-agent` |
|
||||
| Engine `approval/view.rs::ApprovalView`, `approval/elevation.rs::ElevationView`, `auto_review.rs`; GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/review.rs::Workspace::render_restore_confirmation` | `ApprovalCard`, `ApprovalSubject`, `ApprovalState`, `ApprovalChoice`, `ApprovalPaint`, `DecisionBand`, `DecisionBandPlan`, `DecisionBandAction`, `DecisionBandSave`, `ReviewVerdict`, `ReviewAggregate` | Composite | `approval-command`, `approval-outside`, `approval-patch`, `approval-elevation`, `approval-clipped`, `approval-spoofed`, `approval-native-band`, `approval-native-band-collapsed`, `review-verdicts`, `review-aggregate` |
|
||||
| GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/files.rs::Workspace::render_artifact_column` | `AttentionQueue`, `AttentionItem`, `ArtifactShelf`, `Artifact` | Composite | `attention-queue`, `attention-focused`, `attention-narrow`, `attention-empty`, `artifact`, `artifact-shelf`, `artifact-narrow`, `artifact-unknown`, `artifact-empty` |
|
||||
| Engine `views/mod.rs::ConfigView`, `ConfigRow`, `ConfigView::render_setting_detail`; GPUI `settings.rs::Spec`, `Applies`, `workspace/settings/mod.rs::Settings` | `SettingRow`, `SettingDetail`, `SettingWords` | Composite | `setting-row`, `setting-detail` |
|
||||
|
||||
-276
@@ -1,276 +0,0 @@
|
||||
//! Live data displays: `cargo run --example data`.
|
||||
//!
|
||||
//! A sparkline takes a new value every 400 ms, the trend draws in, and the
|
||||
//! bars glide to new values every three seconds. r replays, m switches
|
||||
//! between full and reduced motion, q or Escape exits.
|
||||
//!
|
||||
//! `--json FILE` shows one agent-sent [`DataDisplay`] instead.
|
||||
//! `--text` prints the final frame and each display's plain description.
|
||||
//! `--svg DIR` writes frames at fixed draw-in points.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use codewhale_ratatui::{
|
||||
DataBarItem, DataDisplay, DataWords, MotionEasing, MotionMode, MotionPolicy, MotionStep,
|
||||
MotionTiming, Paint, Role, Theme,
|
||||
testing::{self, Profile},
|
||||
};
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::{Terminal, backend::CrosstermBackend, buffer::Buffer, layout::Rect};
|
||||
|
||||
const TICK: Duration = Duration::from_millis(400);
|
||||
const RESHUFFLE: Duration = Duration::from_secs(3);
|
||||
const DRAW_IN: MotionTiming =
|
||||
MotionTiming::space(Duration::from_millis(1_400), MotionEasing::EaseOut);
|
||||
const GLIDE: MotionTiming = MotionTiming::PANEL;
|
||||
|
||||
struct Noise(u64);
|
||||
|
||||
impl Noise {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1);
|
||||
(self.0 >> 33) as f64 / f64::from(1u32 << 31)
|
||||
}
|
||||
}
|
||||
|
||||
struct Board {
|
||||
noise: Noise,
|
||||
requests: Vec<f64>,
|
||||
latency: Vec<f64>,
|
||||
bars: DataDisplay,
|
||||
bars_before: DataDisplay,
|
||||
draw: MotionStep,
|
||||
glide: MotionStep,
|
||||
agent: Option<DataDisplay>,
|
||||
}
|
||||
|
||||
fn crate_bars(noise: &mut Noise) -> DataDisplay {
|
||||
DataDisplay::Bars {
|
||||
label: "Tests by crate".into(),
|
||||
unit: "tests".into(),
|
||||
items: ["engine", "tui", "runtime", "protocol", "config"]
|
||||
.into_iter()
|
||||
.map(|name| DataBarItem::new(name, (40.0 + noise.next() * 400.0).round()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Board {
|
||||
fn new(agent: Option<DataDisplay>) -> Self {
|
||||
let mut noise = Noise(7);
|
||||
let latency = (0..96)
|
||||
.map(|i| 80.0 + 45.0 * (f64::from(i) / 9.0).sin() + noise.next() * 30.0)
|
||||
.collect();
|
||||
let requests = (0..40).map(|_| 20.0 + noise.next() * 60.0).collect();
|
||||
let bars = crate_bars(&mut noise);
|
||||
Self {
|
||||
noise,
|
||||
requests,
|
||||
latency,
|
||||
bars_before: DataDisplay::Bars {
|
||||
label: String::new(),
|
||||
unit: String::new(),
|
||||
items: Vec::new(),
|
||||
},
|
||||
bars,
|
||||
draw: MotionStep::at_rest(0.0),
|
||||
glide: MotionStep::at_rest(1.0),
|
||||
agent,
|
||||
}
|
||||
}
|
||||
|
||||
fn replay(&mut self, now: Instant, policy: MotionPolicy) {
|
||||
self.draw = MotionStep::at_rest(0.0);
|
||||
self.draw.go(1.0, now, DRAW_IN, policy);
|
||||
}
|
||||
|
||||
fn tick(&mut self) {
|
||||
let last = self.requests.last().copied().unwrap_or(50.0);
|
||||
let next = (last + (self.noise.next() - 0.5) * 30.0).clamp(5.0, 120.0);
|
||||
self.requests.push(next.round());
|
||||
if self.requests.len() > 200 {
|
||||
self.requests.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn reshuffle(&mut self, now: Instant, policy: MotionPolicy) {
|
||||
self.bars_before = std::mem::replace(&mut self.bars, crate_bars(&mut self.noise));
|
||||
self.glide = MotionStep::at_rest(0.0);
|
||||
self.glide.go(1.0, now, GLIDE, policy);
|
||||
}
|
||||
|
||||
fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme, draw: f32, glide: f32) {
|
||||
buf.set_style(area, theme.bg(Role::Background));
|
||||
let inner = Rect::new(
|
||||
area.x + 2,
|
||||
area.y + 1,
|
||||
area.width.saturating_sub(4),
|
||||
area.height.saturating_sub(2),
|
||||
);
|
||||
if let Some(agent) = &self.agent {
|
||||
agent.view().drawn(draw).paint(inner, buf, theme);
|
||||
return;
|
||||
}
|
||||
let row =
|
||||
|y: u16, h: u16| Rect::new(inner.x, inner.y + y, inner.width, h).intersection(inner);
|
||||
let requests = DataDisplay::Sparkline {
|
||||
label: "Requests".into(),
|
||||
unit: "rpm".into(),
|
||||
values: self.requests.clone(),
|
||||
};
|
||||
let latency = DataDisplay::Trend {
|
||||
label: "Latency".into(),
|
||||
unit: "ms".into(),
|
||||
values: self.latency.clone(),
|
||||
rows: Some(9),
|
||||
};
|
||||
requests.view().drawn(draw).paint(row(0, 1), buf, theme);
|
||||
latency.view().drawn(draw).paint(row(2, 9), buf, theme);
|
||||
self.bars
|
||||
.view()
|
||||
.from(&self.bars_before)
|
||||
.drawn(glide.min(draw))
|
||||
.paint(row(12, 6), buf, theme);
|
||||
}
|
||||
|
||||
fn displays(&self) -> Vec<DataDisplay> {
|
||||
if let Some(agent) = &self.agent {
|
||||
return vec![agent.clone()];
|
||||
}
|
||||
vec![
|
||||
DataDisplay::Sparkline {
|
||||
label: "Requests".into(),
|
||||
unit: "rpm".into(),
|
||||
values: self.requests.clone(),
|
||||
},
|
||||
DataDisplay::Trend {
|
||||
label: "Latency".into(),
|
||||
unit: "ms".into(),
|
||||
values: self.latency.clone(),
|
||||
rows: Some(9),
|
||||
},
|
||||
self.bars.clone(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let value = |flag: &str| {
|
||||
args.iter()
|
||||
.position(|a| a == flag)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned()
|
||||
};
|
||||
let agent = match value("--json") {
|
||||
Some(path) => {
|
||||
let json = std::fs::read_to_string(&path)?;
|
||||
match DataDisplay::from_json(&json) {
|
||||
Ok(shown) => Some(shown),
|
||||
Err(e) => {
|
||||
eprintln!("{path}: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let board = Board::new(agent);
|
||||
|
||||
if args.iter().any(|a| a == "--text") {
|
||||
let buf = testing::render(84, 20, |area, buf| {
|
||||
board.paint(area, buf, &Profile::DarkTrue.theme(), 1.0, 1.0);
|
||||
});
|
||||
println!("{}", testing::text(&buf));
|
||||
for shown in board.displays() {
|
||||
println!("{}", shown.describe(&DataWords::default()));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(dir) = value("--svg") {
|
||||
let dir = PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let theme = Profile::DarkTrue.theme();
|
||||
for (i, at) in [0.2_f32, 0.5, 0.8, 1.0].into_iter().enumerate() {
|
||||
let buf = testing::render(84, 20, |area, buf| board.paint(area, buf, &theme, at, at));
|
||||
let path = dir.join(format!("data-{i}.svg"));
|
||||
std::fs::write(&path, testing::svg(&buf, &theme))?;
|
||||
println!("{}", path.display());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
run(board)
|
||||
}
|
||||
|
||||
fn run(mut board: Board) -> io::Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut out = io::stdout();
|
||||
execute!(out, EnterAlternateScreen)?;
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(out))?;
|
||||
let theme = Theme::detect().tui();
|
||||
let mut mode = MotionMode::Full;
|
||||
let mut policy = MotionPolicy::new(mode, &theme);
|
||||
let start = Instant::now();
|
||||
board.replay(start, policy);
|
||||
let (mut next_tick, mut next_shuffle) = (start + TICK, start + RESHUFFLE);
|
||||
|
||||
let result = loop {
|
||||
let now = Instant::now();
|
||||
if now >= next_tick {
|
||||
board.tick();
|
||||
next_tick = now + TICK;
|
||||
}
|
||||
if now >= next_shuffle {
|
||||
board.reshuffle(now, policy);
|
||||
next_shuffle = now + RESHUFFLE;
|
||||
}
|
||||
let (draw, glide) = (
|
||||
board.draw.drawn(now, policy),
|
||||
board.glide.drawn(now, policy),
|
||||
);
|
||||
terminal.draw(|f| board.paint(f.area(), f.buffer_mut(), &theme, draw, glide))?;
|
||||
|
||||
let due = [
|
||||
board.draw.next_frame_at(now, policy),
|
||||
board.glide.next_frame_at(now, policy),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain([next_tick, next_shuffle])
|
||||
.min()
|
||||
.unwrap_or(next_tick);
|
||||
if event::poll(due.saturating_duration_since(Instant::now()))?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
&& key.kind != KeyEventKind::Release
|
||||
{
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
|
||||
KeyCode::Char('r') => board.replay(Instant::now(), policy),
|
||||
KeyCode::Char('m') => {
|
||||
mode = if mode == MotionMode::Full {
|
||||
MotionMode::Reduced
|
||||
} else {
|
||||
MotionMode::Full
|
||||
};
|
||||
policy = MotionPolicy::new(mode, &theme);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
result
|
||||
}
|
||||
-1261
File diff suppressed because it is too large.
Load diff
@@ -59,12 +59,10 @@ pub use tabs::*;
|
||||
pub use toggle::*;
|
||||
|
||||
// Package Display.
|
||||
mod chart;
|
||||
mod diff;
|
||||
mod progress;
|
||||
mod receipt;
|
||||
mod tree;
|
||||
pub use chart::*;
|
||||
pub use diff::*;
|
||||
pub use progress::*;
|
||||
pub use receipt::*;
|
||||
|
||||
@@ -366,14 +366,6 @@ impl MotionStep {
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress for a draw-in, in `0..=1`: eased where `policy` lets space
|
||||
/// move, the target where it does not. Feed it to a data display's
|
||||
/// `drawn`.
|
||||
#[must_use]
|
||||
pub fn drawn(&self, now: Instant, policy: MotionPolicy) -> f32 {
|
||||
self.shown(now, policy, MotionChannel::Space)
|
||||
}
|
||||
|
||||
/// A column between `from` and `to`: slide a marker, nudge a panel.
|
||||
#[must_use]
|
||||
pub fn offset(&self, now: Instant, policy: MotionPolicy, from: u16, to: u16) -> u16 {
|
||||
|
||||
-360
@@ -1,360 +0,0 @@
|
||||
---
|
||||
source: src/components/chart.rs
|
||||
assertion_line: 1242
|
||||
expression: "testing::snapshot(12, |area, buf, theme|\n{\n let row = |y: u16, h: u16|\n { Rect::new(area.x, area.y + y, area.width, h).intersection(area) };\n DataSparkline::new(\"Requests\",\n &values).unit(\"rpm\").paint(row(0, 1), buf, theme);\n DataBars::new(\"Tokens by model\",\n &items).unit(\"tokens\").paint(row(2, 5), buf, theme);\n DataTrend::new(\"Latency\",\n &values).unit(\"ms\").rows(5).paint(row(7, 5), buf, theme);\n})"
|
||||
---
|
||||
== dark-truecolor · 40x12
|
||||
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
|
||||
|
||||
Tokens by model
|
||||
opus ██████████████████ 12.4k tokens
|
||||
sonnet ███████████▌ 7900 tokens
|
||||
haiku ████▌ 3100 tokens
|
||||
cache 0 tokens
|
||||
Latency now 45.6 ms
|
||||
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
|
||||
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
|
||||
│⠁ ⠑⠢⡀ ⡠⠊
|
||||
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
|
||||
---
|
||||
r0 c0..8 fg=Foreground bg=Background bold
|
||||
r0 c8..10 bg=Background
|
||||
r0 c10..26 fg=Accent bg=Background
|
||||
r0 c26..28 bg=Background
|
||||
r0 c28..32 fg=Muted bg=Background
|
||||
r0 c32..40 fg=Foreground bg=Background
|
||||
r1 c0..40 bg=Background
|
||||
r2 c0..15 fg=Foreground bg=Background bold
|
||||
r2 c15..40 bg=Background
|
||||
r3 c0..6 fg=Foreground bg=Background
|
||||
r3 c6..8 bg=Background
|
||||
r3 c8..26 fg=Accent bg=Background
|
||||
r3 c26..28 bg=Background
|
||||
r3 c28..40 fg=Muted bg=Background
|
||||
r4 c0..6 fg=Foreground bg=Background
|
||||
r4 c6..8 bg=Background
|
||||
r4 c8..20 fg=Accent bg=Background
|
||||
r4 c20..28 bg=Background
|
||||
r4 c28..39 fg=Muted bg=Background
|
||||
r4 c39..40 bg=Background
|
||||
r5 c0..6 fg=Foreground bg=Background
|
||||
r5 c6..8 bg=Background
|
||||
r5 c8..13 fg=Accent bg=Background
|
||||
r5 c13..28 bg=Background
|
||||
r5 c28..39 fg=Muted bg=Background
|
||||
r5 c39..40 bg=Background
|
||||
r6 c0..6 fg=Foreground bg=Background
|
||||
r6 c6..28 bg=Background
|
||||
r6 c28..36 fg=Muted bg=Background
|
||||
r6 c36..40 bg=Background
|
||||
r7 c0..7 fg=Foreground bg=Background bold
|
||||
r7 c7..9 bg=Background
|
||||
r7 c9..13 fg=Muted bg=Background
|
||||
r7 c13..20 fg=Foreground bg=Background
|
||||
r7 c20..40 bg=Background
|
||||
r8 c0..8 fg=Muted bg=Background
|
||||
r8 c8..9 fg=Dim bg=Background
|
||||
r8 c9..11 bg=Background
|
||||
r8 c11..18 fg=Accent bg=Background
|
||||
r8 c18..31 bg=Background
|
||||
r8 c31..38 fg=Accent bg=Background
|
||||
r8 c38..40 bg=Background
|
||||
r9 c0..8 fg=Muted bg=Background
|
||||
r9 c8..9 fg=Dim bg=Background
|
||||
r9 c9..12 fg=Accent bg=Background
|
||||
r9 c12..18 bg=Background
|
||||
r9 c18..19 fg=Accent bg=Background
|
||||
r9 c19..29 bg=Background
|
||||
r9 c29..32 fg=Accent bg=Background
|
||||
r9 c32..37 bg=Background
|
||||
r9 c37..40 fg=Accent bg=Background
|
||||
r10 c0..8 fg=Muted bg=Background
|
||||
r10 c8..9 fg=Dim bg=Background
|
||||
r10 c9..10 fg=Accent bg=Background
|
||||
r10 c10..19 bg=Background
|
||||
r10 c19..22 fg=Accent bg=Background
|
||||
r10 c22..28 bg=Background
|
||||
r10 c28..30 fg=Accent bg=Background
|
||||
r10 c30..40 bg=Background
|
||||
r11 c0..8 fg=Muted bg=Background
|
||||
r11 c8..9 fg=Dim bg=Background
|
||||
r11 c9..21 bg=Background
|
||||
r11 c21..28 fg=Accent bg=Background
|
||||
r11 c28..40 bg=Background
|
||||
|
||||
== dark-truecolor · 80x12
|
||||
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
|
||||
|
||||
Tokens by model
|
||||
opus ██████████████████████████████████████████████████████████ 12.4k tokens
|
||||
sonnet █████████████████████████████████████ 7900 tokens
|
||||
haiku ██████████████▌ 3100 tokens
|
||||
cache 0 tokens
|
||||
Latency now 45.6 ms min 17.2 ms max 69.7 ms
|
||||
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
|
||||
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
|
||||
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
|
||||
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
|
||||
---
|
||||
r0 c0..8 fg=Foreground bg=Background bold
|
||||
r0 c8..10 bg=Background
|
||||
r0 c10..38 fg=Accent bg=Background
|
||||
r0 c38..40 bg=Background
|
||||
r0 c40..44 fg=Muted bg=Background
|
||||
r0 c44..52 fg=Foreground bg=Background
|
||||
r0 c52..54 bg=Background
|
||||
r0 c54..58 fg=Muted bg=Background
|
||||
r0 c58..66 fg=Foreground bg=Background
|
||||
r0 c66..68 bg=Background
|
||||
r0 c68..72 fg=Muted bg=Background
|
||||
r0 c72..80 fg=Foreground bg=Background
|
||||
r1 c0..80 bg=Background
|
||||
r2 c0..15 fg=Foreground bg=Background bold
|
||||
r2 c15..80 bg=Background
|
||||
r3 c0..6 fg=Foreground bg=Background
|
||||
r3 c6..8 bg=Background
|
||||
r3 c8..66 fg=Accent bg=Background
|
||||
r3 c66..68 bg=Background
|
||||
r3 c68..80 fg=Muted bg=Background
|
||||
r4 c0..6 fg=Foreground bg=Background
|
||||
r4 c6..8 bg=Background
|
||||
r4 c8..45 fg=Accent bg=Background
|
||||
r4 c45..68 bg=Background
|
||||
r4 c68..79 fg=Muted bg=Background
|
||||
r4 c79..80 bg=Background
|
||||
r5 c0..6 fg=Foreground bg=Background
|
||||
r5 c6..8 bg=Background
|
||||
r5 c8..23 fg=Accent bg=Background
|
||||
r5 c23..68 bg=Background
|
||||
r5 c68..79 fg=Muted bg=Background
|
||||
r5 c79..80 bg=Background
|
||||
r6 c0..6 fg=Foreground bg=Background
|
||||
r6 c6..68 bg=Background
|
||||
r6 c68..76 fg=Muted bg=Background
|
||||
r6 c76..80 bg=Background
|
||||
r7 c0..7 fg=Foreground bg=Background bold
|
||||
r7 c7..9 bg=Background
|
||||
r7 c9..13 fg=Muted bg=Background
|
||||
r7 c13..20 fg=Foreground bg=Background
|
||||
r7 c20..22 bg=Background
|
||||
r7 c22..26 fg=Muted bg=Background
|
||||
r7 c26..33 fg=Foreground bg=Background
|
||||
r7 c33..35 bg=Background
|
||||
r7 c35..39 fg=Muted bg=Background
|
||||
r7 c39..46 fg=Foreground bg=Background
|
||||
r7 c46..80 bg=Background
|
||||
r8 c0..8 fg=Muted bg=Background
|
||||
r8 c8..9 fg=Dim bg=Background
|
||||
r8 c9..14 bg=Background
|
||||
r8 c14..29 fg=Accent bg=Background
|
||||
r8 c29..60 bg=Background
|
||||
r8 c60..61 fg=Accent bg=Background
|
||||
r8 c61..62 bg=Background
|
||||
r8 c62..75 fg=Accent bg=Background
|
||||
r8 c75..80 bg=Background
|
||||
r9 c0..8 fg=Muted bg=Background
|
||||
r9 c8..9 fg=Dim bg=Background
|
||||
r9 c9..10 bg=Background
|
||||
r9 c10..14 fg=Accent bg=Background
|
||||
r9 c14..29 bg=Background
|
||||
r9 c29..32 fg=Accent bg=Background
|
||||
r9 c32..56 bg=Background
|
||||
r9 c56..60 fg=Accent bg=Background
|
||||
r9 c60..61 bg=Background
|
||||
r9 c61..62 fg=Accent bg=Background
|
||||
r9 c62..75 bg=Background
|
||||
r9 c75..80 fg=Accent bg=Background
|
||||
r10 c0..8 fg=Muted bg=Background
|
||||
r10 c8..9 fg=Dim bg=Background
|
||||
r10 c9..10 fg=Accent bg=Background
|
||||
r10 c10..32 bg=Background
|
||||
r10 c32..38 fg=Accent bg=Background
|
||||
r10 c38..53 bg=Background
|
||||
r10 c53..57 fg=Accent bg=Background
|
||||
r10 c57..80 bg=Background
|
||||
r11 c0..8 fg=Muted bg=Background
|
||||
r11 c8..9 fg=Dim bg=Background
|
||||
r11 c9..38 bg=Background
|
||||
r11 c38..53 fg=Accent bg=Background
|
||||
r11 c53..80 bg=Background
|
||||
|
||||
== dark-truecolor · 120x12
|
||||
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
|
||||
|
||||
Tokens by model
|
||||
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
|
||||
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
|
||||
haiku ████████████████████████▌ 3100 tokens
|
||||
cache 0 tokens
|
||||
Latency now 45.6 ms min 17.2 ms max 69.7 ms
|
||||
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
|
||||
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
|
||||
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
|
||||
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
|
||||
---
|
||||
r0 c0..8 fg=Foreground bg=Background bold
|
||||
r0 c8..10 bg=Background
|
||||
r0 c10..58 fg=Accent bg=Background
|
||||
r0 c58..60 bg=Background
|
||||
r0 c60..64 fg=Muted bg=Background
|
||||
r0 c64..72 fg=Foreground bg=Background
|
||||
r0 c72..74 bg=Background
|
||||
r0 c74..78 fg=Muted bg=Background
|
||||
r0 c78..86 fg=Foreground bg=Background
|
||||
r0 c86..88 bg=Background
|
||||
r0 c88..92 fg=Muted bg=Background
|
||||
r0 c92..100 fg=Foreground bg=Background
|
||||
r0 c100..120 bg=Background
|
||||
r1 c0..120 bg=Background
|
||||
r2 c0..15 fg=Foreground bg=Background bold
|
||||
r2 c15..120 bg=Background
|
||||
r3 c0..6 fg=Foreground bg=Background
|
||||
r3 c6..8 bg=Background
|
||||
r3 c8..106 fg=Accent bg=Background
|
||||
r3 c106..108 bg=Background
|
||||
r3 c108..120 fg=Muted bg=Background
|
||||
r4 c0..6 fg=Foreground bg=Background
|
||||
r4 c6..8 bg=Background
|
||||
r4 c8..71 fg=Accent bg=Background
|
||||
r4 c71..108 bg=Background
|
||||
r4 c108..119 fg=Muted bg=Background
|
||||
r4 c119..120 bg=Background
|
||||
r5 c0..6 fg=Foreground bg=Background
|
||||
r5 c6..8 bg=Background
|
||||
r5 c8..33 fg=Accent bg=Background
|
||||
r5 c33..108 bg=Background
|
||||
r5 c108..119 fg=Muted bg=Background
|
||||
r5 c119..120 bg=Background
|
||||
r6 c0..6 fg=Foreground bg=Background
|
||||
r6 c6..108 bg=Background
|
||||
r6 c108..116 fg=Muted bg=Background
|
||||
r6 c116..120 bg=Background
|
||||
r7 c0..7 fg=Foreground bg=Background bold
|
||||
r7 c7..9 bg=Background
|
||||
r7 c9..13 fg=Muted bg=Background
|
||||
r7 c13..20 fg=Foreground bg=Background
|
||||
r7 c20..22 bg=Background
|
||||
r7 c22..26 fg=Muted bg=Background
|
||||
r7 c26..33 fg=Foreground bg=Background
|
||||
r7 c33..35 bg=Background
|
||||
r7 c35..39 fg=Muted bg=Background
|
||||
r7 c39..46 fg=Foreground bg=Background
|
||||
r7 c46..120 bg=Background
|
||||
r8 c0..8 fg=Muted bg=Background
|
||||
r8 c8..9 fg=Dim bg=Background
|
||||
r8 c9..16 bg=Background
|
||||
r8 c16..40 fg=Accent bg=Background
|
||||
r8 c40..89 bg=Background
|
||||
r8 c89..91 fg=Accent bg=Background
|
||||
r8 c91..92 bg=Background
|
||||
r8 c92..112 fg=Accent bg=Background
|
||||
r8 c112..120 bg=Background
|
||||
r9 c0..8 fg=Muted bg=Background
|
||||
r9 c8..9 fg=Dim bg=Background
|
||||
r9 c9..10 bg=Background
|
||||
r9 c10..17 fg=Accent bg=Background
|
||||
r9 c17..40 bg=Background
|
||||
r9 c40..45 fg=Accent bg=Background
|
||||
r9 c45..83 bg=Background
|
||||
r9 c83..89 fg=Accent bg=Background
|
||||
r9 c89..90 bg=Background
|
||||
r9 c90..92 fg=Accent bg=Background
|
||||
r9 c92..112 bg=Background
|
||||
r9 c112..120 fg=Accent bg=Background
|
||||
r10 c0..8 fg=Muted bg=Background
|
||||
r10 c8..9 fg=Dim bg=Background
|
||||
r10 c9..11 fg=Accent bg=Background
|
||||
r10 c11..45 bg=Background
|
||||
r10 c45..54 fg=Accent bg=Background
|
||||
r10 c54..77 bg=Background
|
||||
r10 c77..83 fg=Accent bg=Background
|
||||
r10 c83..120 bg=Background
|
||||
r11 c0..8 fg=Muted bg=Background
|
||||
r11 c8..9 fg=Dim bg=Background
|
||||
r11 c9..54 bg=Background
|
||||
r11 c54..78 fg=Accent bg=Background
|
||||
r11 c78..120 bg=Background
|
||||
|
||||
== no-color · 40x12
|
||||
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
|
||||
|
||||
Tokens by model
|
||||
opus ██████████████████ 12.4k tokens
|
||||
sonnet ███████████▌ 7900 tokens
|
||||
haiku ████▌ 3100 tokens
|
||||
cache 0 tokens
|
||||
Latency now 45.6 ms
|
||||
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
|
||||
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
|
||||
│⠁ ⠑⠢⡀ ⡠⠊
|
||||
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
|
||||
|
||||
== no-color · 80x12
|
||||
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
|
||||
|
||||
Tokens by model
|
||||
opus ██████████████████████████████████████████████████████████ 12.4k tokens
|
||||
sonnet █████████████████████████████████████ 7900 tokens
|
||||
haiku ██████████████▌ 3100 tokens
|
||||
cache 0 tokens
|
||||
Latency now 45.6 ms min 17.2 ms max 69.7 ms
|
||||
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
|
||||
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
|
||||
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
|
||||
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
|
||||
|
||||
== no-color · 120x12
|
||||
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
|
||||
|
||||
Tokens by model
|
||||
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
|
||||
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
|
||||
haiku ████████████████████████▌ 3100 tokens
|
||||
cache 0 tokens
|
||||
Latency now 45.6 ms min 17.2 ms max 69.7 ms
|
||||
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
|
||||
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
|
||||
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
|
||||
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
|
||||
|
||||
== ascii · 40x12
|
||||
Requests _.---=####=--.__ now 45.6 rpm
|
||||
|
||||
Tokens by model
|
||||
opus ################## 12.4k tokens
|
||||
sonnet ############ 7900 tokens
|
||||
haiku ##### 3100 tokens
|
||||
cache 0 tokens
|
||||
Latency now 45.6 ms
|
||||
69.7 ms | ....... .....:.
|
||||
|... : ... ...
|
||||
|. ... ..
|
||||
17.2 ms | .:.....
|
||||
|
||||
== ascii · 80x12
|
||||
Requests _________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
|
||||
|
||||
Tokens by model
|
||||
opus ########################################################## 12.4k tokens
|
||||
sonnet ##################################### 7900 tokens
|
||||
haiku ############### 3100 tokens
|
||||
cache 0 tokens
|
||||
Latency now 45.6 ms min 17.2 ms max 69.7 ms
|
||||
69.7 ms | ............... . .............
|
||||
| .... ... .... . .....
|
||||
|. ...... ....
|
||||
17.2 ms | ...............
|
||||
|
||||
== ascii · 120x12
|
||||
Requests --==##########----.._________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
|
||||
|
||||
Tokens by model
|
||||
opus ################################################################################################## 12.4k tokens
|
||||
sonnet ############################################################### 7900 tokens
|
||||
haiku ######################### 3100 tokens
|
||||
cache 0 tokens
|
||||
Latency now 45.6 ms min 17.2 ms max 69.7 ms
|
||||
69.7 ms | ........................ .. ....................
|
||||
| ....... ..... ...... .. ........
|
||||
|.. ......... ......
|
||||
17.2 ms | ........................
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
//! Gallery: data displays fully drawn, mid draw-in, empty and from agent JSON.
|
||||
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
|
||||
use super::Entry;
|
||||
use crate::{DataBarItem, DataBars, DataDisplay, DataSparkline, DataTrend, Paint, Theme};
|
||||
|
||||
fn series() -> Vec<f64> {
|
||||
(0..64)
|
||||
.map(|i| 120.0 + 60.0 * (f64::from(i) / 6.0).sin() + f64::from((i * 7) % 11) * 3.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn items() -> Vec<DataBarItem> {
|
||||
vec![
|
||||
DataBarItem::new("engine", 412.0),
|
||||
DataBarItem::new("tui", 268.0),
|
||||
DataBarItem::new("runtime", 131.0),
|
||||
DataBarItem::new("protocol", 44.0),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(area: Rect, y: u16, h: u16) -> Rect {
|
||||
Rect::new(area.x, area.y.saturating_add(y), area.width, h).intersection(area)
|
||||
}
|
||||
|
||||
fn sparklines(area: Rect, buf: &mut Buffer, theme: &Theme) {
|
||||
let values = series();
|
||||
DataSparkline::new("Requests", &values)
|
||||
.unit("rpm")
|
||||
.paint(row(area, 0, 1), buf, theme);
|
||||
DataSparkline::new("Requests", &values)
|
||||
.unit("rpm")
|
||||
.drawn(0.45)
|
||||
.paint(row(area, 1, 1), buf, theme);
|
||||
DataSparkline::new("Requests", &[]).paint(row(area, 2, 1), buf, theme);
|
||||
}
|
||||
|
||||
fn bars(area: Rect, buf: &mut Buffer, theme: &Theme) {
|
||||
let items = items();
|
||||
DataBars::new("Tests by crate", &items).paint(row(area, 0, 5), buf, theme);
|
||||
}
|
||||
|
||||
fn bars_growing(area: Rect, buf: &mut Buffer, theme: &Theme) {
|
||||
let items = items();
|
||||
DataBars::new("Tests by crate", &items)
|
||||
.previous(&[300.0, 300.0, 300.0, 300.0])
|
||||
.drawn(0.5)
|
||||
.paint(row(area, 0, 5), buf, theme);
|
||||
}
|
||||
|
||||
fn trend(area: Rect, buf: &mut Buffer, theme: &Theme) {
|
||||
let values = series();
|
||||
DataTrend::new("Latency", &values)
|
||||
.unit("ms")
|
||||
.rows(7)
|
||||
.paint(area, buf, theme);
|
||||
}
|
||||
|
||||
fn trend_drawing(area: Rect, buf: &mut Buffer, theme: &Theme) {
|
||||
let values = series();
|
||||
DataTrend::new("Latency", &values)
|
||||
.unit("ms")
|
||||
.rows(7)
|
||||
.drawn(0.6)
|
||||
.paint(area, buf, theme);
|
||||
}
|
||||
|
||||
fn from_agent(area: Rect, buf: &mut Buffer, theme: &Theme) {
|
||||
let shown = DataDisplay::from_json(
|
||||
r#"{"kind":"bars","label":"Tokens this turn","unit":"tokens",
|
||||
"items":[{"label":"read","value":18200},{"label":"wrote","value":2400},
|
||||
{"label":"cached","value":61000}]}"#,
|
||||
)
|
||||
.expect("gallery fixture is a valid data display");
|
||||
shown.view().paint(area, buf, theme);
|
||||
}
|
||||
|
||||
pub(crate) fn entries() -> Vec<Entry> {
|
||||
vec![
|
||||
Entry {
|
||||
name: "data-sparkline",
|
||||
width: 80,
|
||||
height: 3,
|
||||
draw: sparklines,
|
||||
},
|
||||
Entry {
|
||||
name: "data-bars",
|
||||
width: 60,
|
||||
height: 5,
|
||||
draw: bars,
|
||||
},
|
||||
Entry {
|
||||
name: "data-bars-growing",
|
||||
width: 60,
|
||||
height: 5,
|
||||
draw: bars_growing,
|
||||
},
|
||||
Entry {
|
||||
name: "data-trend",
|
||||
width: 72,
|
||||
height: 7,
|
||||
draw: trend,
|
||||
},
|
||||
Entry {
|
||||
name: "data-trend-drawing",
|
||||
width: 72,
|
||||
height: 7,
|
||||
draw: trend_drawing,
|
||||
},
|
||||
Entry {
|
||||
name: "data-from-agent",
|
||||
width: 60,
|
||||
height: 4,
|
||||
draw: from_agent,
|
||||
},
|
||||
]
|
||||
}
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user