Compare commits

..
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 c0c80ffa07 fix(web): Plugins page names what each permission mode does with an agent-written plugin and the real skill command (CW-8087 PL-04 repair 1)
Auto-Review's reviewer model judges and Plan refuses, per the recorded ruling, so the
page no longer says approval is always asked outside Full Access. /skill install is
the command that adds a skill. The coming lead says the rest of the page is the
released version, and the meta description no longer carries a sentence that outlives
the 0.10.3 release.

Checks: vitest 92 files / 1303 tests passed, tsc exit 0, eslint exit 0, check:facts exit 0,
next build exit 0; built /en and /zh plugins pages fetched and show the new text.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:43 -08:00
CodeWhale BotandClaude Sonnet 5.5 66987f4fb5 feat(web): the Plugins page says plugin for what a person adds and labels what 0.10.3 brings as coming (CW-8087 PL-04)
The page described "everything is a plugin" as if the released terminal
already listed skills, connectors and hooks as plugins. It does not: a plugin
bundle carries them, and /skills, /mcp and /hooks add them one at a time. The
title, lead and meta now say that, and the heading "One format for every
extension" becomes "What a plugin can add", since one format is not released.

What 0.10.3 adds goes in a new section, "Coming in 0.10.3, not in the current
release": one Plugins place with kind labels, Standard and Creator mode per
conversation, the approval rule for a plugin the agent wrote, and the old
Plan/Work control renamed Permissions. The section renders only while the
published release is older than 0.10.3 (pluginsComingApplies) and is in the
site search under the same condition. English and Simplified Chinese carry the
text; the other 16 languages show the English page body through the existing
fallback, and no dictionary key was added or removed, so the 18-language
parity checks are unchanged.

Also: the authoring button opens the site's own plugin-authoring page (written
from the released tag) instead of the main-branch document; the DeepSeek
Harness compatibility link says it describes current source, because that
document is not in the v0.10.2 tag; the desktop app page names Plugins as one
noun with skills, connectors and hooks inside it; docs map descriptions say a
connector is an MCP server, a hook is added, and a skill can come inside a
plugin; the unrendered product copy says a skill is shared inside a plugin.

Checks run in web/: tsc --noEmit, eslint, check:facts, check:locales,
check:docs, vitest (91 files, 1301 tests plus 2 new), next build; the built
/en, /zh and /de plugins pages were fetched and the coming section checked.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:42 -08:00
CodeWhale BotandClaude Sonnet 5.5 bd1d61630a docs(plugins): repair round 2 for review of CW-8071
plugin-creator step 7 says to write the Full Access plugin in an ordinary
project folder, because install refuses a name already used by the bundle
step 1 creates. English RUNTIME_API gets the same after-0.10.2 label as zh.
Agent docs regenerated; preflight --check OK.

At landing: the skills and plugins rows in docs/features.toml are
shortened to the registry's 120-character summary limit (they were 122
and 153, which failed feature_registry_matches_code); features JSON
regenerated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:40 -08:00
CodeWhale BotandClaude Sonnet 5.5 45794614da docs(plugins): repair round 1 for review of CW-8071
Docs no longer claim a single Plugins list exists: hand-made skills, servers
and hooks are plugins of a kind, still managed in Extensions and Customize,
and the one list is stated as planned for 0.10.3. plugin-creator step 7 now
gives the install, trust with review token and enable steps that the Full
Access exception accepts. Drop the generation 22 comment. zh RUNTIME_API says
ACP plugin and MCP tools are main after 0.10.2.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 3dbb084a9e docs(plugins): the docs say plugin, in English and Simplified Chinese (CW-8071)
PLUGINS, SKILLS, MCP and HOOKS open by saying everything a person adds is a plugin;
a skill, an MCP connector and a hook are each a plugin or part of one and keep their
kind as a label, with their files, trust rules and commands unchanged. Connection
stays the word for a model provider or another agent and nothing else. PLUGINS no
longer says DSH plugin UI is unsupported. features.toml names the three kinds as
plugin kinds (ids unchanged); agent docs and feature data regenerated.

Checks: scripts/preflight.sh --check (see the review packet).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:37 -08:00
CodeWhale BotandClaude Sonnet 5.5 50b4d5b45d docs(plugins): correct the stale creator texts of ruling R8 (CW-8071)
plugin-creator no longer tells an agent never to automate trust: in Full Access an
agent may trust and enable a plugin it wrote and installed from its own project,
through the session's approval, recorded as the agent's (section 142.4), and code or
screen plugins point at cordis-plugin-development instead of a second format. The
0.10.2 body (generation 19) is retained and listed by hash so untouched copies
upgrade; the bundled generation is 22. DSH_PLUGIN_COMPAT now says imported client
UIs are served on the admitted seats instead of unsupported. The Simplified Chinese
EXTENSIONS and RUNTIME_API say ACP sessions get the extension host, plugin tools and
configured MCP servers, as the English does. The vocabulary script says /pod answers
Unknown command and codewhale pod is prompt text.

Checks: codewhale-skills nextest 188 passed 0 skipped; clippy -D warnings clean;
scripts/check-tui-product-vocabulary.sh exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:36 -08:00
CodeWhale BotandClaude Sonnet 5.5 7b60b85ac3 fix(extension-host): cordisInspect commands.register states the alias and refusal rules the registry enforces (CR-06a, CW-8073)
A built-in clash gets plugin-<plugin-slug>-<command> and is refused if invalid or taken; a name another plugin registered is refused. Catalog regenerated; --check exit 0; inspect tests 17/17 on Node and Bun; typecheck clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:34 -08:00
CodeWhale BotandClaude Sonnet 5.5 f94b43012a fix(extension-host): cordisInspect catalog states the persona section and activation result contracts the host enforces (CR-06a, CW-8073)
systemPrompt.section is named deployment:persona-prefix with the order getSectionOrder returns; subagents result is a getter resolving to stopReason and output blocks. Command, avatar and hook limits corrected; non-JSON provider output is refused as DSH does. The method-existence test now checks each method on the class that serves it; a renamed shim method fails it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:33 -08:00
CodeWhale BotandClaude Sonnet 5.5 34b0631492 feat(extension-host): cordisInspect data that describes this host (CR-06a, CW-8073)
A dependency-light cordisInspect service with list() and query() in the
shape DSH's cordis_inspect_list/query tools call. Host providers: Service,
Event, Config (Tool only when a source is supplied); client providers:
Slots and Theme. The catalog is generated from compatibility.json, the
host's refused-event list in root.ts, the shell-hook event map and the
Engine seat table, plus one hand-kept method table; manifests pass DSH's
own supported-schema check and inputs its validator. Not wired to the live
host (CR-06b); dist/ is not regenerated.

Checks: node --test test/inspect.test.mjs 15 pass 0 fail; bun test 15 pass
0 fail; npm run typecheck clean; generator --check clean; preflight OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:32 -08:00
CodeWhale BotandClaude Sonnet 5.5 3d4158cf07 test(tui): real-terminal thinking stream, stop, restore and sign-in notice at four widths (CW-7876)
Adds a PTY scenario to the existing cucumber harness, compiled only on unix
with long-running-tests: stream thinking, stop, and restore at four terminal
widths, the one-time sign-in notice with Esc dismissal, and a saved choice to
hide thinking surviving the new default.

Checks: farm run 20261112T073916Z-ENG-TUI-rebased-pty4-b3fc94, 3 tests run,
3 passed (1 slow), 60 skipped, exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:30 -08:00
CodeWhale BotandClaude Sonnet 5.5 4bd3e15373 fix(tui): a paused stream shows the thinking it already received (CW-7876)
When the provider paused mid-stream, reasoning already received stayed
unrendered until the next token arrived. The owed revision now renders on a
later display beat (render_owed_revision), so the transcript shows what has
arrived. Covered by a_paused_stream_renders_the_reasoning_it_already_received
and the PTY scenario.

Checks: farm tests run 20261112T061929Z-ENG-TUI-rebased-tests-d67557, 7557
passed / 10 skipped, exit 0; clippy run 20261112T061950Z-ENG-TUI-rebased-clippy-50d460
clean, exit 0; PTY run 20261112T073916Z-ENG-TUI-rebased-pty4-b3fc94, 3 of 3
passed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:29 -08:00
CodeWhale BotandClaude Sonnet 5.5 192a7306ed fix(tui): the footer clock says thinking, like the transcript (CW-7876)
The footer phase word and clock said reasoning while the transcript header
said thinking. Both now say thinking, in the 12 locale packs that carried the
old word and the localization library; uk and ru keep their own wording. The
ui tests assert the new clock text.

Checks: farm tests run 20261112T061929Z-ENG-TUI-rebased-tests-d67557, 7557
passed / 10 skipped, exit 0; locale parity PASS; vocabulary check exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:28 -08:00
CodeWhale BotandClaude Sonnet 5.5 4ec3c45b47 feat(settings): model thinking shows by default; a saved choice is kept (CW-7876)
Thinking traces are first-class: show_thinking now defaults to true in the
runtime Settings and the config schema, while thinking_default_expanded stays
false (live while streaming, collapsed once the answer starts). Docs, the
features.toml row, the zh-Hans docs, the config panel goldens and the derived
web json follow. A show_thinking value already present in settings.toml is
read as an explicit choice and kept. Note that earlier saves wrote the old
default into that file, so only installs without the key get the new default;
whether to migrate those is a founder decision recorded in the review packet.

Checks: farm tests run 20261112T061929Z-ENG-TUI-rebased-tests-d67557, 7557
passed / 10 skipped, exit 0, including feature_registry (2 passed); clippy run
20261112T061950Z-ENG-TUI-rebased-clippy-50d460 clean, exit 0; check-facts OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:26 -08:00
CodeWhale BotandClaude Sonnet 5.5 cf6e4c702e fix(tui): the sign-in notice is a one-time footer notice that Esc dismisses (CW-7876)
The "account preferences not loaded" notice was pushed into the transcript
on every refresh, so it repeated and scrolled away with the conversation.
It is now a standing footer notice shown once per sign-in
(account_preferences_notice_shown), and Esc dismisses it while idle. The
message names the remedy within 40 columns and carries meaning in words, not
colour. New string added to all 15 locale packs and the settings schema.

Checks: farm tests run 20261112T061929Z-ENG-TUI-rebased-tests-d67557, 7557
passed / 10 skipped, exit 0; clippy run 20261112T061950Z-ENG-TUI-rebased-clippy-50d460
clean, exit 0; PTY run 20261112T073916Z-ENG-TUI-rebased-pty4-b3fc94, 3 of 3
passed; locale parity PASS; vocabulary check exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:24 -08:00
CodeWhale BotandClaude Opus 5.5 d82eeaf592 docs(sandbox): Fleet workers keep the managed variable; named files fail closed; serve --mcp refuses shell (CW-8052)
CHANGELOG: the Sandbox bullet's managed-configuration sentence now says what
the four CW-8052 commits do.

Checks: round 0 nextest 987/987 passed and clippy clean on runtime, engine,
app-server and TUI; repair round on the rebased tip nextest 1058/1058 passed
(runtime lib 922, engine fleet::host 26, TUI config_stay 110); preflight OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:23 -08:00
CodeWhale BotandClaude Opus 5.5 5aed666083 fix(mcp): serve --mcp does not expose shell tools under a tool launcher (CW-8052)
The stdio MCP server builds its own ToolContext, whose shell manager has
no launcher, deny list or prefer_bwrap. When the loaded config carries a
managed tool_launcher, run_mcp_server now removes every expose_tools entry
that maps to exec_shell (shell, exec_shell) before the registry is built,
so no shell tool is registered, and names each one on stderr. Wiring the
launcher would also need the deny list and read guard; refusing is the
closed form the review allowed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:22 -08:00
CodeWhale BotandClaude Opus 5.5 1396698607 fix(fleet): local workers keep the parent's managed config variable (CW-8052)
A Fleet local worker is spawned with a cleaned environment rebuilt from
the child allow-list, which has no CODEWHALE_* names, so it lost
CODEWHALE_MANAGED_CONFIG_PATH and read the user config's
managed_config_path instead. base_env_from now carries both managed-config
names from the parent for every local worker (not only when a sessions
directory is set), and worker_env drops them from the request so an
allow-listed request cannot replace or add them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:20 -08:00
CodeWhale BotandClaude Opus 5.5 72e5bcdb39 fix(config): a named managed file that cannot be read is a load error (CW-8052)
One helper, managed_config_file, now resolves the managed path for the
loader and for the three "who controls this" readers: the variable first
(an empty CODEWHALE_MANAGED_CONFIG_PATH defers to the legacy alias instead
of hiding it), then the config file's managed_config_path, then the
default path. The variable is read through codewhale_env_var, under the
test environment lock.

A file named by the variable or by managed_config_path that is missing,
a dangling link, a directory or unreadable used to load with no managed
layer; it now fails the load. Only a missing file at the default path
means no managed layer, and a config file's managed_config_path can no
longer point away from an existing default-path file. The readers report
Ambiguous where the loader would fail.

A provider that only a plugin declares used to fail under a managed file
with the saved-session message; it now says that plugin providers are not
usable under a managed configuration and what to do.

Tests that isolated themselves with an absent managed file now name an
empty one (no_managed_config writes it). New test covers missing,
directory, config-key and blank-variable cases; the late-provider test
now pins the error instead of passing on any Err.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:19 -08:00
CodeWhale BotandClaude Sonnet 5.5 e6183ad966 fix(usage-reporting): installers force the kill switch for their own run; doctor names a saved opt-out as config (CW-8033)
The installers (web/public/install.sh, scripts/release/install.sh,
scripts/release/install.ps1) now run their closing `doctor --installs --brief`
with CODEWHALE_TELEMETRY=0 and CODEWHALE_NO_UPDATE_CHECK=1 for that command
only, and print one line saying usage reporting is on by default and how to
turn it off. v0.10.2 already honours both variables, so nothing needs the next
release. `doctor` is classed read-only and does not arm reporting at v0.10.2 or
main, so this is a guard that does not depend on that classification.

The dispatcher hands the child CODEWHALE_TELEMETRY with the resolved value, so
a saved opt-out read as an environment kill switch and doctor printed "(env)".
The dispatcher now also passes CODEWHALE_TELEMETRY_SOURCE; the child uses it
only to label the source, only next to the dispatcher's floor statement, and
never to change the decision. A hand-set CODEWHALE_TELEMETRY=0 still reads
"(env)".

Wording: the two clap help strings and the English TUI strings say "usage
reporting".

Checks: node install-safety.test.js 74 passed; farm nextest of the four new or
touched tests 4 passed, and a wider telemetry sweep (config, cli, tui,
localization) 206 passed, 1 failed, the failure being a test expectation fixed
in this commit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:17 -08:00
CodeWhale BotandClaude Sonnet 5.5 7ed8f92649 fix(engine): withhold tools whose encoded name exceeds the route limit; make decode exact (CW-8041)
to_api_tool_name expands every character outside [A-Za-z0-9_] (a dot is
nine characters, a hyphen two), so a long qualified MCP or plugin tool name
could encode past the provider limit and fail every retry with a 400 on
tools.N.name. A shortened name could not be decoded back, so no shortening
is attempted: the encoder stays injective and nothing is truncated.

Policy, applied in the one encoder module (client/wire.rs) and used by the
chat, responses and Anthropic builders:
- A tool whose encoded name exceeds the limit is withheld through the
  existing omitted-tools path. Forcing it with tool_choice fails with an
  error giving the tool name, its encoded length, the limit and what to
  change.
- A tool name already present in message history that exceeds a documented
  limit (Anthropic, OpenAI and Codex, DeepSeek) refuses the request with
  its own message: the tool was called earlier in the session, so switch to
  a route with a larger limit or start a new session. Routes with no
  documented limit send history unchanged. History is never rewritten.
- The omitted-tools warning (core/events.rs, one message) names the tools
  and states the limits and what to do. The exact encoded length is not in
  it: the warning carries names already cut to 64 characters; adding it
  needs a new field on the warning event.

Limit rule, in tool_name_limit(provider, wire): where a provider documents
its own limit it is used; otherwise the default is the limit of the wire
the route speaks, 128 on the Anthropic Messages wire and 64 on the OpenAI
chat and responses wires, except Ollama, Ollama Cloud, vLLM, SGLang and
custom routes, which have no name limit and use 128.
- Documented, read 2026-11-10: Anthropic 128 (define-tools page,
  ^[a-zA-Z0-9_-]{1,128}$); DeepSeek 128 on its chat and Anthropic wires
  (chat-completion API reference); OpenAI 64 (OpenAPI spec, function name
  description).
- Wire default, no documented limit found: MiniMax and ModelStudio on the
  Anthropic wire (128; their Anthropic-compatible references, read
  2026-11-10, state no tool-name limit), every other Anthropic-wire route
  (128), and every other chat or responses provider including Gemini (64;
  a wire default, not a documented provider limit).

Decode: from_api_tool_name ran a lenient second pass that rewrote bare
x00002E-style text, so a tool literally named ax00002Eb decoded to a.b, a
different tool. A name that is the canonical encoding of its own exact
decode now returns that decode and skips the lenient pass, so a legal tool
name can no longer be turned into another. The cost: a model that strips
both dashes from an encoded name is no longer recovered; the old
foo_x00002Ebar expectation is changed accordingly.

Tests: encoding collision and round trip over hostile names, limit counting,
limit per wire and provider, a 100-character name offered on the
MiniMax Anthropic wire and withheld on a chat wire, over-long MCP-style
names withheld per provider in the Anthropic, chat and responses builders,
forced-tool and history refusals, and a 65 to 128 character history name sent unchanged on Ollama, Custom and MiniMax chat routes, and the warning wording. Farm: nextest -p codewhale-engine client:: and core::events:: 664 run, 664 passed;
workspace clippy (ci.yml flags) clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:16 -08:00
CodeWhale BotandClaude Sonnet 5.5 a937eaccbc fix(engine): encode the tool name in a chat function-object tool_choice (CW-8040)
map_tool_choice_for_chat encoded only the internal {"type":"tool","name"}
shape. A choice already in chat-completions form,
{"type":"function","function":{"name":"web.run"}}, fell into the
passthrough arm and named a tool the request never declared, because the
declaration is sent as web-x00002E-run. The same held for the flat
{"type":"function","name":...} form and for a bare tool-name string, both
of which forced_tool_name already treats as a forced tool.

All of these now go out as {"type":"function","function":{"name":
to_api_tool_name(name)}}. Reserved strings (auto, none, required) and a
function choice with no name are unchanged.

Shape per OpenAI's published OpenAPI spec (ChatCompletionNamedToolChoice),
read 2026-11-10. The regression test covers web.run in all four shapes and
checks the built request body against the declared tool name.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 01:55:14 -08:00
CodeWhale BotandClaude Sonnet 5.5 a7d9986382 fix(app-server): match a gone store spelled through a symlink or another letter case when retiring a dead owner receipt (CW-8054)
The dead-owner discard compared the lexically normalised selected store with the
receipt's data_dir, so a home spelled through a symlinked ancestor (for example
/tmp on macOS) or in another letter case never matched and startup stayed
refused. Both sides now resolve the deepest existing ancestor and re-append the
missing tail, and on macOS and Windows the final comparison folds letter case.
Adds a symlinked-home test (unix) and a case-spelling test (macOS).

The one new canonicalize is admitted to the blocking-calls budget; it runs
inside owner_work, off the async runtime.

Checks: nextest -p codewhale-app-server --test daemon_socket 18/18 passed;
clippy -p codewhale-app-server --all-targets --all-features 0 warnings;
blocking-calls budget within budget; fmt clean.

Squashed from bf3c88bde3 and 6cdf29d72c.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:13 -08:00
CodeWhale BotandClaude Sonnet 5.5 665fb506ff test(engine): settle the queue fixture and bound idle reads by measured time (CW-8070)
The settled-nap test waited a fixed 2.3s for a fresh queue file to age and
bounded fingerprint reads by the nominal 3s window. On a slow runner the queue
could still be unsettled when counting began, or the sleep could overshoot and
fit more 1s naps than the bound allowed. Settle the stamp through the existing
fixture, wait for every worker to report a settled schedule, and derive the
bound from measured elapsed time. Product code is unchanged.

nextest stress 20/20 passed (farm run 20261112T061942Z-nap-stress-90898e);
clippy -p codewhale-engine --all-targets --all-features clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:11 -08:00
CodeWhale BotandClaude Opus 5.5 5e2366a1c4 fix(subagent): remove the workspace-wide delegated coordination lock (CW-7966)
A stale or suspended Codewhale session in a folder held
`.codewhale/state/subagents.v1.lock`, and every later session there
failed the exclusive v1->v3 cutover acquire, fell back to private or
read-only sub-agent state for the life of the process, and repeated
"Runtime coordination was opened without its writer lock" on each
sidebar refresh.

The v3 store already owns its concurrency control: the bounded
per-operation `subagents.v3.transaction.lock` and per-writer boot
leases, with per-writer snapshot merge, record ownership, terminal
receipts and stale/foreign record rejection in `merge_publish`. This
removes `CoordinationProcessLock` and everything that only it used: the
process-lock slot and its required/exclusive/read-only flags, the
session-private fallback, the cutover-blocked marker, the per-admitted-
workspace writer locks, and the lock-only constructor flag. The v1->v3
cutover now runs under the transaction lock only, as the architecture
doc already allows. Store admission that fails is retried on the next
delegated write (`admitted_store_authority`) instead of staying refused
for the life of the process; a corrupt store still fails every write
because each transaction validates the image it reads.

A Runtime that admits a selected workspace other than its own state root
used to take an exclusive flock there; it now refuses shared-workspace
writers while any session holds a live boot lease in that workspace's own
store, treating only NotFound as "no session there". The state
directory's `.gitignore` is written by store admission after the cutover
and the image read, so a refused admission through a linked `.codewhale`
writes no new file there. `cleanup_due` is false while a state path
exists and the store is not admitted, so a refused store shows its
refusal once rather than on every sidebar refresh.
`new_exclusive_runtime_subagent_manager` stays as an alias of the shared
Runtime constructor for its app-server caller.

Also carries the out-of-ownership edits the change requires: the
coordination-lock wording in engine.rs, coord.rs and coord/ledger.rs
comments, the runtime_threads test comment, the six TUI conformance
goldens (the engine no longer creates subagents.v1.lock), the two docs,
and the blocking-calls budget for subagent/mod.rs (std_fs 3 to 2).

Regression: `a_stale_session_never_makes_a_new_session_read_only`, a
two-process check; it fails on main.
`a_linked_codewhale_admission_writes_no_gitignore_through_it` replaces the deleted linked-lock test.

Squashed from a5a2dade1c and c1e7863392 plus the review's requested
patch; see the review packet for the evidence.

Checks: subagent filter: 824 run, 824 passed (nextest, farm
20261112T062100Z-ref04land-nextest-81c0b0); TUI conformance::events 4/4
(20261112T062102Z-ref04land-golden-bb9851); fmt clean; blocking-calls
budget within budget.

CHANGELOG: the three 0.10.3 coordination-lock entries are replaced by one
entry that says what the code now does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:55:09 -08:00
161 changed files with 1932 additions and 7310 deletions

No files matched your search

+8 -51
View File
@@ -4,12 +4,11 @@
# alias = Display Name <[email protected]>
#
# The right-hand side must use GitHub's numeric noreply address so a harvested
# commit authored with it, or a Co-authored-by trailer in the commit's final
# trailer block, links to the contributor's account. GitHub resolves the
# address by its numeric id, so the id must be the contributor's own
# (`gh api users/<login> --jq .id`). The left-hand side may be a GitHub login,
# old-style noreply address, raw email from a contributor commit, or local
# machine email seen in older harvested history.
# commit authored with it links to the contributor's account. Only commit
# authorship puts someone in the repository's contributor list; a co-author
# trailer shows on their profile but not there. The left-hand side may be a
# GitHub login, old-style noreply address, raw email from a contributor commit,
# or local machine email seen in older harvested history.
hmbown = Hmbown <[email protected]>
[email protected] = Hmbown <[email protected]>
@@ -51,7 +50,6 @@ Oliver-ZPLiu = Oliver-ZPLiu <[email protected]>
idling11 = idling11 <[email protected]>
Hanmiao Li = idling11 <[email protected]>
[email protected] = idling11 <[email protected]>
894876246 = idling11 <[email protected]>
angziii = angziii <[email protected]>
aboimpinto = aboimpinto <[email protected]>
Paulo Aboim Pinto = aboimpinto <[email protected]>
@@ -81,24 +79,19 @@ Vince = Liu-Vince <[email protected]>
[email protected] = Liu-Vince <[email protected]>
LI-Jialu = LI-Jialu <[email protected]>
JiarenWang = JiarenWang <[email protected]>
wdw8276 = kitty <3972439+mars-base@users.noreply.github.com>
mars-base = kitty <[email protected]>
[email protected] = kitty <[email protected]>
wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com>
pengyou200902 = pengyou200902 <[email protected]>
linzhiqin2003 = linzhiqin2003 <[email protected]>
LING71671 = LING71671 <[email protected]>
[email protected] = LING71671 <[email protected]>
JasonOA888 = JasonOA888 <[email protected]>
Inference1 = Inference1 <[email protected]>
hongqitai = hongqitai <[email protected]>
gordonlu = gordonlu <[email protected]>
angus-guo = gus <217034332+im[email protected]>
imgusguo = gus <217034332+im[email protected]>
[email protected] = gus <[email protected]>
angus-guo = gus <217034332+angus-[email protected]>
gus.guo@tec-do.com = gus <217034332+angus-[email protected]>
gaord = gaord <[email protected]>
Ben Gao = gaord <[email protected]>
[email protected] = gaord <[email protected]>
bengao168 = gaord <[email protected]>
MXAntian = MXAntian <[email protected]>
[email protected] = MXAntian <[email protected]>
DarrellThomas = Darrell Thomas <[email protected]>
@@ -254,7 +247,6 @@ [email protected] = zhuowp <[email protected]>
[email protected] = 秋月凉梦 <[email protected]>
asto18089 = asto18089 <[email protected]>
[email protected] = asto18089 <[email protected]>
xiechimon = Xmon Dai <[email protected]>
yrk111222 = Rongkun Yan <[email protected]>
VincentCorleone = Vincent Corleone <[email protected]>
@@ -286,38 +278,3 @@ Felixkw12 = Felix Wunderlich <[email protected]>
[email protected] = Felix Wunderlich <[email protected]>
Felix Wunderlich = Felix Wunderlich <[email protected]>
[email protected] = luochen211 <[email protected]>
AiurArtanis = AiurArtanis <[email protected]>
[email protected] = AiurArtanis <[email protected]>
Apeiron0w0 = Apeiron <[email protected]>
[email protected] = Apeiron <[email protected]>
[email protected] = ChaceLyee2101 <[email protected]>
AmosLeeeeeeee = ChaceLyee2101 <[email protected]>
ddlw = ddlw <[email protected]>
mo-vic = movic <[email protected]>
[email protected] = movic <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
xulongzhe = luola <[email protected]>
[email protected] = luola <[email protected]>
ZhulongNT = SONG ZHENRUI <[email protected]>
[email protected] = SONG ZHENRUI <[email protected]>
xiluoduyu = xiluoduyu <[email protected]>
[email protected] = xiluoduyu <[email protected]>
wlon = Stephen Xu <[email protected]>
[email protected] = Stephen Xu <[email protected]>
krisclarkdev = Kristopher Clark <[email protected]>
THatch26 = THatch26 <[email protected]>
jeoor = Kayro <[email protected]>
harvey2011888 = harvey2011888 <[email protected]>
duanchao-lab = DC <[email protected]>
dumbjack = dumbjack <[email protected]>
[email protected] = dumbjack <[email protected]>
AquaZhang = IceTear <[email protected]>
disappear00 = idontknow <[email protected]>
namezzb = sailorZ <[email protected]>
alphapku-ux = Alpha Wu <[email protected]>
BX166 = BX166 <[email protected]>
c020627 = c020627 <[email protected]>
[email protected] = Parman Mohammadalizadeh <[email protected]>
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
-14
View File
@@ -14,17 +14,3 @@ Hunter Bown <[email protected]> Cursor Agent <[email protected]>
Hunter Bown <[email protected]> cursoragent <[email protected]>
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
ddlw <[email protected]> <[email protected]>
dmitri-0 <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
AiurArtanis <[email protected]> <[email protected]>
Apeiron <[email protected]> <[email protected]>
ChaceLyee2101 <[email protected]> <[email protected]>
movic <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
luola <[email protected]> <[email protected]>
SONG ZHENRUI <[email protected]> <[email protected]>
xiluoduyu <[email protected]> <[email protected]>
+3 -10
View File
@@ -259,16 +259,9 @@ prefer merging the contributor's PR itself). When a contributor's work
lands as our commit, the contributor is that commit's **author** (`git commit
--author` with their GitHub-linked address), it carries `Harvested from PR #N by
@handle`, and maintainers or agents go in `Co-authored-by`. GitHub's contributor
count takes commit authors and the `Co-authored-by` trailers in a commit's final
trailer block, and only addresses linked to an account: a trailer cut off by a
blank line, an unlinked email, or a noreply address carrying someone else's
numeric id credits nobody or the wrong person. Harvested commits authored by
maintainer identities without such a trailer left their contributors off it,
which is why `docs/CONTRIBUTORS.md` carries the full wall
(`scripts/contributor-wall.py`). Credit lost in history that cannot change is
restored by a later commit with one trailer per person, naming each pull
request; `scripts/check-contributor-credit.py` fails the release window when a
harvest, a trailer or a noreply id would leave someone uncounted.
list counts commit authors only: about 200 harvested commits authored by
maintainer identities left their contributors off it, which is why
`docs/CONTRIBUTORS.md` carries the full wall (`scripts/contributor-wall.py`).
A maintainer closes their PR by hand with a thank-you naming the commit;
nothing closes a PR automatically. Canonical human identities come from
`.github/AUTHOR_MAP`.
+1 -20
View File
@@ -7,15 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **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.
@@ -676,7 +669,7 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
- **Windows uninstall removes the receipt and update leftovers.** The installer's uninstaller also deletes `.codewhale-install.sha256` and `codewhale.old-*`/`codew.old-*` left by a self-update, so `bin` can be removed. Refs #7091.
- **Windows `/undo` matches recorded file paths regardless of case.** A write recorded under a different drive-letter or directory case, or a `\\?\` prefix, is no longer dropped from the restore plan. Refs #6970.
- **Windows: Fleet and metrics read live files again.** Replaying a Fleet ledger and reading audit or receipt records no longer fail with a sharing violation while their writer is still open; the same regular-file, link and identity checks apply. Plugin enablement now says which snapshot check refused (stage root, content identity or final check) instead of one generic message. Generated protocol and SDK exports keep LF line endings in Windows checkouts.
- **A held coordination lock no longer makes a new session read-only.** When another session owns the delegated-agent store, the new session starts on private state and says so, instead of refusing every sub-agent with “Failed to clean up sub-agents”.
- **Sessions in one folder no longer leave each other's sub-agents read-only or private:** a second session works beside another one, stale or suspended included, the older sub-agent store upgrades right away, and a store that cannot be opened is retried on the next sub-agent change instead of needing a restart. While another Codewhale session, a suspended one included, is open in a folder the desktop app or `serve` was asked to work in (other than the folder it started in), that Runtime still refuses sub-agents that would edit the folder directly; isolated worktrees are unaffected. A sub-agent store that cannot be opened shows its refusal once instead of repeating cleanup warnings on every sidebar refresh. Restart any session started with Codewhale 0.10.2 or older: once the folder upgrades it can no longer save sub-agent state (Refs #7232).
- **A dev server can outlive the turn in an interactive session.** `persist:true` now works in the TUI and desktop when the thread has Full Access; restricted postures and Fleet/sub-agents still refuse it.
- **`codewhale exec --add-dir <path>`** (repeatable) and `addDirs` on `POST /v1/threads` let the file tools read and write a sibling directory, a second checkout or a shared scratch root without turning on Full Access.
- **Unsettled usage is persisted in production, not only in tests.** The interactive sink now records a legacy usage receipt when a provider response has no settlement, so children and turns stop reporting settlement failure.
@@ -713,12 +706,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
composer. Real Pinyin input on macOS and Windows is not yet confirmed.
Thanks [@cmdcorp6534](https://github.com/cmdcorp6534) for the report
(Refs #2323).
- **Sub-agents beside an older session:** while a Codewhale 0.10.2 session
still uses a folder's older delegated-agent store, a 0.10.3 session in the
same folder keeps working sub-agents with private state that is not saved,
instead of refusing every sub-agent action. The older session's files are
left untouched, and the store upgrades when a session starts after the older
one exits (Refs #7232).
- **Fleet members:** a replayed member start now matches only within its own
session, so two sessions that reuse an operation key no longer receive each
other's agent.
@@ -747,12 +734,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
bare self-package exports resolve inside the selected package directory.
Invalid or incompatible ranges and paths outside the reviewed package still
refuse. Real published-plugin activation remains unqualified.
- **Coordination lock diagnostics (#7232):** stale lock-file contents no longer
identify an owner PID. A refused legacy admission keeps its reason in Work
and shows one notice per cause instead of repeating cleanup warnings on
every sidebar refresh. Legacy migration still requires the actual holder to
exit and the refused owning Runtime to restart; locks and retained state
remain protected.
- **A local proxy that stops mid-session no longer strands the session (#7231):** the model, catalog, search, vision, finance, MCP-registry, profile and remote-control clients watch a loopback proxy port and go direct while it refuses connections, then resume proxying when it returns. Only a definite refusal from every resolved address changes the route; remote proxies, `NO_PROXY` matches, credentials and the existing precedence are unchanged. Set `CODEWHALE_LOCAL_PROXY_FALLBACK=0` to keep the old build-time snapshot.
- **Proxy diagnostic privacy ([#7233](https://github.com/codewhale-hq/codewhale/issues/7233)):** malformed MCP proxy warnings omit configured values; local-proxy log labels mask userinfo and omit URL paths, queries and fragments. Routing is unchanged. All 23 existing proxy tests passed on macOS; runtime log capture and other platforms remain unqualified. Thanks [@SparkofSpike](https://github.com/SparkofSpike) for the report.
- **Fork, undo and retry work after a workspace drift notice (#7234):** the history-boundary walks now treat a whole `<context_update>` envelope as engine-owned, so a session that received one can be cut at every saved turn. A person quoting a notice or a truncated envelope still counts as their own prompt.
-84
View File
@@ -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);
-26
View File
@@ -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"]);
+4 -10
View File
@@ -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
-3
View File
@@ -141,9 +141,6 @@ pub enum ConfigAction {
// Views and pickers.
OpenConfigView,
OpenModePicker,
SetConversationMode {
id: String,
},
OpenThemePicker,
OpenStatusPicker,
OpenProviderPicker,
+5 -10
View File
@@ -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 {
+325 -87
View File
@@ -140,17 +140,48 @@ impl EnvRuntimeOverrides {
Self {
provider,
provider_source,
model: env_non_blank(&["CODEWHALE_MODEL", "DEEPSEEK_MODEL", "DEEPSEEK_DEFAULT_TEXT_MODEL"]),
volcengine_model: env_non_blank(&["VOLCENGINE_MODEL", "VOLCENGINE_ARK_MODEL"]),
wanjie_ark_model: env_non_blank(&["WANJIE_ARK_MODEL", "WANJIE_MODEL", "WANJIE_MAAS_MODEL"]),
openrouter_model: env_non_blank(&["OPENROUTER_MODEL"]),
orcarouter_model: env_non_blank(&["ORCAROUTER_MODEL"]),
moonshot_model: env_non_blank(&["MOONSHOT_MODEL", "KIMI_MODEL_NAME", "KIMI_MODEL"]),
xiaomi_mimo_model: env_non_blank(&["XIAOMI_MIMO_MODEL", "MIMO_MODEL"]),
xiaomi_mimo_mode: env_non_blank(&["XIAOMI_MIMO_MODE", "MIMO_MODE"]),
novita_model: env_non_blank(&["NOVITA_MODEL"]),
fireworks_model: env_non_blank(&["FIREWORKS_MODEL"]),
arcee_model: env_non_blank(&["ARCEE_MODEL"]),
model: std::env::var("CODEWHALE_MODEL")
.or_else(|_| std::env::var("DEEPSEEK_MODEL"))
.or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_model: std::env::var("VOLCENGINE_MODEL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
.or_else(|_| std::env::var("WANJIE_MODEL"))
.or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_model: std::env::var("OPENROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_model: std::env::var("ORCAROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_model: std::env::var("MOONSHOT_MODEL")
.or_else(|_| std::env::var("KIMI_MODEL_NAME"))
.or_else(|_| std::env::var("KIMI_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
.or_else(|_| std::env::var("MIMO_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
.or_else(|_| std::env::var("MIMO_MODE"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_model: std::env::var("NOVITA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_model: std::env::var("FIREWORKS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_model: std::env::var("ARCEE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
verbosity: env_non_blank(&["CODEWHALE_VERBOSITY", "DEEPSEEK_VERBOSITY"]),
auth_mode: env_non_blank(&["CODEWHALE_AUTH_MODE", "DEEPSEEK_AUTH_MODE"]),
log_level: env_non_blank(&["CODEWHALE_LOG_LEVEL", "DEEPSEEK_LOG_LEVEL"]),
@@ -185,7 +216,9 @@ impl EnvRuntimeOverrides {
None
}
}),
http_headers: env_non_blank(&["CODEWHALE_HTTP_HEADERS", "DEEPSEEK_HTTP_HEADERS"])
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
.ok()
.and_then(|value| match parse_http_headers(&value) {
Ok(h) => Some(h),
Err(_) => {
@@ -194,85 +227,290 @@ impl EnvRuntimeOverrides {
}
})
.filter(|headers| !headers.is_empty()),
active_route_base_url: env_non_blank(&["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"]),
deepseek_anthropic_base_url: env_non_blank(&["DEEPSEEK_ANTHROPIC_BASE_URL", "DEEPSEEK_CLAUDE_BASE_URL"]),
nvidia_base_url: env_non_blank(&["NVIDIA_NIM_BASE_URL", "NIM_BASE_URL", "NVIDIA_BASE_URL"]),
openai_base_url: env_non_blank(&["OPENAI_BASE_URL"]),
atlascloud_base_url: env_non_blank(&["ATLASCLOUD_BASE_URL"]),
volcengine_base_url: env_non_blank(&["VOLCENGINE_BASE_URL", "VOLCENGINE_ARK_BASE_URL", "ARK_BASE_URL"]),
wanjie_ark_base_url: env_non_blank(&["WANJIE_ARK_BASE_URL", "WANJIE_BASE_URL", "WANJIE_MAAS_BASE_URL"]),
openrouter_base_url: env_non_blank(&["OPENROUTER_BASE_URL"]),
orcarouter_base_url: env_non_blank(&["ORCAROUTER_BASE_URL"]),
xiaomi_mimo_base_url: env_non_blank(&["XIAOMI_MIMO_BASE_URL", "MIMO_BASE_URL"]),
novita_base_url: env_non_blank(&["NOVITA_BASE_URL"]),
fireworks_base_url: env_non_blank(&["FIREWORKS_BASE_URL"]),
siliconflow_base_url: env_non_blank(&["SILICONFLOW_BASE_URL"]),
siliconflow_model: env_non_blank(&["SILICONFLOW_MODEL"]),
arcee_base_url: env_non_blank(&["ARCEE_BASE_URL"]),
moonshot_base_url: env_non_blank(&["MOONSHOT_BASE_URL", "KIMI_BASE_URL"]),
sglang_base_url: env_non_blank(&["SGLANG_BASE_URL"]),
vllm_base_url: env_non_blank(&["VLLM_BASE_URL"]),
ollama_base_url: env_non_blank(&["OLLAMA_BASE_URL"]),
ollama_cloud_base_url: env_non_blank(&["OLLAMA_CLOUD_BASE_URL"]),
ollama_cloud_model: env_non_blank(&["OLLAMA_CLOUD_MODEL"]),
huggingface_base_url: env_non_blank(&["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]),
huggingface_model: env_non_blank(&["HUGGINGFACE_MODEL", "HF_MODEL"]),
modelscope_base_url: env_non_blank(&["MODELSCOPE_BASE_URL"]),
modelscope_model: env_non_blank(&["MODELSCOPE_MODEL"]),
together_base_url: env_non_blank(&["TOGETHER_BASE_URL"]),
together_model: env_non_blank(&["TOGETHER_MODEL"]),
qianfan_base_url: env_non_blank(&["QIANFAN_BASE_URL", "BAIDU_QIANFAN_BASE_URL"]),
qianfan_model: env_non_blank(&["QIANFAN_MODEL", "BAIDU_QIANFAN_MODEL"]),
openai_codex_base_url: env_non_blank(&["OPENAI_CODEX_BASE_URL", "CODEX_BASE_URL"]),
openai_codex_model: env_non_blank(&["OPENAI_CODEX_MODEL", "CODEX_MODEL"]),
anthropic_base_url: env_non_blank(&["ANTHROPIC_BASE_URL"]),
anthropic_model: env_non_blank(&["ANTHROPIC_MODEL"]),
openmodel_base_url: env_non_blank(&["OPENMODEL_BASE_URL"]),
openmodel_model: env_non_blank(&["OPENMODEL_MODEL"]),
zai_base_url: env_non_blank(&["ZAI_BASE_URL", "Z_AI_BASE_URL", "ZHIPU_BASE_URL", "ZHIPUAI_BASE_URL", "BIGMODEL_BASE_URL"]),
zai_model: env_non_blank(&["ZAI_MODEL", "Z_AI_MODEL", "ZHIPU_MODEL", "ZHIPUAI_MODEL", "BIGMODEL_MODEL", "GLM_MODEL"]),
stepfun_base_url: env_non_blank(&["STEPFUN_BASE_URL", "STEP_BASE_URL"]),
stepfun_model: env_non_blank(&["STEPFUN_MODEL", "STEP_MODEL"]),
minimax_base_url: env_non_blank(&["MINIMAX_BASE_URL"]),
minimax_anthropic_base_url: env_non_blank(&["MINIMAX_ANTHROPIC_BASE_URL"]),
minimax_model: env_non_blank(&["MINIMAX_MODEL"]),
deepinfra_base_url: env_non_blank(&["DEEPINFRA_BASE_URL"]),
deepinfra_model: env_non_blank(&["DEEPINFRA_MODEL"]),
sakana_base_url: env_non_blank(&["SAKANA_BASE_URL"]),
sakana_model: env_non_blank(&["SAKANA_MODEL"]),
longcat_base_url: env_non_blank(&["LONGCAT_BASE_URL"]),
longcat_model: env_non_blank(&["LONGCAT_MODEL"]),
opencode_go_base_url: env_non_blank(&["OPENCODE_GO_BASE_URL"]),
opencode_go_model: env_non_blank(&["OPENCODE_GO_MODEL"]),
opencode_zen_base_url: env_non_blank(&["OPENCODE_ZEN_BASE_URL"]),
opencode_zen_model: env_non_blank(&["OPENCODE_ZEN_MODEL"]),
meta_base_url: env_non_blank(&["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"]),
meta_model: env_non_blank(&["META_MODEL_API_MODEL", "MODEL_API_MODEL"]),
xai_base_url: env_non_blank(&["XAI_BASE_URL"]),
xai_model: env_non_blank(&["XAI_MODEL"]),
google_base_url: env_non_blank(&["GOOGLE_BASE_URL", "GEMINI_BASE_URL"]),
google_model: env_non_blank(&["GOOGLE_MODEL", "GEMINI_MODEL"]),
mistral_base_url: env_non_blank(&["MISTRAL_BASE_URL"]),
mistral_model: env_non_blank(&["MISTRAL_MODEL"]),
telecomjs_base_url: env_non_blank(&["TELECOMJS_BASE_URL"]),
telecomjs_model: env_non_blank(&["TELECOMJS_MODEL"]),
edenai_base_url: env_non_blank(&["EDENAI_BASE_URL"]),
edenai_model: env_non_blank(&["EDENAI_MODEL"]),
zenmux_base_url: env_non_blank(&["ZENMUX_BASE_URL"]),
zenmux_model: env_non_blank(&["ZENMUX_MODEL"]),
csdn_base_url: env_non_blank(&["CSDN_BASE_URL"]),
csdn_model: env_non_blank(&["CSDN_MODEL"]),
concentrate_base_url: env_non_blank(&["CONCENTRATE_BASE_URL"]),
concentrate_model: env_non_blank(&["CONCENTRATE_MODEL"]),
active_route_base_url: std::env::var("CODEWHALE_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
.or_else(|_| std::env::var("NIM_BASE_URL"))
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_base_url: std::env::var("OPENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
.or_else(|_| std::env::var("ARK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
.or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
.or_else(|_| std::env::var("MIMO_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_base_url: std::env::var("NOVITA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_model: std::env::var("SILICONFLOW_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_base_url: std::env::var("ARCEE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
.or_else(|_| std::env::var("KIMI_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
sglang_base_url: std::env::var("SGLANG_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
vllm_base_url: std::env::var("VLLM_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
.or_else(|_| std::env::var("HF_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_model: std::env::var("HUGGINGFACE_MODEL")
.or_else(|_| std::env::var("HF_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_base_url: std::env::var("MODELSCOPE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_model: std::env::var("MODELSCOPE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_base_url: std::env::var("TOGETHER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_model: std::env::var("TOGETHER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
qianfan_model: std::env::var("QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
.or_else(|_| std::env::var("CODEX_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
.or_else(|_| std::env::var("CODEX_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_model: std::env::var("ANTHROPIC_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_model: std::env::var("OPENMODEL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zai_base_url: std::env::var("ZAI_BASE_URL")
.or_else(|_| std::env::var("Z_AI_BASE_URL"))
.or_else(|_| std::env::var("ZHIPU_BASE_URL"))
.or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
.or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
zai_model: std::env::var("ZAI_MODEL")
.or_else(|_| std::env::var("Z_AI_MODEL"))
.or_else(|_| std::env::var("ZHIPU_MODEL"))
.or_else(|_| std::env::var("ZHIPUAI_MODEL"))
.or_else(|_| std::env::var("BIGMODEL_MODEL"))
.or_else(|_| std::env::var("GLM_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
.or_else(|_| std::env::var("STEP_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_model: std::env::var("STEPFUN_MODEL")
.or_else(|_| std::env::var("STEP_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_base_url: std::env::var("MINIMAX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_model: std::env::var("MINIMAX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_model: std::env::var("DEEPINFRA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_base_url: std::env::var("SAKANA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_model: std::env::var("SAKANA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_base_url: std::env::var("LONGCAT_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_model: std::env::var("LONGCAT_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
meta_model: std::env::var("META_MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
xai_base_url: std::env::var("XAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xai_model: std::env::var("XAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
google_base_url: std::env::var("GOOGLE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
google_model: std::env::var("GOOGLE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
mistral_model: std::env::var("MISTRAL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_model: std::env::var("TELECOMJS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_base_url: std::env::var("EDENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_model: std::env::var("EDENAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_base_url: std::env::var("ZENMUX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_model: std::env::var("ZENMUX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_base_url: std::env::var("CSDN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_model: std::env::var("CSDN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_base_url: std::env::var("CONCENTRATE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_model: std::env::var("CONCENTRATE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
// `CODEWHALE_API_BASE` is a trust boundary, not a plain string:
// an origin this route would refuse to send a bearer to is
// dropped here rather than resolved into a route.
codewhale_base_url: provider::codewhale_api_base_from_env(),
codewhale_model: env_non_blank(&["CODEWHALE_MODEL"]),
modelstudio_token_plan_base_url: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_BASE_URL"]),
modelstudio_token_plan_model: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_MODEL"]),
modelstudio_coding_plan_base_url: env_non_blank(&["MODELSTUDIO_CODING_PLAN_BASE_URL"]),
modelstudio_coding_plan_model: env_non_blank(&["MODELSTUDIO_CODING_PLAN_MODEL"]),
codewhale_model: std::env::var("CODEWHALE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
}
}
+7 -1
View File
@@ -605,7 +605,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
def(
"show_thinking",
SettingKind::Bool(SHOW_THINKING),
"false",
"true",
ui(
TAB_APPEARANCE,
"display",
@@ -1546,6 +1546,12 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"false",
None,
),
def(
"account_preferences_notice_shown",
SettingKind::String,
"",
None,
),
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
def(
"work_surface_bottom_migrated",
+7 -6
View File
@@ -27,11 +27,10 @@ pub fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> std::time::D
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
),
_ => stream_open_timeout_from_env(
crate::env_overrides::env_non_blank(&[
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
])
.as_deref(),
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
.ok()
.as_deref(),
),
}
}
@@ -51,7 +50,9 @@ pub fn stream_open_timeout_from_env(value: Option<&str>) -> std::time::Duration
/// `false`, ...) leaves HTTP/2 on.
#[must_use]
pub fn force_http1_from_env() -> bool {
crate::env_overrides::env_non_blank(&["CODEWHALE_FORCE_HTTP1", "DEEPSEEK_FORCE_HTTP1"])
std::env::var("CODEWHALE_FORCE_HTTP1")
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
.ok()
.map(|v| v.trim().to_ascii_lowercase())
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
-54
View File
@@ -10767,60 +10767,6 @@ fn blank_or_unrecognized_env_overrides_do_not_shadow_config_or_legacy_vars() {
));
}
#[test]
fn blank_primary_env_vars_fall_through_to_their_legacy_aliases() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let names = [
"CODEWHALE_FORCE_HTTP1",
"DEEPSEEK_FORCE_HTTP1",
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
];
let saved: Vec<(&str, Option<OsString>)> = names
.iter()
.map(|name| (*name, env::var_os(name)))
.collect();
// SAFETY: env mutation is serialized by `env_lock` and restored below.
unsafe {
env::set_var("CODEWHALE_MODEL", "");
env::set_var("DEEPSEEK_MODEL", "legacy-model");
env::set_var("CODEWHALE_BASE_URL", " ");
env::set_var("DEEPSEEK_BASE_URL", "https://legacy.example/v1");
env::set_var("MOONSHOT_BASE_URL", "");
env::set_var("KIMI_BASE_URL", "https://kimi.example/v1");
env::set_var("CODEWHALE_FORCE_HTTP1", "");
env::set_var("DEEPSEEK_FORCE_HTTP1", "1");
env::set_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "");
env::set_var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS", "120");
}
let env_overrides = EnvRuntimeOverrides::load();
let force_http1 = crate::stream_timeouts::force_http1_from_env();
let open_timeout = crate::stream_timeouts::resolve_stream_open_timeout(None);
unsafe {
for (name, value) in saved {
match value {
Some(value) => env::set_var(name, value),
None => env::remove_var(name),
}
}
}
assert_eq!(env_overrides.model.as_deref(), Some("legacy-model"));
assert_eq!(
env_overrides.active_route_base_url.as_deref(),
Some("https://legacy.example/v1")
);
assert_eq!(
env_overrides.moonshot_base_url.as_deref(),
Some("https://kimi.example/v1")
);
assert!(force_http1);
assert_eq!(open_timeout, std::time::Duration::from_secs(120));
}
#[cfg(unix)]
#[test]
fn failed_legacy_state_copy_leaves_no_partial_primary() {
+3 -5
View File
@@ -1183,7 +1183,7 @@ impl Git {
overrides: Vec<(String, &'static str)>,
) -> anyhow::Result<Command> {
let mut command = Self::review_base(workspace)?;
let mut count = 3;
let mut count = 2;
for (key, value) in overrides {
// A subsection may contain '='; `-c key=value` would then
// override a different key. Separate env fields preserve it.
@@ -1195,7 +1195,7 @@ impl Git {
Ok(command)
}
/// Git with fsmonitor, hooks, lazy fetch, replace objects and path quoting disabled,
/// Git with fsmonitor, hooks, lazy fetch and replace objects disabled,
/// running in `workspace`. [`Self::review_command`] adds filter overrides.
pub fn review_base(workspace: &Path) -> anyhow::Result<Command> {
use anyhow::Context;
@@ -1257,7 +1257,7 @@ impl Git {
// Both phases must observe the same effective repository config.
.env_remove("GIT_CONFIG")
.env_remove("GIT_CONFIG_PARAMETERS")
.env("GIT_CONFIG_COUNT", "3")
.env("GIT_CONFIG_COUNT", "2")
.env("GIT_CONFIG_KEY_0", "core.fsmonitor")
.env("GIT_CONFIG_VALUE_0", "false")
.env("GIT_CONFIG_KEY_1", "core.hooksPath")
@@ -1265,8 +1265,6 @@ impl Git {
"GIT_CONFIG_VALUE_1",
if cfg!(windows) { "NUL" } else { "/dev/null" },
)
.env("GIT_CONFIG_KEY_2", "core.quotePath")
.env("GIT_CONFIG_VALUE_2", "false")
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_NO_REPLACE_OBJECTS", "1");
Ok(command)
@@ -2152,7 +2152,10 @@ fn git_push_refspec_is_protected(refspec: &str) -> bool {
// Tag-like names (`v1`, `v0.9.1`): git resolves branch-vs-tag on the
// server, so treat them as publishes.
let mut chars = lower.chars();
chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit())
if chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit()) {
return true;
}
false
}
fn git_tag_tokens_are_publish_like(tokens: &[&str]) -> bool {
+2 -15
View File
@@ -314,8 +314,8 @@ pub struct EngineConfig {
pub session_id: Option<String>,
/// Optional host-owned root for delegated-agent runtime state.
///
/// When unset, the worker ledger, complete transcript artifacts and
/// coordination lock retain their historical location under
/// When unset, the worker ledger and complete transcript artifacts
/// retain their historical location under
/// `workspace/.codewhale/state`. Embedders may set a session-scoped root
/// to separate that control-plane state from the execution workspace.
/// Child cwd and file authority still derive from `workspace`; hosts using
@@ -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");
-3
View File
@@ -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 {
-3
View File
@@ -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| {
@@ -203,13 +203,10 @@ impl Drop for TestOverlay {
fn overlay_granted() -> bool {
#[cfg(test)]
{
TEST_OVERLAY.with(std::cell::Cell::get)
}
#[cfg(not(test))]
{
false
if TEST_OVERLAY.with(std::cell::Cell::get) {
return true;
}
false
}
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
-13
View File
@@ -187,19 +187,6 @@ pub const AGENTS: &[ExternalAgent] = &[
adapter_binary: None,
adapter_cli_env: None,
},
ExternalAgent {
id: "qwen-code",
name: "Qwen Code",
binary: "qwen",
acp_args: Some(&["--acp"]),
website: "https://github.com/QwenLM/qwen-code",
min_version: Some("0.6.1"),
upgrade: Some("npm i -g @qwen-code/qwen-code@latest"),
login: None,
adapter: None,
adapter_binary: None,
adapter_cli_env: None,
},
];
const FIXTURE_AGENT: ExternalAgent = ExternalAgent {
+5 -1
View File
@@ -1748,7 +1748,11 @@ impl FleetManager {
return Err(start_error);
}
};
Ok(started)
if !started {
return Ok(false);
}
Ok(true)
}
fn start_leased_workers(
-44
View File
@@ -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.
-15
View File
@@ -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,
-128
View File
@@ -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}"))?;
+1 -5
View File
@@ -11615,10 +11615,9 @@ async fn compact_thread_receipt_keeps_exact_named_custom_identity() -> Result<()
#[tokio::test]
async fn compact_thread_with_real_engine_reaches_terminal_status() -> Result<()> {
// A real engine takes the workspace coordination lock exclusively, and
// nextest runs tests in parallel processes with this crate as their working
// directory. A `"."` workspace would therefore be the crate directory
// itself, and two exclusive holders of it cannot both start.
// itself, shared by every test process.
let workspace = tempfile::tempdir()?;
let manager = RuntimeThreadManager::open(
{
@@ -19625,7 +19624,6 @@ fn answer_session_snapshot(
system_prompt: None,
instruction_assembly: None,
mode: String::new(),
agent_preset: None,
});
}
}
@@ -19705,7 +19703,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 +22036,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(())
}
+1 -19
View File
@@ -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);
}
}
+1 -1
View File
@@ -1721,7 +1721,7 @@ mod tests {
.expect_err("mutation must fail when its receipt cannot persist")
.to_string();
assert!(
error.contains("Runtime coordination was opened without its writer lock"),
error.contains("delegated durable store admission failed"),
"{error}"
);
assert!(
@@ -133,9 +133,14 @@ impl WriteScopeClaim {
return true;
}
}
self.contracts
if self
.contracts
.iter()
.any(|contract| other.contracts.iter().any(|other| other == contract))
{
return true;
}
false
}
#[must_use]
@@ -321,9 +326,10 @@ pub struct CoordinationDetailProjection {
pub metrics: CoordinationDetailMetrics,
pub bounded: bool,
pub limit: usize,
/// Whether this process currently holds the workspace coordination flock.
/// When false, durable ledger writes are skipped and the UI must say so —
/// a counter must never tick on a turn the engine has already settled.
/// Whether this manager's durable store is admitted. When false, durable
/// ledger writes are refused until admission is retried and the UI must
/// say so — a counter must never tick on a turn the engine has already
/// settled.
#[serde(default = "default_process_lock_held")]
pub process_lock_held: bool,
/// Human-readable reason when [`Self::process_lock_held`] is false.
+134 -435
View File
@@ -499,7 +499,6 @@ const SUBAGENT_TRANSCRIPT_ARTIFACT_DIR: &str = "subagent-transcripts";
const SUBAGENT_STATE_SCHEMA_VERSION: u32 = 1;
const SUBAGENT_STATE_FILE: &str = "subagents.v3.json";
const LEGACY_SUBAGENT_STATE_FILE: &str = "subagents.v1.json";
const SUBAGENT_STATE_LOCK_FILE: &str = "subagents.v1.lock";
const SUBAGENT_RESTART_REASON: &str = "Interrupted by process restart";
const SUBAGENT_SESSION_CLOSED_REASON: &str = "Interrupted: parent session closed";
#[cfg(test)]
@@ -3933,12 +3932,6 @@ impl SubAgent {
}
}
/// Manager for active sub-agents.
struct CoordinationProcessLock {
release: Option<std::sync::mpsc::SyncSender<()>>,
thread: Option<std::thread::JoinHandle<()>>,
}
/// Marker prefix retained for presentation compatibility with same-process
/// handover notes from earlier hosts. It does not control lock acquisition
/// or retry.
@@ -3954,123 +3947,6 @@ pub const COORDINATION_LOCK_TIMEOUT_MARKER: &str =
pub const SESSION_PRIVATE_COORDINATION: &str = "another Codewhale session still uses this folder's older delegated-agent store, so its upgrade waits; sub-agents in this session keep private state that other sessions do not see and that is not saved, and the store upgrades when a session starts after the other one exits";
#[derive(Debug)]
struct SubAgentCutoverBlocked(anyhow::Error);
impl std::fmt::Display for SubAgentCutoverBlocked {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for SubAgentCutoverBlocked {}
impl CoordinationProcessLock {
fn acquire(state_root: &Path, exclusive: bool) -> Result<Self> {
let requested_root = normalize_subagent_workspace(state_root);
// Root and path for the lock. A non-home workspace keeps itself as the
// root, so a repository-supplied `.codewhale` link is walked and
// refused before `create_dir_all` could follow it into the target. A
// home workspace resolves its own junction once and still walks
// everything below it.
let (walk_root, lock_path) = codewhale_util::files::workspace_state_path(
&requested_root,
&Path::new("state").join(SUBAGENT_STATE_LOCK_FILE),
);
let lock_dir = lock_path
.parent()
.map_or_else(|| walk_root.clone(), Path::to_path_buf);
// Refuse a linked `.codewhale` or `state` before creating anything:
// `create_dir_all` follows links and would populate the target.
reject_root_relative_symlinks(&walk_root, &lock_path)?;
fs::create_dir_all(&lock_dir)?;
let ignore = lock_dir.join(".gitignore");
let ignored = reject_root_relative_symlinks(&walk_root, &ignore).and_then(|()| {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&ignore)
{
Ok(mut file) => file.write_all(b"*\n").map_err(Into::into),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(error.into()),
}
});
if let Err(error) = ignored {
tracing::warn!(target: "subagent", %error, "could not write {}", ignore.display());
}
// Creating a missing root can change its canonical spelling on
// Windows (for example by adding a `\\?\` prefix). Re-resolve both
// sides before checking containment.
let state_root = normalize_subagent_workspace(state_root);
let (walk_root, lock_path) = codewhale_util::files::workspace_state_path(
&state_root,
&Path::new("state").join(SUBAGENT_STATE_LOCK_FILE),
);
reject_root_relative_symlinks(&walk_root, &lock_path)?;
let lock_file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)?;
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
let thread = std::thread::spawn(move || {
let mut lock = fd_lock::RwLock::new(lock_file);
let acquired = if exclusive {
lock.try_write().map(|guard| {
let _ = ready_tx.send(Ok::<(), String>(()));
let _guard = guard;
let _ = release_rx.recv();
})
} else {
lock.try_read().map(|guard| {
let _ = ready_tx.send(Ok::<(), String>(()));
let _guard = guard;
let _ = release_rx.recv();
})
};
match acquired {
Ok(()) => {}
Err(error) => {
let _ = ready_tx.send(Err(error.to_string()));
}
}
});
match ready_rx.recv_timeout(Duration::from_secs(5)) {
Ok(Ok(())) => Ok(Self {
release: Some(release_tx),
thread: Some(thread),
}),
Ok(Err(error)) => {
let _ = thread.join();
Err(anyhow!(
"delegated coordination lock is unavailable for {}: {error}; close any other running or suspended Codewhale session using this workspace, then restart this Runtime if admission was refused",
state_root.display()
))
}
Err(error) => {
drop(release_tx);
let _ = thread.join();
Err(anyhow!(
"{COORDINATION_LOCK_TIMEOUT_MARKER} for {}: {error}",
state_root.display()
))
}
}
}
}
impl Drop for CoordinationProcessLock {
fn drop(&mut self) {
self.release.take();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
/// Only the captured Runtime owner admits roots. Held handles are not serialized;
/// a restored attributed claim must be re-admitted before it can authorize work.
struct AdmittedCoordinationRoot {
@@ -4106,19 +3982,12 @@ pub struct SubAgentManager {
worker_origins: HashMap<String, WorkerOrigin>,
worker_origin_schema: u32,
workspace: PathBuf,
/// Root that owns the delegated-agent ledger, complete transcript
/// artifacts and coordination lock. Defaults to `workspace`; embedders may
/// Root that owns the delegated-agent ledger and complete transcript
/// artifacts. Defaults to `workspace`; embedders may
/// isolate it without changing child execution cwd or file authority.
state_root: PathBuf,
state_path: Option<PathBuf>,
state_unreachable_warned: bool,
coordination_process_lock: std::sync::Mutex<Option<Arc<CoordinationProcessLock>>>,
coordination_process_lock_required: bool,
coordination_exclusive: bool,
coordination_snapshot_read_only: bool,
store_cutover_blocked: bool,
session_private: bool,
workspace_writer_locks: HashMap<PathBuf, Arc<CoordinationProcessLock>>,
/// Configured default per-child model-turn budget (`[subagents]
/// default_max_steps`, #5324). `None` keeps the unbounded Fleet default;
/// an explicit spawn `max_steps` still wins. Zero means unbounded.
@@ -4759,13 +4628,6 @@ impl SubAgentManager {
state_root,
state_path: None,
state_unreachable_warned: false,
coordination_process_lock: std::sync::Mutex::new(None),
coordination_process_lock_required: false,
coordination_exclusive: false,
coordination_snapshot_read_only: false,
store_cutover_blocked: false,
session_private: false,
workspace_writer_locks: HashMap::new(),
max_steps: None,
wall_time: None,
max_agents,
@@ -4831,22 +4693,6 @@ impl SubAgentManager {
return Err("coordination workspace identity is unsupported on this platform".into());
#[cfg(any(unix, windows))]
{
if self.coordination_exclusive
&& !self.coordination_snapshot_read_only
&& normalize_subagent_workspace(&lexical)
!= normalize_subagent_workspace(&self.state_root)
&& !self.workspace_writer_locks.contains_key(&canonical_root)
{
match CoordinationProcessLock::acquire(&lexical, true) {
Ok(lock) => {
self.workspace_writer_locks
.insert(canonical_root.clone(), Arc::new(lock));
}
Err(error) => {
tracing::warn!(target: "subagent", %error, "admitted coordination workspace without its writer lock; shared-workspace writers there stay refused until it is free");
}
}
}
let receipt = CoordinationClaimScope {
canonical_root,
platform: platform.into(),
@@ -4866,44 +4712,61 @@ impl SubAgentManager {
}
}
fn ensure_origin_writer_lock(&mut self, origin: &WorkerOrigin) -> Result<(), String> {
let (Some(selected), Some(scope)) = (&origin.selected_workspace, &origin.scope) else {
fn refuse_live_selected_store_writers(&self, origin: &WorkerOrigin) -> Result<(), String> {
let (Some(selected), Some(_)) = (&origin.selected_workspace, &origin.scope) else {
return Ok(());
};
if !self.coordination_exclusive
|| normalize_subagent_workspace(selected)
== normalize_subagent_workspace(&self.state_root)
|| self
.workspace_writer_locks
.contains_key(&scope.canonical_root)
if normalize_subagent_workspace(selected) == normalize_subagent_workspace(&self.state_root)
{
return Ok(());
}
let lock =
CoordinationProcessLock::acquire(selected, true).map_err(|error| error.to_string())?;
self.workspace_writer_locks
.insert(scope.canonical_root.clone(), Arc::new(lock));
let path = default_state_path(selected).map_err(|error| error.to_string())?;
let unreadable = |error: std::io::Error| {
format!(
"cannot check {} for another open Codewhale session ({error}); shared-workspace writers there stay refused, or launch the builder in an isolated worktree",
selected.display()
)
};
let directory = path
.parent()
.ok_or_else(|| "delegated store has no parent".to_string())?;
let entries = match fs::read_dir(directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(unreadable(error)),
};
for entry in entries {
let name = entry.map_err(unreadable)?.file_name();
let Some(writer) = name
.to_str()
.and_then(|name| name.strip_prefix("subagents.v3.writer."))
.and_then(|name| name.strip_suffix(".lock"))
else {
continue;
};
if valid_store_writer(writer) && store_writer_is_live(&path, writer) {
return Err(format!(
"another Codewhale session ({writer}) has {} open; shared-workspace writers there stay refused until it exits, or launch the builder in an isolated worktree",
selected.display()
));
}
}
Ok(())
}
fn ensure_worker_coordination_locks(
&mut self,
fn admit_worker_shared_writes(
&self,
spec: &AgentWorkerSpec,
origin: &WorkerOrigin,
) -> Result<(), String> {
let claim = worker_coordination_claim(spec)?;
if claim.as_ref().is_some_and(|(_, isolated)| *isolated) {
return Ok(());
match worker_coordination_claim(spec)? {
Some((_, false)) => self.refuse_live_selected_store_writers(origin),
_ => Ok(()),
}
self.ensure_coordination_process_lock()?;
if claim.is_some() {
self.ensure_origin_writer_lock(origin)?;
}
Ok(())
}
pub fn ensure_registered_worker_coordination_locks(
&mut self,
&self,
worker_id: &str,
) -> Result<(), String> {
let spec = self
@@ -4918,7 +4781,7 @@ impl SubAgentManager {
.cloned()
.ok_or_else(|| "worker origin is missing from the owner journal".to_string())?;
self.validate_worker_origin(&origin)?;
self.ensure_worker_coordination_locks(&spec, &origin)
self.admit_worker_shared_writes(&spec, &origin)
}
fn admitted_coordination_root(&self, workspace: &Path) -> Option<&AdmittedCoordinationRoot> {
@@ -5316,7 +5179,6 @@ impl SubAgentManager {
.map_err(|error| error.to_string());
}
self.ensure_coordination_process_lock()?;
if let Some(root) = self.admitted_coordination_root(workspace) {
root.validate()?;
}
@@ -5395,7 +5257,6 @@ impl SubAgentManager {
.map_err(|error| error.to_string());
}
self.ensure_coordination_process_lock()?;
self.coordination
.update_decision_status(decision_id, status, owner, expected_version)
}
@@ -5442,7 +5303,6 @@ impl SubAgentManager {
.map_err(|error| error.to_string());
}
self.ensure_coordination_process_lock()?;
let expected_owner = self.nearest_common_fan_in_owner(&input_decisions)?;
if owner != expected_owner {
return Err(format!(
@@ -5714,19 +5574,13 @@ impl SubAgentManager {
},
bounded: true,
limit,
process_lock_held: !self.session_private && {
// Retry acquire on each projection so a session that lost the
// flock at construction recovers once the previous owner exits.
let _ = self.coordination_process_lock_status();
self.holds_coordination_process_lock()
},
process_lock_note: if self.session_private {
Some(SESSION_PRIVATE_COORDINATION.to_string())
} else if self.holds_coordination_process_lock() {
None
} else {
self.coordination_process_lock_status().err()
},
process_lock_held: self.state_path.is_none() || self.store_authority.is_some(),
process_lock_note: (self.state_path.is_some() && self.store_authority.is_none()).then(
|| {
"delegated store admission failed; it is retried on the next delegated write"
.to_string()
},
),
}
}
@@ -5861,7 +5715,6 @@ impl SubAgentManager {
.and_then(|outcome| outcome);
}
self.ensure_coordination_process_lock()?;
let Some(existing) = self
.coordination
.write_claims
@@ -5948,7 +5801,6 @@ impl SubAgentManager {
.map_err(|error| error.to_string());
}
self.ensure_coordination_process_lock()?;
let active = self.active_coordination_owners();
let released = self
.coordination
@@ -6073,109 +5925,12 @@ impl SubAgentManager {
match SubAgentStoreAuthority::open(&self.state_root, &path, &self.current_session_boot_id) {
Ok(authority) => self.store_authority = Some(authority),
Err(error) => {
self.coordination_snapshot_read_only = true;
self.store_cutover_blocked = error
.chain()
.any(|cause| cause.is::<SubAgentCutoverBlocked>());
tracing::warn!(target: "subagent", %error, "delegated store admission failed; writable state remains refused");
tracing::warn!(target: "subagent", %error, "delegated store admission failed; it is retried on the next delegated write");
}
}
self
}
fn into_session_private(mut self) -> Self {
self.state_path = None;
self.store_authority = None;
self.coordination_snapshot_read_only = false;
self.coordination_process_lock_required = false;
self.session_private = true;
tracing::warn!(target: "subagent", "{SESSION_PRIVATE_COORDINATION}");
self
}
fn require_coordination_process_lock(mut self) -> Self {
self.coordination_process_lock_required = true;
if self.store_authority.is_none()
&& let Some(path) = self.state_path.as_ref()
{
match SubAgentStoreAuthority::open(
&self.state_root,
path,
&self.current_session_boot_id,
) {
Ok(authority) => self.store_authority = Some(authority),
Err(error) => {
self.coordination_snapshot_read_only = true;
tracing::warn!(target: "subagent", %error, "delegated store migration or admission failed; no durable writes are admitted");
return self;
}
}
}
let acquired =
CoordinationProcessLock::acquire(&self.state_root, self.coordination_exclusive)
.ok()
.map(Arc::new);
if acquired.is_none() {
return self.into_session_private();
}
*self
.coordination_process_lock
.get_mut()
.expect("coordination lock slot poisoned") = acquired;
self
}
/// Terminal sessions retry failed acquisition when the previous owner
/// exits. A Runtime loaded without its writer lock must restart rather
/// than publish its stale snapshot. The flock is held on an open fd —
/// deleting the lock file does not release it (#5036).
fn ensure_coordination_process_lock(&self) -> Result<(), String> {
if self.coordination_snapshot_read_only {
return Err(
"Runtime coordination was opened without its writer lock; stop and restart this Runtime process after the other session exits"
.into(),
);
}
if !self.coordination_process_lock_required {
return Ok(());
}
let mut slot = self
.coordination_process_lock
.lock()
.expect("coordination lock slot poisoned");
if slot.is_some() {
return Ok(());
}
match CoordinationProcessLock::acquire(&self.state_root, self.coordination_exclusive) {
Ok(lock) => {
*slot = Some(Arc::new(lock));
Ok(())
}
Err(error) => Err(error.to_string()),
}
}
/// Whether this process currently owns the workspace coordination flock.
/// Read-only inspection of the in-process slot — does not attempt acquire.
#[must_use]
pub fn holds_coordination_process_lock(&self) -> bool {
if !self.coordination_process_lock_required {
// Tests and non-durable managers do not require the flock.
return true;
}
self.coordination_process_lock
.lock()
.expect("coordination lock slot poisoned")
.is_some()
}
/// Probe lock ownership for UI/inspect surfaces. Retries acquire when the
/// slot is empty so a terminal session can recover after the previous
/// owner exits (#2.6 / #5036); read-only Runtime snapshots require restart.
pub fn coordination_process_lock_status(&self) -> Result<(), String> {
self.ensure_coordination_process_lock()
}
#[must_use]
pub fn with_running_heartbeat_timeout(mut self, timeout: Duration) -> Self {
self.running_heartbeat_timeout = if timeout.is_zero() {
@@ -6223,18 +5978,31 @@ impl SubAgentManager {
.is_some_and(|authority| authority.owned.lock().contains(id))
}
fn admitted_store_authority(&mut self) -> Result<Option<Arc<SubAgentStoreAuthority>>> {
let Some(path) = self.state_path.as_ref() else {
return Ok(None);
};
if let Some(authority) = self.store_authority.as_ref() {
return Ok(Some(authority.clone()));
}
let authority =
SubAgentStoreAuthority::open(&self.state_root, path, &self.current_session_boot_id)
.map_err(|error| {
anyhow!(
"delegated durable store admission failed ({error:#}); it is retried on the next delegated write"
)
})?;
self.store_authority = Some(authority.clone());
Ok(Some(authority))
}
pub(crate) fn with_store_transaction<T>(
&mut self,
operation: impl FnOnce(&mut Self) -> Result<T>,
) -> Result<T> {
if self.state_path.is_none() {
let Some(authority) = self.admitted_store_authority()? else {
return operation(self);
}
self.ensure_coordination_process_lock()
.map_err(anyhow::Error::msg)?;
let authority = self.store_authority.clone().ok_or_else(|| {
anyhow!("delegated durable store admission failed; inspect before retrying")
})?;
};
if authority.is_current_transaction() {
return operation(self);
}
@@ -6435,8 +6203,6 @@ impl SubAgentManager {
worker_id: &str,
payload: PersistedSubAgentState,
) -> Result<Option<AgentWorkerRecord>> {
self.ensure_coordination_process_lock()
.map_err(anyhow::Error::msg)?;
if self.state_path.is_none() {
return Ok(None);
}
@@ -6534,8 +6300,6 @@ impl SubAgentManager {
/// completes. Callers may `.join()` for synchronous semantics or drop it
/// for fire-and-forget.
fn persist_state(&self) -> Result<std::thread::JoinHandle<()>> {
self.ensure_coordination_process_lock()
.map_err(anyhow::Error::msg)?;
let Some((_, payload)) = self.build_persist_payload()? else {
return Ok(std::thread::spawn(|| {}));
};
@@ -6543,16 +6307,7 @@ impl SubAgentManager {
.store_authority
.clone()
.ok_or_else(|| anyhow!("delegated store authority is unavailable"))?;
let writer_locks = self
.coordination_process_lock
.lock()
.expect("coordination lock slot poisoned")
.iter()
.cloned()
.chain(self.workspace_writer_locks.values().cloned())
.collect::<Vec<_>>();
Ok(std::thread::spawn(move || {
let _writer_locks = writer_locks;
if let Err(error) = authority.publish(&payload, None, None) {
tracing::warn!(target: "subagent", %error, "failed to persist sub-agent checkpoint");
}
@@ -6560,8 +6315,6 @@ impl SubAgentManager {
}
fn persist_state_synchronously(&self) -> Result<()> {
self.ensure_coordination_process_lock()
.map_err(anyhow::Error::msg)?;
let Some((_, payload)) = self.build_persist_payload()? else {
return Ok(());
};
@@ -6624,8 +6377,6 @@ impl SubAgentManager {
/// Unlike `persist_state`, this performs disk I/O **synchronously** to
/// guarantee data is flushed before the process exits.
pub fn flush_pending_persist(&mut self) -> Result<()> {
self.ensure_coordination_process_lock()
.map_err(anyhow::Error::msg)?;
if self.persist_pending {
self.persist_state_synchronously()?;
self.last_persist_at = Some(Instant::now());
@@ -6848,8 +6599,7 @@ impl SubAgentManager {
}
pub fn finalize_session_close_for_session(&mut self, active_session_id: &str) -> Result<usize> {
if !self.coordination_snapshot_read_only
&& (self.state_path.is_none() || self.store_authority.is_some())
if (self.state_path.is_none() || self.store_authority.is_some())
&& !self.agents.values().any(|agent| {
self.owns_store_record(&agent.id)
&& self.agent_is_owned_by_session(agent, active_session_id)
@@ -7108,10 +6858,9 @@ impl SubAgentManager {
let Some((claim, isolated_worktree)) = worker_coordination_claim(spec)? else {
return Ok(());
};
// Isolated-worktree builders mutate their own checkout, not the
// shared workspace, so they must not contend for the per-workspace
// coordination process lock (#5036).
self.ensure_worker_coordination_locks(spec, origin)?;
if !isolated_worktree {
self.refuse_live_selected_store_writers(origin)?;
}
let claim = self.namespace_write_claim(&origin_workspace, isolated_worktree, claim)?;
let scope = if isolated_worktree {
None
@@ -7191,11 +6940,7 @@ impl SubAgentManager {
let previous_origins = self.worker_origins.clone();
let previous_schema = self.worker_origin_schema;
let claim = worker_coordination_claim(&spec)?;
// Same isolation rule as `preflight_worker_coordination` (#5036).
let isolated_worktree = claim
.as_ref()
.is_some_and(|(_, isolated_worktree)| *isolated_worktree);
self.ensure_worker_coordination_locks(&spec, &origin)?;
self.admit_worker_shared_writes(&spec, &origin)?;
let previous_worker_records = self.worker_records.clone();
let previous_coordination = self.coordination.clone();
let persisted_claim = claim
@@ -7247,18 +6992,6 @@ impl SubAgentManager {
let worker_id_for_log = spec.worker_id.clone();
self.register_worker_for_session(spec, &origin.owner_session_id, None);
self.install_worker_origin(&worker_id_for_log, origin);
if isolated_worktree && self.ensure_coordination_process_lock().is_err() {
// This process does not own the durable coordination state and an
// isolated-worktree builder must not be blocked on it: register
// in memory only and leave the durable ledger to the lock owner.
// The persist-layer lock check stays intact (#5036).
tracing::warn!(
target: "subagent",
worker = %worker_id_for_log,
"registered isolated-worktree worker in memory only; delegated coordination lock is owned elsewhere"
);
return Ok(());
}
if let Err(error) = self.persist_state_synchronously() {
self.worker_records = previous_worker_records;
self.coordination = previous_coordination;
@@ -7684,7 +7417,6 @@ impl SubAgentManager {
.map_err(|error| error.to_string());
}
self.ensure_coordination_process_lock()?;
let worker_id = spec.worker_id.clone();
let previous = self
.worker_records
@@ -10327,20 +10059,9 @@ impl SubAgentManager {
));
}
}
if write_capable {
// Isolated-worktree children mutate their own checkout, so they
// do not contend for the shared-workspace process lock (#5036).
if !options.isolated_worktree {
self.ensure_coordination_process_lock()
.map_err(anyhow::Error::msg)?;
self.ensure_origin_writer_lock(&origin)
.map_err(anyhow::Error::msg)?;
}
if self.coordination_process_lock_required && self.state_path.is_none() {
return Err(anyhow!(
"write-capable sub-agent launch requires a durable coordination state path"
));
}
if write_capable && !options.isolated_worktree {
self.refuse_live_selected_store_writers(&origin)
.map_err(anyhow::Error::msg)?;
}
let runtime_origin_turn_id = if runtime.context.runtime.active_thread_id.as_deref()
== Some(runtime.context.state_namespace.as_str())
@@ -10574,7 +10295,9 @@ impl SubAgentManager {
self.resume_targets.insert(source.clone(), agent_id.clone());
}
self.agents.insert(agent_id.clone(), agent);
let persist_result = self.persist_state_synchronously();
let persist_result = self
.admitted_store_authority()
.and_then(|_| self.persist_state_synchronously());
agent = self
.agents
.remove(&agent_id)
@@ -10887,8 +10610,6 @@ impl SubAgentManager {
identity: &ChildActivationIdentity,
) -> Result<impl FnOnce() -> Result<()> + Send + 'static + use<>> {
self.exact_activation(identity)?;
self.ensure_coordination_process_lock()
.map_err(anyhow::Error::msg)?;
let (path, payload) = self
.build_persist_payload()?
.ok_or_else(|| anyhow!("child activation has no durable worker store"))?;
@@ -10913,16 +10634,7 @@ impl SubAgentManager {
.clone()
.ok_or_else(|| anyhow!("child activation has no store authority"))?;
let identity = identity.clone();
let writer_locks = self
.coordination_process_lock
.lock()
.expect("coordination lock slot poisoned")
.iter()
.cloned()
.chain(self.workspace_writer_locks.values().cloned())
.collect::<Vec<_>>();
Ok(move || {
let _writer_locks = writer_locks;
store_authority.publish(&payload, None, None)?;
let persisted = read_durable_subagent_store(&state_root, &path)?.state;
let worker = persisted
@@ -11509,13 +11221,10 @@ impl SubAgentManager {
}
}
// Deliberately NOT here: a pass that terminalized every Running agent
// without a live task handle whenever this process lacked the
// coordination flock (#2.6). Two Codewhale sessions in one workspace is
// ordinary usage, and failure to append to a shared bookkeeping ledger
// is not evidence that live work has stopped. Liveness is decided by
// the heartbeat above — which is actual evidence — never by who owns
// the flock (owner report, 2026-08-04). See `docs/architecture/
// delegated-coordination.md` for what lock loss legitimately costs.
// without a live task handle because another session shares the
// workspace (#2.6). Two Codewhale sessions in one workspace is ordinary
// usage, and liveness is decided by the heartbeat above — which is
// actual evidence (owner report, 2026-08-04).
// The retained lineage owns continuation lookup and descendant
// control. Keep the agents projection while live work references it.
@@ -11645,11 +11354,12 @@ impl SubAgentManager {
}
/// #3803: whether the next sidebar refresh should attempt cleanup.
/// Read-only managers without a process lock only render their refusal.
/// Other managers use the last attempt's interval to bound cleanup locking.
/// A manager whose durable store is not admitted only renders its refusal;
/// admission is retried on the next delegated write. Other managers use the
/// last attempt's interval to bound cleanup locking.
#[must_use]
pub fn cleanup_due(&self, min_interval: Duration) -> bool {
(!self.coordination_snapshot_read_only || self.holds_coordination_process_lock())
(self.state_path.is_none() || self.store_authority.is_some())
&& self
.last_cleanup_at
.is_none_or(|last| last.elapsed() >= min_interval)
@@ -13790,22 +13500,6 @@ fn ensure_subagent_store_cutover(root: &Path, path: &Path) -> Result<()> {
let _ = codewhale_config::private_directory::PrivateDirectory::inspect(&legacy)?;
return Ok(());
}
let _migration = if legacy.is_dir() {
None
} else {
Some(
CoordinationProcessLock::acquire(root, true)
.map_err(|error| anyhow::Error::new(SubAgentCutoverBlocked(error)))?,
)
};
if path.exists() {
read_durable_subagent_store(root, path)?;
anyhow::ensure!(
legacy.is_dir(),
"delegated migration did not retain its legacy-write tombstone"
);
return Ok(());
}
let staging = parent.join("subagents.v1.cutover");
let backup_name = "legacy.json";
let original_name = "original.json";
@@ -13882,6 +13576,20 @@ fn ensure_subagent_store_cutover(root: &Path, path: &Path) -> Result<()> {
write_json_atomic(root, path, &DurableSubAgentStore::from_legacy(state))
}
fn write_state_gitignore(root: &Path, path: &Path) -> Result<()> {
let ignore = checked_subagent_state_path(root, &path.with_file_name(".gitignore"))?;
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&ignore)
{
Ok(mut file) => file.write_all(b"*\n")?,
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error.into()),
}
Ok(())
}
fn store_writer_is_live(path: &Path, writer: &str) -> bool {
if !valid_store_writer(writer) {
return true;
@@ -13954,6 +13662,9 @@ impl SubAgentStoreAuthority {
let _opening = acquire_store_transaction(root, path)?;
ensure_subagent_store_cutover(root, path)?;
let image = read_durable_subagent_store(root, path)?;
if let Err(error) = write_state_gitignore(root, path) {
tracing::warn!(target: "subagent", %error, "could not ignore the delegated state directory");
}
sync_subagent_store_directory(
path.parent()
.ok_or_else(|| anyhow!("delegated store has no parent"))?,
@@ -14494,24 +14205,16 @@ pub fn new_shared_subagent_manager_with_timeout(
)
}
pub fn new_shared_runtime_subagent_manager(
workspace: PathBuf,
workers: usize,
) -> Result<SharedSubAgentManager> {
new_runtime_subagent_manager(workspace, workers, false)
}
pub fn new_exclusive_runtime_subagent_manager(
workspace: PathBuf,
workers: usize,
) -> Result<SharedSubAgentManager> {
new_runtime_subagent_manager(workspace, workers, true)
new_shared_runtime_subagent_manager(workspace, workers)
}
fn new_runtime_subagent_manager(
pub fn new_shared_runtime_subagent_manager(
workspace: PathBuf,
workers: usize,
exclusive: bool,
) -> Result<SharedSubAgentManager> {
type RuntimeManagerRegistry =
parking_lot::Mutex<HashMap<PathBuf, (usize, std::sync::Weak<RwLock<SubAgentManager>>)>>;
@@ -14536,12 +14239,6 @@ fn new_runtime_subagent_manager(
.with_admission_limit(workers)
.with_launch_concurrency(workers)
.with_state_path(state_path);
manager.coordination_exclusive = exclusive;
manager = manager.require_coordination_process_lock();
if let Err(error) = manager.ensure_coordination_process_lock() {
manager.coordination_snapshot_read_only = true;
tracing::warn!(target: "subagent", %error, "Runtime coordination is read-only; stop and restart this Runtime process after the other session exits to load current state and regain delegated writes");
}
manager.load_state()?;
let manager = Arc::new(RwLock::new(manager));
managers.insert(key, (workers, Arc::downgrade(&manager)));
@@ -14551,7 +14248,7 @@ fn new_runtime_subagent_manager(
/// Create a shared sub-agent manager with an explicit control-plane state root.
///
/// `workspace` remains the child execution and file-authority root. The worker
/// ledger, complete transcript artifacts and coordination lock live under
/// ledger and complete transcript artifacts live under
/// `state_root/.codewhale/state`. Distinct state roots intentionally do not
/// share write claims; an embedding host that runs them against the same
/// workspace must provide any required cross-session write coordination.
@@ -14594,25 +14291,8 @@ pub fn new_shared_subagent_manager_with_state_root_and_timeout(
if let Some(state_path) = state_path {
manager = manager.with_state_path(state_path);
}
manager = if manager.store_cutover_blocked {
manager.into_session_private()
} else {
manager.require_coordination_process_lock()
};
if let Err(error) = manager.ensure_coordination_process_lock() {
tracing::warn!(target: "subagent", %error, "this session cannot append to the shared coordination ledger");
}
// Loading is a read. It was previously gated behind the *write* flock,
// which left a second session in the same workspace blind to every
// decision and write claim already recorded — and holding a default,
// empty ledger that would be written straight over the real one the
// moment the first session exited and the flock became acquirable
// (owner report, 2026-08-04). Read unconditionally; the write gate on
// `persist_state`/`persist_state_synchronously` still checks this
// manager's coordinator admission before publishing.
if let Err(err) = manager.load_state() {
manager.coordination_snapshot_read_only = true;
tracing::warn!(target: "subagent", ?err, "failed to load sub-agent state; durable writes remain refused");
tracing::warn!(target: "subagent", ?err, "failed to load sub-agent state");
}
Arc::new(RwLock::new(manager))
}
@@ -23153,10 +22833,29 @@ mod owner_scoped_coordination_tests {
let mut manager = authority.try_write().unwrap();
admit(&mut manager, &original);
admit(&mut manager, &selected);
let independent =
SubAgentManager::new(selected.clone(), 2).require_coordination_process_lock();
assert!(independent.session_private);
assert!(independent.ensure_coordination_process_lock().is_ok());
let independent = SubAgentManager::new(selected.clone(), 2)
.with_state_path(default_state_path(&selected).unwrap());
assert!(independent.store_authority.is_some());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let state_directory = default_state_path(&selected)
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let mode = fs::metadata(&state_directory).unwrap().permissions().mode();
fs::set_permissions(&state_directory, fs::Permissions::from_mode(0o000)).unwrap();
let unreadable =
manager.preflight_worker_coordination(&writer("selected", &selected, "src"));
fs::set_permissions(&state_directory, fs::Permissions::from_mode(mode)).unwrap();
assert!(unreadable.is_err(), "an unlistable store must fail closed");
}
let refused = manager
.preflight_worker_coordination(&writer("selected", &selected, "src"))
.unwrap_err();
assert!(refused.contains("another Codewhale session"), "{refused}");
drop(independent);
manager
.preflight_worker_coordination(&writer("original", &original, "src"))
.unwrap();
+236 -272
View File
@@ -1223,21 +1223,18 @@ fn coordination_hot_paths_count_only_active_authoritative_owners() {
}
#[test]
fn runtime_mount_coordination_lock_is_shared_with_parallel_sessions() {
fn runtime_mount_and_parallel_sessions_both_append_durably() {
let tmp = tempdir().expect("tempdir");
let runtime_authority = new_shared_runtime_subagent_manager(tmp.path().to_path_buf(), 8)
.expect("admit TUI runtime writer");
let session =
SubAgentManager::new(tmp.path().to_path_buf(), 8).require_coordination_process_lock();
session
.ensure_coordination_process_lock()
.expect("a second session appends beside the runtime");
assert!(!session.coordination_snapshot_read_only);
let session = SubAgentManager::new(tmp.path().to_path_buf(), 8)
.with_state_path(default_state_path(tmp.path()).expect("state path"));
assert!(session.store_authority.is_some());
session
.persist_state_synchronously()
.expect("session append");
let runtime = runtime_authority.try_write().expect("runtime lock");
assert!(!runtime.coordination_snapshot_read_only);
assert!(runtime.store_authority.is_some());
runtime
.persist_state_synchronously()
.expect("runtime append");
@@ -1363,10 +1360,9 @@ fn headless_worker_registration_enforces_live_claims_and_projects_context() {
.iter()
.any(|record| record.owner_session_id == "owner-worker-c")
);
let independent =
SubAgentManager::new(tmp.path().to_path_buf(), 8).require_coordination_process_lock();
assert!(independent.session_private);
assert!(independent.ensure_coordination_process_lock().is_ok());
let independent = SubAgentManager::new(tmp.path().to_path_buf(), 8)
.with_state_path(default_state_path(tmp.path()).expect("state path"));
assert!(independent.store_authority.is_some());
drop(independent);
drop(authority_a);
drop(authority_b);
@@ -1575,25 +1571,21 @@ fn prior_session_owners_never_count_as_active_coordination_owners() {
}
#[test]
fn isolated_worktree_workers_skip_the_coordination_process_lock() {
fn parallel_sessions_admit_shared_and_isolated_writers_durably() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().canonicalize().expect("canonical workspace");
let state_path = default_state_path(&workspace).expect("state path");
let holder = SubAgentManager::new(workspace.clone(), 4).require_coordination_process_lock();
holder
.ensure_coordination_process_lock()
.expect("holder owns the lock");
let holder = SubAgentManager::new(workspace.clone(), 4).with_state_path(state_path.clone());
assert!(holder.store_authority.is_some());
let mut contender =
SubAgentManager::new(workspace.clone(), 4).require_coordination_process_lock();
contender
.ensure_coordination_process_lock()
.expect("second manager now also owns shared lock (coexistence)");
let mut contender = SubAgentManager::new(workspace.clone(), 4).with_state_path(state_path);
assert!(contender.store_authority.is_some());
let shared = make_write_worker_spec("shared-writer", workspace.clone(), "src/shared");
contender
.preflight_worker_coordination(&shared)
.expect("shared-workspace writer proceeds with shared lock");
.expect("shared-workspace writer proceeds beside another session");
let mut isolated = make_write_worker_spec("isolated-writer", workspace.clone(), "src/iso");
isolated
@@ -1603,69 +1595,64 @@ fn isolated_worktree_workers_skip_the_coordination_process_lock() {
.worktree = true;
contender
.preflight_worker_coordination(&isolated)
.expect("isolated-worktree writer preflights without the lock");
.expect("isolated-worktree writer preflights beside another session");
contender
.register_worker_with_coordination(isolated)
.expect("isolated-worktree writer registers without the lock");
// Once the previous owner exits, a manager that failed earlier must
// acquire the lock on retry instead of replaying a memoized failure.
.expect("isolated-worktree writer registers durably");
drop(holder);
contender
.ensure_coordination_process_lock()
.expect("lock acquisition retries after the holder exits");
contender
.preflight_worker_coordination(&shared)
.expect("shared-workspace writer proceeds once the lock is held");
.expect("shared-workspace writer proceeds after the other session exits");
}
#[test]
fn coordination_detail_projection_reports_process_lock_ownership() {
fn coordination_detail_projection_reports_store_admission_and_its_retry() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().canonicalize().expect("canonical workspace");
let state_path = default_state_path(&workspace).expect("state path");
let holder = SubAgentManager::new(workspace.clone(), 4).require_coordination_process_lock();
holder
.ensure_coordination_process_lock()
.expect("holder owns the lock");
let holder = SubAgentManager::new(workspace.clone(), 4).with_state_path(state_path.clone());
let held = holder.coordination_detail_projection(None, 8);
assert!(
held.process_lock_held,
"holder projection must report lock held"
);
assert!(held.process_lock_held);
assert!(held.process_lock_note.is_none());
let contender = SubAgentManager::new(workspace, 4).require_coordination_process_lock();
let contender = SubAgentManager::new(workspace.clone(), 4).with_state_path(state_path.clone());
let shared = contender.coordination_detail_projection(None, 8);
// With shared locks both holders coexist — contender reports held and no note.
assert!(shared.process_lock_held);
assert!(shared.process_lock_note.is_none());
let committed = std::fs::read(&state_path).expect("committed store");
std::fs::write(&state_path, b"{").expect("corrupt store");
let mut refused = SubAgentManager::new(workspace, 4).with_state_path(state_path.clone());
let projection = refused.coordination_detail_projection(None, 8);
assert!(!projection.process_lock_held);
assert!(projection.process_lock_note.is_some());
assert!(!refused.cleanup_due(Duration::ZERO));
assert!(refused.release_stale_write_claims(None).is_err());
assert!(!refused.cleanup_due(Duration::ZERO));
std::fs::write(&state_path, committed).expect("restore store");
refused
.release_stale_write_claims(None)
.expect("admission is retried once the store is readable");
assert!(
shared.process_lock_held,
"contender projection must report lock held with shared locks"
);
assert!(
shared.process_lock_note.is_none(),
"shared projection carries no note"
refused
.coordination_detail_projection(None, 8)
.process_lock_held
);
assert!(refused.cleanup_due(Duration::ZERO));
}
/// A second Codewhale session in the same workspace is ordinary usage. Losing
/// the coordination flock means "I cannot append to the shared ledger" — it
/// must never mean "my agents are dead". Bookkeeping failure and liveness are
/// separate concerns (owner report, 2026-08-04).
/// A second Codewhale session in the same workspace is ordinary usage and
/// must never mean "my agents are dead". Bookkeeping and liveness are separate
/// concerns (owner report, 2026-08-04).
#[test]
fn a_second_session_without_the_lock_keeps_its_running_agents_live() {
fn a_second_session_keeps_its_running_agents_live() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().canonicalize().expect("canonical workspace");
let holder = SubAgentManager::new(workspace.clone(), 4).require_coordination_process_lock();
holder
.ensure_coordination_process_lock()
.expect("holder owns the lock");
let mut contender = SubAgentManager::new(workspace, 4).require_coordination_process_lock();
contender
.ensure_coordination_process_lock()
.expect("contender now also owns shared lock");
let _first = SubAgentManager::new(workspace.clone(), 4);
let mut contender = SubAgentManager::new(workspace, 4);
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
@@ -1680,9 +1667,6 @@ fn a_second_session_without_the_lock_keeps_its_running_agents_live() {
PathBuf::from("."),
contender.session_boot_id().to_string(),
);
// Fresh, well inside the heartbeat window: nothing about this agent is
// stale. The only thing "wrong" with it is that this process cannot write
// the shared ledger.
agent.last_activity_at = Instant::now();
let agent_id = agent.id.clone();
contender.agents.insert(agent_id.clone(), agent);
@@ -1692,7 +1676,7 @@ fn a_second_session_without_the_lock_keeps_its_running_agents_live() {
.expect("durable worker operation");
assert_eq!(
cancelled, 0,
"lock loss alone must not terminalize anything"
"another session alone must not terminalize anything"
);
let snap = contender
.get_result(&agent_id)
@@ -1700,26 +1684,21 @@ fn a_second_session_without_the_lock_keeps_its_running_agents_live() {
assert_eq!(
snap.status,
SubAgentStatus::Running,
"a live agent stays Running when this session cannot write the ledger: {:?}",
"a live agent stays Running beside another session: {:?}",
snap.status
);
}
/// A second session shares the workspace coordination read-lock and must load
/// the existing ledger before it persists any of its own state. That preserves
/// prior decisions instead of replacing them with an empty ledger.
/// A second session must load the existing ledger before it persists any of
/// its own state. That preserves prior decisions instead of replacing them
/// with an empty ledger.
#[test]
fn a_second_session_with_the_shared_lock_loads_the_workspace_ledger() {
fn a_second_session_loads_the_workspace_ledger() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().canonicalize().expect("canonical workspace");
let state_path = default_state_path(&workspace).expect("state path");
let mut first = SubAgentManager::new(workspace.clone(), 4)
.with_state_path(state_path.clone())
.require_coordination_process_lock();
first
.ensure_coordination_process_lock()
.expect("first session owns the lock");
let mut first = SubAgentManager::new(workspace.clone(), 4).with_state_path(state_path.clone());
first
.record_coordination_decision(DecisionRecord {
decision_id: "shared-decision".to_string(),
@@ -1737,20 +1716,15 @@ fn a_second_session_with_the_shared_lock_loads_the_workspace_ledger() {
.persist_state_synchronously()
.expect("first session persists the ledger");
// Second session in the same workspace while the first still holds the
// shared flock. It coexists and must see the workspace ledger.
let second = new_shared_subagent_manager(workspace.clone(), 4);
{
let guard = second.blocking_read();
assert!(
guard.holds_coordination_process_lock(),
"test premise: the second session joins the shared flock"
);
assert!(guard.store_authority.is_some());
let ledger = guard.coordination_snapshot();
assert_eq!(
ledger.decisions.len(),
1,
"second session must load the workspace ledger it cannot write"
"second session must load the workspace ledger"
);
assert_eq!(ledger.decisions[0].decision_id, "shared-decision");
}
@@ -1759,12 +1733,9 @@ fn a_second_session_with_the_shared_lock_loads_the_workspace_ledger() {
drop(first);
{
let guard = second.blocking_write();
guard
.ensure_coordination_process_lock()
.expect("shared lock remains available after the first session exits");
guard
.persist_state_synchronously()
.expect("second session persists once it owns the lock");
.expect("second session persists after the first exits");
}
let mut replayed = SubAgentManager::new(workspace, 4).with_state_path(state_path);
replayed.load_state().expect("reload the workspace ledger");
@@ -1776,23 +1747,15 @@ fn a_second_session_with_the_shared_lock_loads_the_workspace_ledger() {
}
/// The complement of the test above: an orphan still terminalizes, but only on
/// heartbeat evidence. Lock ownership is not part of the decision either way.
/// heartbeat evidence. Another session is not part of the decision either way.
#[test]
fn cleanup_terminalizes_orphans_on_heartbeat_evidence_not_on_lock_ownership() {
fn cleanup_terminalizes_orphans_on_heartbeat_evidence_not_on_other_sessions() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().canonicalize().expect("canonical workspace");
let holder = SubAgentManager::new(workspace.clone(), 4).require_coordination_process_lock();
holder
.ensure_coordination_process_lock()
.expect("holder owns the lock");
let mut contender = SubAgentManager::new(workspace, 4)
.with_running_heartbeat_timeout(Duration::from_secs(30))
.require_coordination_process_lock();
contender
.ensure_coordination_process_lock()
.expect("contender now also owns shared lock");
let _first = SubAgentManager::new(workspace.clone(), 4);
let mut contender =
SubAgentManager::new(workspace, 4).with_running_heartbeat_timeout(Duration::from_secs(30));
let insert_orphan = |manager: &mut SubAgentManager, name: &str, idle: Duration| {
let (input_tx, _input_rx) = mpsc::unbounded_channel();
@@ -1832,7 +1795,7 @@ fn cleanup_terminalizes_orphans_on_heartbeat_evidence_not_on_lock_ownership() {
.expect("fresh orphan still listed")
.status,
SubAgentStatus::Running,
"a fresh orphan is not terminalized just because the flock is elsewhere"
"a fresh orphan is not terminalized just because another session is open"
);
assert!(
matches!(
@@ -11351,7 +11314,7 @@ async fn write_capable_agent_does_not_launch_when_durable_registration_fails() {
.expect_err("writer must fail before spawn when its durable claim cannot commit")
.to_string();
assert!(
error.contains("durably register") || error.contains("writer lock"),
error.contains("durably register") || error.contains("store admission failed"),
"{error}"
);
@@ -12230,7 +12193,10 @@ fn persist_state_rejects_state_path_outside_state_root() {
.persist_state()
.expect_err("outside state path should fail");
assert!(format!("{err:#}").contains("without its writer lock"));
assert!(
format!("{err:#}").contains("must stay within state root"),
"{err:#}"
);
}
/// A user who keeps application state on another volume makes the workspace's
@@ -12405,9 +12371,7 @@ fn explicit_state_roots_isolate_managers_for_the_same_execution_workspace() {
let mut manager = manager.try_write().expect("manager write lock");
assert_eq!(manager.workspace.as_path(), workspace.as_path());
assert_eq!(manager.state_root.as_path(), expected_root.as_path());
manager
.ensure_coordination_process_lock()
.expect("independent state-root coordination lock");
assert!(manager.store_authority.is_some());
manager.register_worker(make_worker_spec(worker_id, workspace.clone()));
manager
.persist_state_synchronously()
@@ -12462,31 +12426,21 @@ fn persist_state_rejects_symlinked_state_directory() {
);
}
/// A linked `.codewhale` must be refused before anything is created: the
/// coordination lock used to `create_dir_all` through the link first.
#[cfg(unix)]
#[test]
fn coordination_lock_refuses_linked_codewhale_without_writing_through_it() {
fn a_linked_codewhale_admission_writes_no_gitignore_through_it() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let outside = tmp.path().join("outside");
let root = tmp.path().canonicalize().expect("canonical root");
let workspace = root.join("workspace");
let outside = root.join("outside");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
std::fs::create_dir_all(&outside).expect("mkdir outside");
std::os::unix::fs::symlink(&outside, workspace.join(".codewhale")).expect("link .codewhale");
let Err(err) = CoordinationProcessLock::acquire(&workspace, false) else {
panic!("a linked .codewhale must be refused");
};
let message = format!("{err:#}");
let manager = new_shared_subagent_manager(workspace, 4);
assert!(manager.blocking_read().store_authority.is_none());
assert!(
message.contains("must not traverse symlinks")
|| message.contains("must stay within state root"),
"{message}"
);
assert_eq!(
std::fs::read_dir(&outside).expect("read outside").count(),
0,
"nothing may be created through the link"
!outside.join("state").join(".gitignore").exists(),
"a refused admission writes no .gitignore through the link"
);
}
@@ -20874,24 +20828,66 @@ fn write_json_atomic_survives_concurrent_writers() {
assert!(leftover.is_empty(), "temp files leaked: {leftover:?}");
}
fn two_process_decision(id: &str) -> DecisionRecord {
DecisionRecord {
decision_id: id.to_string(),
subject: id.to_string(),
status: DecisionStatus::Accepted,
owner: "root".to_string(),
scope: vec!["src".to_string()],
constraints: Vec::new(),
evidence_handles: Vec::new(),
version: 1,
sequence: 0,
}
}
fn wait_for_two_process_file(path: &Path, child: &mut std::process::Child, what: &str) {
let deadline = Instant::now() + Duration::from_secs(30);
while !path.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
if !path.exists() {
let _ = child.kill();
panic!("{what} never became ready");
}
}
#[test]
fn coordination_process_lock_rejects_second_process() {
const ROLE_ENV: &str = "CODEWHALE_TEST_COORDINATION_LOCK_ROLE";
const WORKSPACE_ENV: &str = "CODEWHALE_TEST_COORDINATION_LOCK_WORKSPACE";
fn a_stale_session_never_makes_a_new_session_read_only() {
const ROLE_ENV: &str = "CODEWHALE_TEST_COORDINATION_ROLE";
const WORKSPACE_ENV: &str = "CODEWHALE_TEST_COORDINATION_WORKSPACE";
const TEST_NAME: &str =
"tools::subagent::tests::coordination_process_lock_rejects_second_process";
"tools::subagent::tests::a_stale_session_never_makes_a_new_session_read_only";
if let Some(role) = std::env::var_os(ROLE_ENV) {
let workspace = PathBuf::from(std::env::var_os(WORKSPACE_ENV).expect("workspace env"));
if role == "legacy-holder" {
let _legacy_session = CoordinationProcessLock::acquire(&workspace, false)
.expect("an older session holds the shared lock");
std::fs::write(workspace.join("holder.ready"), b"ready").unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let wait_for_release = || {
let deadline = Instant::now() + Duration::from_secs(60);
while !workspace.join("holder.release").exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(workspace.join("holder.release").exists(), "release timeout");
};
if role == "legacy-holder" {
let lock_file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(
workspace
.join(".codewhale")
.join("state")
.join("subagents.v1.lock"),
)
.expect("open legacy lock");
let legacy = fd_lock::RwLock::new(lock_file);
let _guard = legacy
.try_read()
.expect("an older session holds the legacy lock");
std::fs::write(workspace.join("holder.ready"), b"ready").unwrap();
wait_for_release();
return;
}
let manager = new_shared_subagent_manager_with_timeout(
@@ -20901,78 +20897,82 @@ fn coordination_process_lock_rejects_second_process() {
Duration::from_secs(30),
4,
);
let mut guard = manager.try_write().unwrap();
assert!(guard.store_authority.is_some(), "{role:?} store admitted");
assert!(
guard
.coordination_detail_projection(None, 8)
.process_lock_held
);
let name = role.to_string_lossy().into_owned();
guard
.record_coordination_decision(two_process_decision(&format!("{name}-decision")))
.expect("record a decision beside the other session");
guard.register_worker(make_worker_spec(
&format!("{name}-worker"),
workspace.clone(),
));
guard
.persist_state_synchronously()
.expect("publish beside the other session");
if role == "holder" {
manager
.try_read()
.unwrap()
.ensure_coordination_process_lock()
.expect("holder owns lock");
std::fs::write(workspace.join("holder.ready"), b"ready").unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while !workspace.join("holder.release").exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(workspace.join("holder.release").exists(), "release timeout");
wait_for_release();
} else {
manager
.try_read()
.unwrap()
.ensure_coordination_process_lock()
.expect("second process now also succeeds with shared lock");
assert!(
guard
.coordination_snapshot()
.decisions
.iter()
.any(|decision| decision.decision_id == "holder-decision"),
"the new session merges the open session's ledger"
);
}
return;
}
let dir = tempdir().expect("tempdir");
let workspace = dir.path().canonicalize().expect("canonical workspace");
let test_binary = std::env::current_exe().expect("test binary");
let mut holder = std::process::Command::new(&test_binary)
.arg("--exact")
.arg(TEST_NAME)
.arg("--nocapture")
.env(ROLE_ENV, "holder")
.env(WORKSPACE_ENV, &workspace)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn lock holder");
let deadline = Instant::now() + Duration::from_secs(5);
while !workspace.join("holder.ready").exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
if !workspace.join("holder.ready").exists() {
let _ = holder.kill();
let output = holder.wait_with_output().expect("holder output");
panic!(
"holder never acquired lock:\nstdout:\n{}\nstderr:\n{}",
let spawn = |role: &str, workspace: &Path| {
std::process::Command::new(&test_binary)
.arg("--exact")
.arg(TEST_NAME)
.arg("--nocapture")
.env(ROLE_ENV, role)
.env(WORKSPACE_ENV, workspace)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn session process")
};
let finish = |child: std::process::Child, what: &str| {
let output = child.wait_with_output().expect("session output");
assert!(
output.status.success(),
"{what} failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let contender = std::process::Command::new(&test_binary)
.arg("--exact")
.arg(TEST_NAME)
.arg("--nocapture")
.env(ROLE_ENV, "contender")
.env(WORKSPACE_ENV, &workspace)
.output()
.expect("spawn lock contender");
assert!(
contender.status.success(),
"contender failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&contender.stdout),
String::from_utf8_lossy(&contender.stderr)
);
};
let dir = tempdir().expect("tempdir");
let workspace = dir.path().canonicalize().expect("canonical workspace");
let mut holder = spawn("holder", &workspace);
wait_for_two_process_file(&workspace.join("holder.ready"), &mut holder, "open session");
finish(spawn("contender", &workspace), "new session");
std::fs::write(workspace.join("holder.release"), b"release").unwrap();
let output = holder.wait_with_output().expect("holder output");
assert!(
output.status.success(),
"holder failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
finish(holder, "open session");
let workers = load_persisted_agent_worker_records_with_state_root(&workspace, &workspace)
.expect("read merged store");
for id in ["holder-worker", "contender-worker"] {
assert!(
workers.iter().any(|record| record.spec.worker_id == id),
"{id} survives the other session's publication"
);
}
let mut replayed = SubAgentManager::new(workspace.clone(), 4)
.with_state_path(default_state_path(&workspace).unwrap());
replayed.load_state().expect("reload merged ledger");
assert_eq!(replayed.coordination.decisions.len(), 2);
let legacy_dir = tempdir().expect("tempdir");
let legacy_workspace = legacy_dir
@@ -20988,75 +20988,42 @@ fn coordination_process_lock_rejects_second_process() {
)
.unwrap();
let legacy_bytes = std::fs::read(&legacy_file).unwrap();
let mut legacy_holder = std::process::Command::new(&test_binary)
.arg("--exact")
.arg(TEST_NAME)
.arg("--nocapture")
.env(ROLE_ENV, "legacy-holder")
.env(WORKSPACE_ENV, &legacy_workspace)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn legacy lock holder");
let deadline = Instant::now() + Duration::from_secs(5);
while !legacy_workspace.join("holder.ready").exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
if !legacy_workspace.join("holder.ready").exists() {
let _ = legacy_holder.kill();
let output = legacy_holder
.wait_with_output()
.expect("legacy holder output");
panic!(
"legacy holder never acquired lock:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let private = new_shared_subagent_manager(legacy_workspace.clone(), 4);
let mut legacy_holder = spawn("legacy-holder", &legacy_workspace);
wait_for_two_process_file(
&legacy_workspace.join("holder.ready"),
&mut legacy_holder,
"older session",
);
let upgraded = new_shared_subagent_manager(legacy_workspace.clone(), 4);
{
let guard = private.blocking_read();
let mut guard = upgraded.blocking_write();
assert!(
guard.ensure_coordination_process_lock().is_ok(),
"a session beside an older one keeps working sub-agents"
guard.store_authority.is_some(),
"a stale older session must not leave the new one read-only"
);
let projection = guard.coordination_detail_projection(None, 8);
assert!(!projection.process_lock_held);
assert!(
projection
.process_lock_note
.as_deref()
.is_some_and(|note| note.starts_with(SESSION_PRIVATE_COORDINATION)),
projection.process_lock_held,
"{:?}",
projection.process_lock_note
);
assert!(projection.process_lock_note.is_none());
guard
.record_coordination_decision(two_process_decision("upgraded-decision"))
.expect("the new session writes while the older one is still open");
assert!(guard.cleanup_due(Duration::from_secs(3600)));
}
assert_eq!(
std::fs::read(&legacy_file).unwrap(),
legacy_bytes,
"the older session's store stays untouched"
);
assert!(
!state_dir.join(SUBAGENT_STATE_FILE).exists(),
"no forced cutover while the older session runs"
);
std::fs::write(legacy_workspace.join("holder.release"), b"release").unwrap();
let output = legacy_holder
.wait_with_output()
.expect("legacy holder output");
assert!(
output.status.success(),
"legacy holder failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let upgraded = new_shared_subagent_manager(legacy_workspace, 4);
assert!(
state_dir.join(SUBAGENT_STATE_FILE).exists() && legacy_file.is_dir(),
"the next session upgrades the store once the older one exits"
"the store upgrades without waiting for the older session to exit"
);
assert!(upgraded.blocking_read().holds_coordination_process_lock());
drop(private);
assert_eq!(
std::fs::read(legacy_file.join("legacy.json")).unwrap(),
legacy_bytes,
"the older store is retained byte for byte"
);
std::fs::write(legacy_workspace.join("holder.release"), b"release").unwrap();
finish(legacy_holder, "older session");
}
// === agent(action="wait") + peek throttling (#4097) ===
@@ -24171,35 +24138,32 @@ fn validate_resume_from_source(
}
/// Owner report 2026-08-04: a model/provider switch spawns the new engine's
/// manager while the old engine still holds the workspace coordination flock
/// in this same process. That losing acquisition must classify itself as a
/// same-process handover (not "another Codewhale process"), and must
/// self-heal via the projection retry once the previous owner drops.
/// manager while the old engine's manager is still open in this same process.
/// Both stay admitted, and the new one keeps writing after the old one drops.
#[test]
fn coordination_lock_loss_to_own_process_reads_as_handover_and_self_heals() {
fn a_same_process_engine_handover_keeps_both_managers_admitted() {
let tmp = tempdir().expect("tempdir");
let state_path = default_state_path(tmp.path()).expect("state path");
let first =
SubAgentManager::new(tmp.path().to_path_buf(), 1).require_coordination_process_lock();
SubAgentManager::new(tmp.path().to_path_buf(), 1).with_state_path(state_path.clone());
let second = SubAgentManager::new(tmp.path().to_path_buf(), 1).with_state_path(state_path);
assert!(
first.holds_coordination_process_lock(),
"first manager must own the flock"
first
.coordination_detail_projection(None, 8)
.process_lock_held
);
let second =
SubAgentManager::new(tmp.path().to_path_buf(), 1).require_coordination_process_lock();
assert!(
second.holds_coordination_process_lock(),
"second manager now also holds shared flock (coexistence)"
second
.coordination_detail_projection(None, 8)
.process_lock_held
);
let note = second.coordination_process_lock_status();
assert!(note.is_ok(), "shared lock should not error: {note:?}");
first
.persist_state_synchronously()
.expect("old engine publishes");
drop(first);
assert!(
second.coordination_process_lock_status().is_ok(),
"retry must acquire once the previous engine dropped the flock"
);
assert!(second.holds_coordination_process_lock());
second
.persist_state_synchronously()
.expect("new engine publishes after the old one drops");
}
// -- resume-from-checkpoint tests (checkpoint-based continuation) --
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "actiu",
"ExtensionPromptUnavailable": "Les instruccions de les extensions no estan disponibles; consulta /plugin show.",
"ProfileConstitutionUnavailableLocal": "No s'han pogut carregar les preferències del compte de Codewhale; aquest torn fa servir la configuració local. La clau del proveïdor continua funcionant; executa codewhale login per recuperar les preferències del compte.",
"AccountPreferencesNotLoaded": "Compte no carregat. Executa {command}. La clau del proveïdor continua funcionant.",
"ComposerOversizedSubmitHeld": "No s’ha enviat: el missatge supera els {limit} caràcters i no s’ha pogut desar en un fitxer de text enganxat ({error}). El text complet continua al camp d’entrada: escurça’l o habilita l’escriptura a .codewhale/pastes i torna a prémer Enter.",
"ComposerOversizedSubmitHeldShort": "No s’ha enviat: supera els {limit} caràcters; no s’ha desat el fitxer d’enganxat.",
"CmdPluginOwnerReport": "Amfitrió d’extensions:\n Estat: {state}\n Eines actives ({count}): {tools}",
@@ -91,22 +92,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ó.",
@@ -1683,7 +1668,7 @@
"PhaseIdle": "inactiu",
"PhaseDraft": "esborrany",
"PhaseWorking": "treballant",
"PhaseReasoning": "raonant",
"PhaseReasoning": "pensant",
"PhaseReading": "llegint",
"PhaseUsingTool": "usant una eina",
"PhaseSubagents": "subagents en execució",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "aktiv",
"ExtensionPromptUnavailable": "Anweisungen von Erweiterungen sind nicht verfügbar; prüfe /plugin show.",
"ProfileConstitutionUnavailableLocal": "Die Codewhale-Kontoeinstellungen konnten nicht geladen werden; dieser Durchlauf nutzt deine lokalen Einstellungen. Dein Anbieter-Schlüssel funktioniert weiter; führe codewhale login aus, um die Kontoeinstellungen wiederherzustellen.",
"AccountPreferencesNotLoaded": "Konto nicht geladen. Führe {command} aus. Dein Anbieter-Schlüssel funktioniert weiter.",
"ComposerOversizedSubmitHeld": "Nicht gesendet: Diese Nachricht enthält mehr als {limit} Zeichen und konnte nicht als Einfügedatei gespeichert werden ({error}). Der vollständige Text steht noch im Eingabefeld: Kürze ihn oder mache .codewhale/pastes beschreibbar und drücke dann erneut Enter.",
"ComposerOversizedSubmitHeldShort": "Nicht gesendet: über {limit} Zeichen; Einfügedatei nicht gespeichert.",
"CmdPluginOwnerReport": "Erweiterungshost:\n Status: {state}\n Aktive Werkzeuge ({count}): {tools}",
@@ -91,22 +92,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.",
@@ -1683,7 +1668,7 @@
"PhaseIdle": "ruhend",
"PhaseDraft": "entwurf",
"PhaseWorking": "arbeitet",
"PhaseReasoning": "überlegt",
"PhaseReasoning": "denkt nach",
"PhaseReading": "liest",
"PhaseUsingTool": "nutzt Tool",
"PhaseSubagents": "Subagenten laufen",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "live",
"ExtensionPromptUnavailable": "Extension instructions are unavailable; inspect /plugin show.",
"ProfileConstitutionUnavailableLocal": "Codewhale account preferences could not be loaded, so this turn uses your local settings. Your provider key still works; run codewhale login to restore account preferences.",
"AccountPreferencesNotLoaded": "Account not loaded. Run {command}. Your provider key still works.",
"ComposerOversizedSubmitHeld": "Not sent: this message is over {limit} characters and could not be saved as a paste file ({error}). The full text is still in the composer: shorten it, or make .codewhale/pastes writable, then press Enter again.",
"ComposerOversizedSubmitHeldShort": "Not sent: over {limit} characters; paste file not saved.",
"CmdPluginOwnerReport": "Extension host:\n State: {state}\n Live tools ({count}): {tools}",
@@ -91,22 +92,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.",
@@ -1706,7 +1691,7 @@
"PhaseIdle": "idle",
"PhaseDraft": "draft",
"PhaseWorking": "working",
"PhaseReasoning": "reasoning",
"PhaseReasoning": "thinking",
"PhaseReading": "reading",
"PhaseUsingTool": "using tool",
"PhaseSubagents": "agents underway",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "activo",
"ExtensionPromptUnavailable": "Las instrucciones de las extensiones no están disponibles; revisa /plugin show.",
"ProfileConstitutionUnavailableLocal": "No se pudieron cargar las preferencias de la cuenta de Codewhale; este turno usa tu configuración local. Tu clave del proveedor sigue funcionando; ejecuta codewhale login para restaurar las preferencias de la cuenta.",
"AccountPreferencesNotLoaded": "Cuenta no cargada. Ejecuta {command}. Tu clave del proveedor sigue funcionando.",
"ComposerOversizedSubmitHeld": "No se envió: este mensaje supera los {limit} caracteres y no se pudo guardar en un archivo de texto pegado ({error}). El texto completo sigue en el campo de entrada: acórtalo o habilita la escritura en .codewhale/pastes y vuelve a presionar Enter.",
"ComposerOversizedSubmitHeldShort": "No se envió: supera los {limit} caracteres; no se guardó el archivo pegado.",
"CmdPluginOwnerReport": "Host de extensiones:\n Estado: {state}\n Herramientas activas ({count}): {tools}",
@@ -91,22 +92,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.",
@@ -1706,7 +1691,7 @@
"PhaseIdle": "inactivo",
"PhaseDraft": "borrador",
"PhaseWorking": "trabajando",
"PhaseReasoning": "razonando",
"PhaseReasoning": "pensando",
"PhaseReading": "leyendo",
"PhaseUsingTool": "usando herramienta",
"PhaseSubagents": "subagentes en curso",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "actif",
"ExtensionPromptUnavailable": "Les instructions des extensions sont indisponibles ; consultez /plugin show.",
"ProfileConstitutionUnavailableLocal": "Les préférences du compte Codewhale n'ont pas pu être chargées ; ce tour utilise vos réglages locaux. Votre clé de fournisseur fonctionne toujours ; lancez codewhale login pour rétablir les préférences du compte.",
"AccountPreferencesNotLoaded": "Compte non chargé. Lancez {command}. Votre clé de fournisseur fonctionne toujours.",
"ComposerOversizedSubmitHeld": "Non envoyé : ce message dépasse {limit} caractères et n’a pas pu être enregistré dans un fichier de texte collé ({error}). Le texte intégral reste dans la zone de saisie : raccourcissez-le ou autorisez l’écriture dans .codewhale/pastes, puis appuyez à nouveau sur Enter.",
"ComposerOversizedSubmitHeldShort": "Non envoyé : plus de {limit} caractères ; fichier collé non enregistré.",
"CmdPluginOwnerReport": "Hôte des extensions :\n État : {state}\n Outils actifs ({count}) : {tools}",
@@ -91,22 +92,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.",
@@ -1683,7 +1668,7 @@
"PhaseIdle": "inactif",
"PhaseDraft": "brouillon",
"PhaseWorking": "travail en cours",
"PhaseReasoning": "raisonnement",
"PhaseReasoning": "réflexion",
"PhaseReading": "lecture",
"PhaseUsingTool": "utilisation d'outil",
"PhaseSubagents": "sous-agents en cours",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "सक्रिय",
"ExtensionPromptUnavailable": "एक्सटेंशन के निर्देश उपलब्ध नहीं हैं; /plugin show से जाँचें।",
"ProfileConstitutionUnavailableLocal": "Codewhale खाते की प्राथमिकताएँ लोड नहीं हो सकीं, इसलिए यह टर्न आपकी स्थानीय सेटिंग्स का उपयोग करता है। आपकी प्रदाता कुंजी काम करती रहेगी; खाते की प्राथमिकताएँ वापस लाने के लिए codewhale login चलाएँ।",
"AccountPreferencesNotLoaded": "खाता लोड नहीं हुआ। {command} चलाएँ। आपकी प्रदाता कुंजी काम करती रहेगी।",
"ComposerOversizedSubmitHeld": "नहीं भेजा गया: इस संदेश में {limit} से अधिक वर्ण हैं और इसे पेस्ट फ़ाइल के रूप में सहेजा नहीं जा सका ({error})। पूरा पाठ अभी भी इनपुट बॉक्स में है: इसे छोटा करें या .codewhale/pastes में लिखने की अनुमति दें, फिर Enter दबाएँ।",
"ComposerOversizedSubmitHeldShort": "नहीं भेजा गया: {limit} से अधिक वर्ण; पेस्ट फ़ाइल सहेजी नहीं गई।",
"CmdPluginOwnerReport": "एक्सटेंशन होस्ट:\n स्थिति: {state}\n सक्रिय टूल ({count}): {tools}",
@@ -91,22 +92,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": "इस सत्र में कोई सामान्य आर्टिफ़ैक्ट फ़ाइल नहीं मिली।",
@@ -1683,7 +1668,7 @@
"PhaseIdle": "निष्क्रिय",
"PhaseDraft": "मसौदा",
"PhaseWorking": "काम चल रहा",
"PhaseReasoning": "तर्क चल रहा",
"PhaseReasoning": "सोच रहा",
"PhaseReading": "पढ़ रहा",
"PhaseUsingTool": "टूल इस्तेमाल हो रहा",
"PhaseSubagents": "सबएजेंट चल रहे हैं",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "aktif",
"ExtensionPromptUnavailable": "Instruksi ekstensi tidak tersedia; periksa /plugin show.",
"ProfileConstitutionUnavailableLocal": "Preferensi akun Codewhale tidak dapat dimuat, jadi giliran ini memakai pengaturan lokal. Kunci penyedia tetap berfungsi; jalankan codewhale login untuk memulihkan preferensi akun.",
"AccountPreferencesNotLoaded": "Akun tidak dimuat. Jalankan {command}. Kunci penyedia tetap berfungsi.",
"ComposerOversizedSubmitHeld": "Belum terkirim: pesan ini melebihi {limit} karakter dan tidak dapat disimpan sebagai berkas teks tempelan ({error}). Teks lengkap masih ada di kolom input: persingkat teks atau izinkan penulisan ke .codewhale/pastes, lalu tekan Enter lagi.",
"ComposerOversizedSubmitHeldShort": "Belum terkirim: melebihi {limit} karakter; berkas tempelan tidak tersimpan.",
"CmdPluginOwnerReport": "Host ekstensi:\n Status: {state}\n Alat aktif ({count}): {tools}",
@@ -91,22 +92,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.",
@@ -1683,7 +1668,7 @@
"PhaseIdle": "diam",
"PhaseDraft": "draf",
"PhaseWorking": "bekerja",
"PhaseReasoning": "menalar",
"PhaseReasoning": "berpikir",
"PhaseReading": "membaca",
"PhaseUsingTool": "memakai tool",
"PhaseSubagents": "subagen berjalan",
+1 -16
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "稼働中",
"ExtensionPromptUnavailable": "拡張機能の指示を利用できません。/plugin show で確認してください。",
"ProfileConstitutionUnavailableLocal": "Codewhale アカウントの設定を読み込めなかったため、このターンはローカル設定を使います。プロバイダーキーはそのまま使えます。アカウント設定を戻すには codewhale login を実行してください。",
"AccountPreferencesNotLoaded": "アカウントを読み込めませんでした。{command} を実行してください。プロバイダーキーはそのまま使えます。",
"ComposerOversizedSubmitHeld": "未送信:メッセージが {limit} 文字を超えており、貼り付けファイルに保存できませんでした({error})。全文は入力欄に残っています。短くするか .codewhale/pastes を書き込み可能にしてから、もう一度 Enter を押してください。",
"ComposerOversizedSubmitHeldShort": "未送信:{limit} 文字超。貼り付けファイルは未保存です。",
"CmdPluginOwnerReport": "拡張機能ホスト:\n 状態: {state}\n 有効なツール ({count}): {tools}",
@@ -91,22 +92,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": "このセッションに通常のアーティファクトファイルはありません。",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "실행 중",
"ExtensionPromptUnavailable": "확장 기능의 지침을 사용할 수 없습니다. /plugin show에서 확인하세요.",
"ProfileConstitutionUnavailableLocal": "Codewhale 계정 설정을 불러오지 못해 이번 턴은 로컬 설정을 사용합니다. 공급자 키는 계속 사용할 수 있으며, 계정 설정을 복원하려면 codewhale login을 실행하세요.",
"AccountPreferencesNotLoaded": "계정을 불러오지 못했습니다. {command}을 실행하세요. 공급자 키는 계속 사용할 수 있습니다.",
"ComposerOversizedSubmitHeld": "전송하지 못했습니다. 메시지가 {limit}자를 초과하며 붙여넣기 파일로 저장할 수 없습니다({error}). 전체 텍스트는 입력란에 그대로 있습니다. 내용을 줄이거나 .codewhale/pastes에 쓰기 권한을 부여한 뒤 Enter를 다시 누르세요.",
"ComposerOversizedSubmitHeldShort": "전송 안 됨: {limit}자 초과, 붙여넣기 파일 저장 실패.",
"CmdPluginOwnerReport": "확장 호스트:\n 상태: {state}\n 활성 도구 ({count}): {tools}",
@@ -91,22 +92,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": "이 세션에 일반 산출물 파일이 없습니다.",
@@ -1706,7 +1691,7 @@
"PhaseIdle": "유휴",
"PhaseDraft": "초안",
"PhaseWorking": "작업 중",
"PhaseReasoning": "추론 중",
"PhaseReasoning": "생각 중",
"PhaseReading": "읽는 중",
"PhaseUsingTool": "도구 사용 중",
"PhaseSubagents": "서브에이전트 실행 중",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "ativo",
"ExtensionPromptUnavailable": "As instruções das extensões não estão disponíveis; confira /plugin show.",
"ProfileConstitutionUnavailableLocal": "Não foi possível carregar as preferências da conta Codewhale; este turno usa suas configurações locais. Sua chave do provedor continua funcionando; execute codewhale login para restaurar as preferências da conta.",
"AccountPreferencesNotLoaded": "Conta não carregada. Execute {command}. Sua chave do provedor continua funcionando.",
"ComposerOversizedSubmitHeld": "Não enviado: esta mensagem excede {limit} caracteres e não pôde ser salva em um arquivo de texto colado ({error}). O texto completo continua no campo de entrada: reduza-o ou permita a gravação em .codewhale/pastes e pressione Enter novamente.",
"ComposerOversizedSubmitHeldShort": "Não enviado: acima de {limit} caracteres; arquivo colado não salvo.",
"CmdPluginOwnerReport": "Host de extensões:\n Estado: {state}\n Ferramentas ativas ({count}): {tools}",
@@ -91,22 +92,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.",
@@ -1706,7 +1691,7 @@
"PhaseIdle": "ocioso",
"PhaseDraft": "rascunho",
"PhaseWorking": "trabalhando",
"PhaseReasoning": "raciocinando",
"PhaseReasoning": "pensando",
"PhaseReading": "lendo",
"PhaseUsingTool": "usando ferramenta",
"PhaseSubagents": "subagentes em andamento",
+1 -16
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "активен",
"ExtensionPromptUnavailable": "Инструкции расширений недоступны; проверьте /plugin show.",
"ProfileConstitutionUnavailableLocal": "Не удалось загрузить настройки аккаунта Codewhale, поэтому этот ход использует локальные настройки. Ключ провайдера продолжает работать; выполните codewhale login, чтобы вернуть настройки аккаунта.",
"AccountPreferencesNotLoaded": "Аккаунт не загружен. Выполните {command}. Ключ провайдера продолжает работать.",
"ComposerOversizedSubmitHeld": "Не отправлено: сообщение длиннее {limit} символов, и его не удалось сохранить в файл вставленного текста ({error}). Полный текст остался в поле ввода: сократите его или разрешите запись в .codewhale/pastes, затем снова нажмите Enter.",
"ComposerOversizedSubmitHeldShort": "Не отправлено: больше {limit} символов; файл вставки не сохранён.",
"CmdPluginOwnerReport": "Хост расширений:\n Состояние: {state}\n Активные инструменты ({count}): {tools}",
@@ -91,22 +92,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": "Обычные файлы артефактов для этого сеанса не найдены.",
+1 -16
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "активний",
"ExtensionPromptUnavailable": "Інструкції розширень недоступні; перевірте /plugin show.",
"ProfileConstitutionUnavailableLocal": "Не вдалося завантажити налаштування облікового запису Codewhale, тому цей хід використовує локальні налаштування. Ключ провайдера й далі працює; виконайте codewhale login, щоб повернути налаштування облікового запису.",
"AccountPreferencesNotLoaded": "Обліковий запис не завантажено. Виконайте {command}. Ключ провайдера й далі працює.",
"ComposerOversizedSubmitHeld": "Не надіслано: повідомлення перевищує {limit} символів, і його не вдалося зберегти у файл вставленого тексту ({error}). Повний текст залишається в полі введення: скоротіть його або дозвольте запис у .codewhale/pastes, а потім знову натисніть Enter.",
"ComposerOversizedSubmitHeldShort": "Не надіслано: понад {limit} символів; файл вставки не збережено.",
"CmdPluginOwnerReport": "Хост розширень:\n Стан: {state}\n Активні інструменти ({count}): {tools}",
@@ -91,22 +92,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": "Звичайних файлів артефактів для цього сеансу не знайдено.",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "đang chạy",
"ExtensionPromptUnavailable": "Không thể tải hướng dẫn của tiện ích mở rộng; kiểm tra /plugin show.",
"ProfileConstitutionUnavailableLocal": "Không thể tải tùy chọn tài khoản Codewhale nên lượt này dùng cài đặt cục bộ. Khóa nhà cung cấp của bạn vẫn hoạt động; chạy codewhale login để khôi phục tùy chọn tài khoản.",
"AccountPreferencesNotLoaded": "Chưa tải được tài khoản. Chạy {command}. Khóa nhà cung cấp của bạn vẫn hoạt động.",
"ComposerOversizedSubmitHeld": "Chưa gửi: tin nhắn vượt quá {limit} ký tự và không thể lưu thành tệp văn bản đã dán ({error}). Toàn bộ nội dung vẫn còn trong ô nhập: hãy rút ngắn hoặc cho phép ghi vào .codewhale/pastes, rồi nhấn Enter lần nữa.",
"ComposerOversizedSubmitHeldShort": "Chưa gửi: vượt quá {limit} ký tự; chưa lưu tệp dán.",
"CmdPluginOwnerReport": "Máy chủ tiện ích mở rộng:\n Trạng thái: {state}\n Công cụ đang hoạt động ({count}): {tools}",
@@ -91,22 +92,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.",
@@ -1706,7 +1691,7 @@
"PhaseIdle": "rảnh",
"PhaseDraft": "nháp",
"PhaseWorking": "đang xử lý",
"PhaseReasoning": "đang suy luận",
"PhaseReasoning": "đang suy nghĩ",
"PhaseReading": "đang đọc",
"PhaseUsingTool": "đang dùng công cụ",
"PhaseSubagents": "đang chạy tác tử con",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "运行中",
"ExtensionPromptUnavailable": "扩展指令不可用;请通过 /plugin show 查看。",
"ProfileConstitutionUnavailableLocal": "无法加载 Codewhale 账号偏好,本轮改用本地设置。你的提供商密钥仍可正常使用;运行 codewhale login 可恢复账号偏好。",
"AccountPreferencesNotLoaded": "账号未加载。请运行 {command}。你的提供商密钥仍可正常使用。",
"ComposerOversizedSubmitHeld": "未发送:消息超过 {limit} 个字符,无法保存为粘贴文本文件({error})。完整文本仍保留在输入框中:请缩短内容,或允许写入 .codewhale/pastes,然后再次按 Enter。",
"ComposerOversizedSubmitHeldShort": "未发送:超过 {limit} 个字符;粘贴文件未保存。",
"CmdPluginOwnerReport": "扩展宿主:\n 状态:{state}\n 活动工具({count}):{tools}",
@@ -91,22 +92,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": "此会话没有常规产物文件。",
@@ -1706,7 +1691,7 @@
"PhaseIdle": "空闲",
"PhaseDraft": "草稿",
"PhaseWorking": "工作中",
"PhaseReasoning": "推理中",
"PhaseReasoning": "思考中",
"PhaseReading": "读取中",
"PhaseUsingTool": "使用工具中",
"PhaseSubagents": "智能体工作中",
+2 -17
View File
@@ -25,6 +25,7 @@
"TerminalDockLive": "執行中",
"ExtensionPromptUnavailable": "擴充功能指令無法使用;請透過 /plugin show 查看。",
"ProfileConstitutionUnavailableLocal": "無法載入 Codewhale 帳號偏好,本輪改用本機設定。你的服務商金鑰仍可正常使用;執行 codewhale login 可恢復帳號偏好。",
"AccountPreferencesNotLoaded": "帳號未載入。請執行 {command}。你的服務商金鑰仍可正常使用。",
"ComposerOversizedSubmitHeld": "未傳送:訊息超過 {limit} 個字元,無法儲存為貼上文字檔案({error})。完整文字仍保留在輸入框中:請縮短內容,或允許寫入 .codewhale/pastes,然後再次按 Enter。",
"ComposerOversizedSubmitHeldShort": "未傳送:超過 {limit} 個字元;貼上檔案未儲存。",
"CmdPluginOwnerReport": "擴充功能主機:\n 狀態:{state}\n 活動工具({count}):{tools}",
@@ -91,22 +92,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": "此工作階段沒有一般產物檔案。",
@@ -1704,7 +1689,7 @@
"PhaseIdle": "閒置",
"PhaseDraft": "草稿",
"PhaseWorking": "工作中",
"PhaseReasoning": "推理中",
"PhaseReasoning": "思考中",
"PhaseReading": "讀取中",
"PhaseUsingTool": "使用工具中",
"PhaseSubagents": "代理程式工作中",
+2 -32
View File
@@ -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,
@@ -730,6 +714,7 @@ pub enum MessageId {
ExtensionsInventorySkills,
ExtensionPromptUnavailable,
ProfileConstitutionUnavailableLocal,
AccountPreferencesNotLoaded,
ExtensionsMarketplaceDetail,
ExtensionsMarketplaceUnavailable,
ExtensionsMcpEmpty,
@@ -3130,22 +3115,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,
@@ -3683,6 +3652,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::ExtensionsInventorySkills,
MessageId::ExtensionPromptUnavailable,
MessageId::ProfileConstitutionUnavailableLocal,
MessageId::AccountPreferencesNotLoaded,
MessageId::ExtensionsMarketplaceDetail,
MessageId::ExtensionsMarketplaceUnavailable,
MessageId::ExtensionsMcpEmpty,
+6 -51
View File
@@ -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)
-3
View File
@@ -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": []}),
+37 -6
View File
@@ -492,6 +492,8 @@ pub struct Settings {
/// One-time Operate-retirement toast has been shown. Same suppression
/// contract as `yolo_deprecation_shown`.
pub operate_deprecation_shown: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_preferences_notice_shown: Option<String>,
/// Round 3 (2026-09-01) moved the work bar under the composer. Every
/// settings.toml saved before that carries `work_surface_placement =
/// "top"` — the old default, persisted verbatim by ordinary saves, not a
@@ -650,9 +652,7 @@ impl Default for Settings {
mention_menu_limit: 128,
mention_walk_depth: 10,
mention_menu_behavior: "fuzzy".to_string(),
// Reasoning is useful when explicitly requested, but it should
// never displace the actual conversation in the default TUI.
show_thinking: false,
show_thinking: true,
thinking_default_expanded: false,
thinking_preview_lines: default_thinking_preview_lines(),
thinking_highlight: true,
@@ -694,6 +694,7 @@ impl Default for Settings {
feature_intro_shown: false,
yolo_deprecation_shown: false,
operate_deprecation_shown: false,
account_preferences_notice_shown: None,
legacy_operate_default: false,
work_surface_bottom_migrated: false,
behavioral_tip_impressions: std::collections::BTreeMap::new(),
@@ -3747,6 +3748,36 @@ mod tests {
assert_eq!(reloaded.permission_posture.as_deref(), Some("ask"));
}
#[test]
fn thinking_shows_by_default_and_a_saved_choice_survives_reload_and_other_saves() {
let _g = config_path_test_guard();
let tmp = tempfile::tempdir().expect("tempdir");
let settings_path = tmp.path().join("settings.toml");
let _config_override =
EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
let absent = Settings::load().expect("load without a settings file");
assert!(absent.show_thinking);
assert!(!absent.thinking_default_expanded);
assert!(!absent.is_set("show_thinking"));
std::fs::write(&settings_path, "theme = \"light\"\n").expect("settings without the key");
let unnamed = Settings::load().expect("load settings without the key");
assert!(unnamed.show_thinking);
assert!(!unnamed.is_set("show_thinking"));
for saved in [false, true] {
std::fs::write(&settings_path, format!("show_thinking = {saved}\n"))
.expect("saved choice");
let loaded = Settings::load().expect("load the saved choice");
assert_eq!(loaded.show_thinking, saved);
assert!(loaded.is_set("show_thinking"));
Settings::transact(|settings| settings.set("locale", "en")).expect("another save");
let reloaded = Settings::load().expect("reload after another save");
assert_eq!(reloaded.show_thinking, saved);
}
}
#[test]
fn settings_load_keeps_top_placement_chosen_after_the_bottom_migration() {
let _g = config_path_test_guard();
@@ -3886,7 +3917,7 @@ mod tests {
let mut settings = Settings::default();
// Density is calm by default; motion is an independent axis.
assert!(settings.calm_mode);
assert!(!settings.show_thinking);
settings.show_thinking = false;
let changed = settings.apply_preset("CALM").expect("calm preset applies");
assert_eq!(
@@ -3916,8 +3947,8 @@ mod tests {
assert!(settings.fancy_animations);
assert_eq!(settings.transcript_spacing, "spacious");
assert_eq!(settings.tool_collapse_mode, "compact");
// Thinking is opt-in so the transcript stays focused on the chat.
assert!(!settings.show_thinking);
assert!(settings.show_thinking);
assert!(!settings.thinking_default_expanded);
}
#[test]
-16
View File
@@ -1371,9 +1371,6 @@ fn relativize_candidate(
.map(fs::Metadata::is_dir)
.unwrap_or_else(|| candidate.ends_with('/'));
if rel_path.as_os_str().is_empty() {
return None;
}
let rel_string = path_to_string(&rel_path)?;
Some((rel_string, exists, is_dir))
}
@@ -1658,19 +1655,6 @@ mod tests {
assert!(!entry.is_dir);
}
#[test]
fn workspace_root_is_not_an_active_path() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().canonicalize().expect("canonical root");
fs::write(root.join("笔记 📝.md"), "旧").expect("write");
let mut ws = WorkingSet::default();
let mention = format!("<file path=\"{}/笔记 📝.md\">", root.display());
ws.observe_user_message(&mention, &root);
assert!(!ws.entries.contains_key(""), "{:?}", ws.entries.keys());
}
#[test]
fn observe_tool_call_extracts_paths_from_input() {
let tmp = TempDir::new().expect("tempdir");
+1 -36
View File
@@ -1457,7 +1457,6 @@ impl SnapshotRepo {
.arg(&self.git_dir)
.arg("--work-tree")
.arg(&self.work_tree)
.args(["-c", "core.quotePath=false"])
.args(args);
let output = run_bounded_git(&mut cmd, subcommand)?;
if !output.status.success() {
@@ -1851,7 +1850,7 @@ impl SnapshotRepo {
.duration_since(UNIX_EPOCH)
.map_err(|e| io_other(format!("clock error: {e}")))?
.as_secs() as i64;
let cutoff = now.saturating_sub(i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX));
let cutoff = now - max_age.as_secs() as i64;
let snapshots = self.list(usize::MAX)?;
if snapshots.is_empty() {
@@ -3949,22 +3948,6 @@ mod tests {
assert_eq!(list[0].label, "turn:1");
}
#[test]
fn prune_with_an_age_beyond_i64_keeps_every_snapshot() {
let tmp = tempdir().unwrap();
let (repo, _home) = make_repo(tmp.path());
std::fs::write(repo.work_tree().join("f.txt"), "v0").unwrap();
repo.snapshot("turn:0").unwrap();
std::thread::sleep(Duration::from_millis(1100));
let removed = repo
.prune_older_than(Duration::from_secs(u64::MAX))
.unwrap();
assert_eq!(removed, 0);
assert_eq!(repo.list(10).unwrap().len(), 1);
}
/// The 2026-08-04 regression: with a cut in the MIDDLE of history,
/// `prune_older_than` used to `update-ref HEAD <oldest survivor>`, which
/// orphaned (and gc destroyed) the NEWEST snapshots while keeping the
@@ -4909,24 +4892,6 @@ mod tests {
assert_eq!(none.patch, "");
}
#[test]
fn work_tree_changes_since_names_non_ascii_paths_as_written() {
let tmp = tempdir().unwrap();
let (repo, _home) = make_repo(tmp.path());
let ws = repo.work_tree().to_path_buf();
std::fs::write(ws.join("笔记 📝.md"), b"old\n").unwrap();
let before = repo.snapshot("pre-turn:1").expect("snapshot");
git_output(&repo, &["config", "core.quotePath", "true"]);
std::fs::write(ws.join("笔记 📝.md"), b"new\n").unwrap();
let changes = repo
.work_tree_changes_since(&before, 1 << 20)
.expect("changes");
assert_eq!(changes.names, "笔记 📝.md\n");
assert!(changes.stat.contains("笔记 📝.md"), "{}", changes.stat);
assert!(changes.patch.contains("b/笔记 📝.md"), "{}", changes.patch);
}
/// A tree the repo no longer holds — the receipt outlived the object —
/// is reported as absent rather than as a diff failure, so a caller can
/// tell pruning from a broken repo without reading git's stderr.
+2 -37
View File
@@ -11,7 +11,7 @@ mod runtime_aliases;
use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex, MutexGuard};
@@ -1453,7 +1453,6 @@ impl StateStore {
self.with_session_index_lock(|| {
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(&self.session_index_path)
.with_context(|| {
@@ -1462,14 +1461,7 @@ impl StateStore {
self.session_index_path.display()
)
})?;
let mut last = *b"\n";
if file.metadata()?.len() > 0 {
file.seek(SeekFrom::End(-1))?;
file.read_exact(&mut last)?;
}
let separator = if last[0] == b'\n' { "" } else { "\n" };
writeln!(file, "{separator}{encoded}")
.context("failed to append session index entry")?;
writeln!(file, "{encoded}").context("failed to append session index entry")?;
// Durability: without this a crash mid-write can leave a torn
// final line. Reads tolerate one (see `session_index_map`), but
// not losing the entry beats recovering from having lost it.
@@ -2930,33 +2922,6 @@ mod tests {
);
}
#[test]
fn session_index_append_after_an_unterminated_torn_line_is_kept() {
let store = temp_state_store("session-index-unterminated");
store
.append_thread_name("thread-1", Some("first".to_string()), 1, None)
.expect("append first entry");
{
let mut file = OpenOptions::new()
.append(true)
.open(&store.session_index_path)
.expect("open session index");
write!(file, "{{\"thread_id\":\"thread-2\",\"thread_na").expect("write torn line");
}
store
.append_thread_name("thread-3", Some("third".to_string()), 3, None)
.expect("append third entry");
assert_eq!(
store
.find_thread_name_by_id("thread-3")
.expect("lookup thread-3")
.as_deref(),
Some("third"),
);
}
/// Hook fired by compaction between writing the snapshot and renaming it
/// over the live index — the window a concurrent append can be lost in.
/// Only the store whose path a test registered is affected, so tests
+1 -20
View File
@@ -7,15 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **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.
@@ -676,7 +669,7 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
- **Windows uninstall removes the receipt and update leftovers.** The installer's uninstaller also deletes `.codewhale-install.sha256` and `codewhale.old-*`/`codew.old-*` left by a self-update, so `bin` can be removed. Refs #7091.
- **Windows `/undo` matches recorded file paths regardless of case.** A write recorded under a different drive-letter or directory case, or a `\\?\` prefix, is no longer dropped from the restore plan. Refs #6970.
- **Windows: Fleet and metrics read live files again.** Replaying a Fleet ledger and reading audit or receipt records no longer fail with a sharing violation while their writer is still open; the same regular-file, link and identity checks apply. Plugin enablement now says which snapshot check refused (stage root, content identity or final check) instead of one generic message. Generated protocol and SDK exports keep LF line endings in Windows checkouts.
- **A held coordination lock no longer makes a new session read-only.** When another session owns the delegated-agent store, the new session starts on private state and says so, instead of refusing every sub-agent with “Failed to clean up sub-agents”.
- **Sessions in one folder no longer leave each other's sub-agents read-only or private:** a second session works beside another one, stale or suspended included, the older sub-agent store upgrades right away, and a store that cannot be opened is retried on the next sub-agent change instead of needing a restart. While another Codewhale session, a suspended one included, is open in a folder the desktop app or `serve` was asked to work in (other than the folder it started in), that Runtime still refuses sub-agents that would edit the folder directly; isolated worktrees are unaffected. A sub-agent store that cannot be opened shows its refusal once instead of repeating cleanup warnings on every sidebar refresh. Restart any session started with Codewhale 0.10.2 or older: once the folder upgrades it can no longer save sub-agent state (Refs #7232).
- **A dev server can outlive the turn in an interactive session.** `persist:true` now works in the TUI and desktop when the thread has Full Access; restricted postures and Fleet/sub-agents still refuse it.
- **`codewhale exec --add-dir <path>`** (repeatable) and `addDirs` on `POST /v1/threads` let the file tools read and write a sibling directory, a second checkout or a shared scratch root without turning on Full Access.
- **Unsettled usage is persisted in production, not only in tests.** The interactive sink now records a legacy usage receipt when a provider response has no settlement, so children and turns stop reporting settlement failure.
@@ -713,12 +706,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
composer. Real Pinyin input on macOS and Windows is not yet confirmed.
Thanks [@cmdcorp6534](https://github.com/cmdcorp6534) for the report
(Refs #2323).
- **Sub-agents beside an older session:** while a Codewhale 0.10.2 session
still uses a folder's older delegated-agent store, a 0.10.3 session in the
same folder keeps working sub-agents with private state that is not saved,
instead of refusing every sub-agent action. The older session's files are
left untouched, and the store upgrades when a session starts after the older
one exits (Refs #7232).
- **Fleet members:** a replayed member start now matches only within its own
session, so two sessions that reuse an operation key no longer receive each
other's agent.
@@ -747,12 +734,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
bare self-package exports resolve inside the selected package directory.
Invalid or incompatible ranges and paths outside the reviewed package still
refuse. Real published-plugin activation remains unqualified.
- **Coordination lock diagnostics (#7232):** stale lock-file contents no longer
identify an owner PID. A refused legacy admission keeps its reason in Work
and shows one notice per cause instead of repeating cleanup warnings on
every sidebar refresh. Legacy migration still requires the actual holder to
exit and the refused owning Runtime to restart; locks and retained state
remain protected.
- **A local proxy that stops mid-session no longer strands the session (#7231):** the model, catalog, search, vision, finance, MCP-registry, profile and remote-control clients watch a loopback proxy port and go direct while it refuses connections, then resume proxying when it returns. Only a definite refusal from every resolved address changes the route; remote proxies, `NO_PROXY` matches, credentials and the existing precedence are unchanged. Set `CODEWHALE_LOCAL_PROXY_FALLBACK=0` to keep the old build-time snapshot.
- **Proxy diagnostic privacy ([#7233](https://github.com/codewhale-hq/codewhale/issues/7233)):** malformed MCP proxy warnings omit configured values; local-proxy log labels mask userinfo and omit URL paths, queries and fragments. Routing is unchanged. All 23 existing proxy tests passed on macOS; runtime log capture and other platforms remain unqualified. Thanks [@SparkofSpike](https://github.com/SparkofSpike) for the report.
- **Fork, undo and retry work after a workspace drift notice (#7234):** the history-boundary walks now treat a whole `<context_update>` envelope as engine-owned, so a session that received one can be cut at every saved turn. A person quoting a notice or a truncated envelope still counts as their own prompt.
-2
View File
@@ -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,
+4 -35
View File
@@ -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");
@@ -157,8 +157,7 @@ fn session_diff(app: &App) -> std::io::Result<Option<DebugDiffObservation>> {
/// The workspace repository's own uncommitted changes: what `/diff` shows
/// before the session has a restore point to compare against.
fn workspace_git_diff(workspace: &std::path::Path) -> DebugDiffObservation {
let git =
|args: &[&str]| Git::output(&[&["-c", "core.quotePath=false"], args].concat(), workspace);
let git = |args: &[&str]| Git::output(args, workspace);
// Outside a repository `git diff` prints nothing on stdout, which would
// read as "no changes".
match git(&["rev-parse", "--is-inside-work-tree"]) {
@@ -215,7 +214,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(),
}
}
-1
View File
@@ -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> {
+10 -16
View File
@@ -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,
-1
View File
@@ -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());
}
-73
View File
@@ -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"]);
+54 -33
View File
@@ -87,6 +87,27 @@ pub(crate) use types::{
WORKFLOW_DRAFT_INSTRUCTION_PREFIX,
};
const ACCOUNT_PREFERENCES_NOTICE: &str = "account-preferences-notice";
fn account_sign_in_marker(profile: Option<&str>) -> String {
use sha2::{Digest, Sha256};
let api_base = codewhale_engine::profile_constitution::runtime_account_api_base();
let session = codewhale_secrets::account::AccountSessionStore::new(
codewhale_secrets::Secrets::file_backed_read_only(),
profile,
&api_base,
)
.runtime_info_at(Utc::now())
.ok()
.and_then(|info| info.session_id)
.unwrap_or_default();
Sha256::digest(session.as_bytes())
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect()
}
// === Types ===
/// One login owns one mailbox. A cancelled task can only write its abandoned
@@ -2277,8 +2298,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>,
@@ -2450,6 +2469,7 @@ pub struct App {
/// re-render per character. `None` means "no bump since the last
/// finalize" so the first chunk of a block always renders immediately.
pub thinking_revision_last_bump_at: Option<Instant>,
pub(crate) thinking_revision_owed: bool,
/// Newline-gated streaming collector state.
pub streaming_state: StreamingState,
/// Live approximate output tokens for the current assistant stream.
@@ -3793,6 +3813,38 @@ impl App {
);
}
pub(crate) fn notify_account_preferences_not_loaded(&mut self) {
let sign_in = account_sign_in_marker(self.config_profile.as_deref());
self.notify_account_preferences_not_loaded_for(&sign_in);
}
fn notify_account_preferences_not_loaded_for(&mut self, sign_in: &str) {
let first_for_sign_in = Settings::transact_opt(|settings| {
if settings.account_preferences_notice_shown.as_deref() == Some(sign_in) {
return Ok(None);
}
settings.account_preferences_notice_shown = Some(sign_in.to_string());
Ok(Some(()))
});
if matches!(first_for_sign_in, Ok(None)) {
return;
}
let text = self
.tr(MessageId::AccountPreferencesNotLoaded)
.replace("{command}", "codewhale login");
self.push_status_toast_record(StatusToast::standing(
text,
StatusToastLevel::Warning,
ACCOUNT_PREFERENCES_NOTICE,
));
}
pub(crate) fn dismiss_account_preferences_notice(&mut self) -> bool {
let before = self.status_toasts.len();
self.retire_event_notices(ACCOUNT_PREFERENCES_NOTICE);
self.status_toasts.len() != before
}
/// One-release migration notice for the Shift+Tab/Ctrl+T rebinding: users
/// pressing Shift+Tab expecting the old thinking cycle land here first.
fn notify_keybinding_migration_once(&mut self) {
@@ -5592,37 +5644,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();
+1 -2
View File
@@ -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,
@@ -1144,6 +1142,7 @@ impl App {
streaming_thinking_active_entry: None,
pending_thinking_translation_cells: HashMap::new(),
thinking_revision_last_bump_at: None,
thinking_revision_owed: false,
streaming_state: StreamingState::new(),
streaming_output_token_estimate: 0,
reasoning_buffer: String::new(),
+40
View File
@@ -184,6 +184,46 @@ fn feature_intro_shows_once_persists_then_is_idempotent() {
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn account_preferences_notice_shows_once_per_sign_in_and_can_be_dismissed() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.toml");
let _env = EnvVarGuard::set(
"DEEPSEEK_CONFIG_PATH",
config_path.to_string_lossy().as_ref(),
);
let shown = |app: &App| {
let notice = app
.tr(MessageId::AccountPreferencesNotLoaded)
.replace("{command}", "codewhale login");
app.status_toasts
.iter()
.filter(|toast| toast.text == notice && toast.kind == StatusToastKind::Standing)
.count()
};
let mut app = App::new(test_options(false), &Config::default());
app.notify_account_preferences_not_loaded_for("first-sign-in");
app.notify_account_preferences_not_loaded_for("first-sign-in");
assert_eq!(shown(&app), 1);
assert_eq!(
Settings::load()
.expect("settings should load")
.account_preferences_notice_shown
.as_deref(),
Some("first-sign-in")
);
assert!(app.dismiss_account_preferences_notice());
assert_eq!(shown(&app), 0);
let mut later_session = App::new(test_options(false), &Config::default());
later_session.notify_account_preferences_not_loaded_for("first-sign-in");
assert_eq!(shown(&later_session), 0, "the same sign-in is told once");
later_session.notify_account_preferences_not_loaded_for("second-sign-in");
assert_eq!(shown(&later_session), 1, "a later sign-in is told again");
}
#[test]
fn initial_input_scenario() {
// Scenario consolidation of: initial_input_prefill_waits_for_manual_submit, initial_input_submit_marks_startup_dispatch
-4
View File
@@ -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
+2 -2
View File
@@ -284,8 +284,8 @@ impl ContextMenuView {
if start > 0 {
rows.push(MenuRow::MoreAbove(start));
}
for (idx, entry) in self.entries.iter().enumerate().take(end).skip(start) {
if idx > start && entry.section_start {
for idx in start..end {
if idx > start && self.entries[idx].section_start {
rows.push(MenuRow::Divider);
}
rows.push(MenuRow::Entry(idx));
-288
View File
@@ -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)
}
-19
View File
@@ -403,25 +403,6 @@ mod tests {
assert!(!payload.truncated);
}
#[test]
fn mentions_name_non_ascii_paths_as_written() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
Command::new("git")
.args(["config", "core.quotePath", "true"])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("笔记 📝.md"), "旧\n").unwrap();
commit_all(dir.path(), "initial");
std::fs::write(dir.path().join("笔记 📝.md"), "新\n").unwrap();
let status = resolve_git_mention(GitMentionKind::Status, dir.path());
let diff = resolve_git_mention(GitMentionKind::Diff, dir.path());
assert!(status.block.contains("笔记 📝.md"), "{}", status.block);
assert!(diff.block.contains("b/笔记 📝.md"), "{}", diff.block);
}
#[test]
fn diff_covers_staged_and_unstaged_changes() {
let dir = tempfile::tempdir().unwrap();
@@ -9,7 +9,7 @@
│ Background (default) ✎ │current terminal
│ Pet mode Off [ ] │saved terminal
│ Quiet transcript On [x] │startup terminal
│ Model reasoning in chat Off [ ] │source settings.toml
│ Model reasoning in chat On [x] │source settings.toml
│ Thinking Default Expanded Off [ ] │scope SAVED
│ Thinking Preview Lines 2 ✎ │apply applies on save
│ Thinking background highlight On [x] │kind choice
@@ -9,7 +9,7 @@
Background (default) ✎ SAVED █
Pet mode Off [ ] SAVED █
Quiet transcript On [x] SAVED █
Model reasoning in chat Off [ ] SAVED █
Model reasoning in chat On [x] SAVED █
Thinking Default Expanded Off [ ] SAVED █
Thinking Preview Lines 2 ✎ SAVED │
Thinking background highlight On [x] SAVED │
-1
View File
@@ -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;
+3 -25
View File
@@ -22,8 +22,8 @@ use crate::tui::views::{
};
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
use codewhale_engine::session_manager::{
MAX_SESSION_TITLE_CHARS, SavedSession, SessionListFilter, SessionManager, SessionMetadata,
extract_title, extract_user_prompt, strip_thinking_tags,
SavedSession, SessionListFilter, SessionManager, SessionMetadata, extract_title,
extract_user_prompt, strip_thinking_tags,
};
use codewhale_engine::session_projection::{MAX_PROJECTED_SESSIONS, SessionQuery, SessionSortMode};
use codewhale_localization::{Locale, MessageId, tr};
@@ -787,7 +787,7 @@ impl SessionPickerView {
self.status = Some(tr(self.locale, MessageId::SessionsNoSelection).into_owned());
return ViewAction::None;
};
if new_title.is_empty() || new_title.chars().count() > MAX_SESSION_TITLE_CHARS {
if new_title.is_empty() || new_title.len() > 100 {
self.status = Some(tr(self.locale, MessageId::SessionsTitleLength).into_owned());
return ViewAction::None;
}
@@ -2045,28 +2045,6 @@ mod tests {
);
}
#[test]
fn rename_selected_limits_title_by_characters() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
let manager = SessionManager::default_location().expect("session manager");
let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]);
saved.metadata.id = "session-cjk".to_string();
manager.save_session(&saved).expect("save session");
let mut view = picker_with(vec![saved.metadata.clone()], None);
let title = "中文会话标题".repeat(6);
assert!(matches!(
view.rename_selected(&title),
ViewAction::Emit(ViewEvent::SessionRenamed { .. })
));
assert_eq!(
manager.load_session("session-cjk").unwrap().metadata.title,
title
);
}
#[test]
fn archive_toggle_persists_hides_the_row_and_emits_an_archive_receipt() {
let _lock = crate::test_support::lock_test_env();
+46 -6
View File
@@ -30,15 +30,17 @@ use crate::tui::history::HistoryCell;
/// frame cap (see `frame_rate_limiter`) yet imperceptible as lag.
///
/// Correctness: this only skips *intermediate* repaints. Appended content is
/// never dropped — it lands in the cell immediately — and finalize always
/// forces a bump so the final reasoning text is fully rendered.
/// never dropped — it lands in the cell immediately — the display beat renders
/// a skipped bump once the window has passed, and finalize always forces a
/// bump so the final reasoning text is fully rendered.
const THINKING_REVISION_THROTTLE: Duration = Duration::from_millis(100);
/// Bump the active-cell revision for a streaming thinking mutation, but at
/// most once per [`THINKING_REVISION_THROTTLE`] window. Returns whether a bump
/// was actually emitted. Skipped bumps coalesce into the next one (or into the
/// forced finalize bump), so no content is ever lost — only redundant
/// intermediate re-wraps are dropped.
/// was actually emitted. Skipped bumps coalesce into the next one, into
/// [`render_owed_revision`] on a later display beat, or into the forced
/// finalize bump, so no content is ever lost — only redundant intermediate
/// re-wraps are dropped.
fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
let due = app
.thinking_revision_last_bump_at
@@ -47,9 +49,17 @@ fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
app.thinking_revision_last_bump_at = Some(now);
app.bump_active_cell_revision();
}
app.thinking_revision_owed = !due;
due
}
pub(super) fn render_owed_revision(app: &mut App, now: Instant) -> bool {
if app.streaming_thinking_active_entry.is_none() {
app.thinking_revision_owed = false;
}
app.thinking_revision_owed && bump_thinking_revision_throttled(app, now)
}
/// Ensure an in-flight Thinking entry exists in `active_cell` and return its
/// entry index. If no thinking entry is currently streaming, push a fresh one.
/// P2.3: thinking shares the active cell with subsequent tool calls so the
@@ -76,7 +86,8 @@ pub(super) fn ensure_active_entry(app: &mut App) -> usize {
/// committed to the cell immediately; the active-cell revision bump that
/// triggers a re-wrap of the live tail is debounced to at most one per
/// [`THINKING_REVISION_THROTTLE`] window (#1620). Skipped bumps coalesce into
/// the next append or the forced finalize bump, so no content is ever lost.
/// the next append, a later display beat or the forced finalize bump, so no
/// content is ever lost.
pub(super) fn append(app: &mut App, entry_idx: usize, text: &str) {
append_at(app, entry_idx, text, Instant::now());
}
@@ -308,6 +319,7 @@ pub(super) fn finalize_active_entry(app: &mut App, duration: Option<f32>, remain
// the debounce window so the next thinking block's first chunk renders
// immediately rather than being coalesced into a stale window.
app.thinking_revision_last_bump_at = None;
app.thinking_revision_owed = false;
app.bump_active_cell_revision();
true
}
@@ -426,6 +438,34 @@ mod tests {
);
}
#[test]
fn a_paused_stream_renders_the_reasoning_it_already_received() {
let mut app = test_app();
let entry = ensure_active_entry(&mut app);
app.thinking_revision_last_bump_at = None;
let t0 = Instant::now();
append_at(&mut app, entry, "first slice ", t0);
let shown = app.active_cell_revision;
append_at(&mut app, entry, "held back", t0 + Duration::from_millis(16));
assert_eq!(app.active_cell_revision, shown);
assert!(!render_owed_revision(
&mut app,
t0 + Duration::from_millis(32)
));
assert_eq!(app.active_cell_revision, shown);
assert!(render_owed_revision(
&mut app,
t0 + THINKING_REVISION_THROTTLE
));
assert_eq!(app.active_cell_revision, shown.wrapping_add(1));
assert!(!render_owed_revision(
&mut app,
t0 + THINKING_REVISION_THROTTLE * 3
));
assert_eq!(app.active_cell_revision, shown.wrapping_add(1));
}
/// #7123: a reasoning block that opens while the display beat still holds
/// an uncommitted slice of prose must not drop that slice. `start_block`
/// resets `StreamingState`, so the tail has to be delivered first.
+4 -4
View File
@@ -1441,8 +1441,8 @@ fn prose_cells_fill_full_width_on_ultrawide_by_default() {
let mut cache = TranscriptViewCache::new();
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
let width = max_line_width(&cell.lines);
for idx in 0..3 {
let width = max_line_width(&cache.per_cell[idx].lines);
assert!(
width > RETIRED_RAIL_MEASURE,
"prose cell {idx} wrapped to {width} columns — still on the retired \
@@ -1495,8 +1495,8 @@ fn transcript_prose_measure_caps_prose_but_not_tools() {
let mut cache = TranscriptViewCache::new();
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
let width = max_line_width(&cell.lines);
for idx in 0..3 {
let width = max_line_width(&cache.per_cell[idx].lines);
assert!(
width > 105,
"prose cell {idx} wrapped to {width} columns — still on the retired \
-1
View File
@@ -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?;
}
+5 -52
View File
@@ -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(())
}
+9 -15
View File
@@ -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;
}
@@ -7235,14 +7232,16 @@ pub(crate) async fn run_event_loop(
continue;
}
// An idle operator can dismiss the persistent context warning
// without affecting vim or attachment handling. While a turn
// is active Esc retains its cancellation meaning.
// or the account preferences notice without affecting vim or
// attachment handling. While a turn is active Esc retains its
// cancellation meaning.
KeyCode::Esc
if !app.is_loading
&& app.input.is_empty()
&& !slash_menu_open
&& !mention_menu_open
&& app.dismiss_context_pressure_warning() =>
&& (app.dismiss_context_pressure_warning()
|| app.dismiss_account_preferences_notice()) =>
{
continue;
}
@@ -8904,7 +8903,6 @@ mod launch_session_sync_tests {
model,
workspace: workspace.path().to_path_buf(),
mode: AppMode::Agent,
agent_preset: None,
})
.await
.expect("sync session");
@@ -9330,14 +9328,10 @@ pub(super) fn apply_engine_status(app: &mut App, message: String) -> bool {
app.add_message(HistoryCell::System { content: message });
return true;
}
// The account-profile fallback notice is sent once per engine; keep it in
// the transcript so the next turn's status line cannot erase it.
let retain = app.tr(MessageId::ProfileConstitutionUnavailableLocal) == message.as_str();
if retain {
app.add_message(HistoryCell::System {
content: message.clone(),
});
if app.tr(MessageId::ProfileConstitutionUnavailableLocal) == message.as_str() {
app.notify_account_preferences_not_loaded();
return false;
}
app.status_message = Some(message);
retain
false
}
+4 -2
View File
@@ -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
@@ -1556,7 +1555,10 @@ pub(crate) fn commit_streaming_display_tick(
updated = true;
}
if app.streaming_state.has_pending_stream_text(0) {
if streaming_thinking::render_owed_revision(app, now) {
updated = true;
}
if app.streaming_state.has_pending_stream_text(0) || app.thinking_revision_owed {
stream_display_clock.note_delta(now);
}
@@ -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")
);
}
}
+5 -17
View File
@@ -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() {
-1
View File
@@ -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;
}
-7
View File
@@ -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));
+1 -1
View File
@@ -1110,7 +1110,7 @@ mod screen_mode_tests {
viewport: ratatui::Viewport::Inline(3),
},
)
.map_err(|err| match err {})
.map_err(|err| io::Error::other(err.to_string()))
},
)
.expect("a successful probe must switch");
+26 -10
View File
@@ -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;
@@ -27344,7 +27343,7 @@ fn turn_inspector_switches_isolated_full_turn_pages_with_tagged_reasoning_and_ou
let latest = pager.body_text();
assert!(latest.contains("SECOND-PROMPT"), "{latest}");
assert!(latest.contains("[∿ reasoning 1/1 · running]"), "{latest}");
assert!(latest.contains("[∿ thinking 1/1 · running]"), "{latest}");
assert!(latest.contains("SECOND-THOUGHT"), "{latest}");
assert!(latest.contains("[◆ · running]"), "{latest}");
assert!(latest.contains("SECOND-ANSWER"), "{latest}");
@@ -27356,7 +27355,7 @@ fn turn_inspector_switches_isolated_full_turn_pages_with_tagged_reasoning_and_ou
for expected in [
"FIRST-PROMPT",
"keep this input line",
"[∿ reasoning 1/1 · Done]",
"[∿ thinking 1/1 · Done]",
"FIRST-THOUGHT",
"full reasoning tail",
"[⚙ using tool]",
@@ -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(
@@ -35490,23 +35488,41 @@ fn extension_prompt_origin_keeps_denials_and_other_policy_holds() {
}
#[test]
fn account_profile_fallback_notice_stays_in_the_transcript() {
fn account_profile_fallback_notice_is_a_standing_notice_outside_the_transcript() {
let _home = SettingsHomeGuard::new();
let mut app = App::new(create_test_options(), &Config::default());
let notice = app
let engine_notice = app
.tr(MessageId::ProfileConstitutionUnavailableLocal)
.to_string();
let before = app.history.len();
assert!(super::event_loop::apply_engine_status(
let status_before = app.status_message.clone();
assert!(!super::event_loop::apply_engine_status(
&mut app,
notice.clone()
engine_notice
));
assert_eq!(app.history.len(), before);
assert_eq!(app.status_message, status_before);
assert!(!super::event_loop::apply_engine_status(
&mut app,
"Executing tools sequentially".into()
));
assert_eq!(app.history.len(), before + 1);
assert!(matches!(&app.history[before], HistoryCell::System { content } if *content == notice));
assert_eq!(app.history.len(), before);
let notice = app
.tr(MessageId::AccountPreferencesNotLoaded)
.replace("{command}", "codewhale login");
let standing = |app: &App| {
app.status_toasts
.iter()
.find(|toast| toast.text == notice)
.cloned()
};
app.sync_status_message_to_toasts();
let shown = standing(&app).expect("a later routine status does not erase the notice");
assert_eq!(shown.level, StatusToastLevel::Warning);
assert_eq!(shown.ttl_ms, None);
assert!(app.dismiss_account_preferences_notice());
assert!(standing(&app).is_none());
assert!(!app.dismiss_account_preferences_notice());
}
#[test]
@@ -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();
}
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more