Compare commits

..
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 3f99cdab9e fix(config): show the telemetry setting default as on, matching the runtime
The settings schema declared telemetry default false while the resolver
(telemetry_consent_from_env) treats an unset value as on, so the settings
API reported default false for a default-on policy.

Landed in Engine push 18 (CW-7875) with the CHANGELOG line the review wrote
(CW-7987 REVIEW.md finding 2; the packet's line was not used). This is the
committed part only: CW-7987 stays open for the active-version readback and
the ingest bundle rebuilt from the frozen candidate. Review: farm runs
20261112T110021Z-gtelrev-config-37102e (codewhale-config, 21 run, 21 passed,
823 skipped) and 20261112T110024Z-gtelrev-telem-751b20 (codewhale-telemetry,
53 run, 53 passed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 18:02:40 -08:00
CodeWhale BotandClaude Sonnet 5.5 ae18591d10 feat(router): preserve explicit per-question decision abstention
An explicit abstained:true on a System One answer now survives decoding and the
routing receipt, and can never authorize Auto (tier), the thinking effort, a
shadow noul read or a shadow chat intent, even at a zero confidence floor. A
non-boolean abstained value fails answer decoding and is refused like any other
malformed answer. Provider usage evidence and the declared fallback are
unchanged. The custom decision endpoint half of the original change is not
included.

Receipt field abstained_questions touches crates/models, cost_status and the
tui activity_detail fixture outside the slice owner list; it is additive with a
serde default.

Landed in Engine push 18 (CW-7875) with the CHANGELOG line from the review (CW-7992 REVIEW.md finding 1). Review: farm run 20261112T112213Z-leg01review-tip-faf9ea, 42 run, 42 passed, 7181 skipped; counterfactual run 20261112T112313Z-leg01review-cf-032c60 fails 2 of 3 with the guards removed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 18:02:27 -08:00
CodeWhale BotandClaude Sonnet 5.5 4e20c2ba06 test(engine): expire the dropped-dispatch deadline only after the client call is entered (CW-8127)
The dropped-dispatch test gave the nested call a real 100 ms deadline. On a
slow runner that deadline could expire before bind_dispatch settled the
unknown-usage record, so the biased wall-clock select won with zero records
(left: 0, right: 1). Give the call a 600 s deadline, wait until the fixture
client is entered (the record is settled before the client is called), then
pause tokio time and advance 601 s so the deadline always fires after dispatch.
The wait selects on entered against the dispatch with a panic if the dispatch
ends first, inside a 10 s real-time timeout, so a regression that stops the
dispatch before the client fails at once instead of hanging. Every assertion is
unchanged. Product code is unchanged.

nextest stress 40/40 passed, 6 tests per iteration, target test 40/40 (farm run
20261112T113538Z-cw8127r1-new-33edc8); the old test under the same stress failed
1 of 20 at left: 0 (farm run 20261112T104528Z-cw8127-old-7569e0).
clippy -p codewhale-engine --all-targets --all-features -D warnings clean (farm
run 20261112T113538Z-cw8127r1-clippy-373b1e); cargo fmt --check and
scripts/preflight.sh --check OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:02:19 -08:00
CodeWhale BotandClaude Sonnet 5.5 b87bd535c2 fix(app-server): plugin index names plugins by id, labels tools, signals cut name lists (CW-8049 PL-02 repair 1)
/v1/commands rows carry plugin_id (not the name) and workspace_sourced; the
index projects an active plugin's commands from those rows, labels native and
language-server plugins as tool, sources project commands as project, and
reports a cut or control-character name list under problems. Doc comment
removed.

Checks (Boat farm, Linux x86_64, at c3bc310042): nextest -p codewhale-app-server
-p codewhale-tui -E 'test(/plugin_index|command_catalog|get_v1_commands|user_registry|commands::discovery/)':
51 run, 51 passed, 6713 skipped (run 20261112T080110Z-plidx4-test-26f07e);
clippy -D warnings for engine, app-server and tui: clean
(run 20261112T080110Z-plidx4-clippy-124ba8). fmt, runtime-routes and facts
were the worker's local checks; preflight was not run on that tip.

Landing (push 18): rebased onto push 17; agent-docs.generated.json conflicted
with push 17's regeneration and was regenerated with derive-facts at each of
the two picks. docs/RUNTIME_ROUTES.md: check-runtime-routes PASS, no change.
CHANGELOG line from the round-2 review wording.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:02:11 -08:00
CodeWhale BotandClaude Sonnet 5.5 969059fb83 feat(app-server): one read route lists every plugin kind (CW-8049 PL-02)
GET /v1/apps/plugin-index projects the plugin registry, the built-in
switch catalog, skills, connectors, hooks and hand-written commands into
one list with kind, source and state. Each item embeds the row its owner's
route returns by calling that owner's handler, so a field an owner adds
appears here without a second edit; an owner that fails is named under
unavailable and the rest still list. A plugin item carries the names of the
skills, connectors, hooks, commands and profiles it adds, and those are
not listed again as items. Replaces nothing; the owners keep their routes.

A user command row now says which plugin added it (UserCommand.plugin,
CommandCatalogEntry.plugin, filled from the registry's plugin authority),
so a plugin's markdown command is not shown as hand-written. The wiring
in runtime_api.rs (mod line, route line, that field) and the
command_catalog and tui hunks are separable from plugin_index.rs.

Checks: farm nextest 6 run, 6 passed; farm clippy -D warnings clean for
engine, app-server, tui; preflight OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:02:03 -08:00
CodeWhale BotandClaude Fable 5.2 09797fa316 refactor(catalog): one model listing for both commands, and the inventory stops judging windows by name
With the readings proved equal on one captured catalog, the second copies
go:

- `route_preferences::list_models` no longer assembles its own roster
  (catalog rows, saved default, sort, dedup). It calls
  `provider_lake::listed_models_for_route`, the same function `models`
  prints.
- The routing inventory no longer asks the bundled catalog whether a model
  name has a context window to decide if a number is verified. The
  resolved route's own source decides; a route that does not resolve is
  unverified.

Farm (Linux x86_64), this commit: catalog_parity 1 test run: 1 passed;
config and engine lib tests filtered
`catalog model pricing cost_status route`: 1316 passed, 0 failed; clippy
-D warnings for config and engine (all targets, all features) and for cli
and app-server (all targets): clean.

Landing (push 18): CHANGELOG line for the pair (REF-05, CW-7971) from the
round-2 review, added in this commit.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 18:01:54 -08:00
CodeWhale BotandClaude Fable 5.2 242938a572 fix(catalog): both model commands, the Runtime API, routing and cost read one captured catalog the same way
The new check loads one captured Models.dev catalog (the bundled seed,
sha256 7c48830c…d256) and an id-only Anthropic roster into a sealed home,
then reads three routes through `model list` / `model resolve`, `models`,
`GET /v1/providers/{id}/models`, the routing inventory and the cost
envelope: the official Anthropic route, a custom endpoint serving a
same-named model, and the Z.ai plan endpoint. It prints every reading and
fails on any disagreement.

On the previous source it found three:

- `model list` skipped a custom endpoint unless its table was literally
  named `custom`. It now lists every configured custom route under the
  route's own name.
- `model resolve` refused a custom endpoint's saved model ("no registered
  model") because the legacy registry was asked about an endpoint that owns
  its own roster. The registry is no longer consulted for those routes; the
  route resolver and a fetched roster still decide.
- The routing inventory gave a custom endpoint the context window and
  output limit of a same-named model and marked the window verified. It
  now takes the window, its source and the output limit from the resolved
  route, so a borrowed number is marked unverified and an unpublished
  output limit stays unknown.

`provider_lake::listed_models_for_route` is the list body of `models`
moved out unchanged so the check can read it.

One spelling difference is left and reported, not asserted: the inventory
normalises a listed `glm-5.1` to `GLM-5.1`.

One disagreement is also named, not hidden: a custom endpoint has no
published window, so routing still reports 200,000 for a saved model whose
name matches a known one (the router's long-context tag reads it), marked
unverified, while the Runtime API reports unknown. The check records the
Runtime reading as unknown and allows exactly that pair as a known
difference; any other window disagreement fails.

The check also pins the charge side: a custom endpoint gets no charge
(`UnknownBillingBasis`); the Z.ai plan route gets no charge
(`NotMoneyMetered`) even for a model that has a list rate, and both
surfaces show that same list rate; an id-only first-party model with no
price row reads unknown on both surfaces and produces no charge.

Farm (Linux x86_64): config, engine and app-server lib tests filtered
`catalog_parity catalog model pricing cost_status route`: 1444 passed,
0 failed; clippy -D warnings for config, engine, cli and app-server (all
targets, all features): clean.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:01:39 -08:00
CodeWhale BotandClaude Sonnet 5.5 9e68dcc886 fix(acp): a plugin tool's nested approval reaches the editor as a permission request that names the nested tool (CW-8050)
When a plugin tool asks the core to run a tool that needs approval, the ACP
projection resolves the nested execution id to the admitted plugin call and
shows the request on it. The request is built from the approval.required
payload (nested tool name, input, summary) so title, kind and rawInput show
what is being approved, and it carries no status so the running plugin call is
not reset to pending. The gates test and journey test live in acp_journey and
assert the second request names write and carries nested.txt.

Docs (RUNTIME_API, EXTENSIONS, zh_hans) and web/data/agent-docs.generated.json
updated.

Checks: nextest -p codewhale-app-server -E 'test(acp)' on the build farm,
48 run, 48 passed, 815 skipped; cargo fmt --check clean; preflight --check OK.

Landing (push 18): rebased onto push 17. The zh_hans EXTENSIONS and
RUNTIME_API paragraphs conflicted with push 17's CW-8071 wording of the same
ACP paragraph; kept push 17's text and added this change's sentences where
the English copies have them. agent-docs.generated.json regenerated with
derive-facts. CHANGELOG line from the round-2 review.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:01:32 -08:00
115 changed files with 2106 additions and 4571 deletions

No files matched your search

+6
View File
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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).
- **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) (PR 7293).
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **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.
+50 -15
View File
@@ -47,6 +47,18 @@ impl Drop for TurnClaim {
}
}
}
fn admitted_call<'a>(
calls: &'a HashMap<String, PendingToolCall>,
execution: &str,
) -> Option<(&'a str, &'a PendingToolCall)> {
let mut id = execution;
loop {
if let Some((key, call)) = calls.get_key_value(id) {
return Some((key.as_str(), call));
}
id = id.rsplit_once('.')?.0;
}
}
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
}
@@ -419,27 +431,50 @@ impl PromptRun {
&& pending.turn_id == turn
&& pending.tool_call_id.as_deref() == Some(execution)
}) {
let call = calls
.get(execution)
let (shown, call) = admitted_call(calls, execution)
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
let id = uuid::Uuid::new_v4().to_string();
let title = tool_call_title(call);
let text = p
let nested = shown != execution;
let nested_call;
let shown_call = if nested {
nested_call = PendingToolCall {
execution_id: execution.to_string(),
name: p
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or(&call.name)
.to_string(),
input: p.get("input").cloned().unwrap_or(Value::Null),
};
&nested_call
} else {
call
};
let title = tool_call_title(shown_call);
let description = p
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.map_or_else(|| title.clone(), str::to_string);
.filter(|text| !text.trim().is_empty());
let summary = p
.get("summary")
.and_then(Value::as_str)
.filter(|text| nested && !text.trim().is_empty());
let text = match (summary, description) {
(Some(summary), Some(description)) => format!("{summary}\n{description}"),
(Some(text), None) | (None, Some(text)) => text.to_string(),
(None, None) => title.clone(),
};
let mut fields = acp::ToolCallUpdateFields::new()
.title(title)
.kind(tool_call_kind(shown_call))
.raw_input((!shown_call.input.is_null()).then(|| shown_call.input.clone()))
.content(vec![acp::ToolCallContent::from(text)]);
if !nested {
fields = fields.status(acp::ToolCallStatus::Pending);
}
let request = acp::RequestPermissionRequest::new(
session.to_string(),
acp::ToolCallUpdate::new(
execution.to_string(),
acp::ToolCallUpdateFields::new()
.title(title)
.kind(tool_call_kind(call))
.status(acp::ToolCallStatus::Pending)
.raw_input((!call.input.is_null()).then(|| call.input.clone()))
.content(vec![acp::ToolCallContent::from(text)]),
),
acp::ToolCallUpdate::new(shown.to_string(), fields),
vec![
acp::PermissionOption::new(
"allow-once",
-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,
+12
View File
@@ -129,6 +129,7 @@ mod members;
mod memory_lens;
mod mobile;
mod plans;
mod plugin_index;
mod plugins;
pub(crate) mod review;
mod secrets;
@@ -3372,6 +3373,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/skills/{name}/audit", get(audit_skill_api))
.route("/v1/apps/mcp/tools", get(list_mcp_tools))
.route("/v1/apps/plugins", get(plugins::list_plugins))
.route("/v1/apps/plugin-index", get(plugin_index::get_plugin_index))
.route("/v1/apps/avatars", get(avatars::list_avatars))
.route(
"/v1/apps/avatars/{handle}/atlases/{page}",
@@ -5875,6 +5877,10 @@ struct CommandCatalogEntry {
#[serde(skip_serializing_if = "Option::is_none")]
origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin_id: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
workspace_sourced: bool,
#[serde(skip_serializing_if = "Option::is_none")]
registration_id: Option<String>,
}
@@ -5933,6 +5939,8 @@ fn command_catalog(
shadowed_by,
shadowed_aliases,
origin: None,
plugin_id: None,
workspace_sourced: false,
registration_id: None,
});
}
@@ -5956,6 +5964,8 @@ fn command_catalog(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: None,
plugin_id: command.plugin_id.clone(),
workspace_sourced: command.workspace_sourced,
registration_id: None,
});
}
@@ -6038,6 +6048,8 @@ async fn list_commands(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: Some(reference.origin.clone()),
plugin_id: Some(reference.plugin_id.clone()),
workspace_sourced: false,
registration_id: query
.thread_id
.as_deref()
@@ -0,0 +1,489 @@
use std::collections::{BTreeSet, HashMap};
use std::path::{Path as FsPath, PathBuf};
use axum::Json;
use axum::extract::{Query, State};
use serde::Serialize;
use serde_json::Value;
use super::plugins::{self, PluginSummaryEntry};
use super::{ApiError, CommandsQuery, HooksQuery, RuntimeApiState};
const MAX_ADDED_NAMES: usize = 512;
#[derive(Debug, Serialize)]
pub(super) struct PluginIndexResponse {
workspace: String,
items: Vec<IndexItem>,
unavailable: Vec<IndexNote>,
problems: Vec<IndexNote>,
}
#[derive(Debug, Serialize)]
struct IndexNote {
kind: &'static str,
message: String,
}
#[derive(Debug, Serialize)]
struct IndexItem {
id: String,
kind: &'static str,
name: String,
description: Option<String>,
source: &'static str,
state: &'static str,
labels: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
adds: Option<PluginAdds>,
#[serde(skip_serializing_if = "Option::is_none")]
tier: Option<&'static str>,
owner: &'static str,
row: Value,
}
#[derive(Debug, Default, Serialize)]
struct PluginAdds {
skills: Vec<String>,
connectors: Vec<String>,
hooks: Vec<String>,
commands: Vec<String>,
profiles: Vec<String>,
}
impl PluginAdds {
fn labels(&self, entry: &PluginSummaryEntry) -> Vec<&'static str> {
[
("skill", !self.skills.is_empty()),
("connector", !self.connectors.is_empty()),
("hook", !self.hooks.is_empty()),
("command", !self.commands.is_empty()),
(
"tool",
entry.inventory.native > 0 || entry.inventory.lsp > 0,
),
("profile", !self.profiles.is_empty()),
]
.into_iter()
.filter_map(|(label, present)| present.then_some(label))
.collect()
}
}
fn row_value<T: Serialize>(row: &T) -> Value {
serde_json::to_value(row).unwrap_or(Value::Null)
}
fn note_failure(unavailable: &mut Vec<IndexNote>, kind: &'static str, error: &ApiError) {
unavailable.push(IndexNote {
kind,
message: error.message.clone(),
});
}
fn plugin_source(origin: &str) -> &'static str {
match origin {
"codewhale-builtin" => "built_in",
"workspace-codewhale" => "project",
_ => "user",
}
}
fn plugin_state(entry: &PluginSummaryEntry) -> &'static str {
if matches!(
entry.trust_status,
"content-changed" | "capabilities-changed"
) || entry.state == "enabled-untrusted"
{
return "needs_review";
}
match entry.state {
"active" => "enabled",
"disabled" => "disabled",
_ => "unavailable",
}
}
fn add_stem(names: &mut BTreeSet<String>, path: &FsPath, extension: Option<&str>, lowercase: bool) {
if let Some(wanted) = extension
&& path.extension().and_then(|value| value.to_str()) != Some(wanted)
{
return;
}
if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) {
names.insert(if lowercase {
stem.to_lowercase()
} else {
stem.to_string()
});
}
}
async fn component_names(
paths: &[PathBuf],
extension: Option<&str>,
lowercase: bool,
omitted: &mut bool,
) -> Vec<String> {
let mut names = BTreeSet::new();
for path in paths {
let Ok(meta) = tokio::fs::symlink_metadata(path).await else {
continue;
};
if meta.is_file() {
add_stem(&mut names, path, extension, lowercase);
} else if meta.is_dir()
&& let Ok(mut entries) = tokio::fs::read_dir(path).await
{
let mut seen = 0;
while let Ok(Some(entry)) = entries.next_entry().await {
seen += 1;
if seen > MAX_ADDED_NAMES {
*omitted = true;
break;
}
if entry.file_type().await.is_ok_and(|kind| kind.is_file()) {
add_stem(&mut names, &entry.path(), extension, lowercase);
}
}
}
if names.len() >= MAX_ADDED_NAMES {
*omitted = true;
break;
}
}
if names.iter().any(|name| name.chars().any(char::is_control)) {
*omitted = true;
names.retain(|name| !name.chars().any(char::is_control));
}
names.into_iter().take(MAX_ADDED_NAMES).collect()
}
async fn plugin_adds(
plugin: &codewhale_plugins::types::LoadedPlugin,
omitted: &mut bool,
) -> PluginAdds {
let mut skills: Vec<String> = plugin
.skill_snapshots
.iter()
.map(|skill| skill.name.clone())
.collect();
skills.sort();
let mut connectors: Vec<String> = plugin
.manifest
.mcp_servers
.as_ref()
.map(|servers| servers.keys().cloned().collect())
.unwrap_or_default();
connectors.sort();
PluginAdds {
skills,
connectors,
hooks: component_names(&plugin.components.hooks, None, false, omitted).await,
commands: component_names(&plugin.components.commands, Some("md"), true, omitted).await,
profiles: component_names(&plugin.components.agents, Some("toml"), false, omitted).await,
}
}
async fn plugin_adds_by_id(
state: &RuntimeApiState,
omitted: &mut BTreeSet<String>,
) -> HashMap<String, PluginAdds> {
let registry = state
.plugin_discovery
.registry_for_workspace(&state.workspace);
let mut adds = HashMap::new();
for plugin in registry.list() {
let mut cut = false;
adds.insert(
plugin.id.as_str().to_string(),
plugin_adds(plugin, &mut cut).await,
);
if cut {
omitted.insert(plugin.name().to_string());
}
}
adds
}
fn skill_source(
path: Option<&PathBuf>,
is_bundled: bool,
skills_dir: &FsPath,
workspace: &FsPath,
) -> &'static str {
if is_bundled {
return "built_in";
}
match path {
Some(path) if path.starts_with(skills_dir) => "user",
Some(path) if path.starts_with(workspace) => "project",
Some(_) => "other_app",
None => "plugin",
}
}
pub(super) async fn get_plugin_index(
State(state): State<RuntimeApiState>,
) -> Result<Json<PluginIndexResponse>, ApiError> {
let mut items = Vec::new();
let mut unavailable = Vec::new();
let mut problems = Vec::new();
let commands = super::list_commands(
State(state.clone()),
Query(CommandsQuery { thread_id: None }),
)
.await;
let mut plugin_commands: HashMap<String, BTreeSet<String>> = HashMap::new();
if let Ok(Json(response)) = &commands {
for command in &response.commands {
if let (Some(plugin_id), "user") = (command.plugin_id.as_ref(), command.kind) {
plugin_commands
.entry(plugin_id.clone())
.or_default()
.insert(command.name.clone());
}
}
}
match plugins::list_plugins(State(state.clone())).await {
Ok(Json(response)) => {
let mut omitted = BTreeSet::new();
let mut adds_by_id = plugin_adds_by_id(&state, &mut omitted).await;
for name in &omitted {
problems.push(IndexNote {
kind: "plugin",
message: format!(
"plugin '{name}' adds more files than are listed, or a file name with control characters; some names are left out of `adds`"
),
});
}
for diagnostic in &response.registry_diagnostics {
problems.push(IndexNote {
kind: "plugin",
message: diagnostic.message.clone(),
});
}
for entry in &response.plugins {
let mut adds = adds_by_id.remove(&entry.id);
if entry.state == "active"
&& commands.is_ok()
&& let Some(adds) = adds.as_mut()
{
adds.commands = plugin_commands
.remove(&entry.id)
.map(|names| names.into_iter().collect())
.unwrap_or_default();
}
items.push(IndexItem {
id: format!("plugin:{}", entry.id),
kind: "plugin",
name: entry
.display_name
.clone()
.unwrap_or_else(|| entry.name.clone()),
description: entry.description.clone(),
source: plugin_source(entry.origin),
state: plugin_state(entry),
labels: adds
.as_ref()
.map(|adds| adds.labels(entry))
.unwrap_or_default(),
plugin: None,
adds,
tier: None,
owner: "/v1/apps/plugins",
row: row_value(entry),
});
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match plugins::list_plugin_catalog(State(state.clone())).await {
Ok(Json(catalog)) => {
for (tier, rows) in [
("builtin", &catalog.builtin),
("experimental", &catalog.experimental),
("more", &catalog.more),
] {
for row in rows {
items.push(IndexItem {
id: format!("plugin:catalog:{}", row.id),
kind: "plugin",
name: row.title.clone(),
description: row.description.clone(),
source: "built_in",
state: if !row.available {
"unavailable"
} else if row.enabled {
"enabled"
} else {
"disabled"
},
labels: Vec::new(),
plugin: None,
adds: None,
tier: Some(tier),
owner: "/v1/apps/plugins/catalog",
row: row_value(row),
});
}
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match super::list_skills(State(state.clone())).await {
Ok(Json(response)) => {
for warning in &response.warnings {
problems.push(IndexNote {
kind: "skill",
message: warning.clone(),
});
}
for skill in &response.skills {
if skill.plugin_id.is_some() {
continue;
}
items.push(IndexItem {
id: format!("skill:{}", skill.name),
kind: "skill",
name: skill.name.clone(),
description: Some(skill.description.clone()),
source: skill_source(
skill.path.as_ref(),
skill.is_bundled,
&response.directory,
&state.workspace,
),
state: if skill.enabled { "enabled" } else { "disabled" },
labels: vec!["skill"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/skills",
row: row_value(skill),
});
}
}
Err(error) => note_failure(&mut unavailable, "skill", &error),
}
match super::list_mcp_servers(State(state.clone())).await {
Ok(Json(response)) => {
for server in &response.servers {
let source = match server.origin {
"plugin" => continue,
"project" => "project",
"runtime" => "runtime",
_ => "user",
};
items.push(IndexItem {
id: format!("connector:{}", server.name),
kind: "connector",
name: server.name.clone(),
description: None,
source,
state: if !server.enabled {
"disabled"
} else if server.auth_required {
"needs_sign_in"
} else {
"enabled"
},
labels: vec!["connector"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/apps/mcp/servers",
row: row_value(server),
});
}
}
Err(error) => note_failure(&mut unavailable, "connector", &error),
}
match super::list_hooks(State(state.clone()), Query(HooksQuery { thread_id: None })).await {
Ok(Json(response)) => {
for problem in &response.problems {
problems.push(IndexNote {
kind: "hook",
message: problem.clone(),
});
}
let mut ordinals: HashMap<&'static str, usize> = HashMap::new();
for hook in &response.hooks {
let ordinal = ordinals.entry(hook.event).or_default();
*ordinal += 1;
let source = match hook.source {
"plugin" => continue,
"project" => "project",
_ => "user",
};
let name = hook
.name
.clone()
.unwrap_or_else(|| format!("{} {}", hook.event, ordinal));
items.push(IndexItem {
id: format!("hook:{source}:{}:{name}", hook.event),
kind: "hook",
name,
description: None,
source,
state: if response.enabled {
"enabled"
} else {
"disabled"
},
labels: vec!["hook"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/hooks",
row: row_value(hook),
});
}
}
Err(error) => note_failure(&mut unavailable, "hook", &error),
}
match commands {
Ok(Json(response)) => {
for command in &response.commands {
let (source, plugin) = match (command.kind, command.plugin_id.as_ref()) {
("builtin", _) => continue,
("user", Some(_)) => continue,
("extension", plugin_id) => ("plugin", plugin_id.cloned()),
_ if command.workspace_sourced => ("project", None),
_ => ("user", None),
};
items.push(IndexItem {
id: format!("command:{}", command.name),
kind: "command",
name: command.name.clone(),
description: command.summary.clone(),
source,
state: "enabled",
labels: vec!["command"],
plugin,
adds: None,
tier: None,
owner: "/v1/commands",
row: row_value(command),
});
}
}
Err(error) => note_failure(&mut unavailable, "command", &error),
}
Ok(Json(PluginIndexResponse {
workspace: state.workspace.display().to_string(),
items,
unavailable,
problems,
}))
}
+2 -1
View File
@@ -15,6 +15,7 @@ use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::sleep;
use uuid::Uuid;
mod catalog_parity;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
@@ -28,6 +29,7 @@ mod headless_catalog;
mod member_operations;
#[cfg(unix)]
mod owner_bootstrap;
mod plugin_index;
mod plugin_ui;
mod profile_constitution;
#[cfg(any(unix, windows))]
@@ -7649,7 +7651,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);
@@ -0,0 +1,600 @@
use super::*;
use codewhale_config::catalog::{
CatalogOffering, CatalogSource, CatalogStatus, ProviderCatalogDelta, base_url_fingerprint,
now_unix,
};
use codewhale_engine::cost_status::EffectiveRouteEnvelope;
use codewhale_engine::model_inventory::ModelInventory;
use codewhale_runtime::config::ProviderIdentity;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
const SINGULAR: &str = "model list / model resolve";
const PLURAL: &str = "models";
const RUNTIME: &str = "runtime api";
const ROUTING: &str = "routing inventory";
const COST: &str = "cost";
const UNKNOWN: &str = "unknown";
const ROSTER: &[&str] = &["claude-opus-5", "claude-haiku-4-5", "claude-parity-probe-1"];
type Readings = BTreeMap<(String, String, &'static str), BTreeMap<&'static str, String>>;
struct Route {
label: &'static str,
api_path: &'static str,
identity: ProviderIdentity,
base_url: String,
models: &'static [&'static str],
}
fn cold_process() {
codewhale_engine::provider_catalog_live::reset_cache_for_test();
codewhale_engine::provider_lake::clear_live_snapshot();
}
fn known<T: ToString>(value: Option<T>) -> String {
value.map_or_else(|| UNKNOWN.to_string(), |value| value.to_string())
}
fn rate(value: &Value) -> String {
let number = match value {
Value::Number(number) => number.as_f64(),
Value::String(text) => text.parse::<f64>().ok(),
_ => None,
};
known(number)
}
fn roster(mut models: Vec<String>) -> String {
models.sort();
models.dedup();
models.join(",")
}
fn read(
readings: &mut Readings,
route: &Route,
model: &str,
field: &'static str,
surface: &'static str,
value: String,
) {
readings
.entry((route.label.to_string(), model.to_string(), field))
.or_default()
.insert(surface, value);
}
fn persist_id_only_roster(identity: &ProviderIdentity, base_url: &str) {
let fingerprint = base_url_fingerprint(base_url);
let fetched_at = now_unix();
let ticket = codewhale_engine::provider_catalog_live::begin_refresh_for_identity(
identity.provider,
identity.key.as_str(),
base_url,
);
assert_eq!(
codewhale_engine::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: identity.key.to_string(),
base_url_fingerprint: fingerprint.clone(),
fetched_at,
offerings: ROSTER
.iter()
.map(|id| CatalogOffering {
provider: identity.key.to_string(),
wire_model_id: (*id).to_string(),
endpoint_key: "chat".into(),
source: CatalogSource::Live {
base_url_fingerprint: fingerprint.clone(),
fetched_at,
},
..Default::default()
})
.collect(),
},
),
Some(CatalogStatus::Fresh)
);
}
fn read_singular(readings: &mut Readings, config_path: &Path, route: &Route) -> Result<()> {
cold_process();
let store = codewhale_config::ConfigStore::load(Some(config_path.to_path_buf()))?;
let listed =
codewhale_engine::route_preferences::list_models(&store, Some(route.identity.provider))?
.into_iter()
.find(|(listed, _)| listed == route.identity.key.as_str())
.map(|(_, models)| roster(models));
read(
readings,
route,
"*",
"roster",
SINGULAR,
listed.unwrap_or_else(|| "route not listed".into()),
);
for model in route.models {
let limits = codewhale_engine::route_preferences::resolve_model(
config_path,
route.identity.key.as_str(),
&route.base_url,
Some(model),
)
.map(|candidate| candidate.limits());
let (context, output) = match limits {
Ok(limits) => (known(limits.context_tokens), known(limits.output_tokens)),
Err(error) => (format!("refused: {error}"), format!("refused: {error}")),
};
read(readings, route, model, "limit.context", SINGULAR, context);
read(readings, route, model, "limit.output", SINGULAR, output);
}
Ok(())
}
fn read_plural(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
cold_process();
codewhale_engine::models_dev_live::maybe_load_persisted_cache();
let mut route_config = config.clone();
route_config
.scope_to_provider_identity(&route.identity)
.map_err(anyhow::Error::msg)?;
read(
readings,
route,
"*",
"roster",
PLURAL,
roster(codewhale_engine::provider_lake::listed_models_for_route(
&route_config,
&route.identity,
)),
);
Ok(())
}
async fn read_runtime(
readings: &mut Readings,
client: &reqwest::Client,
addr: &SocketAddr,
route: &Route,
) -> Result<()> {
let body: Value = client
.get(format!("http://{addr}/v1/providers/{}", route.api_path))
.send()
.await?
.error_for_status()?
.json()
.await?;
let models = body["models"].as_array().context("models array")?;
read(
readings,
route,
"*",
"roster",
RUNTIME,
roster(
models
.iter()
.filter_map(|model| model["id"].as_str().map(str::to_string))
.collect(),
),
);
for model in route.models {
let facts = models
.iter()
.find(|entry| entry["id"] == *model)
.map_or(&Value::Null, |entry| &entry["facts"]);
read(
readings,
route,
model,
"limit.context",
RUNTIME,
known(facts["effective_limits"]["context_tokens"].as_u64()),
);
read(
readings,
route,
model,
"limit.output",
RUNTIME,
known(facts["effective_limits"]["output_tokens"].as_u64()),
);
read(
readings,
route,
model,
"window",
RUNTIME,
known(facts["effective_context"]["tokens"].as_u64()),
);
read(
readings,
route,
model,
"window.source",
RUNTIME,
known(facts["effective_context"]["source"].as_str()),
);
read(
readings,
route,
model,
"price.input",
RUNTIME,
rate(&facts["pricing"]["input_per_million"]),
);
read(
readings,
route,
model,
"price.output",
RUNTIME,
rate(&facts["pricing"]["output_per_million"]),
);
read(
readings,
route,
model,
"price.basis",
RUNTIME,
format!(
"provenance={} status={} source={}",
facts["pricing"]["provenance"], facts["pricing_status"], facts["source"]
),
);
}
Ok(())
}
fn read_routing(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
let inventory = ModelInventory::from_config(config).map_err(anyhow::Error::msg)?;
let candidates: Vec<_> = inventory
.candidates
.iter()
.filter(|candidate| candidate.identity == route.identity)
.collect();
read(
readings,
route,
"*",
"roster",
ROUTING,
roster(
candidates
.iter()
.map(|candidate| candidate.model.clone())
.collect(),
),
);
for model in route.models {
let candidate = candidates
.iter()
.find(|candidate| candidate.model == *model);
read(
readings,
route,
model,
"window",
ROUTING,
known(candidate.map(|candidate| candidate.context_window)),
);
read(
readings,
route,
model,
"window.source",
ROUTING,
known(candidate.map(|candidate| {
if candidate.context_window_unverified {
"unverified"
} else {
"verified"
}
})),
);
read(
readings,
route,
model,
"limit.output",
ROUTING,
known(candidate.and_then(|candidate| candidate.max_output)),
);
}
Ok(())
}
fn read_cost(readings: &mut Readings, config: &Config, route: &Route) {
let usage = Usage {
input_tokens: 100_000,
output_tokens: 10_000,
..Default::default()
};
for model in route.models {
let envelope = EffectiveRouteEnvelope::from_admitted(
Some(config),
&route.identity,
*model,
Some(&route.base_url),
chrono::Utc::now(),
);
let quote = envelope.provider_live_pricing.as_ref();
let audit = envelope.audit(&usage);
read(
readings,
route,
model,
"price.input",
COST,
quote
.and_then(|quote| quote.input_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.output",
COST,
quote
.and_then(|quote| quote.output_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.basis",
COST,
format!(
"provenance={:?} billing={:?} charge_usd={} unpriced={:?}",
quote.map(|quote| quote.provenance.clone()),
envelope.billing_mode,
known(audit.exact_usd.as_deref()),
audit.unpriced_reason,
),
);
}
}
#[tokio::test]
async fn one_captured_catalog_reads_the_same_on_every_model_surface() -> Result<()> {
let _env = lock_test_env();
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
let root = tempfile::tempdir()?;
let captured = codewhale_config::catalog::BUNDLED_MODELS_DEV_JSON;
let catalog_path = root.path().join("captured-models-dev.json");
fs::write(&catalog_path, captured)?;
let _catalog = EnvVarGuard::set("CODEWHALE_MODELS_DEV_PATH", &catalog_path);
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _cli = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
cold_process();
let config_path = root.path().join("config.toml");
fs::write(
&config_path,
r#"provider = "anthropic"
[providers.anthropic]
api_key = "parity-fixture-key"
model = "claude-opus-5"
[providers.zai]
api_key = "parity-fixture-key"
model = "GLM-5.3"
[providers.lab]
kind = "openai-compatible"
base_url = "http://127.0.0.1:9/v1"
api_key = "parity-fixture-key"
model = "claude-haiku-4-5"
"#,
)?;
let config = Config::load(Some(config_path.clone()), None)?;
let route = |label: &'static str,
api_path: &'static str,
key: &str,
models: &'static [&'static str]|
-> Result<Route> {
let identity = config
.resolve_provider_pin_identity(key)
.map_err(anyhow::Error::msg)?;
Ok(Route {
label,
api_path,
base_url: config.base_url_for_route(&identity),
identity,
models,
})
};
let routes = [
route(
"anthropic (official, id-only roster)",
"anthropic/models",
"anthropic",
ROSTER,
)?,
route(
"lab (custom endpoint, same spelling)",
"custom/models?model_provider_id=lab",
"lab",
&["claude-haiku-4-5"],
)?,
route(
"zai (plan endpoint)",
"zai/models",
"zai",
&["GLM-5.3", "GLM-5.2"],
)?,
];
assert!(
codewhale_engine::models_dev_live::refresh(false)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?
> 0
);
persist_id_only_roster(&routes[0].identity, &routes[0].base_url);
let mut readings = Readings::new();
for route in &routes {
read_singular(&mut readings, &config_path, route)?;
read_plural(&mut readings, &config, route)?;
}
cold_process();
let (addr, _, handle) = spawn_test_server_with_config_path(config_path.clone())
.await?
.expect("loopback server required");
let client = codewhale_release::tls::reqwest_client();
for route in &routes {
read_runtime(&mut readings, &client, &addr, route).await?;
read_routing(&mut readings, &config, route)?;
read_cost(&mut readings, &config, route);
}
handle.abort();
cold_process();
println!(
"PARITY captured_catalog sha256={} bytes={}",
Sha256::digest(captured.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
captured.len()
);
let mut differing = Vec::new();
let mut known_differences: Vec<String> = Vec::new();
for ((route, model, field), surfaces) in &readings {
let mut values: Vec<String> = surfaces
.values()
.map(|value| {
if *field == "roster" {
roster(
value
.to_ascii_lowercase()
.split(',')
.map(str::to_string)
.collect(),
)
} else {
value.clone()
}
})
.collect();
values.sort();
values.dedup();
let compared = !matches!(*field, "price.basis" | "window.source");
let borrowed_window = *field == "window"
&& surfaces.get(RUNTIME).map(String::as_str) == Some(UNKNOWN)
&& readings
.get(&(route.clone(), model.clone(), "window.source"))
.and_then(|sources| sources.get(ROUTING))
.map(String::as_str)
== Some("unverified");
let verdict = if !compared {
"info"
} else if borrowed_window {
known_differences.push(format!("{route} | {model} | {surfaces:?}"));
"KNOWN-DIFFERENCE"
} else if values.len() == 1 {
"agree"
} else {
differing.push(format!("{route} | {model} | {field} | {surfaces:?}"));
"DIFFER"
};
println!("PARITY {verdict} | {route} | {model} | {field} | {surfaces:?}");
}
let value = |route: &Route, model: &str, field: &'static str, surface: &'static str| {
readings
.get(&(route.label.to_string(), model.to_string(), field))
.and_then(|surfaces| surfaces.get(surface))
.cloned()
.unwrap_or_default()
};
assert_eq!(
value(&routes[0], "*", "roster", SINGULAR),
"claude-haiku-4-5,claude-opus-5,claude-parity-probe-1",
"the singular command lists the route's own roster, not every bundled row"
);
assert_eq!(
value(&routes[0], "claude-opus-5", "limit.context", RUNTIME),
"1000000"
);
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "limit.context", RUNTIME),
"200000"
);
assert_eq!(value(&routes[0], "claude-opus-5", "price.input", COST), "5");
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "price.output", COST),
"5"
);
for field in ["limit.context", "limit.output", "price.input"] {
assert_eq!(
value(&routes[1], "claude-haiku-4-5", field, RUNTIME),
UNKNOWN,
"a custom endpoint borrows no fact from a same-named model: {field}"
);
}
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "window.source", ROUTING),
"unverified",
"routing never presents a same-named model's window as this endpoint's"
);
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "limit.output", ROUTING),
UNKNOWN
);
for model in ["GLM-5.3", "GLM-5.2"] {
let basis = value(&routes[2], model, "price.basis", COST);
assert!(
basis.contains("NotMoneyMetered") && basis.contains("charge_usd=unknown"),
"a plan route is never billed per token: {model}: {basis}"
);
}
for (model, rate) in [("GLM-5.3", UNKNOWN), ("GLM-5.2", "1.4")] {
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[2], model, "price.input", surface),
rate,
"the list rate is shown the same everywhere and never turned into a charge: {model} {surface}"
);
}
}
let basis = value(&routes[1], "claude-haiku-4-5", "price.basis", COST);
assert!(
basis.contains("UnknownBillingBasis") && basis.contains("charge_usd=unknown"),
"a custom endpoint is never given a charge: {basis}"
);
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[0], "claude-parity-probe-1", "price.input", surface),
UNKNOWN,
"a metered first-party model with no price row reads unknown: {surface}"
);
}
let basis = value(&routes[0], "claude-parity-probe-1", "price.basis", COST);
assert!(
basis.contains("charge_usd=unknown") && !basis.contains("unpriced=None"),
"an unpriced model produces no charge and says why: {basis}"
);
assert_eq!(
known_differences,
vec![
"lab (custom endpoint, same spelling) | claude-haiku-4-5 | {\"routing inventory\": \"200000\", \"runtime api\": \"unknown\"}"
],
"the only named difference is the custom endpoint's borrowed routing window"
);
assert!(
differing.is_empty(),
"surfaces disagree on the same captured catalog:\n{}",
differing.join("\n")
);
Ok(())
}
@@ -28,6 +28,8 @@ fn user(name: &str) -> UserCommand {
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: None,
workspace_sourced: false,
builtin_bindings: vec![name.to_string()],
}
}
@@ -0,0 +1,139 @@
use super::*;
use codewhale_engine::command_catalog::{BuiltinCommand, CommandCatalog, UserCommand};
struct Catalog(Arc<std::sync::Mutex<Option<String>>>);
impl CommandCatalog for Catalog {
fn answers_to(&self, _name: &str) -> bool {
false
}
fn builtins(&self) -> Vec<BuiltinCommand> {
Vec::new()
}
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
let plugin_id = self.0.lock().expect("plugin id").clone();
let command = |name: &str, plugin: Option<String>| UserCommand {
name: name.to_string(),
aliases: Vec::new(),
description: Some("A command.".to_string()),
usage: None,
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: plugin,
workspace_sourced: false,
builtin_bindings: Vec::new(),
};
vec![command("mine", None), command("ship", plugin_id)]
}
}
#[tokio::test]
async fn the_index_lists_every_kind_once_and_a_plugin_carries_what_it_adds() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("index-route");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let plugin_id = Arc::new(std::sync::Mutex::new(None));
let _catalog = codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(Catalog(
plugin_id.clone(),
)));
let sources = root.join("sources");
fs::create_dir_all(&sources)?;
let bundle = write_plugin_source_bundle(&sources, "indexed")?;
fs::create_dir_all(bundle.join("commands"))?;
fs::write(bundle.join("commands/ship.md"), "Ship it.\n")?;
fs::create_dir_all(bundle.join("agents"))?;
fs::write(bundle.join("agents/reviewer.toml"), "name = \"reviewer\"\n")?;
fs::write(
bundle.join("plugin.toml"),
"schema_version = 1\n[plugin]\nname = \"indexed\"\nversion = \"1.0.0\"\n\
description = \"test bundle\"\n\n[skills]\npath = \"skills\"\n\
[commands]\npath = \"commands\"\n[agents]\npath = \"agents\"\n",
)?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
plugin_ui::install_active(
&client,
&format!("http://{addr}/v1/apps/plugins"),
bundle.display().to_string(),
)
.await?;
let listed: Value = client
.get(format!("http://{addr}/v1/apps/plugins"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let id = listed["plugins"]
.as_array()
.and_then(|plugins| plugins.iter().find(|plugin| plugin["name"] == "indexed"))
.and_then(|plugin| plugin["id"].as_str())
.expect("installed plugin id")
.to_string();
*plugin_id.lock().expect("plugin id") = Some(id.clone());
let commands: Value = client
.get(format!("http://{addr}/v1/commands"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let ship = commands["commands"]
.as_array()
.and_then(|rows| rows.iter().find(|row| row["name"] == "ship"))
.expect("ship row");
assert_eq!(ship["plugin_id"], id.as_str());
let index: Value = client
.get(format!("http://{addr}/v1/apps/plugin-index"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let items = index["items"].as_array().expect("items");
let find = |id: &str| items.iter().find(|item| item["id"] == id);
let plugin = items
.iter()
.find(|item| item["kind"] == "plugin" && item["row"]["name"] == "indexed")
.unwrap_or_else(|| panic!("plugin row in {index}"));
assert_eq!(plugin["kind"], "plugin");
assert_eq!(plugin["state"], "enabled");
assert_eq!(plugin["adds"]["skills"], serde_json::json!(["greet"]));
assert_eq!(plugin["adds"]["commands"], serde_json::json!(["ship"]));
assert_eq!(plugin["adds"]["profiles"], serde_json::json!(["reviewer"]));
assert_eq!(
plugin["labels"],
serde_json::json!(["skill", "command", "profile"])
);
assert_eq!(plugin["row"]["name"], "indexed");
assert!(find("skill:greet").is_none());
assert!(find("command:ship").is_none());
let mine = find("command:mine").expect("a hand-written command is a row");
assert_eq!(mine["source"], "user");
assert_eq!(mine["labels"], serde_json::json!(["command"]));
assert!(
items
.iter()
.any(|item| item["kind"] == "plugin" && item["tier"] == "builtin"),
"built-in switches are rows"
);
assert_eq!(
index["unavailable"].as_array().map(Vec::len),
Some(0),
"{index}"
);
handle.abort();
Ok(())
}
@@ -79,7 +79,7 @@ fn write_native_ui_bundle(dir: &Path, name: &str) -> Result<PathBuf> {
Ok(bundle)
}
async fn install_active(
pub(super) async fn install_active(
client: &reqwest::Client,
base: &str,
source: String,
+345 -2
View File
@@ -3,13 +3,13 @@ mod acp_support;
use acp_support::{Client, fixture_config, text_of, updates};
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
use codewhale_engine::llm_client::mock::{CannedTurn, MockLlmClient, canned};
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use serde_json::{Value, json};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
@@ -497,3 +497,346 @@ async fn extension_journey() -> Result<()> {
host.shutdown().await;
Ok(())
}
const GATES_MCP_FIXTURE: &str = r#"import readline from 'node:readline';
readline.createInterface({input:process.stdin}).on('line', line => {
const r = JSON.parse(line); if (r.id === undefined) return;
let result = {};
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
if (r.method === 'tools/list') result = {tools:['echo','hidden'].map(name => ({name,description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}))};
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-ran:' + r.params.name + ':' + r.params.arguments.text}]};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
});
"#;
struct Outcome {
response: Value,
frames: Vec<Value>,
asked: Vec<Value>,
}
impl Outcome {
fn rendered(&self) -> String {
serde_json::to_string(&self.frames).unwrap_or_default()
}
fn completed(&self) -> usize {
updates(&self.frames, "tool_call_update")
.iter()
.filter(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
.count()
}
fn titles(&self) -> Vec<String> {
self.asked
.iter()
.map(|frame| {
frame["params"]["toolCall"]["title"]
.as_str()
.unwrap_or_default()
.to_string()
})
.collect()
}
fn announced_ids(&self) -> Vec<String> {
updates(&self.frames, "tool_call")
.iter()
.filter_map(|frame| frame.pointer("/params/update/toolCallId")?.as_str())
.map(str::to_string)
.collect()
}
}
async fn run_prompt(
client: &mut Client,
session: &str,
allow: &dyn Fn(usize) -> bool,
) -> Result<Outcome> {
client.next_id += 1;
let prompt_id = client.next_id;
client
.send(
json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":[{"type":"text","text":"go"}]}}),
)
.await?;
let mut frames = Vec::new();
let mut asked = Vec::new();
let response = loop {
let frame = client.recv().await?;
if frame["method"] == "session/request_permission" {
let option = if allow(asked.len()) {
"allow-once"
} else {
"reject-once"
};
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],
"result":{"outcome":{"outcome":"selected","optionId":option}}}))
.await?;
asked.push(frame);
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
break frame;
} else {
frames.push(frame);
}
};
Ok(Outcome {
response,
frames,
asked,
})
}
async fn new_session(
client: &mut Client,
manager: &RuntimeThreadManager,
workspace: &Path,
turns: Vec<CannedTurn>,
plan: bool,
) -> Result<String> {
manager.set_test_model_client(Arc::new(MockLlmClient::new(turns)));
let (created, _) = client
.request("session/new", json!({"cwd":workspace}))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId: {created}"))?
.to_string();
if plan {
let (set, _) = client
.request(
"session/set_mode",
json!({"sessionId":session,"modeId":"plan"}),
)
.await?;
assert!(set.get("error").is_none(), "{set}");
}
Ok(session)
}
#[test]
fn extension_tools_keep_the_permission_boundary_in_an_acp_session() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(180), gates())
.await
.map_err(|_| anyhow!("extension gates exceeded 180s"))?
})
})?
.join()
.map_err(|_| anyhow!("extension gates thread panicked"))?
}
async fn gates() -> Result<()> {
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
let Some(node) = node_for_tests("extension_tools_keep_the_permission_boundary") else {
return Ok(());
};
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["plugin-context", "core-call"]).await;
let host = fixture.manager(node.clone());
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = fixture.workspace().to_path_buf();
std::fs::write(workspace.join("seed.txt"), "seed-content-xyz")?;
let server = dir.path().join("fixture-mcp.mjs");
std::fs::write(&server, GATES_MCP_FIXTURE)?;
let mcp_config = dir.path().join("mcp.json");
std::fs::write(
&mcp_config,
serde_json::to_vec(&json!({"servers":{
"fixture":{"command":node,"args":[server],"disabled_tools":["hidden"]},
"off":{"command":node,"args":[server],"disabled":true}
}}))?,
)?;
let runtime_dir = dir.path().join("runtime");
let mut config = fixture_config();
config.set_feature("mcp", true).unwrap();
config.set_feature("extension_host", true).unwrap();
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 16,
},
fixture.registry(),
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
codewhale_engine::session_manager::default_sessions_dir()?,
None,
None,
)?;
let (mut client, agent_read, agent_write) = Client::connect(false);
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let manager_for_script = Arc::clone(&manager);
let script =
async {
let manager = manager_for_script;
let result = async {
client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n1",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested allowed"),
],
false,
)
.await?;
let allowed = run_prompt(&mut client, &session, &|_| true).await?;
assert!(allowed.response.get("error").is_none(), "{}", allowed.response);
assert_eq!(allowed.response["result"]["stopReason"], "end_turn");
assert_eq!(allowed.asked.len(), 2, "{:?}", allowed.titles());
assert!(allowed.titles()[0].starts_with("cc_call"));
let announced = allowed.announced_ids();
for request in &allowed.asked {
let id = request["params"]["toolCall"]["toolCallId"]
.as_str()
.unwrap_or_default();
assert!(
announced.iter().any(|known| known == id),
"a nested approval is shown on a tool call the editor knows: {id} {announced:?}"
);
}
let nested = &allowed.asked[1]["params"]["toolCall"];
assert_eq!(nested["title"], "write: nested.txt", "{nested}");
assert_eq!(nested["kind"], "edit", "{nested}");
assert_eq!(nested["rawInput"]["path"], "nested.txt", "{nested}");
assert!(nested.get("status").is_none(), "{nested}");
assert_eq!(
std::fs::read_to_string(workspace.join("nested.txt"))?,
"via-plugin"
);
std::fs::remove_file(workspace.join("nested.txt"))?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n2",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested denied"),
],
false,
)
.await?;
let denied = run_prompt(&mut client, &session, &|index| index == 0).await?;
assert!(denied.response.get("error").is_none(), "{}", denied.response);
assert_eq!(denied.response["result"]["stopReason"], "end_turn");
assert_eq!(denied.asked.len(), 2, "{:?}", denied.titles());
assert!(
!workspace.join("nested.txt").exists(),
"a rejected nested write must not run"
);
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("r1", "ctx_probe", "{}"),
canned::tool_call_turn("r2", "mcp_fixture_echo", r#"{"text":"control"}"#),
canned::tool_call_turn("r3", "ctx_note", r#"{"text":"must-not-be-written"}"#),
canned::tool_call_turn("r4", "mcp_fixture_echo", r#"{"text":"rejected"}"#),
canned::simple_text_turn("rejections done"),
],
false,
)
.await?;
let rejected = run_prompt(&mut client, &session, &|index| index < 2).await?;
assert_eq!(rejected.asked.len(), 4, "{:?}", rejected.titles());
let rendered = rejected.rendered();
assert!(rendered.contains("mcp-ran:echo:control"), "{rendered}");
assert!(!rendered.contains("mcp-ran:echo:rejected"), "{rendered}");
assert!(!rendered.contains("note.txt"), "{rendered}");
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("p1", "ctx_probe", "{}"),
canned::tool_call_turn("p2", "mcp_fixture_echo", r#"{"text":"plan"}"#),
canned::simple_text_turn("plan done"),
],
true,
)
.await?;
let plan = run_prompt(&mut client, &session, &|_| true).await?;
assert!(plan.asked.is_empty(), "{:?}", plan.titles());
assert_eq!(plan.completed(), 0, "{}", plan.rendered());
assert!(!plan.rendered().contains("mcp-ran:"));
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("f1", "mcp_fixture_hidden", r#"{"text":"hid"}"#),
canned::tool_call_turn("f2", "mcp_off_echo", r#"{"text":"off"}"#),
canned::tool_call_turn("f3", "mcp_evil_echo", r#"{"text":"evil"}"#),
canned::tool_call_turn(
"f4",
"start_mcp_server",
r#"{"name":"evil","command":"node","args":["-e","0"]}"#,
),
canned::tool_call_turn("f5", "agent", r#"{"action":"start","prompt":"p"}"#),
canned::simple_text_turn("fabricated done"),
],
false,
)
.await?;
let fabricated = run_prompt(&mut client, &session, &|_| true).await?;
assert!(fabricated.asked.is_empty(), "{:?}", fabricated.titles());
assert_eq!(fabricated.completed(), 0, "{}", fabricated.rendered());
assert!(!fabricated.rendered().contains("mcp-ran:"));
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, scripted) = tokio::join!(connection, script);
served?;
scripted?;
manager.shutdown_and_wait().await?;
host.shutdown().await;
Ok(())
}
-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 {
+1 -1
View File
@@ -1313,7 +1313,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
def(
"telemetry",
SettingKind::Bool(TELEMETRY),
"false",
"true",
ui(
TAB_TRUST,
"network",
+12
View File
@@ -138,6 +138,16 @@ pub struct SystemOneResponse {
pub answers_validated: Option<bool>,
}
impl SystemOneResponse {
pub fn abstained_questions(&self, names: &[&str]) -> Vec<String> {
names
.iter()
.filter(|name| self.answers.get(**name).is_some_and(|a| a.abstained))
.map(|name| (*name).to_string())
.collect()
}
}
impl<'de> Deserialize<'de> for SystemOneResponse {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
@@ -201,6 +211,8 @@ pub struct SystemOneAnswer {
pub probabilities: BTreeMap<String, Option<f64>>,
#[serde(default)]
pub confidence: Option<f64>,
#[serde(default)]
pub abstained: bool,
}
/// Provider usage. OpenRouter adds `cost`; the token-count casing differs
+2
View File
@@ -29,6 +29,8 @@ pub struct UserCommand {
pub takes_arguments: bool,
pub hidden: bool,
pub extension: bool,
pub plugin_id: Option<String>,
pub workspace_sourced: bool,
pub builtin_bindings: Vec<String>,
}
-13
View File
@@ -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 {
+32 -14
View File
@@ -456,7 +456,7 @@ async fn captured_identity_cancel_and_task_bounds_refuse_before_any_provider() {
#[tokio::test]
async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_retaining_authority() {
struct Pending;
struct Pending(Arc<tokio::sync::Notify>);
impl Replies for Pending {
fn effective_route_envelope(
&self,
@@ -478,24 +478,42 @@ async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_ret
&self,
_: MessageRequest,
) -> Pin<Box<dyn Future<Output = anyhow::Result<MessageResponse>> + Send + '_>> {
self.0.notify_one();
Box::pin(std::future::pending())
}
}
let caller = Arc::new(caller_for(Arc::new(Pending), "captured-model"));
let entered = Arc::new(tokio::sync::Notify::new());
let caller = Arc::new(caller_for(
Arc::new(Pending(entered.clone())),
"captured-model",
));
let weak_caller = Arc::downgrade(&caller);
let usage = RlmUsageAccumulator::new();
let result = caller
.dispatch(RlmInvocation {
prompt: "pending".into(),
mode: RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: Some(tokio::time::Instant::now() + Duration::from_millis(100)),
gate: None,
events: None,
usage: usage.clone(),
})
.await;
let mut dispatch = caller.dispatch(RlmInvocation {
prompt: "pending".into(),
mode: RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(600)),
gate: None,
events: None,
usage: usage.clone(),
});
tokio::time::timeout(Duration::from_secs(10), async {
tokio::select! {
biased;
() = entered.notified() => {}
result = &mut dispatch => panic!(
"dropped dispatch never reached the client: {:?}",
result.error
),
}
})
.await
.expect("the dispatched call must reach the client");
tokio::time::pause();
tokio::time::advance(Duration::from_secs(601)).await;
let result = dispatch.await;
assert!(
result
.error
@@ -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,
}
}
+1
View File
@@ -2643,6 +2643,7 @@ pub fn decision_receipt_fixture(source_id: &str) -> RuntimeDecisionReceipt {
probabilities_bp: Default::default(),
confidence_bp: 0,
min_confidence_bp: 5_000,
abstained_questions: Vec::new(),
cost_saving_kept_fast: false,
thinking: None,
provider_reported_cost_usd: Some("0.000012054".to_string()),
@@ -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| {
+5 -23
View File
@@ -282,31 +282,20 @@ impl ModelInventory {
// vendor-family heuristic) carries the number *and* the fact
// that nobody verified it — the auto-router must not read a
// guessed window as a route capability.
let mut context_window_unverified =
codewhale_config::catalog::reviewed::intrinsic_model(&model)
.and_then(|row| row.context_window)
.is_none();
let mut context_window_unverified = true;
if let Ok(route) = crate::route_runtime::resolve_runtime_route_for_identity(
config,
&identity,
Some(&model),
) {
if let Some(context_window) = route.candidate.limits().context_tokens {
capability.context_window = context_window.min(u64::from(u32::MAX)) as u32;
context_window_unverified = !route.context_window.source.is_verified();
}
// A concrete offering maximum is a stronger fact than the
// static compatibility matrix — and is the only way a
// membership route (no static cap) gets a known ceiling.
if let Some(max_output) = route
capability.context_window = route.context_window.tokens;
context_window_unverified = !route.context_window.source.is_verified();
capability.max_output = route
.candidate
.limits()
.output_tokens
.and_then(|tokens| u32::try_from(tokens).ok())
.filter(|tokens| *tokens > 0)
{
capability.max_output = Some(max_output);
}
.filter(|tokens| *tokens > 0);
user_declared = route
.candidate
.applied_limit_overrides()
@@ -316,13 +305,6 @@ impl ModelInventory {
== codewhale_config::route::OverrideSource::UserModelMetadata
});
if user_declared {
context_window_unverified = !route.context_window.source.is_verified();
capability.context_window = route.context_window.tokens;
capability.max_output = route
.candidate
.limits()
.output_tokens
.and_then(|value| u32::try_from(value).ok());
capability.thinking_supported = route.candidate.capabilities().reasoning
== codewhale_config::route::CapabilityState::Supported;
}
+60 -2
View File
@@ -1852,6 +1852,7 @@ pub struct ValidChoice {
pub choice: String,
pub probabilities_bp: BTreeMap<String, u16>,
pub confidence_bp: u16,
pub abstained: bool,
}
/// Validate one `choice` answer against the offered options. Any violation
@@ -1894,6 +1895,7 @@ pub fn validated_choice(answer: Option<&SystemOneAnswer>, options: &[&str]) -> O
choice: choice.to_string(),
probabilities_bp,
confidence_bp: probability_bp(confidence),
abstained: answer.abstained,
})
}
@@ -1982,6 +1984,7 @@ fn decision_attempt_from_response(
probabilities_bp: Default::default(),
confidence_bp: 0,
min_confidence_bp: inventory.router_min_confidence_bp,
abstained_questions: response.abstained_questions(&["tier", "thinking"]),
cost_saving_kept_fast: false,
thinking: None,
provider_reported_cost_usd: response
@@ -2005,17 +2008,18 @@ fn decision_attempt_from_response(
let min_confidence_bp = inventory.router_min_confidence_bp;
// An invalid or unsure `thinking` answer drops only the effort.
let thinking = validated_choice(response.answers.get("thinking"), &DECISION_THINKING_OPTIONS)
.filter(|thinking| thinking.confidence_bp >= min_confidence_bp)
.filter(|thinking| !thinking.abstained && thinking.confidence_bp >= min_confidence_bp)
.map(|thinking| thinking.choice);
let strong_bp = tier.probabilities_bp.get("strong").copied().unwrap_or(0);
let cost_saving_kept_fast =
cost_saving && tier.choice == "strong" && strong_bp < COST_SAVING_STRONG_MIN_BP;
let acted = tier.confidence_bp >= min_confidence_bp;
let acted = !tier.abstained && tier.confidence_bp >= min_confidence_bp;
attempt.decision = Some(AutoRouteDecisionEvidence {
choice: tier.choice.clone(),
probabilities_bp: tier.probabilities_bp,
confidence_bp: tier.confidence_bp,
min_confidence_bp,
abstained_questions: response.abstained_questions(&["tier", "thinking"]),
cost_saving_kept_fast: acted && cost_saving_kept_fast,
thinking: thinking.clone().filter(|_| acted),
provider_reported_cost_usd: response
@@ -4187,6 +4191,60 @@ mod decision_router_tests {
);
}
#[test]
fn explicit_abstention_survives_decode_and_never_authorizes_auto_at_floor_zero() {
let _env = hermetic_env();
let mut config = decision_config("https://openrouter.invalid/api/v1", false, 2);
config
.auto
.as_mut()
.unwrap()
.router
.as_mut()
.unwrap()
.min_confidence = Some(0.0);
let inventory = ModelInventory::from_config(&config).unwrap();
let attempt = |body: serde_json::Value| {
decision_attempt_from_response(false, &inventory, &pair(), None, &parsed(body), 120)
};
let mut body = answer_body(0.1, 0.9);
body["answers"]["thinking"]["abstained"] = true.into();
let effort_abstained = attempt(body.clone());
let recommendation = effort_abstained
.recommendation
.expect("valid tier survives effort abstention");
assert_eq!(recommendation.model, "deepseek-v4-flash");
assert_eq!(recommendation.reasoning_effort, None);
assert_eq!(
effort_abstained.decision.unwrap().abstained_questions,
vec!["thinking"]
);
body["answers"]["tier"]["abstained"] = true.into();
let tier_abstained = attempt(body);
assert!(tier_abstained.recommendation.is_none());
assert_eq!(
tier_abstained.fallback_reason,
Some(AutoRouteHeuristicReason::LowConfidence)
);
assert_eq!(tier_abstained.failure, None);
let decision = tier_abstained.decision.unwrap();
assert_eq!(decision.abstained_questions, vec!["tier", "thinking"]);
assert_eq!(decision.choice, "fast");
assert_eq!(
decision.provider_reported_cost_usd.as_deref(),
Some("0.000019992")
);
}
#[test]
fn non_boolean_abstention_is_refused_not_ignored() {
let mut body = answer_body(0.1, 0.9);
body["answers"]["tier"]["abstained"] = "yes".into();
let attempt = policy(false, &parsed(body));
assert!(attempt.recommendation.is_none());
assert_eq!(attempt.failure, Some(AutoRouterFailure::InvalidAnswer));
}
#[test]
fn invalid_answers_are_rejected_not_repaired() {
let mut outside = answer_body(0.8, 0.6);
+19 -16
View File
@@ -1455,6 +1455,24 @@ pub fn configured_catalog_models_for_route(
ids
}
pub fn listed_models_for_route(route_config: &Config, identity: &ProviderIdentity) -> Vec<String> {
let mut models = configured_catalog_models_for_route(
route_config,
identity.provider,
identity.key.as_str(),
&route_config.active_route_base_url(),
);
if identity.provider != ProviderKind::OpenaiCodex {
let default_model = route_config.default_model();
if !default_model.is_empty() && !default_model.eq_ignore_ascii_case("auto") {
push_unique_model(&mut models, &default_model);
}
models.sort();
models.dedup();
}
models
}
/// Look up the **bundled-snapshot** offering for `(provider, wire_model_id)`,
/// ignoring any live rows merged over it.
///
@@ -2219,23 +2237,8 @@ pub async fn run_models(
route_config
.scope_to_provider_identity(identity)
.map_err(anyhow::Error::msg)?;
let mut models = configured_catalog_models_for_route(
&route_config,
identity.provider,
identity.key.as_str(),
&route_config.active_route_base_url(),
);
let models = listed_models_for_route(&route_config, identity);
let default_model = route_config.default_model();
if identity.provider != ProviderKind::OpenaiCodex
&& !default_model.is_empty()
&& !default_model.eq_ignore_ascii_case("auto")
{
push_unique_model(&mut models, &default_model);
}
if identity.provider != ProviderKind::OpenaiCodex {
models.sort();
models.dedup();
}
if json {
// Preserve the existing array + AvailableModel field shape.
let chatgpt_roster = (identity.provider == ProviderKind::OpenaiCodex)
+28 -35
View File
@@ -325,7 +325,7 @@ pub fn selected_route(path: &Path) -> Result<(String, String, codewhale_config::
pub fn list_models(
store: &codewhale_config::ConfigStore,
filter: Option<ProviderKind>,
) -> Result<Vec<(ProviderKind, Vec<String>)>> {
) -> Result<Vec<(String, Vec<String>)>> {
crate::models_dev_live::maybe_load_persisted_cache();
let mut config = saved_config(store)?;
config.base_url_env_receipt = config
@@ -333,42 +333,33 @@ pub fn list_models(
.map_or(BaseUrlEnvReceipt::NoOwner, |identity| {
BaseUrlEnvReceipt::Route(identity.provider, identity.key.to_string())
});
let mut custom_routes: Vec<String> = config
.providers
.as_ref()
.map(|providers| providers.custom.keys().cloned().collect())
.unwrap_or_default();
custom_routes.sort();
let providers = filter
.as_ref()
.map_or(ProviderKind::all(), std::slice::from_ref);
let mut catalogs = Vec::with_capacity(providers.len());
for provider in providers {
if *provider == ProviderKind::Custom
&& config
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(provider.as_str()))
.is_none()
{
continue;
let routes = if *provider == ProviderKind::Custom {
custom_routes.clone()
} else {
vec![provider.as_str().to_string()]
};
for route in routes {
let identity = config
.legacy_selection_identity(&route)
.map_err(anyhow::Error::msg)?;
let mut route_config = config.clone();
route_config
.scope_to_provider_identity(&identity)
.map_err(anyhow::Error::msg)?;
let models = crate::provider_lake::listed_models_for_route(&route_config, &identity);
catalogs.push((route, models));
}
let identity = config
.legacy_selection_identity(provider.as_str())
.map_err(anyhow::Error::msg)?;
let mut route_config = config.clone();
route_config
.scope_to_provider_identity(&identity)
.map_err(anyhow::Error::msg)?;
let mut models = crate::provider_lake::configured_catalog_models_for_route(
&route_config,
identity.provider,
identity.key.as_str(),
&route_config.active_route_base_url(),
);
if identity.provider != ProviderKind::OpenaiCodex {
let default_model = route_config.default_model();
if !default_model.is_empty() && !default_model.eq_ignore_ascii_case("auto") {
models.push(default_model);
}
models.sort();
models.dedup();
}
catalogs.push((*provider, models));
}
Ok(catalogs)
}
@@ -418,10 +409,12 @@ pub fn resolve_model(
.into(),
);
}
codewhale_agent::ModelRegistry::default().resolve(
model.filter(|model| *model != "auto"),
Some(identity.provider),
)?;
if !crate::provider_catalog_live::provider_owns_live_catalog(identity.provider, base_url) {
codewhale_agent::ModelRegistry::default().resolve(
model.filter(|model| *model != "auto"),
Some(identity.provider),
)?;
}
}
Ok(route?.candidate)
}
@@ -9161,7 +9161,6 @@ impl RuntimeThreadManager {
thread.auto_approve,
)
.mode,
agent_preset: thread.agent_preset.clone(),
})
.await?;
let observed = engine.get_session_snapshot().await?;
@@ -14917,7 +14916,6 @@ source_usage_records: Default::default(),
thread.auto_approve,
)
.mode,
agent_preset: thread.agent_preset.clone(),
})
.await
.map_err(|e| anyhow!("Failed to sync thread session: {e}"))?;
@@ -19625,7 +19625,6 @@ fn answer_session_snapshot(
system_prompt: None,
instruction_assembly: None,
mode: String::new(),
agent_preset: None,
});
}
}
@@ -19705,7 +19704,6 @@ async fn compaction_lifecycle_emits_item_events_with_compaction_counts() -> Resu
system_prompt: None,
instruction_assembly: None,
mode: "agent".into(),
agent_preset: None,
});
let (reply, received) = oneshot::channel();
let withdraw = CancellationToken::new();
@@ -22039,7 +22037,6 @@ mod runtime_image_inputs {
model: stored_thread.model.clone(),
workspace: stored_thread.workspace.clone(),
mode: AppMode::Agent,
agent_preset: None,
})
.await?;
let followup = reopened
@@ -1,7 +1,5 @@
use super::*;
use crate::conversation_modes::{CREATOR, CreatorAvailabilityGuard, ModeRefusal, STANDARD};
use crate::core::engine::Engine;
use crate::llm_client::mock::MockLlmClient;
fn refusal_code(error: &anyhow::Error) -> &'static str {
error
@@ -134,58 +132,3 @@ async fn creator_is_listed_unavailable_until_it_loads_its_tools() -> Result<()>
assert_eq!(refusal_code(&refused), ModeRefusal::MODE_UNAVAILABLE);
Ok(())
}
#[tokio::test]
async fn a_runtime_thread_sync_keeps_its_conversation_mode() -> Result<()> {
let tmp = tempfile::tempdir()?;
let manager = test_manager(tmp.path().to_path_buf())?;
let _available = CreatorAvailabilityGuard::set(None);
let thread = manager.create_thread(create_request(Some(CREATOR))).await?;
let mock = Arc::new(MockLlmClient::new(Vec::new()));
let (engine, handle) = Engine::new_with_model_client(
EngineConfig {
workspace: tmp.path().to_path_buf(),
model: thread.model.clone(),
subagents_enabled: false,
snapshots_enabled: false,
memory_enabled: false,
terminal_chrome_enabled: false,
..Default::default()
},
&Config::default(),
mock,
);
manager
.install_test_engine(&thread.id, handle.clone())
.await?;
let run = tokio::spawn(engine.run());
let messages = vec![Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "first".to_string(),
cache_control: None,
}],
}];
let session = crate::session_manager::create_saved_session(
&messages,
&thread.model,
&thread.workspace,
0,
None,
);
manager
.synchronize_history_operation(&thread.id, &session)
.await?;
let observed = handle.get_session_snapshot().await?;
assert_eq!(observed.agent_preset.as_deref(), Some(CREATOR));
manager
.synchronize_history_operation(&thread.id, &session)
.await?;
assert_eq!(
handle.get_session_snapshot().await?.agent_preset.as_deref(),
Some(CREATOR)
);
handle.send(Op::Shutdown).await?;
tokio::time::timeout(Duration::from_secs(10), run).await??;
Ok(())
}
+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);
}
}
+26 -2
View File
@@ -451,6 +451,11 @@ async fn evaluate(
.map_or_else(Default::default, |v| v.probabilities_bp.clone()),
confidence_bp: intent.as_ref().map_or(0, |v| v.confidence_bp),
min_confidence_bp: 5_000,
abstained_questions: response.abstained_questions(&[
"intent",
"needs_tool",
"answerable_from_context",
]),
cost_saving_kept_fast: false,
thinking: None,
provider_reported_cost_usd: response
@@ -521,7 +526,7 @@ fn unit_interval(value: Option<f64>) -> Option<f64> {
/// fast route.
fn read_noul(response: &SystemOneResponse, key: &str) -> Option<f64> {
let answer = response.answers.get(key)?;
(answer.kind == "noul")
(answer.kind == "noul" && !answer.abstained)
.then_some(answer.noul)
.and_then(unit_interval)
}
@@ -548,7 +553,9 @@ fn derive_route(response: &SystemOneResponse) -> Route {
response.answers.get("intent"),
&["code_change", "code_question", "command", "chat", "other"],
)
.is_some_and(|intent| intent.choice == "chat" && intent.confidence_bp >= 5_000);
.is_some_and(|intent| {
!intent.abstained && intent.choice == "chat" && intent.confidence_bp >= 5_000
});
if intent_is_calibrated_chat && needs_tool.is_some_and(|nt| nt <= 0.2) {
return Route::PlainChat;
}
@@ -564,6 +571,23 @@ mod tests {
const TYPESAFE_TEST_KEY: &str = "sf-typesafe-test-key-0123456789";
#[test]
fn explicit_abstention_never_authorizes_a_shadow_route() {
let mut value = json!({"answers":{
"intent":{"type":"choice","choice":"chat","confidence":0.9,
"probabilities":{"chat":0.9,"code_change":0.1,"code_question":0.0,"command":0.0,"other":0.0}},
"needs_tool":{"type":"noul","noul":0.1},
"answerable_from_context":{"type":"noul","noul":0.1}}});
let decoded =
|value: Value| serde_json::from_value::<SystemOneResponse>(value).expect("response");
assert_eq!(derive_route(&decoded(value.clone())), Route::PlainChat);
value["answers"]["intent"]["abstained"] = true.into();
assert_eq!(derive_route(&decoded(value.clone())), Route::Unknown);
value["answers"]["needs_tool"]["noul"] = 0.95.into();
value["answers"]["needs_tool"]["abstained"] = true.into();
assert_eq!(derive_route(&decoded(value)), Route::Unknown);
}
/// Fields drop in declaration order: restore the environment before the
/// lock is released, or another test observes our overrides.
struct Env {
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "endormiscat",
"PetWatchUnavailable": "La telemetria de la mascota està en pausa. /pet on ho torna a intentar.",
"ConversationChangedBeforeUndo": "La conversa ha canviat abans de completar l’acció de desfer. No s’ha desfet res ni s’ha enviat cap nou intent. Revisa la conversa actual abans de tornar-ho a provar.",
"ConversationModePickerTitle": "Mode",
"ConversationModeGroupConversation": "Mode de conversa",
"ConversationModeGroupPermissions": "Permisos",
"ConversationModeStandardName": "Mode estàndard",
"ConversationModeStandardDescription": "L'agent de cada dia amb les seves eines i habilitats habituals.",
"ConversationModeCreatorName": "Mode creador",
"ConversationModeCreatorDescription": "Crea i prova connectors en aquesta conversa.",
"ConversationModeNotReady": "Encara no està a punt en aquesta versió.",
"ConversationModeLockedNote": "Fix després del primer missatge. Comença'n una altra amb {command}.",
"ConversationModeLockedRefusal": "El mode de conversa queda fix quan s'envia el primer missatge. Comença una conversa nova amb {command}.",
"ConversationModeUnknown": "Mode de conversa desconegut «{id}». Disponibles: {modes}.",
"ConversationModeUnavailableRefusal": "{name} no està disponible: {reason}",
"ConversationModeSet": "Mode de conversa: {name}.",
"ConversationModeLabelUnavailable": "{name} (no disponible)",
"ConversationModeSavedUnavailableNotice": "Aquesta conversa es va desar en {name}, que no està disponible aquí ({reason}). Se'n mostra l'historial.",
"ConversationModeResumeUnreadable": "No s'ha pogut llegir la conversa desada per comprovar-ne el mode; no s'ha iniciat ni canviat res.",
"SessionArchiveExported": "Sessió exportada",
"SessionArchiveSizes": "elements / mida sense comprimir / arxiu",
"SessionArchiveNoArtifacts": "No s’han trobat fitxers d’artefactes regulars en aquesta sessió.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "dösend",
"PetWatchUnavailable": "Die Haustier-Telemetrie pausiert. /pet on versucht es erneut.",
"ConversationChangedBeforeUndo": "Die Unterhaltung wurde geändert, bevor das Rückgängigmachen abgeschlossen war. Es wurde nichts rückgängig gemacht und kein erneuter Versuch gesendet. Prüfe die aktuelle Unterhaltung, bevor du es erneut versuchst.",
"ConversationModePickerTitle": "Modus",
"ConversationModeGroupConversation": "Gesprächsmodus",
"ConversationModeGroupPermissions": "Berechtigungen",
"ConversationModeStandardName": "Standardmodus",
"ConversationModeStandardDescription": "Der Alltagsagent mit seinen üblichen Werkzeugen und Skills.",
"ConversationModeCreatorName": "Creator-Modus",
"ConversationModeCreatorDescription": "Plugins in diesem Gespräch bauen und testen.",
"ConversationModeNotReady": "In dieser Version noch nicht bereit.",
"ConversationModeLockedNote": "Nach der ersten Nachricht festgelegt. Ein anderes Gespräch starten mit {command}.",
"ConversationModeLockedRefusal": "Der Gesprächsmodus ist festgelegt, sobald die erste Nachricht gesendet wurde. Starte ein neues Gespräch mit {command}.",
"ConversationModeUnknown": "Unbekannter Gesprächsmodus „{id}“. Verfügbar: {modes}.",
"ConversationModeUnavailableRefusal": "{name} ist nicht verfügbar: {reason}",
"ConversationModeSet": "Gesprächsmodus: {name}.",
"ConversationModeLabelUnavailable": "{name} (nicht verfügbar)",
"ConversationModeSavedUnavailableNotice": "Dieses Gespräch wurde im {name} gespeichert, der hier nicht verfügbar ist ({reason}). Sein Verlauf wird angezeigt.",
"ConversationModeResumeUnreadable": "Das gespeicherte Gespräch konnte nicht gelesen werden, um seinen Modus zu prüfen. Es wurde nichts gestartet oder geändert.",
"SessionArchiveExported": "Sitzung exportiert",
"SessionArchiveSizes": "Einträge / unkomprimierte Größe / Archivgröße",
"SessionArchiveNoArtifacts": "Keine regulären Artefaktdateien für diese Sitzung gefunden.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "dozing",
"PetWatchUnavailable": "Pet telemetry paused. /pet on retries.",
"ConversationChangedBeforeUndo": "The conversation changed before undo could finish. Nothing was undone and no retry was sent. Review the current conversation before trying again.",
"ConversationModePickerTitle": "Mode",
"ConversationModeGroupConversation": "Conversation mode",
"ConversationModeGroupPermissions": "Permissions",
"ConversationModeStandardName": "Standard mode",
"ConversationModeStandardDescription": "The everyday agent with its usual tools and skills.",
"ConversationModeCreatorName": "Creator mode",
"ConversationModeCreatorDescription": "Build and test plugins in this conversation.",
"ConversationModeNotReady": "Not ready yet in this version.",
"ConversationModeLockedNote": "Fixed after the first message. Start another with {command}.",
"ConversationModeLockedRefusal": "The conversation mode is fixed once the first message is sent. Start a new conversation with {command}.",
"ConversationModeUnknown": "Unknown conversation mode \"{id}\". Available: {modes}.",
"ConversationModeUnavailableRefusal": "{name} is not available: {reason}",
"ConversationModeSet": "Conversation mode: {name}.",
"ConversationModeLabelUnavailable": "{name} (unavailable)",
"ConversationModeSavedUnavailableNotice": "This conversation was saved in {name}, which is not available here ({reason}). Its history is shown.",
"ConversationModeResumeUnreadable": "Could not read the saved conversation to check its mode, so nothing was started or changed.",
"SessionArchiveExported": "Exported session",
"SessionArchiveSizes": "members / uncompressed / archive",
"SessionArchiveNoArtifacts": "No regular artifacts found for this session.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "dormitando",
"PetWatchUnavailable": "La telemetría de la mascota está en pausa. /pet on reintenta.",
"ConversationChangedBeforeUndo": "La conversación cambió antes de completar la acción de deshacer. No se deshizo nada ni se envió un nuevo intento. Revisa la conversación actual antes de volver a intentarlo.",
"ConversationModePickerTitle": "Modo",
"ConversationModeGroupConversation": "Modo de conversación",
"ConversationModeGroupPermissions": "Permisos",
"ConversationModeStandardName": "Modo estándar",
"ConversationModeStandardDescription": "El agente de todos los días con sus herramientas y habilidades habituales.",
"ConversationModeCreatorName": "Modo creador",
"ConversationModeCreatorDescription": "Crea y prueba plugins en esta conversación.",
"ConversationModeNotReady": "Aún no está listo en esta versión.",
"ConversationModeLockedNote": "Fijo tras el primer mensaje. Inicia otra con {command}.",
"ConversationModeLockedRefusal": "El modo de conversación queda fijo al enviar el primer mensaje. Inicia una conversación nueva con {command}.",
"ConversationModeUnknown": "Modo de conversación desconocido «{id}». Disponibles: {modes}.",
"ConversationModeUnavailableRefusal": "{name} no está disponible: {reason}",
"ConversationModeSet": "Modo de conversación: {name}.",
"ConversationModeLabelUnavailable": "{name} (no disponible)",
"ConversationModeSavedUnavailableNotice": "Esta conversación se guardó en {name}, que no está disponible aquí ({reason}). Se muestra su historial.",
"ConversationModeResumeUnreadable": "No se pudo leer la conversación guardada para comprobar su modo, así que no se inició ni se cambió nada.",
"SessionArchiveExported": "Sesión exportada",
"SessionArchiveSizes": "elementos / tamaño sin comprimir / archivo",
"SessionArchiveNoArtifacts": "No se encontraron archivos de artefactos regulares en esta sesión.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "assoupi",
"PetWatchUnavailable": "La télémétrie de la mascotte est en pause. /pet on réessaie.",
"ConversationChangedBeforeUndo": "La conversation a changé avant la fin de l’annulation. Rien n’a été annulé et aucune nouvelle tentative n’a été envoyée. Vérifiez la conversation actuelle avant de réessayer.",
"ConversationModePickerTitle": "Mode",
"ConversationModeGroupConversation": "Mode de conversation",
"ConversationModeGroupPermissions": "Autorisations",
"ConversationModeStandardName": "Mode standard",
"ConversationModeStandardDescription": "L'agent du quotidien avec ses outils et compétences habituels.",
"ConversationModeCreatorName": "Mode créateur",
"ConversationModeCreatorDescription": "Créer et tester des plugins dans cette conversation.",
"ConversationModeNotReady": "Pas encore prêt dans cette version.",
"ConversationModeLockedNote": "Fixé après le premier message. Pour en démarrer une autre : {command}.",
"ConversationModeLockedRefusal": "Le mode de conversation est fixé dès l'envoi du premier message. Démarrez une nouvelle conversation avec {command}.",
"ConversationModeUnknown": "Mode de conversation inconnu « {id} ». Disponibles : {modes}.",
"ConversationModeUnavailableRefusal": "{name} n'est pas disponible : {reason}",
"ConversationModeSet": "Mode de conversation : {name}.",
"ConversationModeLabelUnavailable": "{name} (indisponible)",
"ConversationModeSavedUnavailableNotice": "Cette conversation a été enregistrée en {name}, indisponible ici ({reason}). Son historique est affiché.",
"ConversationModeResumeUnreadable": "Impossible de lire la conversation enregistrée pour vérifier son mode ; rien n'a été démarré ni modifié.",
"SessionArchiveExported": "Session exportée",
"SessionArchiveSizes": "éléments / taille non compressée / archive",
"SessionArchiveNoArtifacts": "Aucun fichier d’artefact ordinaire trouvé pour cette session.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "ऊँघ रहा है",
"PetWatchUnavailable": "पालतू की टेलीमेट्री रुकी हुई है। /pet on से फिर कोशिश करें।",
"ConversationChangedBeforeUndo": "पूर्ववत करने की प्रक्रिया पूरी होने से पहले बातचीत बदल गई। कुछ भी पूर्ववत नहीं किया गया और दोबारा अनुरोध नहीं भेजा गया। फिर से कोशिश करने से पहले मौजूदा बातचीत की समीक्षा करें।",
"ConversationModePickerTitle": "मोड",
"ConversationModeGroupConversation": "बातचीत मोड",
"ConversationModeGroupPermissions": "अनुमतियाँ",
"ConversationModeStandardName": "मानक मोड",
"ConversationModeStandardDescription": "रोज़ के उपयोग वाला एजेंट, अपने सामान्य टूल और स्किल के साथ।",
"ConversationModeCreatorName": "क्रिएटर मोड",
"ConversationModeCreatorDescription": "इस बातचीत में प्लगइन बनाएँ और परखें।",
"ConversationModeNotReady": "इस संस्करण में अभी तैयार नहीं है।",
"ConversationModeLockedNote": "पहले संदेश के बाद तय हो जाता है। दूसरी शुरू करने के लिए {command} चलाएँ।",
"ConversationModeLockedRefusal": "पहला संदेश भेजते ही बातचीत का मोड तय हो जाता है। {command} से नई बातचीत शुरू करें।",
"ConversationModeUnknown": "अज्ञात बातचीत मोड \"{id}\"। उपलब्ध: {modes}।",
"ConversationModeUnavailableRefusal": "{name} उपलब्ध नहीं है: {reason}",
"ConversationModeSet": "बातचीत मोड: {name}।",
"ConversationModeLabelUnavailable": "{name} (उपलब्ध नहीं)",
"ConversationModeSavedUnavailableNotice": "यह बातचीत {name} में सहेजी गई थी, जो यहाँ उपलब्ध नहीं है ({reason})। इसका इतिहास दिखाया गया है।",
"ConversationModeResumeUnreadable": "इसका मोड जाँचने के लिए सहेजी गई बातचीत नहीं पढ़ी जा सकी, इसलिए कुछ शुरू या बदला नहीं गया।",
"SessionArchiveExported": "सत्र निर्यात किया गया",
"SessionArchiveSizes": "प्रविष्टियाँ / असंपीड़ित आकार / संग्रह का आकार",
"SessionArchiveNoArtifacts": "इस सत्र में कोई सामान्य आर्टिफ़ैक्ट फ़ाइल नहीं मिली।",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "terlelap",
"PetWatchUnavailable": "Telemetri hewan peliharaan dijeda. /pet on mencoba lagi.",
"ConversationChangedBeforeUndo": "Percakapan berubah sebelum pembatalan selesai. Tidak ada yang dibatalkan dan tidak ada permintaan coba lagi yang dikirim. Periksa percakapan saat ini sebelum mencoba lagi.",
"ConversationModePickerTitle": "Mode",
"ConversationModeGroupConversation": "Mode percakapan",
"ConversationModeGroupPermissions": "Izin",
"ConversationModeStandardName": "Mode standar",
"ConversationModeStandardDescription": "Agen sehari-hari dengan alat dan skill yang biasa.",
"ConversationModeCreatorName": "Mode kreator",
"ConversationModeCreatorDescription": "Bangun dan uji plugin dalam percakapan ini.",
"ConversationModeNotReady": "Belum siap di versi ini.",
"ConversationModeLockedNote": "Tetap setelah pesan pertama. Mulai yang lain dengan {command}.",
"ConversationModeLockedRefusal": "Mode percakapan tetap setelah pesan pertama dikirim. Mulai percakapan baru dengan {command}.",
"ConversationModeUnknown": "Mode percakapan \"{id}\" tidak dikenal. Tersedia: {modes}.",
"ConversationModeUnavailableRefusal": "{name} tidak tersedia: {reason}",
"ConversationModeSet": "Mode percakapan: {name}.",
"ConversationModeLabelUnavailable": "{name} (tidak tersedia)",
"ConversationModeSavedUnavailableNotice": "Percakapan ini disimpan dalam {name}, yang tidak tersedia di sini ({reason}). Riwayatnya ditampilkan.",
"ConversationModeResumeUnreadable": "Percakapan tersimpan tidak dapat dibaca untuk memeriksa modenya, jadi tidak ada yang dimulai atau diubah.",
"SessionArchiveExported": "Sesi diekspor",
"SessionArchiveSizes": "jumlah entri / ukuran tanpa kompresi / arsip",
"SessionArchiveNoArtifacts": "Tidak ditemukan berkas artefak biasa untuk sesi ini.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "うたた寝",
"PetWatchUnavailable": "ペットの計測が一時停止しました。/pet on で再試行します。",
"ConversationChangedBeforeUndo": "取り消しが完了する前に会話が変更されました。取り消しも再試行の送信も行っていません。現在の会話を確認してから、もう一度お試しください。",
"ConversationModePickerTitle": "モード",
"ConversationModeGroupConversation": "会話モード",
"ConversationModeGroupPermissions": "権限",
"ConversationModeStandardName": "標準モード",
"ConversationModeStandardDescription": "いつもの道具とスキルを備えた日常用のエージェントです。",
"ConversationModeCreatorName": "クリエイターモード",
"ConversationModeCreatorDescription": "この会話でプラグインを作成・テストします。",
"ConversationModeNotReady": "このバージョンではまだ使えません。",
"ConversationModeLockedNote": "最初のメッセージ後は固定されます。別の会話は {command} で始めます。",
"ConversationModeLockedRefusal": "会話モードは最初のメッセージを送ると固定されます。{command} で新しい会話を始めてください。",
"ConversationModeUnknown": "会話モード「{id}」は不明です。使用可能: {modes}。",
"ConversationModeUnavailableRefusal": "{name} は使えません: {reason}",
"ConversationModeSet": "会話モード: {name}。",
"ConversationModeLabelUnavailable": "{name}(利用不可)",
"ConversationModeSavedUnavailableNotice": "この会話は {name} で保存されましたが、ここでは使えません({reason})。履歴は表示されています。",
"ConversationModeResumeUnreadable": "保存された会話を読み取れず、モードを確認できませんでした。何も開始・変更していません。",
"SessionArchiveExported": "セッションをエクスポートしました",
"SessionArchiveSizes": "項目数 / 非圧縮サイズ / アーカイブサイズ",
"SessionArchiveNoArtifacts": "このセッションに通常のアーティファクトファイルはありません。",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "졸고 있음",
"PetWatchUnavailable": "펫 원격 측정이 일시 중지되었습니다. /pet on 으로 재시도합니다.",
"ConversationChangedBeforeUndo": "실행 취소가 완료되기 전에 대화가 변경되었습니다. 취소된 내용이 없으며 재시도 요청도 보내지 않았습니다. 현재 대화를 확인한 후 다시 시도하세요.",
"ConversationModePickerTitle": "모드",
"ConversationModeGroupConversation": "대화 모드",
"ConversationModeGroupPermissions": "권한",
"ConversationModeStandardName": "표준 모드",
"ConversationModeStandardDescription": "평소 도구와 스킬을 갖춘 일상용 에이전트입니다.",
"ConversationModeCreatorName": "크리에이터 모드",
"ConversationModeCreatorDescription": "이 대화에서 플러그인을 만들고 테스트합니다.",
"ConversationModeNotReady": "이 버전에서는 아직 준비되지 않았습니다.",
"ConversationModeLockedNote": "첫 메시지 이후에는 고정됩니다. 다른 대화는 {command} 로 시작하세요.",
"ConversationModeLockedRefusal": "대화 모드는 첫 메시지를 보내면 고정됩니다. {command} 로 새 대화를 시작하세요.",
"ConversationModeUnknown": "알 수 없는 대화 모드 \"{id}\". 사용 가능: {modes}.",
"ConversationModeUnavailableRefusal": "{name} 사용 불가: {reason}",
"ConversationModeSet": "대화 모드: {name}.",
"ConversationModeLabelUnavailable": "{name} (사용 불가)",
"ConversationModeSavedUnavailableNotice": "이 대화는 {name}에서 저장되었지만 여기서는 사용할 수 없습니다({reason}). 기록은 표시됩니다.",
"ConversationModeResumeUnreadable": "저장된 대화를 읽어 모드를 확인할 수 없어 아무것도 시작하거나 변경하지 않았습니다.",
"SessionArchiveExported": "세션을 내보냈습니다",
"SessionArchiveSizes": "항목 수 / 압축 전 크기 / 아카이브 크기",
"SessionArchiveNoArtifacts": "이 세션에 일반 산출물 파일이 없습니다.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "cochilando",
"PetWatchUnavailable": "A telemetria do pet foi pausada. /pet on tenta novamente.",
"ConversationChangedBeforeUndo": "A conversa mudou antes de concluir a ação de desfazer. Nada foi desfeito e nenhuma nova tentativa foi enviada. Revise a conversa atual antes de tentar novamente.",
"ConversationModePickerTitle": "Modo",
"ConversationModeGroupConversation": "Modo de conversa",
"ConversationModeGroupPermissions": "Permissões",
"ConversationModeStandardName": "Modo padrão",
"ConversationModeStandardDescription": "O agente do dia a dia, com suas ferramentas e habilidades habituais.",
"ConversationModeCreatorName": "Modo criador",
"ConversationModeCreatorDescription": "Crie e teste plugins nesta conversa.",
"ConversationModeNotReady": "Ainda não está pronto nesta versão.",
"ConversationModeLockedNote": "Fixo após a primeira mensagem. Inicie outra com {command}.",
"ConversationModeLockedRefusal": "O modo de conversa fica fixo depois que a primeira mensagem é enviada. Inicie uma nova conversa com {command}.",
"ConversationModeUnknown": "Modo de conversa desconhecido \"{id}\". Disponíveis: {modes}.",
"ConversationModeUnavailableRefusal": "{name} não está disponível: {reason}",
"ConversationModeSet": "Modo de conversa: {name}.",
"ConversationModeLabelUnavailable": "{name} (indisponível)",
"ConversationModeSavedUnavailableNotice": "Esta conversa foi salva no {name}, que não está disponível aqui ({reason}). O histórico dela é exibido.",
"ConversationModeResumeUnreadable": "Não foi possível ler a conversa salva para verificar o modo, então nada foi iniciado ou alterado.",
"SessionArchiveExported": "Sessão exportada",
"SessionArchiveSizes": "itens / tamanho sem compressão / arquivo",
"SessionArchiveNoArtifacts": "Nenhum artefato regular encontrado nesta sessão.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "дремлет",
"PetWatchUnavailable": "Телеметрия питомца приостановлена. /pet on повторит попытку.",
"ConversationChangedBeforeUndo": "Разговор изменился до завершения отмены. Ничего не отменено, повторный запрос не отправлен. Проверьте текущий разговор, прежде чем повторять попытку.",
"ConversationModePickerTitle": "Режим",
"ConversationModeGroupConversation": "Режим беседы",
"ConversationModeGroupPermissions": "Разрешения",
"ConversationModeStandardName": "Стандартный режим",
"ConversationModeStandardDescription": "Повседневный агент с обычными инструментами и навыками.",
"ConversationModeCreatorName": "Режим создателя",
"ConversationModeCreatorDescription": "Создавайте и проверяйте плагины в этой беседе.",
"ConversationModeNotReady": "В этой версии пока не готово.",
"ConversationModeLockedNote": "Фиксируется после первого сообщения. Начните другую командой {command}.",
"ConversationModeLockedRefusal": "Режим беседы фиксируется после отправки первого сообщения. Начните новую беседу командой {command}.",
"ConversationModeUnknown": "Неизвестный режим беседы «{id}». Доступны: {modes}.",
"ConversationModeUnavailableRefusal": "{name} недоступен: {reason}",
"ConversationModeSet": "Режим беседы: {name}.",
"ConversationModeLabelUnavailable": "{name} (недоступен)",
"ConversationModeSavedUnavailableNotice": "Эта беседа сохранена в режиме «{name}», недоступном здесь ({reason}). Её история показана.",
"ConversationModeResumeUnreadable": "Не удалось прочитать сохранённый разговор, чтобы проверить его режим. Ничего не запущено и не изменено.",
"SessionArchiveExported": "Сеанс экспортирован",
"SessionArchiveSizes": "записи / размер без сжатия / размер архива",
"SessionArchiveNoArtifacts": "Обычные файлы артефактов для этого сеанса не найдены.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "дрімає",
"PetWatchUnavailable": "Телеметрію улюбленця призупинено. /pet on повторить спробу.",
"ConversationChangedBeforeUndo": "Розмова змінилася до завершення скасування. Нічого не скасовано, повторний запит не надіслано. Перевірте поточну розмову, перш ніж спробувати знову.",
"ConversationModePickerTitle": "Режим",
"ConversationModeGroupConversation": "Режим розмови",
"ConversationModeGroupPermissions": "Дозволи",
"ConversationModeStandardName": "Стандартний режим",
"ConversationModeStandardDescription": "Щоденний агент зі звичними інструментами та навичками.",
"ConversationModeCreatorName": "Режим творця",
"ConversationModeCreatorDescription": "Створюйте й перевіряйте плагіни в цій розмові.",
"ConversationModeNotReady": "У цій версії ще не готово.",
"ConversationModeLockedNote": "Фіксується після першого повідомлення. Почніть іншу командою {command}.",
"ConversationModeLockedRefusal": "Режим розмови фіксується після надсилання першого повідомлення. Почніть нову розмову командою {command}.",
"ConversationModeUnknown": "Невідомий режим розмови «{id}». Доступні: {modes}.",
"ConversationModeUnavailableRefusal": "{name} недоступний: {reason}",
"ConversationModeSet": "Режим розмови: {name}.",
"ConversationModeLabelUnavailable": "{name} (недоступний)",
"ConversationModeSavedUnavailableNotice": "Цю розмову збережено в режимі «{name}», недоступному тут ({reason}). Її історію показано.",
"ConversationModeResumeUnreadable": "Не вдалося прочитати збережену розмову, щоб перевірити її режим. Нічого не запущено й не змінено.",
"SessionArchiveExported": "Сеанс експортовано",
"SessionArchiveSizes": "записи / розмір без стиснення / розмір архіву",
"SessionArchiveNoArtifacts": "Звичайних файлів артефактів для цього сеансу не знайдено.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "ngủ gật",
"PetWatchUnavailable": "Đã tạm dừng dữ liệu thú cưng. /pet on sẽ thử lại.",
"ConversationChangedBeforeUndo": "Cuộc trò chuyện đã thay đổi trước khi hoàn tất hoàn tác. Không có nội dung nào bị hoàn tác và không gửi yêu cầu thử lại. Hãy kiểm tra cuộc trò chuyện hiện tại trước khi thử lại.",
"ConversationModePickerTitle": "Chế độ",
"ConversationModeGroupConversation": "Chế độ cuộc trò chuyện",
"ConversationModeGroupPermissions": "Quyền",
"ConversationModeStandardName": "Chế độ tiêu chuẩn",
"ConversationModeStandardDescription": "Tác nhân hằng ngày với các công cụ và kỹ năng quen thuộc.",
"ConversationModeCreatorName": "Chế độ sáng tạo",
"ConversationModeCreatorDescription": "Xây dựng và thử plugin trong cuộc trò chuyện này.",
"ConversationModeNotReady": "Phiên bản này chưa sẵn sàng.",
"ConversationModeLockedNote": "Cố định sau tin nhắn đầu tiên. Bắt đầu cuộc khác bằng {command}.",
"ConversationModeLockedRefusal": "Chế độ cuộc trò chuyện được cố định sau khi gửi tin nhắn đầu tiên. Hãy bắt đầu cuộc trò chuyện mới bằng {command}.",
"ConversationModeUnknown": "Không rõ chế độ cuộc trò chuyện \"{id}\". Hiện có: {modes}.",
"ConversationModeUnavailableRefusal": "{name} không khả dụng: {reason}",
"ConversationModeSet": "Chế độ cuộc trò chuyện: {name}.",
"ConversationModeLabelUnavailable": "{name} (không khả dụng)",
"ConversationModeSavedUnavailableNotice": "Cuộc trò chuyện này được lưu ở {name}, hiện không khả dụng ở đây ({reason}). Lịch sử vẫn được hiển thị.",
"ConversationModeResumeUnreadable": "Không đọc được cuộc trò chuyện đã lưu để kiểm tra chế độ, nên không có gì được bắt đầu hoặc thay đổi.",
"SessionArchiveExported": "Đã xuất phiên",
"SessionArchiveSizes": "số mục / dung lượng chưa nén / tệp lưu trữ",
"SessionArchiveNoArtifacts": "Không tìm thấy tệp sản phẩm thông thường trong phiên này.",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "打盹",
"PetWatchUnavailable": "宠物遥测已暂停。/pet on 重试。",
"ConversationChangedBeforeUndo": "撤销完成前,会话已发生变化。未撤销任何内容,也未发送重试请求。请先检查当前会话,再重试。",
"ConversationModePickerTitle": "模式",
"ConversationModeGroupConversation": "对话模式",
"ConversationModeGroupPermissions": "权限",
"ConversationModeStandardName": "标准模式",
"ConversationModeStandardDescription": "日常使用的智能体,带有常用工具和技能。",
"ConversationModeCreatorName": "创作者模式",
"ConversationModeCreatorDescription": "在此对话中构建并测试插件。",
"ConversationModeNotReady": "此版本尚未就绪。",
"ConversationModeLockedNote": "发送第一条消息后即固定。用 {command} 开始另一个对话。",
"ConversationModeLockedRefusal": "发送第一条消息后,对话模式即固定。请用 {command} 开始新对话。",
"ConversationModeUnknown": "未知的对话模式“{id}”。可用:{modes}。",
"ConversationModeUnavailableRefusal": "{name}不可用:{reason}",
"ConversationModeSet": "对话模式:{name}。",
"ConversationModeLabelUnavailable": "{name}(不可用)",
"ConversationModeSavedUnavailableNotice": "此对话保存时使用{name},但这里无法使用({reason})。已显示其历史记录。",
"ConversationModeResumeUnreadable": "无法读取已保存的对话以检查其模式,因此没有开始或更改任何内容。",
"SessionArchiveExported": "会话已导出",
"SessionArchiveSizes": "条目数 / 未压缩大小 / 归档大小",
"SessionArchiveNoArtifacts": "此会话没有常规产物文件。",
-16
View File
@@ -91,22 +91,6 @@
"PetDozing": "打盹",
"PetWatchUnavailable": "寵物遙測已暫停。/pet on 重試。",
"ConversationChangedBeforeUndo": "復原完成前,對話已發生變更。未復原任何內容,也未送出重試請求。請先檢查目前的對話,再重試。",
"ConversationModePickerTitle": "模式",
"ConversationModeGroupConversation": "對話模式",
"ConversationModeGroupPermissions": "權限",
"ConversationModeStandardName": "標準模式",
"ConversationModeStandardDescription": "日常使用的代理,具備常用工具與技能。",
"ConversationModeCreatorName": "創作者模式",
"ConversationModeCreatorDescription": "在此對話中建置並測試外掛。",
"ConversationModeNotReady": "此版本尚未就緒。",
"ConversationModeLockedNote": "傳送第一則訊息後即固定。用 {command} 開始另一個對話。",
"ConversationModeLockedRefusal": "傳送第一則訊息後,對話模式即固定。請用 {command} 開始新對話。",
"ConversationModeUnknown": "未知的對話模式「{id}」。可用:{modes}。",
"ConversationModeUnavailableRefusal": "{name}無法使用:{reason}",
"ConversationModeSet": "對話模式:{name}。",
"ConversationModeLabelUnavailable": "{name}(無法使用)",
"ConversationModeSavedUnavailableNotice": "此對話儲存時使用{name},但這裡無法使用({reason})。已顯示其歷史記錄。",
"ConversationModeResumeUnreadable": "無法讀取已儲存的對話以檢查其模式,因此沒有開始或變更任何內容。",
"SessionArchiveExported": "工作階段已匯出",
"SessionArchiveSizes": "項目數 / 未壓縮大小 / 封存大小",
"SessionArchiveNoArtifacts": "此工作階段沒有一般產物檔案。",
-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,
@@ -3130,22 +3114,6 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::MobileStreamReconnecting,
MessageId::MobileStreamConnected,
MessageId::ConversationChangedBeforeUndo,
MessageId::ConversationModePickerTitle,
MessageId::ConversationModeGroupConversation,
MessageId::ConversationModeGroupPermissions,
MessageId::ConversationModeStandardName,
MessageId::ConversationModeStandardDescription,
MessageId::ConversationModeCreatorName,
MessageId::ConversationModeCreatorDescription,
MessageId::ConversationModeNotReady,
MessageId::ConversationModeLockedNote,
MessageId::ConversationModeLockedRefusal,
MessageId::ConversationModeUnknown,
MessageId::ConversationModeUnavailableRefusal,
MessageId::ConversationModeSet,
MessageId::ConversationModeLabelUnavailable,
MessageId::ConversationModeSavedUnavailableNotice,
MessageId::ConversationModeResumeUnreadable,
MessageId::SessionArchiveExported,
MessageId::SessionArchiveSizes,
MessageId::SessionArchiveNoArtifacts,
+2
View File
@@ -11,6 +11,8 @@ pub struct AutoRouteDecisionEvidence {
pub probabilities_bp: BTreeMap<String, u16>,
pub confidence_bp: u16,
pub min_confidence_bp: u16,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub abstained_questions: Vec<String>,
/// `[auto] cost_saving` kept the fast tier because the strong tier's
/// probability was below the cost-saving floor.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
-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": []}),
+6
View File
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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).
- **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) (PR 7293).
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **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.
-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");
@@ -215,7 +215,6 @@ fn session_sync_payload(app: &App) -> SessionSyncPayload {
model: app.model.clone(),
workspace: app.workspace.clone(),
mode: super::to_command_mode(app.mode),
agent_preset: app.agent_preset(),
}
}
-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,
}
}
+1
View File
@@ -85,6 +85,7 @@ mod tests {
aliases: aliases.iter().map(|s| s.to_string()).collect(),
hidden,
plugin_authority: None,
workspace_sourced: false,
extension: None,
}
}
@@ -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,
+5
View File
@@ -296,6 +296,11 @@ impl codewhale_engine::command_catalog::CommandCatalog for CommandTable {
takes_arguments: metadata.takes_arguments(),
hidden: metadata.hidden,
extension: metadata.extension.is_some(),
plugin_id: metadata
.plugin_authority
.as_ref()
.map(|authority| authority.plugin_id.as_str().to_string()),
workspace_sourced: metadata.workspace_sourced,
builtin_bindings: bindings.remove(&metadata.name).unwrap_or_default(),
})
.collect()
-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());
}
+4
View File
@@ -80,6 +80,7 @@ pub struct UserCommandMetadata {
pub aliases: Vec<String>,
pub hidden: bool,
pub plugin_authority: Option<codewhale_config::PluginAuthority>,
pub workspace_sourced: bool,
/// Set for a command contributed by an extension-host plugin: it runs in
/// the host (`command/run`) instead of expanding `body`.
pub extension: Option<codewhale_engine::extension_host::command::ExtensionCommandRef>,
@@ -246,6 +247,7 @@ impl UserCommandRegistry {
let (mut metadata, errors) =
parse_metadata(entry.name, &entry.content, &entry.path);
metadata.plugin_authority = entry.plugin_authority;
metadata.workspace_sourced = entry.workspace_sourced;
(metadata, errors, entry.path, entry.workspace_sourced)
})
.collect::<Vec<_>>();
@@ -395,6 +397,7 @@ impl UserCommandRegistry {
aliases: Vec::new(),
hidden: false,
plugin_authority: Some(entry.authority),
workspace_sourced: false,
extension: Some(reference),
},
);
@@ -482,6 +485,7 @@ fn parse_metadata(
aliases: Vec::new(),
hidden: false,
plugin_authority: None,
workspace_sourced: false,
extension: None,
};
let mut configured_name = 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"]);
-33
View File
@@ -2277,8 +2277,6 @@ pub struct App {
pub undo_selection: bool,
/// Current session ID for auto-save updates
pub current_session_id: Option<String>,
pub(crate) conversation_mode: Option<String>,
pub(crate) conversation_mode_unavailable: Option<String>,
pub(crate) session_write_receipt:
Option<codewhale_engine::session_manager::SessionWriteReceipt>,
pub(crate) session_write_owner: Option<codewhale_engine::session_manager::SessionWriteOwner>,
@@ -5592,37 +5590,6 @@ impl App {
id
}
pub(crate) fn agent_preset(&self) -> Option<String> {
self.conversation_mode.clone()
}
pub(crate) fn conversation_mode_locked(&self) -> bool {
!self.api_messages.is_empty()
|| self.is_loading
|| !self.queued_messages.is_empty()
|| self.queued_draft.is_some()
|| self.pending_fresh_session.is_some()
}
pub(crate) fn install_conversation_mode(
&mut self,
stored: Option<String>,
unavailable: Option<String>,
) {
self.conversation_mode = stored;
self.conversation_mode_unavailable = unavailable;
self.needs_redraw = true;
}
pub(crate) fn conversation_mode_label(&self) -> Option<String> {
crate::tui::conversation_mode::label(
self.ui_locale,
self.conversation_mode.as_deref(),
self.conversation_mode_unavailable.as_deref(),
None,
)
}
pub(crate) fn set_current_session_id(&mut self, session_id: Option<String>) {
if self.current_session_id != session_id {
self.view_stack.remove_owned_evidence_pagers();
-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,
-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
-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)
}
-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;
-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?;
}
+1
View File
@@ -1771,6 +1771,7 @@ mod tests {
.collect(),
confidence_bp: 6400,
min_confidence_bp: 5000,
abstained_questions: Vec::new(),
cost_saving_kept_fast: false,
thinking: Some("max".to_string()),
provider_reported_cost_usd: Some("0.000019992".to_string()),
+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(())
}
-4
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;
}
@@ -8904,7 +8901,6 @@ mod launch_session_sync_tests {
model,
workspace: workspace.path().to_path_buf(),
mode: AppMode::Agent,
agent_preset: None,
})
.await
.expect("sync session");
-1
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
@@ -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));
-2
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;
@@ -34185,7 +34184,6 @@ fn stale_session_projection_cannot_rewind_a_host_launch() {
system_prompt: None,
instruction_assembly: None,
mode: "agent".into(),
agent_preset: None,
});
let (reply, mut answer) = tokio::sync::oneshot::channel();
let request = codewhale_engine::core::events::SessionCheckpointRequest::new(
@@ -1,338 +0,0 @@
use super::*;
use codewhale_engine::conversation_modes::{CREATOR_NOT_READY_REASON, CreatorAvailabilityGuard};
use codewhale_engine::core::engine::Engine;
use codewhale_engine::core::ops::Op;
fn creator_app() -> App {
let mut app = create_test_app();
app.install_conversation_mode(Some("creator".to_string()), None);
app
}
fn first_message(app: &mut App) {
app.push_api_message(text_message("user", "first message"));
}
fn sent_presets(rx: &mut tokio::sync::mpsc::Receiver<Op>) -> Vec<Option<String>> {
let mut presets = Vec::new();
while let Ok(op) = rx.try_recv() {
if let Op::SyncSession { agent_preset, .. } = op {
presets.push(agent_preset);
}
}
presets
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn mode_change_after_the_first_message_is_refused_as_locked() {
let _available = CreatorAvailabilityGuard::set(None);
let mut app = create_test_app();
let mut engine = mock_engine_handle();
let config = Config::default();
let done =
crate::tui::conversation_mode::set_in_place(&mut app, &config, &engine.handle, "creator")
.await
.expect("a blank conversation changes in place");
assert!(done.contains("Creator mode"), "{done}");
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
assert_eq!(
sent_presets(&mut engine.rx_op),
vec![Some("creator".to_string())]
);
first_message(&mut app);
for requested in ["standard", "creator"] {
let result = crate::tui::conversation_mode::set_in_place(
&mut app,
&config,
&engine.handle,
requested,
)
.await;
if requested == "creator" {
assert!(result.is_ok(), "naming the current mode is not a change");
} else {
let refusal = result.expect_err("locked after the first message");
assert!(refusal.contains("fixed"), "{refusal}");
assert!(refusal.contains("/new"), "{refusal}");
}
}
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
assert!(
sent_presets(&mut engine.rx_op).is_empty(),
"a refusal sends nothing"
);
let result = commands::execute("/mode standard", &mut app);
assert!(matches!(
result.action,
Some(AppAction::SetConversationMode { ref id }) if id == "standard"
));
let mut blank = create_test_app();
blank
.queued_messages
.push_back(crate::tui::app::QueuedMessage::new(
"queued".to_string(),
None,
));
assert!(
blank.conversation_mode_locked(),
"a queued message locks the mode"
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn unknown_and_unavailable_modes_are_refused_with_their_words() {
let mut app = create_test_app();
let engine = mock_engine_handle();
let config = Config::default();
{
let _available = CreatorAvailabilityGuard::set(None);
let refusal = crate::tui::conversation_mode::set_in_place(
&mut app,
&config,
&engine.handle,
"minimal",
)
.await
.expect_err("unknown id");
assert!(
refusal.contains("minimal") && refusal.contains("standard, creator"),
"{refusal}"
);
}
let _missing = CreatorAvailabilityGuard::set(Some(CREATOR_NOT_READY_REASON));
let refusal =
crate::tui::conversation_mode::set_in_place(&mut app, &config, &engine.handle, "creator")
.await
.expect_err("unavailable");
assert!(
refusal.contains("Not ready yet in this version."),
"{refusal}"
);
assert_eq!(app.conversation_mode, None);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn a_saved_unavailable_mode_loads_its_history_and_says_why() {
let _guard = CreatorAvailabilityGuard::set(Some(CREATOR_NOT_READY_REASON));
let hostile = [
"creator".to_string(),
"nonsense".to_string(),
"../x".to_string(),
String::new(),
"x".repeat(1_000_000),
"line\nbreak\u{1b}[31m".to_string(),
];
for stored in hostile {
let mut app = create_test_app();
let mut session = saved_session_with_messages(vec![text_message("user", "history line")]);
session.agent_preset = Some(stored.clone());
apply_loaded_session(&mut app, &mut Config::default(), &session)
.expect("the history loads");
assert_eq!(app.api_messages.len(), 1, "history is shown");
assert_eq!(
app.conversation_mode.as_deref(),
Some(stored.as_str()),
"never silently Standard"
);
assert!(app.conversation_mode_unavailable.is_some());
let label = app.conversation_mode_label().expect("a label");
assert!(label.contains("unavailable"), "{label}");
assert!(label.chars().count() <= 80 && !label.contains('\n') && !label.contains('\u{1b}'));
assert!(app.history.iter().any(|cell| matches!(
cell,
HistoryCell::System { content } if content.contains("which is not available here")
)));
}
let mut app = create_test_app();
let mut session = saved_session_with_messages(vec![text_message("user", "history line")]);
session.agent_preset = Some("creator".to_string());
apply_loaded_session(&mut app, &mut Config::default(), &session).expect("loads");
assert!(app.history.iter().any(|cell| matches!(
cell,
HistoryCell::System { content } if content.contains("Not ready yet in this version.")
)));
let mut standard = create_test_app();
let session = saved_session_with_messages(vec![text_message("user", "history line")]);
assert_eq!(session.agent_preset, None);
apply_loaded_session(&mut standard, &mut Config::default(), &session).expect("loads");
assert_eq!(standard.conversation_mode, None);
assert_eq!(standard.conversation_mode_label(), None);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn clear_fork_and_resume_keep_the_conversation_mode() {
let _available = CreatorAvailabilityGuard::set(None);
let _home = crate::test_support::SealedHome::new();
let mut app = creator_app();
first_message(&mut app);
let cleared = commands::execute("/clear", &mut app);
assert!(matches!(
cleared.action,
Some(AppAction::SyncSession { agent_preset: Some(ref preset), .. }) if preset == "creator"
));
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
let mut resumed = create_test_app();
let mut session = saved_session_with_messages(vec![text_message("user", "older")]);
session.agent_preset = Some("creator".to_string());
apply_loaded_session(&mut resumed, &mut Config::default(), &session).expect("resume");
assert_eq!(resumed.conversation_mode.as_deref(), Some("creator"));
assert_eq!(resumed.conversation_mode_unavailable, None);
assert_eq!(resumed.agent_preset().as_deref(), Some("creator"));
let manager = SessionManager::default_location().unwrap();
let mut snapshot = build_session_snapshot(&mut resumed, &manager).expect("snapshot");
assert_eq!(snapshot.agent_preset.as_deref(), Some("creator"));
snapshot.make_storage_compatible();
let raw = serde_json::to_value(&snapshot).unwrap();
assert_eq!(raw["agent_preset"], "creator");
let mut plain = create_test_app();
let snapshot = build_session_snapshot(&mut plain, &manager).expect("snapshot");
let raw = serde_json::to_value(&snapshot).unwrap();
assert!(raw.get("agent_preset").is_none(), "Standard stores no key");
let mut parent = creator_app();
first_message(&mut parent);
parent.current_session_id = Some(uuid::Uuid::new_v4().to_string());
parent.capture_session_fixture_source();
let forked = commands::execute("/fork", &mut parent);
assert!(!forked.is_error, "{:?}", forked.message);
assert!(matches!(
forked.action,
Some(AppAction::SyncSession { agent_preset: Some(ref preset), .. }) if preset == "creator"
));
let fork_id = parent.current_session_id.clone().unwrap();
let on_disk = manager.load_session(&fork_id).expect("fork record");
assert_eq!(on_disk.agent_preset.as_deref(), Some("creator"));
}
#[tokio::test(flavor = "multi_thread")]
#[allow(clippy::await_holding_lock)]
async fn a_resync_of_the_open_conversation_keeps_its_mode() {
let _available = CreatorAvailabilityGuard::set(None);
let _home = crate::test_support::SealedHome::new();
let workspace = TempDir::new().unwrap();
let other = TempDir::new().unwrap();
let mut config = Config::default().with_legacy_root(
Some("mock-credential".into()),
Some("http://127.0.0.1:1/v1".into()),
);
config.set_feature("mcp", false).unwrap();
config.set_feature("subagents", false).unwrap();
let mut app = App::new(
crate::tui::test_support::test_tui_options(workspace.path()),
&config,
);
app.onboarding_needs_api_key = false;
app.offline_mode = false;
app.install_conversation_mode(Some("creator".to_string()), None);
first_message(&mut app);
app.set_current_session_id(Some(uuid::Uuid::new_v4().to_string()));
let (engine, mut handle) = Engine::new_with_model_client(
codewhale_engine::core::engine::EngineConfig {
workspace: workspace.path().to_path_buf(),
model: app.model.clone(),
snapshots_enabled: false,
subagents_enabled: false,
terminal_chrome_enabled: false,
..Default::default()
},
&config,
Arc::new(codewhale_engine::llm_client::mock::MockLlmClient::new(
Vec::new(),
)),
);
let run = tokio::spawn(engine.run());
let tasks = {
use codewhale_engine::task_manager::{
TaskExecutionResult, TaskManagerConfig, TaskStatus, TaskTerminalReason,
};
struct Idle;
#[async_trait::async_trait]
impl codewhale_engine::task_manager::TaskExecutor for Idle {
async fn execute(
&self,
_: codewhale_engine::task_manager::ExecutionTask,
_: tokio::sync::mpsc::Sender<codewhale_engine::task_manager::TaskExecutionEvent>,
_: tokio_util::sync::CancellationToken,
) -> TaskExecutionResult {
TaskExecutionResult {
status: TaskStatus::Completed,
result_text: None,
error: None,
terminal_reason: TaskTerminalReason::Completed,
}
}
}
TaskManager::start_with_executor(
TaskManagerConfig {
data_dir: workspace.path().into(),
worker_count: 1,
default_workspace: workspace.path().into(),
default_model: "fixture".into(),
default_mode: "plan".into(),
allow_shell: false,
trust_mode: false,
execution_limits: codewhale_engine::task_manager::TaskExecutionLimits::default(),
human_waits_answerable: false,
},
Arc::new(Idle),
)
.await
.unwrap()
};
let mut backend = ColorCompatBackend::new(
std::io::stdout(),
codewhale_palette::ColorDepth::Monochrome,
codewhale_palette::PaletteMode::Dark,
);
backend.set_terminal_size(ratatui::layout::Size::new(80, 24));
let mut terminal = Terminal::new(backend).unwrap();
for action in [
AppAction::SwitchWorkspace {
workspace: other.path().to_path_buf(),
},
AppAction::PluginRegistryChanged,
] {
let result = commands::CommandResult {
message: None,
action: Some(action),
is_error: false,
};
tokio::time::timeout(
Duration::from_secs(60),
apply_command_result(
&mut terminal,
&mut app,
&mut handle,
&tasks,
&mut config,
result,
),
)
.await
.expect("the re-sync finished")
.unwrap();
let snapshot = tokio::time::timeout(Duration::from_secs(20), handle.get_session_snapshot())
.await
.expect("the Engine answered")
.expect("snapshot");
assert_eq!(snapshot.agent_preset.as_deref(), Some("creator"));
assert_eq!(snapshot.messages.len(), 1);
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
}
let _ = handle.send(Op::Shutdown).await;
run.abort();
}
@@ -70,7 +70,6 @@ fn sync_snapshot(
model: snapshot.model.clone(),
workspace: snapshot.workspace.clone(),
mode: AppMode::parse(&snapshot.mode).unwrap(),
agent_preset: snapshot.agent_preset.clone(),
}
}
@@ -478,7 +477,6 @@ fn undo_retry_apply_path_keeps_engine_request_and_reopened_session_consistent()
model: app.model.clone(),
workspace: app.workspace.clone(),
mode: app.mode,
agent_preset: app.agent_preset(),
};
for action in [restore, AppAction::SendMessage("after reopen".into())] {
apply_command_result(
-3
View File
@@ -722,9 +722,6 @@ pub(crate) fn dial_chip(app: &App) -> PostureChip {
if let Some(scope) = filesystem_scope_notice(app) {
chip.0 = format!("{} · {scope}", chip.0).into();
}
if let Some(label) = app.conversation_mode_label() {
chip.0 = format!("{label} · {}", chip.0).into();
}
chip
}
-3
View File
@@ -997,9 +997,6 @@ pub enum ViewEvent {
ModeSelected {
mode: codewhale_config::AppMode,
},
ConversationModeSelected {
id: String,
},
/// Emitted by the `/statusline` picker every time the user toggles an
/// item (live preview) and once more on Enter (final). The handler
/// updates `app.status_items` immediately and persists on `final_save`
+130 -427
View File
@@ -1,4 +1,4 @@
//! `/mode` picker: the conversation's mode first, then permissions.
//! `/mode` picker for the currently supported interactive modes.
use std::cell::RefCell;
@@ -12,140 +12,56 @@ use ratatui::{
};
use unicode_width::UnicodeWidthStr;
use crate::tui::app::{App, AppModeUi};
use crate::tui::conversation_mode as modes;
use crate::tui::app::AppModeUi;
use crate::tui::views::{
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
render_modal_footer, render_modal_surface,
};
use codewhale_config::AppMode;
use codewhale_engine::conversation_modes::ConversationModeTable;
use codewhale_localization::{Locale, MessageId, tr};
use codewhale_localization::Locale;
use codewhale_palette as palette;
use codewhale_runtime::config::Config;
use codewhale_tui_kit::menu_style;
const VISIBLE_MODES: [AppMode; 2] = [AppMode::Agent, AppMode::Plan];
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConversationRow {
id: String,
name: String,
description: String,
unavailable: Option<String>,
current: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Entry {
Conversation(usize),
Permission(AppMode),
}
pub struct ModePickerView {
cursor: usize,
locale: Locale,
rows: Vec<ConversationRow>,
locked: bool,
other_command: String,
row_hitboxes: RefCell<Vec<Rect>>,
}
impl ModePickerView {
pub async fn for_app(app: &App, config: &Config) -> Self {
let table = modes::current_table(config).await;
Self::from_table(
app.mode,
app.ui_locale,
&table,
app.conversation_mode.as_deref(),
app.conversation_mode_locked(),
)
}
#[must_use]
pub fn from_table(
current: AppMode,
locale: Locale,
table: &ConversationModeTable,
conversation: Option<&str>,
locked: bool,
) -> Self {
let current_id = codewhale_engine::conversation_modes::effective_id(conversation);
let rows = table
.modes
pub fn new(current: AppMode, locale: Locale) -> Self {
let cursor = VISIBLE_MODES
.iter()
.map(|mode| ConversationRow {
id: mode.id.clone(),
name: modes::display_name(locale, mode),
description: modes::display_description(locale, mode),
unavailable: (!mode.available).then(|| {
modes::display_reason(
locale,
mode.reason
.as_deref()
.unwrap_or("This mode is unavailable."),
)
}),
current: mode.id == current_id,
})
.collect();
let other_command = modes::new_command(
table
.modes
.iter()
.find(|mode| mode.id != current_id)
.map_or(codewhale_engine::conversation_modes::STANDARD, |mode| {
mode.id.as_str()
}),
);
let mut view = Self {
cursor: 0,
locale,
rows,
locked,
other_command,
row_hitboxes: RefCell::new(Vec::new()),
};
view.cursor = view
.entries()
.iter()
.position(|entry| *entry == Entry::Permission(current))
.position(|mode| *mode == current)
.unwrap_or(0);
view
}
fn entries(&self) -> Vec<Entry> {
let mut entries = Vec::new();
if !self.locked {
entries.extend(
self.rows
.iter()
.enumerate()
.filter(|(_, row)| row.unavailable.is_none())
.map(|(index, _)| Entry::Conversation(index)),
);
}
entries.extend(VISIBLE_MODES.iter().copied().map(Entry::Permission));
entries
}
fn selected(&self) -> Option<Entry> {
self.entries().get(self.cursor).copied()
}
fn event_for(&self, entry: Entry) -> ViewEvent {
match entry {
Entry::Conversation(index) => ViewEvent::ConversationModeSelected {
id: self.rows[index].id.clone(),
},
Entry::Permission(mode) => ViewEvent::ModeSelected { mode },
Self {
cursor,
locale,
row_hitboxes: RefCell::new(Vec::new()),
}
}
fn selected_mode(&self) -> AppMode {
VISIBLE_MODES
.get(self.cursor)
.copied()
.unwrap_or(AppMode::Agent)
}
/// Apply one [`list_nav`](codewhale_tui_kit::list_nav) motion (#6290), returning
/// whether it was consumed. Vertical motions cover the whole list —
/// Home/End and the page keys included. The horizontal axis has nowhere
/// to go on a single-column surface, so those motions are not consumed.
fn apply_motion(&mut self, motion: codewhale_tui_kit::list_nav::Motion) -> bool {
let len = self.entries().len();
let Some(next) = codewhale_tui_kit::list_nav::apply(self.cursor, len, len, motion) else {
let Some(next) = codewhale_tui_kit::list_nav::apply(
self.cursor,
VISIBLE_MODES.len(),
VISIBLE_MODES.len(),
motion,
) else {
return false;
};
self.cursor = next;
@@ -153,22 +69,13 @@ impl ModePickerView {
}
fn select_by_number(&mut self, number: char) -> Option<ViewAction> {
let entries = self.entries();
let idx = entries.iter().position(
|entry| matches!(entry, Entry::Permission(mode) if mode.number() == number),
)?;
let idx = VISIBLE_MODES
.iter()
.position(|mode| mode.number() == number)?;
self.cursor = idx;
Some(ViewAction::EmitAndClose(self.event_for(entries[idx])))
}
fn line_count(&self) -> u16 {
let rows = u16::try_from(self.rows.len()).unwrap_or(u16::MAX);
let conversation = if self.rows.is_empty() {
0
} else {
2 + rows + u16::from(self.locked)
};
conversation + 1 + u16::try_from(VISIBLE_MODES.len()).unwrap_or(2)
Some(ViewAction::EmitAndClose(ViewEvent::ModeSelected {
mode: self.selected_mode(),
}))
}
}
@@ -182,6 +89,10 @@ impl ModalView for ModePickerView {
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
// Movement keys come from the shared vocabulary (#6290), so
// `j`/`k`, Home/End and the page keys mean here exactly what they
// mean on every other list. This match owns only the keys the
// vocabulary does not claim.
if let Some(motion) = codewhale_tui_kit::list_nav::motion(&key)
&& self.apply_motion(motion)
{
@@ -189,10 +100,9 @@ impl ModalView for ModePickerView {
}
match key.code {
KeyCode::Esc => ViewAction::Close,
KeyCode::Enter => match self.selected() {
Some(entry) => ViewAction::EmitAndClose(self.event_for(entry)),
None => ViewAction::None,
},
KeyCode::Enter => ViewAction::EmitAndClose(ViewEvent::ModeSelected {
mode: self.selected_mode(),
}),
KeyCode::Char(number) => self.select_by_number(number).unwrap_or(ViewAction::None),
_ => ViewAction::None,
}
@@ -223,18 +133,14 @@ impl ModalView for ModePickerView {
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup_height = self.line_count() + 7;
let popup_height = u16::try_from(VISIBLE_MODES.len()).unwrap_or(2) + 7;
let popup_area = centered_modal_area(area, 68, popup_height, 44, 8);
render_modal_surface(area, popup_area, buf);
let title = format!(
" {} ",
tr(self.locale, MessageId::ConversationModePickerTitle)
);
let block = Block::default()
.title(Line::from(Span::styled(
title,
" Mode ",
Style::default()
.fg(palette::WHALE_ACTION)
.add_modifier(Modifier::BOLD),
@@ -259,105 +165,47 @@ impl ModalView for ModePickerView {
self.row_hitboxes.borrow_mut().clear();
let entries = self.entries();
let heading_style = Style::default()
.fg(palette::TEXT_MUTED)
.add_modifier(Modifier::BOLD);
let muted = Style::default().fg(palette::TEXT_MUTED);
let width = usize::from(content.width);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut lines = Vec::with_capacity(VISIBLE_MODES.len());
let push_row = |lines: &mut Vec<Line<'static>>,
entry: Option<Entry>,
prefix: String,
text: String,
disabled: bool| {
let cursor_here = entry.is_some_and(|entry| entries.get(self.cursor) == Some(&entry));
let row_style = if cursor_here {
for (idx, mode) in VISIBLE_MODES.iter().copied().enumerate() {
let is_cursor = idx == self.cursor;
let row_style = if is_cursor {
menu_style::selected_row_style()
} else if disabled {
muted
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let hint_style = if cursor_here {
let hint_style = if is_cursor {
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
} else {
muted
Style::default().fg(palette::TEXT_MUTED)
};
let room = width.saturating_sub(UnicodeWidthStr::width(prefix.as_str()));
let text = codewhale_tui_kit::ui_text::semantic_truncate(&text, room);
if entry.is_some() {
let y = content
.y
.saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
self.row_hitboxes
.borrow_mut()
.push(Rect::new(content.x, y, content.width, 1));
}
lines.push(Line::from(vec![
Span::styled(prefix, row_style),
Span::styled(text, hint_style),
]));
};
if !self.rows.is_empty() {
lines.push(Line::from(Span::styled(
tr(self.locale, MessageId::ConversationModeGroupConversation).into_owned(),
heading_style,
)));
if self.locked {
lines.push(Line::from(Span::styled(
codewhale_tui_kit::ui_text::semantic_truncate(
tr(self.locale, MessageId::ConversationModeLockedNote)
.replace("{command}", &format!("`{}`", self.other_command))
.as_str(),
width,
),
muted,
)));
}
for (index, row) in self.rows.iter().enumerate() {
let selectable = !self.locked && row.unavailable.is_none();
let entry = selectable.then_some(Entry::Conversation(index));
let cursor_here =
entry.is_some_and(|entry| entries.get(self.cursor) == Some(&entry));
let pointer = if cursor_here {
codewhale_tui_kit::glyphs::selection_marker(true).to_string()
} else {
" ".to_string()
};
let check = if row.current { "✓" } else { " " };
let pad =
" ".repeat(16usize.saturating_sub(UnicodeWidthStr::width(row.name.as_str())));
let prefix = format!("{pointer} {check} {}{pad}", row.name);
let text = row
.unavailable
.clone()
.unwrap_or_else(|| row.description.clone());
push_row(
&mut lines,
entry,
prefix,
text,
row.unavailable.is_some() || self.locked,
);
}
lines.push(Line::default());
}
lines.push(Line::from(Span::styled(
tr(self.locale, MessageId::ConversationModeGroupPermissions).into_owned(),
heading_style,
)));
for mode in VISIBLE_MODES {
let entry = Entry::Permission(mode);
let cursor_here = entries.get(self.cursor) == Some(&entry);
let pointer = codewhale_tui_kit::glyphs::selection_marker(cursor_here);
let pointer = codewhale_tui_kit::glyphs::selection_marker(is_cursor);
let name = mode.display_name_localized(self.locale);
let hint = mode.picker_hint_localized(self.locale);
// Pad by terminal columns, not scalar count, so wide (CJK) mode
// names keep the hint column aligned.
let pad = " ".repeat(8usize.saturating_sub(UnicodeWidthStr::width(&*name)));
let prefix = format!("{pointer} {}. {name}{pad}", mode.number());
push_row(&mut lines, Some(entry), prefix, hint.into_owned(), false);
// A hint is prose: the pane edge used to cut it mid-word
// (`ask f`, `before ac`). Truncate at a word joint instead —
// a clipped clause reads as a sentence, a clipped word reads
// as a bug.
let hint_width =
usize::from(content.width).saturating_sub(UnicodeWidthStr::width(prefix.as_str()));
let hint = codewhale_tui_kit::ui_text::semantic_truncate(hint.as_ref(), hint_width);
lines.push(Line::from(vec![
Span::styled(prefix, row_style),
Span::styled(hint, hint_style),
]));
self.row_hitboxes.borrow_mut().push(Rect::new(
content.x,
content
.y
.saturating_add(u16::try_from(idx).unwrap_or(u16::MAX)),
content.width,
1,
));
}
Paragraph::new(lines).render(content, buf);
@@ -367,31 +215,18 @@ impl ModalView for ModePickerView {
#[cfg(test)]
mod tests {
use super::*;
use codewhale_engine::conversation_modes::{
self, CREATOR_NOT_READY_REASON, CreatorAvailabilityGuard,
};
use crossterm::event::KeyModifiers;
use ratatui::{Terminal, backend::TestBackend};
fn picker(current: AppMode, conversation: Option<&str>, locked: bool) -> ModePickerView {
ModePickerView::from_table(
current,
Locale::En,
&conversation_modes::table(Some(CREATOR_NOT_READY_REASON.to_string())),
conversation,
locked,
)
}
#[test]
fn opens_on_current_mode() {
let view = picker(AppMode::Plan, None, false);
assert_eq!(view.selected(), Some(Entry::Permission(AppMode::Plan)));
let view = ModePickerView::new(AppMode::Plan, Locale::En);
assert_eq!(view.selected_mode(), AppMode::Plan);
}
#[test]
fn enter_emits_selected_mode() {
let mut view = picker(AppMode::Agent, None, false);
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match action {
@@ -402,27 +237,29 @@ mod tests {
}
}
/// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
/// every overlay to remain readable and fully operable at.
const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
fn render_view(view: ModePickerView, width: u16, height: u16) -> (Buffer, Rect) {
fn render_at(width: u16, height: u16) -> (Buffer, Rect) {
use crate::tui::views::ViewStack;
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
// Pre-fill with a sentinel so any cell the composited modal fails to
// paint (bleed-through) is detectable as a surviving 'X'.
for y in 0..height {
for x in 0..width {
buf[(x, y)].set_symbol("X");
}
}
// Render through the ViewStack so the shared opaque backdrop is painted
// exactly as it is in production.
let mut stack = ViewStack::new();
stack.push(view);
stack.push(ModePickerView::new(AppMode::Agent, Locale::En));
stack.render(area, &mut buf);
(buf, area)
}
fn render_at(width: u16, height: u16) -> (Buffer, Rect) {
render_view(picker(AppMode::Agent, None, false), width, height)
}
fn rows(buf: &Buffer, area: Rect) -> Vec<String> {
(0..area.height)
.map(|y| {
@@ -433,64 +270,42 @@ mod tests {
.collect()
}
fn frame_text(buf: &Buffer, area: Rect) -> String {
rows(buf, area)
.iter()
.map(|row| row.trim_end().to_string())
.filter(|row| !row.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn mode_picker_is_usable_and_opaque_at_blocker_sizes() {
for (w, h) in BLOCKER_SIZES {
for view in [
picker(AppMode::Agent, None, false),
picker(AppMode::Plan, Some("creator"), true),
] {
let (buf, area) = render_view(view, w, h);
let text = rows(&buf, area).join("\n");
let (buf, area) = render_at(w, h);
let text = rows(&buf, area).join("\n");
assert!(text.contains("move"), "{w}x{h}: missing 'move' hint");
assert!(text.contains("select"), "{w}x{h}: missing 'select' hint");
assert!(text.contains("cancel"), "{w}x{h}: missing 'cancel' hint");
// Action labels are present (footer never drops an action).
assert!(text.contains("move"), "{w}x{h}: missing 'move' hint");
assert!(text.contains("select"), "{w}x{h}: missing 'select' hint");
assert!(text.contains("cancel"), "{w}x{h}: missing 'cancel' hint");
// The cursor row carries the charter selection pointer.
assert!(
text.contains(codewhale_tui_kit::glyphs::SELECTION),
"{w}x{h}: missing charter selection pointer"
);
// Composited frame is fully opaque: no sentinel survives and every
// cell carries the modal/backdrop ink background.
assert!(
!text.contains('X'),
"{w}x{h}: background bleed-through into modal surface"
);
let center = &buf[(w / 2, h / 2)];
assert_eq!(
center.bg,
palette::WHALE_BG,
"{w}x{h}: modal interior must be opaque"
);
// No row exceeds the frame width (no horizontal overflow).
for (y, row) in rows(&buf, area).iter().enumerate() {
assert!(
text.contains("Conversation mode") && text.contains("Permissions"),
"{w}x{h}: both groups are labelled"
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
"{w}x{h}: row {y} overflows width: {row:?}"
);
assert!(
text.contains(codewhale_tui_kit::glyphs::SELECTION),
"{w}x{h}: missing charter selection pointer"
);
assert!(
!text.contains('X'),
"{w}x{h}: background bleed-through into modal surface"
);
let lines = rows(&buf, area);
let heading_row = lines
.iter()
.position(|row| row.contains("Conversation mode"))
.expect("heading row");
let heading_byte = lines[heading_row]
.find("Conversation mode")
.expect("heading column");
let heading_x = lines[heading_row][..heading_byte].chars().count();
let padding = &buf[(
u16::try_from(heading_x + "Conversation mode".len() + 3).unwrap(),
u16::try_from(heading_row).unwrap(),
)];
assert_eq!(
padding.bg,
palette::WHALE_BG,
"{w}x{h}: modal interior must be opaque"
);
for (y, row) in rows(&buf, area).iter().enumerate() {
assert!(
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
"{w}x{h}: row {y} overflows width: {row:?}"
);
}
}
}
}
@@ -504,7 +319,8 @@ mod tests {
#[test]
fn number_keys_select_modes() {
let mut view = picker(AppMode::Agent, None, false);
// Visible roster: 1 Act, 2 Plan.
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
let action = view.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert!(matches!(
action,
@@ -513,21 +329,30 @@ mod tests {
})
));
for key in ['3', '4', '5'] {
let mut view = picker(AppMode::Agent, None, false);
let action = view.handle_key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
assert!(matches!(action, ViewAction::None), "{key}");
}
// Retired Operate number (3) selects nothing.
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
let action = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
assert!(matches!(action, ViewAction::None));
// Legacy YOLO shorthand (4) is not offered by the picker.
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
let action = view.handle_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE));
assert!(matches!(action, ViewAction::None));
// Old Operate number (5) is gone — no numeric gaps.
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
let action = view.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE));
assert!(matches!(action, ViewAction::None));
}
#[test]
fn mouse_click_renders_and_selects_mode_row() {
let mut view = picker(AppMode::Agent, None, false);
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
let mut terminal = Terminal::new(TestBackend::new(100, 30)).expect("test terminal");
terminal
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
.expect("render mode picker");
let rect = view.row_hitboxes.borrow()[2];
let rect = view.row_hitboxes.borrow()[1];
let action = view.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: rect.x,
@@ -541,126 +366,4 @@ mod tests {
})
));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn mode_picker_lists_conversation_modes_first_and_locks_after_first_message() {
let _available = CreatorAvailabilityGuard::set(None);
let table = conversation_modes::current_table(Default::default()).await;
for (w, h) in [(80, 24), (120, 32)] {
let open = ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
let (buf, area) = render_view(open, w, h);
let text = frame_text(&buf, area);
println!("--- /mode picker, blank conversation, creator available, {w}x{h}\n{text}");
let order = |needle: &str| {
text.find(needle)
.unwrap_or_else(|| panic!("{needle}: {text}"))
};
assert!(order("Conversation mode") < order("Standard mode"));
assert!(order("Standard mode") < order("Creator mode"));
assert!(order("Creator mode") < order("Permissions"));
assert!(order("Permissions") < order("1. Work"));
assert!(!text.contains("Fixed after the first message"));
let mut open =
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
open.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
open.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(
open.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
ViewAction::EmitAndClose(ViewEvent::ConversationModeSelected { ref id }) if id == "standard"
));
let locked = ModePickerView::from_table(
AppMode::Agent,
Locale::En,
&table,
Some("creator"),
true,
);
let (buf, area) = render_view(locked, w, h);
let text = frame_text(&buf, area);
println!(
"--- /mode picker, after the first message, creator conversation, {w}x{h}\n{text}"
);
assert!(
text.contains("Fixed after the first message. Start another with /new creator.")
);
assert!(text.contains("Creator mode"), "{text}");
}
let mut locked =
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, Some("creator"), true);
assert_eq!(
locked.entries().len(),
2,
"only the permission rows stay selectable"
);
for key in [KeyCode::Up, KeyCode::Up, KeyCode::Home] {
locked.handle_key(KeyEvent::new(key, KeyModifiers::NONE));
assert!(matches!(locked.selected(), Some(Entry::Permission(_))));
}
for number in ['1', '2'] {
let mut locked =
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, true);
assert!(matches!(
locked.handle_key(KeyEvent::new(KeyCode::Char(number), KeyModifiers::NONE)),
ViewAction::EmitAndClose(ViewEvent::ModeSelected { .. })
));
}
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn creator_row_is_listed_disabled_with_its_reason_while_unavailable() {
let runtime_missing =
"Creator mode needs a plugin runtime (Node.js or Bun), and none was found.";
for reason in [CREATOR_NOT_READY_REASON, runtime_missing] {
let _guard = CreatorAvailabilityGuard::set(Some(reason));
let table = conversation_modes::current_table(Default::default()).await;
let view = ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
let (buf, area) = render_view(view, 100, 30);
let text = frame_text(&buf, area);
println!("--- /mode picker, creator unavailable ({reason})\n{text}");
assert!(
text.contains("Creator mode"),
"listed, never hidden: {text}"
);
let shown = if reason == CREATOR_NOT_READY_REASON {
"Not ready yet in this version."
} else {
"Creator mode needs a plugin runtime"
};
assert!(text.contains(shown), "{text}");
assert!(!text.contains(CREATOR_NOT_READY_REASON));
let mut view =
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
assert_eq!(view.entries().len(), 3);
for key in [
KeyCode::Up,
KeyCode::Up,
KeyCode::Up,
KeyCode::Home,
KeyCode::End,
] {
let action = view.handle_key(KeyEvent::new(key, KeyModifiers::NONE));
assert!(matches!(action, ViewAction::None));
}
let mut view =
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
view.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
!matches!(action, ViewAction::EmitAndClose(ViewEvent::ConversationModeSelected { ref id }) if id == "creator"),
"an unavailable mode is never selectable"
);
}
let _guard = CreatorAvailabilityGuard::set(None);
let table = conversation_modes::current_table(Default::default()).await;
let mut view = ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(
view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
ViewAction::EmitAndClose(ViewEvent::ConversationModeSelected { ref id }) if id == "creator"
));
}
}
@@ -1,79 +0,0 @@
use std::time::Duration;
use super::qa_harness::{
harness::{Harness, make_sealed_workspace},
keys,
};
const WAIT: Duration = Duration::from_secs(15);
fn print_frame(label: &str, tui: &mut Harness) {
tui.pump();
let text = tui.frame().text();
println!("--- {label}");
for line in text.lines().filter(|line| !line.trim().is_empty()) {
println!("{}", line.trim_end());
}
}
#[test]
fn shipped_binary_lists_creator_disabled_and_refuses_new_creator_with_the_reason() {
let workspace = make_sealed_workspace().expect("sealed workspace");
std::fs::write(workspace.home().join(".codewhale/.onboarded"), "").unwrap();
let trust = workspace.workspace().join(".deepseek");
std::fs::create_dir_all(&trust).unwrap();
std::fs::write(trust.join("trusted"), "").unwrap();
let mut tui = Harness::builder(Harness::codewhale_binary())
.cwd(workspace.workspace())
.clear_env()
.seal_home(workspace.home())
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
.env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
.env("CODEWHALE_NO_UPDATE_CHECK", "1")
.env("CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE", "1")
.env("NO_ANIMATIONS", "1")
.env("RUST_LOG", "warn")
.args([
"--workspace",
workspace.workspace().to_str().unwrap(),
"--no-project-config",
"--skip-onboarding",
"--fresh",
])
.size(32, 100)
.spawn()
.expect("start TUI");
tui.wait_for_text("New session", WAIT).unwrap();
tui.wait_for_idle(Duration::from_millis(250), Duration::from_secs(5))
.unwrap();
tui.type_line("/new creator").unwrap();
tui.wait_for_text("Not ready yet in this version.", WAIT)
.unwrap();
print_frame("/new creator in the shipped binary", &mut tui);
assert!(
!tui.frame().contains("Creator mode"),
"the conversation stays Standard: {}",
tui.diagnostics()
);
tui.type_line("/mode").unwrap();
tui.wait_for_text("Conversation mode", WAIT).unwrap();
tui.wait_for_text("Permissions", WAIT).unwrap();
print_frame("/mode picker in the shipped binary", &mut tui);
let frame = tui.frame().text();
let creator_row = frame
.lines()
.find(|line| line.contains("Creator mode"))
.expect("Creator is listed");
assert!(
creator_row.contains("Not ready yet in this version."),
"{creator_row}"
);
let first_group = frame.find("Conversation mode").unwrap();
let second_group = frame.find("Permissions").unwrap();
assert!(first_group < frame.find("Standard mode").unwrap());
assert!(frame.find("Creator mode").unwrap() < second_group);
tui.send(keys::key::esc()).unwrap();
tui.shutdown();
}
-2
View File
@@ -21,8 +21,6 @@ mod automations_editor_pty;
mod config_theme_nav_pty;
#[cfg(all(unix, feature = "long-running-tests"))]
mod contextual_tips_pty;
#[cfg(all(unix, feature = "long-running-tests"))]
mod conversation_mode_pty;
mod core_session_command_extraction;
mod directory_listing_acceptance;
mod epic_acceptance_harness;
+1 -1
View File
@@ -545,7 +545,7 @@ Return `{kind:'deny', reason}`, `{kind:'ask', reason?}`, `{kind:'revise', input}
Native hooks run first, then mod listeners in core registration order. Denial always wins; the last accepted input revision wins. Rust re-prepares revised arguments and reruns the existing authority, policy, and approval checks. Input revisions use the existing 32 KiB hook limit; context and reasons use the existing sanitizers. Unloading a fiber removes its listener, and disabling or revoking an owner retires its admitted handles before host teardown. Other workspaces do not receive the call.
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Sessions an editor drives over ACP (`codewhale serve --acp`) get the same extension host, under the same `[features]` flags (`extension_host`, `mcp`): reviewed plugin tools (TypeScript and DSH) and the MCP servers configured in Codewhale appear as ACP tool calls and ask through the session's permission setting; Plan stays read-only. The ACP server's capabilities say so (`agentCapabilities._meta.codewhale`). Servers an ACP client passes in `mcpServers` and configured shell hooks are not attached. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Sessions an editor drives over ACP (`codewhale serve --acp`) get the same extension host, under the same `[features]` flags (`extension_host`, `mcp`): reviewed plugin tools (TypeScript and DSH) and the MCP servers configured in Codewhale appear as ACP tool calls and ask through the session's permission setting; Plan stays read-only. When a plugin tool asks the core to run a tool that needs approval (`exec.core`), the editor's permission request is shown on the plugin tool's call and names the nested tool and its input. The ACP server's capabilities say so (`agentCapabilities._meta.codewhale`). Servers an ACP client passes in `mcpServers` and configured shell hooks are not attached. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
## Asking the core to run a tool
-17
View File
@@ -40,23 +40,6 @@ Run `/mode` to open the mode picker, or switch directly with `/mode work` or
`/mode plan`. `operate` and its spellings still parse, as Work, and say that
Operate was retired, so an older setting or script never errors.
The picker has two groups. **Conversation mode** lists the modes the Engine
offers, Standard mode and Creator mode, each with a one-line description; a
mode that cannot be used yet is listed, disabled, with its reason. **Permissions**
holds Work and Plan, still on `1` and `2`; the conversation modes have no
number keys. A conversation's mode is chosen before its first message and is
fixed after it: `/mode creator` or the picker's first group changes a blank
conversation in place, and after the first message the picker shows the group
as locked and `/mode creator` is refused with a note to start a new
conversation. `/new` starts a Standard conversation, `/new creator` starts a
Creator mode conversation (`--force` still combines). Resume, `/load`, `/fork`
and `/clear` keep the conversation's saved mode, and the mode name appears in the
footer when it is not Standard. A mode never changes a permission: Creator
mode in Plan is still Plan. `codewhale` and `codewhale exec` take
`--conversation-mode <MODE>` to start a new session in a mode; with `--resume`,
`--session-id` or `--continue` it must match the session's saved mode, or the
run exits with code 2 before any model call.
- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.
- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.
- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = "operate"` loads as Work and says so once; a Runtime API request with `mode: "operate"` runs as `agent` with the default posture (Accept Edits) and logs one notice per process, never an error. Agents forked from an older Operate conversation do not inherit its contract. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.
+41 -1
View File
@@ -886,6 +886,13 @@ Plugin tools and configured MCP servers on this transport are on main after
0.10.2; 0.10.2 itself does not have them.
Client-supplied MCP servers, dynamic tools, task/PTY/background shell,
interpreter, subagent and RLM lifecycles are unavailable on this transport.
ACP has no tool search, so every connected MCP tool and its schema is sent with
every request, and a server marked `lazy = true` never starts. A large MCP set
therefore costs tokens on each turn and can exceed a provider's tool limit.
MCP servers a model starts at run time stay out of an ACP-profile host. When
`serve --acp` instead attaches to a running Runtime owner (see below), its turns
use that owner's MCP pool, which includes servers started at run time in other
sessions of the same workspace.
Built-in overrides remove the
whole compatibility alias family. Final dispatch rechecks the profile, so a
fabricated alias, hook rewrite or omitted catalog entry cannot bypass it. Plan
@@ -906,7 +913,11 @@ produced.
Tool updates begin `pending`. `in_progress` means Core reached final dispatch;
`completed`/`failed` and typed image blocks come from the actual Core result.
`session/request_permission` correlates a private JSON-RPC ID to the same
Runtime-minted pending approval and Core execution ID. Only an exact live
Runtime-minted pending approval and Core execution ID. When a plugin tool asks
the core to run a tool that needs approval (`exec.core`), the request is shown
on the plugin tool's call, names the nested tool and carries its input, and the
answer releases the nested call; the nested call is decided under the session's
permission setting. Only an exact live
`allow-once` response can release that waiter; wrong IDs are ignored, invalid
options deny, and cancellation withdraws it. ACP grants neither remembered
permissions nor Native capabilities. Core typed rules, strict hooks, repo law,
@@ -2672,6 +2683,35 @@ thread deletion.
pure over the snapshot — so a client must not look for one. Anything that
mutates the session stays a terminal-side command.
**Plugin index**
- `GET /v1/apps/plugin-index` — `{workspace, items: [...], unavailable: [...],
problems: [...]}`: one list across every plugin, skill, connector, hook and
hand-written command. It is a projection: each item's `row` is the row its
owner's own route returns (`/v1/apps/plugins`, `/v1/apps/plugins/catalog`,
`/v1/skills`, `/v1/apps/mcp/servers`, `/v1/hooks`, `/v1/commands`), and
`owner` names that route. Per item: `id`, `kind` (`plugin`, `skill`,
`connector`, `hook`, `command`), `name`, `description`, `source`
(`built_in`, `user`, `project`, `other_app`, `runtime`, `plugin`), `state`
(`enabled`, `disabled`, `needs_review`, `needs_sign_in`, `unavailable`) and
`labels` (what the item adds: `skill`, `connector`, `hook`, `command`,
`tool`, `profile`). A plugin item also has `adds` (`skills`, `connectors`,
`hooks`, `commands`, `profiles`: names, not rows), and what a plugin adds is
not listed a second time as an item of its own. A built-in switch is a
`plugin` item with a `tier` and no labels. `/v1/commands` rows carry
`plugin_id` (the same id as `/v1/apps/plugins`, and the `plugin:<id>` item)
for a command a plugin adds, and `workspace_sourced` for one read from the
project; a command item's `plugin` is that id. An owner that fails is named in
`unavailable` and the other kinds still list; `problems` carries the owners'
load warnings. Names under `adds` are file names, so a profile's `id` inside its
file can differ; a command another plugin registers through the extension host
is an item with `source` `plugin`. For an active plugin `adds.commands` comes
from the `/v1/commands` rows; for any other plugin, and for `hooks` and
`profiles`, it is the file names found in the bundle, at most 512 per kind,
with names holding control characters left out; a cut list is reported
under `problems`. A hook's row names no plugin, so `adds.hooks` stays file
names. `tool` means the plugin declares native or language-server components;
a screen a plugin draws is not labelled yet.
**Hooks**
- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],
problems: [...]}`: the hook set Runtime API threads run for that workspace
+2 -1
View File
@@ -54,6 +54,7 @@ registration index.
| POST | `/v1/apps/mcp/servers/{name}/enable` |
| POST | `/v1/apps/mcp/servers/{name}/reconnect` |
| GET | `/v1/apps/mcp/tools` |
| GET | `/v1/apps/plugin-index` |
| GET | `/v1/apps/plugins` |
| DELETE / GET | `/v1/apps/plugins/{selector}` |
| POST | `/v1/apps/plugins/{selector}/disable` |
@@ -263,4 +264,4 @@ registration index.
| GET / POST | `/v1/worktrees` |
| POST | `/v1/worktrees/remove` |
The source contains 243 distinct literal paths in this table.
The source contains 244 distinct literal paths in this table.
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more