mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
Compare commits
37
Commits
No files matched your search
+51
-8
@@ -4,11 +4,12 @@
|
||||
# alias = Display Name <[email protected]>
|
||||
#
|
||||
# The right-hand side must use GitHub's numeric noreply address so a harvested
|
||||
# commit authored with it links to the contributor's account. Only commit
|
||||
# authorship puts someone in the repository's contributor list; a co-author
|
||||
# trailer shows on their profile but not there. The left-hand side may be a
|
||||
# GitHub login, old-style noreply address, raw email from a contributor commit,
|
||||
# or local machine email seen in older harvested history.
|
||||
# commit authored with it, or a Co-authored-by trailer in the commit's final
|
||||
# trailer block, links to the contributor's account. GitHub resolves the
|
||||
# address by its numeric id, so the id must be the contributor's own
|
||||
# (`gh api users/<login> --jq .id`). The left-hand side may be a GitHub login,
|
||||
# old-style noreply address, raw email from a contributor commit, or local
|
||||
# machine email seen in older harvested history.
|
||||
|
||||
hmbown = Hmbown <[email protected]>
|
||||
[email protected] = Hmbown <[email protected]>
|
||||
@@ -50,6 +51,7 @@ Oliver-ZPLiu = Oliver-ZPLiu <[email protected]>
|
||||
idling11 = idling11 <[email protected]>
|
||||
Hanmiao Li = idling11 <[email protected]>
|
||||
[email protected] = idling11 <[email protected]>
|
||||
894876246 = idling11 <[email protected]>
|
||||
angziii = angziii <[email protected]>
|
||||
aboimpinto = aboimpinto <[email protected]>
|
||||
Paulo Aboim Pinto = aboimpinto <[email protected]>
|
||||
@@ -79,19 +81,24 @@ Vince = Liu-Vince <[email protected]>
|
||||
[email protected] = Liu-Vince <[email protected]>
|
||||
LI-Jialu = LI-Jialu <[email protected]>
|
||||
JiarenWang = JiarenWang <[email protected]>
|
||||
wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com>
|
||||
wdw8276 = kitty <3972439+mars-base@users.noreply.github.com>
|
||||
mars-base = kitty <[email protected]>
|
||||
[email protected] = kitty <[email protected]>
|
||||
pengyou200902 = pengyou200902 <[email protected]>
|
||||
linzhiqin2003 = linzhiqin2003 <[email protected]>
|
||||
LING71671 = LING71671 <[email protected]>
|
||||
[email protected] = LING71671 <[email protected]>
|
||||
JasonOA888 = JasonOA888 <[email protected]>
|
||||
Inference1 = Inference1 <[email protected]>
|
||||
hongqitai = hongqitai <[email protected]>
|
||||
gordonlu = gordonlu <[email protected]>
|
||||
angus-guo = gus <217034332+angus-[email protected]>
|
||||
gus.guo@tec-do.com = gus <217034332+angus-[email protected]>
|
||||
angus-guo = gus <217034332+im[email protected]>
|
||||
imgusguo = gus <217034332+im[email protected]>
|
||||
[email protected] = gus <[email protected]>
|
||||
gaord = gaord <[email protected]>
|
||||
Ben Gao = gaord <[email protected]>
|
||||
[email protected] = gaord <[email protected]>
|
||||
bengao168 = gaord <[email protected]>
|
||||
MXAntian = MXAntian <[email protected]>
|
||||
[email protected] = MXAntian <[email protected]>
|
||||
DarrellThomas = Darrell Thomas <[email protected]>
|
||||
@@ -247,6 +254,7 @@ [email protected] = zhuowp <[email protected]>
|
||||
[email protected] = 秋月凉梦 <[email protected]>
|
||||
|
||||
asto18089 = asto18089 <[email protected]>
|
||||
[email protected] = asto18089 <[email protected]>
|
||||
xiechimon = Xmon Dai <[email protected]>
|
||||
yrk111222 = Rongkun Yan <[email protected]>
|
||||
VincentCorleone = Vincent Corleone <[email protected]>
|
||||
@@ -278,3 +286,38 @@ Felixkw12 = Felix Wunderlich <[email protected]>
|
||||
[email protected] = Felix Wunderlich <[email protected]>
|
||||
Felix Wunderlich = Felix Wunderlich <[email protected]>
|
||||
[email protected] = luochen211 <[email protected]>
|
||||
|
||||
AiurArtanis = AiurArtanis <[email protected]>
|
||||
[email protected] = AiurArtanis <[email protected]>
|
||||
Apeiron0w0 = Apeiron <[email protected]>
|
||||
[email protected] = Apeiron <[email protected]>
|
||||
[email protected] = ChaceLyee2101 <[email protected]>
|
||||
AmosLeeeeeeee = ChaceLyee2101 <[email protected]>
|
||||
ddlw = ddlw <[email protected]>
|
||||
mo-vic = movic <[email protected]>
|
||||
[email protected] = movic <[email protected]>
|
||||
[email protected] = merchloubna70-dot <[email protected]>
|
||||
[email protected] = merchloubna70-dot <[email protected]>
|
||||
xulongzhe = luola <[email protected]>
|
||||
[email protected] = luola <[email protected]>
|
||||
ZhulongNT = SONG ZHENRUI <[email protected]>
|
||||
[email protected] = SONG ZHENRUI <[email protected]>
|
||||
xiluoduyu = xiluoduyu <[email protected]>
|
||||
[email protected] = xiluoduyu <[email protected]>
|
||||
wlon = Stephen Xu <[email protected]>
|
||||
[email protected] = Stephen Xu <[email protected]>
|
||||
krisclarkdev = Kristopher Clark <[email protected]>
|
||||
THatch26 = THatch26 <[email protected]>
|
||||
jeoor = Kayro <[email protected]>
|
||||
harvey2011888 = harvey2011888 <[email protected]>
|
||||
duanchao-lab = DC <[email protected]>
|
||||
dumbjack = dumbjack <[email protected]>
|
||||
[email protected] = dumbjack <[email protected]>
|
||||
AquaZhang = IceTear <[email protected]>
|
||||
disappear00 = idontknow <[email protected]>
|
||||
namezzb = sailorZ <[email protected]>
|
||||
alphapku-ux = Alpha Wu <[email protected]>
|
||||
BX166 = BX166 <[email protected]>
|
||||
c020627 = c020627 <[email protected]>
|
||||
[email protected] = Parman Mohammadalizadeh <[email protected]>
|
||||
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
|
||||
@@ -259,9 +259,16 @@ prefer merging the contributor's PR itself). When a contributor's work
|
||||
lands as our commit, the contributor is that commit's **author** (`git commit
|
||||
--author` with their GitHub-linked address), it carries `Harvested from PR #N by
|
||||
@handle`, and maintainers or agents go in `Co-authored-by`. GitHub's contributor
|
||||
list counts commit authors only: about 200 harvested commits authored by
|
||||
maintainer identities left their contributors off it, which is why
|
||||
`docs/CONTRIBUTORS.md` carries the full wall (`scripts/contributor-wall.py`).
|
||||
count takes commit authors and the `Co-authored-by` trailers in a commit's final
|
||||
trailer block, and only addresses linked to an account: a trailer cut off by a
|
||||
blank line, an unlinked email, or a noreply address carrying someone else's
|
||||
numeric id credits nobody or the wrong person. Harvested commits authored by
|
||||
maintainer identities without such a trailer left their contributors off it,
|
||||
which is why `docs/CONTRIBUTORS.md` carries the full wall
|
||||
(`scripts/contributor-wall.py`). Credit lost in history that cannot change is
|
||||
restored by a later commit with one trailer per person, naming each pull
|
||||
request; `scripts/check-contributor-credit.py` fails the release window when a
|
||||
harvest, a trailer or a noreply id would leave someone uncounted.
|
||||
A maintainer closes their PR by hand with a thank-you naming the commit;
|
||||
nothing closes a PR automatically. Canonical human identities come from
|
||||
`.github/AUTHOR_MAP`.
|
||||
|
||||
+17
-1
@@ -7,7 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
|
||||
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
|
||||
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
|
||||
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
|
||||
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
|
||||
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
|
||||
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
|
||||
- **Paused stream:** A paused stream now shows the thinking it has already received instead of waiting for the next token.
|
||||
- **Sign-in notice:** The "Account not loaded" notice now appears once per sign-in in the footer, and Esc dismisses it; it no longer lands in the transcript.
|
||||
- **Usage reporting:** Installers now run their closing diagnostic with usage reporting and update checks off and print how to turn usage reporting off. `codewhale doctor` now says a saved opt-out comes from the config file, not the environment, and help and English notices call the feature "usage reporting".
|
||||
- **Long tool names:** Tools whose encoded name is longer than the route's limit are now left out with a visible warning instead of failing every request with a 400. The limit is 64 characters on OpenAI and on other OpenAI-compatible providers that document no larger limit, and 128 on Anthropic-style routes, DeepSeek, Ollama, Ollama Cloud, vLLM, SGLang and custom routes. Forcing such a tool gives an error that names the tool and the limit, and a session that already called one on a route with a documented limit (Anthropic, OpenAI, Codex, DeepSeek) is refused with a message saying to switch route or start a new session. A forced tool_choice in any chat form now names the encoded tool, and a tool whose real name looks like an encoded escape can no longer be mistaken for another tool.
|
||||
- **Start after a hard kill:** The Engine now also starts after a hard kill when its home was spelled through a symbolic link (for example under /tmp on macOS) or, on macOS and Windows, in a different letter case: a dead owner's record for the deleted data directory is discarded instead of refusing every start.
|
||||
- **Context window labels:** the model picker and fleet capability badges now show `1.5M` and `2.1M` for 1,500,000- and 2,100,000-token windows instead of `1.50M` and `2.10M`. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7295).
|
||||
- **REPL output limit:** REPL output is now cut to its byte limit, so Chinese, Japanese, Korean or emoji output no longer comes back up to three times longer than the limit, and the cut never splits a character. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7296).
|
||||
- **Git status with renamed files:** the Git and Changes views no longer fail with "git status failed" or show a phantom row when a file is renamed on the worktree side (for example a moved file marked with `git add -N`), including when the original name starts with non-ASCII text such as Chinese. Thanks [@luochen211](https://github.com/luochen211) (PR 7310).
|
||||
@@ -22,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Simplified Chinese punctuation and /status:** in the zh-Hans interface, the `/router` setup and test screens, label prompts and usage lines now use full-width punctuation (`:;,()`) instead of ASCII marks next to Chinese text, and `/status` values start in one column under Chinese labels instead of starting at a different column for each label. Thanks [@luochen211](https://github.com/luochen211) (PR 7314).
|
||||
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
|
||||
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
|
||||
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
|
||||
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` or `managed_config_path` is applied on every load, and one that is missing or cannot be read stops the load instead of loading with no managed layer; a blank `CODEWHALE_MANAGED_CONFIG_PATH` is now ignored instead of switching the managed layer off, and a config file's `managed_config_path` can no longer point away from an existing file at the default managed path. Fleet local workers inherit the managed-config variable, and `codewhale serve --mcp` does not expose shell tools when the managed configuration it loads sets `tool_launcher`. A configuration whose provider is declared only by a plugin fails to load under a managed configuration, with an error that says to declare the provider table or pick a built-in provider.
|
||||
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
|
||||
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
|
||||
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
|
||||
|
||||
@@ -41,6 +41,34 @@ mod platform {
|
||||
tokio::io::WriteHalf<Connection>,
|
||||
>;
|
||||
|
||||
fn resolved_store_form(path: PathBuf) -> Option<PathBuf> {
|
||||
let checked = codewhale_engine::runtime_threads::checked_runtime_store_root(path).ok()?;
|
||||
let mut tail = Vec::new();
|
||||
let mut probe = checked.as_path();
|
||||
loop {
|
||||
match probe.canonicalize() {
|
||||
Ok(real) => {
|
||||
return Some(tail.iter().rev().fold(real, |acc, part| acc.join(part)));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
tail.push(probe.file_name()?.to_owned());
|
||||
probe = probe.parent()?;
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_store_form_of(path: &std::path::Path) -> PathBuf {
|
||||
resolved_store_form(path.to_path_buf()).unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn same_store_path(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
left == right
|
||||
|| (cfg!(any(target_os = "macos", windows))
|
||||
&& left.to_string_lossy().to_lowercase() == right.to_string_lossy().to_lowercase())
|
||||
}
|
||||
|
||||
fn same_socket_selection(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
if left == right {
|
||||
return true;
|
||||
@@ -228,15 +256,13 @@ mod platform {
|
||||
Ok(())
|
||||
}
|
||||
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
|
||||
let gone =
|
||||
codewhale_engine::runtime_threads::checked_runtime_store_root(selected_store)
|
||||
.is_ok_and(|selected| {
|
||||
selected == self.receipt().data_dir
|
||||
&& matches!(
|
||||
selected.symlink_metadata(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound
|
||||
)
|
||||
});
|
||||
let gone = resolved_store_form(selected_store).is_some_and(|selected| {
|
||||
same_store_path(&selected, &resolved_store_form_of(&self.receipt().data_dir))
|
||||
&& matches!(
|
||||
selected.symlink_metadata(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound
|
||||
)
|
||||
});
|
||||
if !gone || !matches!(self.revalidate_receipt(), Ok(true)) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ async fn persist_exec_checkpoint(
|
||||
Some(write_request.as_ref()),
|
||||
&captured_source,
|
||||
&captured_owner,
|
||||
snapshot.agent_preset.as_deref(),
|
||||
)?;
|
||||
Ok(publication)
|
||||
},
|
||||
@@ -239,6 +240,7 @@ 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;
|
||||
@@ -308,6 +310,10 @@ 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
|
||||
@@ -784,6 +790,7 @@ 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());
|
||||
@@ -792,6 +799,24 @@ 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.
|
||||
@@ -1440,6 +1465,7 @@ pub async fn run_exec_agent(
|
||||
None,
|
||||
source,
|
||||
owner,
|
||||
exec_agent_preset.as_deref(),
|
||||
)
|
||||
});
|
||||
match saved {
|
||||
@@ -2127,6 +2153,7 @@ mod tests {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "exec".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let caller = SessionCheckpointCaller {
|
||||
session_id: snapshot.session_id.clone(),
|
||||
@@ -2195,6 +2222,7 @@ mod tests {
|
||||
None,
|
||||
source.as_ref().unwrap(),
|
||||
snapshot.source.as_ref().unwrap(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
source = Some(publication.write_receipt());
|
||||
@@ -2318,6 +2346,58 @@ 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();
|
||||
@@ -3377,6 +3457,7 @@ 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() {
|
||||
@@ -3422,6 +3503,9 @@ 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,
|
||||
|
||||
@@ -76,6 +76,18 @@ impl McpServerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse_shell_tools(&mut self) -> Vec<String> {
|
||||
let (refused, kept): (Vec<String>, Vec<String>) = std::mem::take(&mut self.expose_tools)
|
||||
.into_iter()
|
||||
.partition(|name| {
|
||||
build_exposed_tools(std::slice::from_ref(name))
|
||||
.iter()
|
||||
.any(|tool| tool.internal == "exec_shell")
|
||||
});
|
||||
self.expose_tools = kept;
|
||||
refused
|
||||
}
|
||||
|
||||
fn from_toml(contents: &str) -> Result<Self> {
|
||||
let config: McpServerConfigFile = toml::from_str(contents)?;
|
||||
Ok(Self {
|
||||
@@ -98,12 +110,21 @@ pub async fn run_mcp_server(
|
||||
workspace: PathBuf,
|
||||
skills_dir: PathBuf,
|
||||
skills_config: std::sync::Arc<codewhale_runtime::config::SkillsConfig>,
|
||||
tool_launcher_configured: bool,
|
||||
) -> Result<()> {
|
||||
// Settings load is a synchronous config read; keep it off the async
|
||||
// worker per the blocking-call convention.
|
||||
let settings = tokio::task::spawn_blocking(McpServerSettings::load)
|
||||
let mut settings = tokio::task::spawn_blocking(McpServerSettings::load)
|
||||
.await
|
||||
.context("MCP server settings task failed")??;
|
||||
if tool_launcher_configured {
|
||||
for name in settings.refuse_shell_tools() {
|
||||
eprintln!(
|
||||
"codewhale mcp server: not exposing '{name}': the managed configuration sets \
|
||||
tool_launcher, and this server does not run commands through it."
|
||||
);
|
||||
}
|
||||
}
|
||||
let server = McpServer::new(workspace, settings, skills_dir, skills_config)?;
|
||||
// stdout carries the protocol; the notice goes to stderr so an operator
|
||||
// whose config lists write tools sees why they are missing.
|
||||
@@ -906,6 +927,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_launcher_refuses_shell_tools_even_when_the_operator_allows_them() {
|
||||
let mut settings = McpServerSettings::from_toml(
|
||||
"[server]\nexpose_tools = [\"file_read\", \"shell\", \"exec_shell\", \"Bash\"]\nrequire_approval = false\n",
|
||||
)
|
||||
.expect("parse config");
|
||||
assert_eq!(settings.refuse_shell_tools(), vec!["shell", "exec_shell"]);
|
||||
let server = test_server(PathBuf::from("."), settings);
|
||||
let names: Vec<&str> = server.tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||
assert_eq!(names, vec!["file_read"]);
|
||||
for internal in ["exec_shell", "Bash", "bash"] {
|
||||
assert!(server.registry.get(internal).is_none(), "{internal}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_config_without_require_approval_withholds_and_names_write_tools() {
|
||||
let settings = McpServerSettings::from_toml(
|
||||
|
||||
@@ -7649,6 +7649,7 @@ 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);
|
||||
|
||||
@@ -296,6 +296,53 @@ async fn dead_owner_whose_store_was_deleted_is_discarded_and_startup_proceeds()
|
||||
fixture.bind_replacement(None).await.unwrap();
|
||||
}
|
||||
|
||||
async fn assert_gone_store_spelled_as_is_discarded(
|
||||
label: &str,
|
||||
spell: impl FnOnce(&Path) -> PathBuf,
|
||||
) {
|
||||
let fixture = BootstrapPublicationFixture::new(label, true, false).await;
|
||||
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
|
||||
let spelled = spell(&fixture.harness.root);
|
||||
assert_ne!(spelled, fixture.owner.data_dir);
|
||||
assert!(matches!(
|
||||
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
|
||||
fixture.owner.config_path.clone(),
|
||||
Some(fixture.owner.socket_path.clone()),
|
||||
&spelled,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.parent
|
||||
.read_private_receipt("daemon.sock.owner.json", 16384)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_through_a_symlink() {
|
||||
assert_gone_store_spelled_as_is_discarded("boot-gone-link", |root| {
|
||||
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
|
||||
root.join("alias").join("runtime")
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_in_another_letter_case() {
|
||||
assert_gone_store_spelled_as_is_discarded("boot-gone-case", |root| {
|
||||
let upper = PathBuf::from(root.to_string_lossy().to_uppercase());
|
||||
assert!(upper.exists(), "this test needs a case-insensitive volume");
|
||||
upper.join("RUNTIME")
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_owner_with_a_missing_store_is_never_discarded() {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-gone-live", false, false).await;
|
||||
|
||||
+56
-2
@@ -117,7 +117,7 @@ struct Cli {
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "BOOL",
|
||||
help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
|
||||
help = "Control usage reporting (default on; Codewhale + PostHog; \
|
||||
durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
|
||||
)]
|
||||
telemetry: Option<bool>,
|
||||
@@ -156,6 +156,10 @@ 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;
|
||||
@@ -1957,7 +1961,7 @@ enum ConfigCommand {
|
||||
Unset {
|
||||
key: String,
|
||||
},
|
||||
/// Review aggregate usage counting by Codewhale and PostHog (default on).
|
||||
/// Review usage reporting by Codewhale and PostHog (default on).
|
||||
Telemetry {
|
||||
/// Optional compatibility form: enable future sessions under this policy version.
|
||||
#[arg(long, value_name = "VERSION")]
|
||||
@@ -2964,6 +2968,10 @@ 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
|
||||
@@ -6792,6 +6800,10 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou
|
||||
codewhale_config::TELEMETRY_FLOOR_ENV,
|
||||
if floor { "1" } else { "0" },
|
||||
);
|
||||
set_tui_env(
|
||||
codewhale_config::TELEMETRY_SOURCE_ENV,
|
||||
resolved_runtime.telemetry_source.as_str(),
|
||||
);
|
||||
if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
|
||||
set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
|
||||
}
|
||||
@@ -7226,6 +7238,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatcher_hands_the_telemetry_source_to_the_tui() {
|
||||
let _lock = env_lock();
|
||||
let _guards = [
|
||||
ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
|
||||
ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
|
||||
ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
|
||||
ScopedEnvVar::remove(codewhale_config::TELEMETRY_SOURCE_ENV),
|
||||
];
|
||||
|
||||
let cli = parse_ok(&["codewhale"]);
|
||||
let mut runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
|
||||
runtime.telemetry = false;
|
||||
runtime.telemetry_source = codewhale_config::TelemetrySource::Config;
|
||||
apply_tui_env(&cli, &runtime, &[]);
|
||||
|
||||
assert_eq!(std::env::var("CODEWHALE_TELEMETRY").as_deref(), Ok("false"));
|
||||
assert!(std::env::var(codewhale_config::TELEMETRY_FLOOR_ENV).is_ok());
|
||||
assert_eq!(
|
||||
std::env::var(codewhale_config::TELEMETRY_SOURCE_ENV).as_deref(),
|
||||
Ok("config")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_command_definition_is_consistent() {
|
||||
Cli::command().debug_assert();
|
||||
@@ -13304,6 +13340,24 @@ verbosity = "concise"
|
||||
assert!(root_tui_passthrough(&cli).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_conversation_mode_is_forwarded_and_is_not_prompt_text() {
|
||||
let cli = parse_ok(&["codewhale", "--conversation-mode", "creator"]);
|
||||
|
||||
assert_eq!(cli.conversation_mode.as_deref(), Some("creator"));
|
||||
assert!(cli.prompt.is_empty());
|
||||
assert_eq!(
|
||||
root_tui_passthrough(&cli).unwrap(),
|
||||
vec!["--conversation-mode", "creator"]
|
||||
);
|
||||
|
||||
let cli = parse_ok(&["codewhale", "--conversation-mode", "creator", "hello"]);
|
||||
assert_eq!(
|
||||
root_tui_passthrough(&cli).unwrap(),
|
||||
vec!["--conversation-mode", "creator", "--prompt", "hello"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_top_level_continue_for_interactive_resume() {
|
||||
let cli = parse_ok(&["codewhale", "--continue"]);
|
||||
|
||||
@@ -1361,6 +1361,7 @@ 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
|
||||
@@ -1486,10 +1487,15 @@ pub trait CommandSessionLifecycleContext {
|
||||
/// `/fork <session_id|prefix>`: explicit-source fork.
|
||||
fn fork_from(&mut self, session_id_or_prefix: &str) -> Result<SessionForkFromReceipt, 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>;
|
||||
/// `/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>;
|
||||
|
||||
/// `/load <path>`: resolve the path (separator-bearing direct vs
|
||||
/// workspace-relative) and validate the saved-session shape without
|
||||
|
||||
@@ -141,6 +141,9 @@ pub enum ConfigAction {
|
||||
// Views and pickers.
|
||||
OpenConfigView,
|
||||
OpenModePicker,
|
||||
SetConversationMode {
|
||||
id: String,
|
||||
},
|
||||
OpenThemePicker,
|
||||
OpenStatusPicker,
|
||||
OpenProviderPicker,
|
||||
|
||||
@@ -2333,10 +2333,14 @@ impl CommandSessionLifecycleContext for FakeLifecycle {
|
||||
.clone()
|
||||
.ok_or_else(|| format!("unexpected fork_from({id}) 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 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 load_session(&mut self, path: &str) -> Result<PathBuf, String> {
|
||||
self.load
|
||||
@@ -2376,6 +2380,7 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2455,7 +2460,7 @@ fn lifecycle_facet_is_object_safe_and_transports_every_outcome() {
|
||||
explicit.sync.workspace,
|
||||
PathBuf::from("/workspace/lifecycle")
|
||||
);
|
||||
let fresh = fake.fresh_session(true).expect("fresh ok");
|
||||
let fresh = fake.fresh_session(true, None).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,6 +417,7 @@ 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 {
|
||||
|
||||
@@ -140,48 +140,17 @@ impl EnvRuntimeOverrides {
|
||||
Self {
|
||||
provider,
|
||||
provider_source,
|
||||
model: std::env::var("CODEWHALE_MODEL")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_MODEL"))
|
||||
.or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
volcengine_model: std::env::var("VOLCENGINE_MODEL")
|
||||
.or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
|
||||
.or_else(|_| std::env::var("WANJIE_MODEL"))
|
||||
.or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openrouter_model: std::env::var("OPENROUTER_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
orcarouter_model: std::env::var("ORCAROUTER_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
moonshot_model: std::env::var("MOONSHOT_MODEL")
|
||||
.or_else(|_| std::env::var("KIMI_MODEL_NAME"))
|
||||
.or_else(|_| std::env::var("KIMI_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
|
||||
.or_else(|_| std::env::var("MIMO_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
|
||||
.or_else(|_| std::env::var("MIMO_MODE"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
novita_model: std::env::var("NOVITA_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
fireworks_model: std::env::var("FIREWORKS_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
arcee_model: std::env::var("ARCEE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
model: env_non_blank(&["CODEWHALE_MODEL", "DEEPSEEK_MODEL", "DEEPSEEK_DEFAULT_TEXT_MODEL"]),
|
||||
volcengine_model: env_non_blank(&["VOLCENGINE_MODEL", "VOLCENGINE_ARK_MODEL"]),
|
||||
wanjie_ark_model: env_non_blank(&["WANJIE_ARK_MODEL", "WANJIE_MODEL", "WANJIE_MAAS_MODEL"]),
|
||||
openrouter_model: env_non_blank(&["OPENROUTER_MODEL"]),
|
||||
orcarouter_model: env_non_blank(&["ORCAROUTER_MODEL"]),
|
||||
moonshot_model: env_non_blank(&["MOONSHOT_MODEL", "KIMI_MODEL_NAME", "KIMI_MODEL"]),
|
||||
xiaomi_mimo_model: env_non_blank(&["XIAOMI_MIMO_MODEL", "MIMO_MODEL"]),
|
||||
xiaomi_mimo_mode: env_non_blank(&["XIAOMI_MIMO_MODE", "MIMO_MODE"]),
|
||||
novita_model: env_non_blank(&["NOVITA_MODEL"]),
|
||||
fireworks_model: env_non_blank(&["FIREWORKS_MODEL"]),
|
||||
arcee_model: env_non_blank(&["ARCEE_MODEL"]),
|
||||
verbosity: env_non_blank(&["CODEWHALE_VERBOSITY", "DEEPSEEK_VERBOSITY"]),
|
||||
auth_mode: env_non_blank(&["CODEWHALE_AUTH_MODE", "DEEPSEEK_AUTH_MODE"]),
|
||||
log_level: env_non_blank(&["CODEWHALE_LOG_LEVEL", "DEEPSEEK_LOG_LEVEL"]),
|
||||
@@ -216,9 +185,7 @@ impl EnvRuntimeOverrides {
|
||||
None
|
||||
}
|
||||
}),
|
||||
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
|
||||
.ok()
|
||||
http_headers: env_non_blank(&["CODEWHALE_HTTP_HEADERS", "DEEPSEEK_HTTP_HEADERS"])
|
||||
.and_then(|value| match parse_http_headers(&value) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => {
|
||||
@@ -227,290 +194,85 @@ impl EnvRuntimeOverrides {
|
||||
}
|
||||
})
|
||||
.filter(|headers| !headers.is_empty()),
|
||||
active_route_base_url: std::env::var("CODEWHALE_BASE_URL")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
|
||||
.or_else(|_| std::env::var("NIM_BASE_URL"))
|
||||
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_base_url: std::env::var("OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
|
||||
.or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
|
||||
.or_else(|_| std::env::var("ARK_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
|
||||
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
|
||||
.or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
|
||||
.or_else(|_| std::env::var("MIMO_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
novita_base_url: std::env::var("NOVITA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
siliconflow_model: std::env::var("SILICONFLOW_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
arcee_base_url: std::env::var("ARCEE_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
|
||||
.or_else(|_| std::env::var("KIMI_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
sglang_base_url: std::env::var("SGLANG_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
vllm_base_url: std::env::var("VLLM_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
|
||||
.or_else(|_| std::env::var("HF_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
huggingface_model: std::env::var("HUGGINGFACE_MODEL")
|
||||
.or_else(|_| std::env::var("HF_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelscope_base_url: std::env::var("MODELSCOPE_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelscope_model: std::env::var("MODELSCOPE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
together_base_url: std::env::var("TOGETHER_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
together_model: std::env::var("TOGETHER_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("BAIDU_QIANFAN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
qianfan_model: std::env::var("QIANFAN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("BAIDU_QIANFAN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
|
||||
.or_else(|_| std::env::var("CODEX_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
|
||||
.or_else(|_| std::env::var("CODEX_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
anthropic_model: std::env::var("ANTHROPIC_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openmodel_model: std::env::var("OPENMODEL_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
zai_base_url: std::env::var("ZAI_BASE_URL")
|
||||
.or_else(|_| std::env::var("Z_AI_BASE_URL"))
|
||||
.or_else(|_| std::env::var("ZHIPU_BASE_URL"))
|
||||
.or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
|
||||
.or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
zai_model: std::env::var("ZAI_MODEL")
|
||||
.or_else(|_| std::env::var("Z_AI_MODEL"))
|
||||
.or_else(|_| std::env::var("ZHIPU_MODEL"))
|
||||
.or_else(|_| std::env::var("ZHIPUAI_MODEL"))
|
||||
.or_else(|_| std::env::var("BIGMODEL_MODEL"))
|
||||
.or_else(|_| std::env::var("GLM_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
|
||||
.or_else(|_| std::env::var("STEP_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
stepfun_model: std::env::var("STEPFUN_MODEL")
|
||||
.or_else(|_| std::env::var("STEP_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
minimax_base_url: std::env::var("MINIMAX_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
minimax_model: std::env::var("MINIMAX_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
deepinfra_model: std::env::var("DEEPINFRA_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
sakana_base_url: std::env::var("SAKANA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
sakana_model: std::env::var("SAKANA_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
longcat_base_url: std::env::var("LONGCAT_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
longcat_model: std::env::var("LONGCAT_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("MODEL_API_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
meta_model: std::env::var("META_MODEL_API_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("MODEL_API_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
xai_base_url: std::env::var("XAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xai_model: std::env::var("XAI_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
google_base_url: std::env::var("GOOGLE_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("GEMINI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
google_model: std::env::var("GOOGLE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("GEMINI_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
mistral_model: std::env::var("MISTRAL_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
telecomjs_model: std::env::var("TELECOMJS_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
edenai_base_url: std::env::var("EDENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
edenai_model: std::env::var("EDENAI_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
zenmux_base_url: std::env::var("ZENMUX_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
zenmux_model: std::env::var("ZENMUX_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
csdn_base_url: std::env::var("CSDN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
csdn_model: std::env::var("CSDN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
concentrate_base_url: std::env::var("CONCENTRATE_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
concentrate_model: std::env::var("CONCENTRATE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
active_route_base_url: env_non_blank(&["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"]),
|
||||
deepseek_anthropic_base_url: env_non_blank(&["DEEPSEEK_ANTHROPIC_BASE_URL", "DEEPSEEK_CLAUDE_BASE_URL"]),
|
||||
nvidia_base_url: env_non_blank(&["NVIDIA_NIM_BASE_URL", "NIM_BASE_URL", "NVIDIA_BASE_URL"]),
|
||||
openai_base_url: env_non_blank(&["OPENAI_BASE_URL"]),
|
||||
atlascloud_base_url: env_non_blank(&["ATLASCLOUD_BASE_URL"]),
|
||||
volcengine_base_url: env_non_blank(&["VOLCENGINE_BASE_URL", "VOLCENGINE_ARK_BASE_URL", "ARK_BASE_URL"]),
|
||||
wanjie_ark_base_url: env_non_blank(&["WANJIE_ARK_BASE_URL", "WANJIE_BASE_URL", "WANJIE_MAAS_BASE_URL"]),
|
||||
openrouter_base_url: env_non_blank(&["OPENROUTER_BASE_URL"]),
|
||||
orcarouter_base_url: env_non_blank(&["ORCAROUTER_BASE_URL"]),
|
||||
xiaomi_mimo_base_url: env_non_blank(&["XIAOMI_MIMO_BASE_URL", "MIMO_BASE_URL"]),
|
||||
novita_base_url: env_non_blank(&["NOVITA_BASE_URL"]),
|
||||
fireworks_base_url: env_non_blank(&["FIREWORKS_BASE_URL"]),
|
||||
siliconflow_base_url: env_non_blank(&["SILICONFLOW_BASE_URL"]),
|
||||
siliconflow_model: env_non_blank(&["SILICONFLOW_MODEL"]),
|
||||
arcee_base_url: env_non_blank(&["ARCEE_BASE_URL"]),
|
||||
moonshot_base_url: env_non_blank(&["MOONSHOT_BASE_URL", "KIMI_BASE_URL"]),
|
||||
sglang_base_url: env_non_blank(&["SGLANG_BASE_URL"]),
|
||||
vllm_base_url: env_non_blank(&["VLLM_BASE_URL"]),
|
||||
ollama_base_url: env_non_blank(&["OLLAMA_BASE_URL"]),
|
||||
ollama_cloud_base_url: env_non_blank(&["OLLAMA_CLOUD_BASE_URL"]),
|
||||
ollama_cloud_model: env_non_blank(&["OLLAMA_CLOUD_MODEL"]),
|
||||
huggingface_base_url: env_non_blank(&["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]),
|
||||
huggingface_model: env_non_blank(&["HUGGINGFACE_MODEL", "HF_MODEL"]),
|
||||
modelscope_base_url: env_non_blank(&["MODELSCOPE_BASE_URL"]),
|
||||
modelscope_model: env_non_blank(&["MODELSCOPE_MODEL"]),
|
||||
together_base_url: env_non_blank(&["TOGETHER_BASE_URL"]),
|
||||
together_model: env_non_blank(&["TOGETHER_MODEL"]),
|
||||
qianfan_base_url: env_non_blank(&["QIANFAN_BASE_URL", "BAIDU_QIANFAN_BASE_URL"]),
|
||||
qianfan_model: env_non_blank(&["QIANFAN_MODEL", "BAIDU_QIANFAN_MODEL"]),
|
||||
openai_codex_base_url: env_non_blank(&["OPENAI_CODEX_BASE_URL", "CODEX_BASE_URL"]),
|
||||
openai_codex_model: env_non_blank(&["OPENAI_CODEX_MODEL", "CODEX_MODEL"]),
|
||||
anthropic_base_url: env_non_blank(&["ANTHROPIC_BASE_URL"]),
|
||||
anthropic_model: env_non_blank(&["ANTHROPIC_MODEL"]),
|
||||
openmodel_base_url: env_non_blank(&["OPENMODEL_BASE_URL"]),
|
||||
openmodel_model: env_non_blank(&["OPENMODEL_MODEL"]),
|
||||
zai_base_url: env_non_blank(&["ZAI_BASE_URL", "Z_AI_BASE_URL", "ZHIPU_BASE_URL", "ZHIPUAI_BASE_URL", "BIGMODEL_BASE_URL"]),
|
||||
zai_model: env_non_blank(&["ZAI_MODEL", "Z_AI_MODEL", "ZHIPU_MODEL", "ZHIPUAI_MODEL", "BIGMODEL_MODEL", "GLM_MODEL"]),
|
||||
stepfun_base_url: env_non_blank(&["STEPFUN_BASE_URL", "STEP_BASE_URL"]),
|
||||
stepfun_model: env_non_blank(&["STEPFUN_MODEL", "STEP_MODEL"]),
|
||||
minimax_base_url: env_non_blank(&["MINIMAX_BASE_URL"]),
|
||||
minimax_anthropic_base_url: env_non_blank(&["MINIMAX_ANTHROPIC_BASE_URL"]),
|
||||
minimax_model: env_non_blank(&["MINIMAX_MODEL"]),
|
||||
deepinfra_base_url: env_non_blank(&["DEEPINFRA_BASE_URL"]),
|
||||
deepinfra_model: env_non_blank(&["DEEPINFRA_MODEL"]),
|
||||
sakana_base_url: env_non_blank(&["SAKANA_BASE_URL"]),
|
||||
sakana_model: env_non_blank(&["SAKANA_MODEL"]),
|
||||
longcat_base_url: env_non_blank(&["LONGCAT_BASE_URL"]),
|
||||
longcat_model: env_non_blank(&["LONGCAT_MODEL"]),
|
||||
opencode_go_base_url: env_non_blank(&["OPENCODE_GO_BASE_URL"]),
|
||||
opencode_go_model: env_non_blank(&["OPENCODE_GO_MODEL"]),
|
||||
opencode_zen_base_url: env_non_blank(&["OPENCODE_ZEN_BASE_URL"]),
|
||||
opencode_zen_model: env_non_blank(&["OPENCODE_ZEN_MODEL"]),
|
||||
meta_base_url: env_non_blank(&["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"]),
|
||||
meta_model: env_non_blank(&["META_MODEL_API_MODEL", "MODEL_API_MODEL"]),
|
||||
xai_base_url: env_non_blank(&["XAI_BASE_URL"]),
|
||||
xai_model: env_non_blank(&["XAI_MODEL"]),
|
||||
google_base_url: env_non_blank(&["GOOGLE_BASE_URL", "GEMINI_BASE_URL"]),
|
||||
google_model: env_non_blank(&["GOOGLE_MODEL", "GEMINI_MODEL"]),
|
||||
mistral_base_url: env_non_blank(&["MISTRAL_BASE_URL"]),
|
||||
mistral_model: env_non_blank(&["MISTRAL_MODEL"]),
|
||||
telecomjs_base_url: env_non_blank(&["TELECOMJS_BASE_URL"]),
|
||||
telecomjs_model: env_non_blank(&["TELECOMJS_MODEL"]),
|
||||
edenai_base_url: env_non_blank(&["EDENAI_BASE_URL"]),
|
||||
edenai_model: env_non_blank(&["EDENAI_MODEL"]),
|
||||
zenmux_base_url: env_non_blank(&["ZENMUX_BASE_URL"]),
|
||||
zenmux_model: env_non_blank(&["ZENMUX_MODEL"]),
|
||||
csdn_base_url: env_non_blank(&["CSDN_BASE_URL"]),
|
||||
csdn_model: env_non_blank(&["CSDN_MODEL"]),
|
||||
concentrate_base_url: env_non_blank(&["CONCENTRATE_BASE_URL"]),
|
||||
concentrate_model: env_non_blank(&["CONCENTRATE_MODEL"]),
|
||||
// `CODEWHALE_API_BASE` is a trust boundary, not a plain string:
|
||||
// an origin this route would refuse to send a bearer to is
|
||||
// dropped here rather than resolved into a route.
|
||||
codewhale_base_url: provider::codewhale_api_base_from_env(),
|
||||
codewhale_model: std::env::var("CODEWHALE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
codewhale_model: env_non_blank(&["CODEWHALE_MODEL"]),
|
||||
modelstudio_token_plan_base_url: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_BASE_URL"]),
|
||||
modelstudio_token_plan_model: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_MODEL"]),
|
||||
modelstudio_coding_plan_base_url: env_non_blank(&["MODELSTUDIO_CODING_PLAN_BASE_URL"]),
|
||||
modelstudio_coding_plan_model: env_non_blank(&["MODELSTUDIO_CODING_PLAN_MODEL"]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,11 @@ pub fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> std::time::D
|
||||
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
|
||||
),
|
||||
_ => stream_open_timeout_from_env(
|
||||
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
|
||||
.ok()
|
||||
.as_deref(),
|
||||
crate::env_overrides::env_non_blank(&[
|
||||
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
|
||||
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
|
||||
])
|
||||
.as_deref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -50,9 +51,7 @@ pub fn stream_open_timeout_from_env(value: Option<&str>) -> std::time::Duration
|
||||
/// `false`, ...) leaves HTTP/2 on.
|
||||
#[must_use]
|
||||
pub fn force_http1_from_env() -> bool {
|
||||
std::env::var("CODEWHALE_FORCE_HTTP1")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
|
||||
.ok()
|
||||
crate::env_overrides::env_non_blank(&["CODEWHALE_FORCE_HTTP1", "DEEPSEEK_FORCE_HTTP1"])
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
@@ -33,6 +33,8 @@ pub const LEGACY_CLI_API_KEY_SOURCE_ENV: &str = "DEEPSEEK_API_KEY_SOURCE";
|
||||
/// [`CLI_API_KEY_SOURCE_ENV`]. Set to `1`/`0` on every delegated run.
|
||||
pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
|
||||
|
||||
pub const TELEMETRY_SOURCE_ENV: &str = "CODEWHALE_TELEMETRY_SOURCE";
|
||||
|
||||
/// Whether an environment-level kill switch forces telemetry off here.
|
||||
///
|
||||
/// A floor is *not* the same as "telemetry resolved to false": off is the
|
||||
@@ -92,6 +94,32 @@ impl TelemetrySource {
|
||||
Self::Default => "default",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_label(label: &str) -> Option<Self> {
|
||||
match label {
|
||||
"cli" => Some(Self::Cli),
|
||||
"env" => Some(Self::Env),
|
||||
"config" => Some(Self::Config),
|
||||
"default" => Some(Self::Default),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn dispatched_telemetry_source() -> Option<TelemetrySource> {
|
||||
parse_bool(&std::env::var(TELEMETRY_FLOOR_ENV).ok()?).ok()?;
|
||||
TelemetrySource::from_label(&std::env::var(TELEMETRY_SOURCE_ENV).ok()?)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn dispatched_telemetry_consent(file_telemetry: Option<bool>) -> (bool, TelemetrySource) {
|
||||
let (on, source) = resolved_telemetry_consent(file_telemetry);
|
||||
match (source, dispatched_telemetry_source()) {
|
||||
(TelemetrySource::Env, Some(declared)) => (on, declared),
|
||||
_ => (on, source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the telemetry environment override, reporting an unreadable value
|
||||
|
||||
@@ -8763,6 +8763,7 @@ struct TelemetryEnvGuard {
|
||||
codewhale: Option<OsString>,
|
||||
deepseek: Option<OsString>,
|
||||
floor: Option<OsString>,
|
||||
source: Option<OsString>,
|
||||
codewhale_endpoint: Option<OsString>,
|
||||
deepseek_endpoint: Option<OsString>,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
@@ -8775,6 +8776,7 @@ impl TelemetryEnvGuard {
|
||||
codewhale: env::var_os("CODEWHALE_TELEMETRY"),
|
||||
deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
|
||||
floor: env::var_os(TELEMETRY_FLOOR_ENV),
|
||||
source: env::var_os(TELEMETRY_SOURCE_ENV),
|
||||
// The endpoint variables are cleared too. Resolution now has a
|
||||
// shipped default, so an ambient endpoint in the developer's or
|
||||
// CI's environment is the difference between pinning the default
|
||||
@@ -8788,6 +8790,7 @@ impl TelemetryEnvGuard {
|
||||
env::remove_var("CODEWHALE_TELEMETRY");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY");
|
||||
env::remove_var(TELEMETRY_FLOOR_ENV);
|
||||
env::remove_var(TELEMETRY_SOURCE_ENV);
|
||||
env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT");
|
||||
}
|
||||
@@ -8808,6 +8811,13 @@ impl TelemetryEnvGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_source(&self, value: &str) {
|
||||
// Safety: test-only environment mutation guarded by the module mutex.
|
||||
unsafe {
|
||||
env::set_var(TELEMETRY_SOURCE_ENV, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_floor(&self, value: &str) {
|
||||
// Safety: test-only environment mutation guarded by the module mutex.
|
||||
unsafe {
|
||||
@@ -8821,6 +8831,7 @@ impl TelemetryEnvGuard {
|
||||
env::remove_var("CODEWHALE_TELEMETRY");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY");
|
||||
env::remove_var(TELEMETRY_FLOOR_ENV);
|
||||
env::remove_var(TELEMETRY_SOURCE_ENV);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8841,6 +8852,10 @@ impl Drop for TelemetryEnvGuard {
|
||||
Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
|
||||
None => env::remove_var(TELEMETRY_FLOOR_ENV),
|
||||
}
|
||||
match self.source.take() {
|
||||
Some(value) => env::set_var(TELEMETRY_SOURCE_ENV, value),
|
||||
None => env::remove_var(TELEMETRY_SOURCE_ENV),
|
||||
}
|
||||
match self.codewhale_endpoint.take() {
|
||||
Some(value) => env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value),
|
||||
None => env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT"),
|
||||
@@ -8995,6 +9010,57 @@ fn telemetry_consent_names_its_source() {
|
||||
assert_eq!(source, TelemetrySource::Env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatched_consent_names_the_dispatchers_source_not_its_hand_off() {
|
||||
let guard = TelemetryEnvGuard::take();
|
||||
|
||||
guard.set("false");
|
||||
guard.set_floor("0");
|
||||
guard.set_source("config");
|
||||
let (on, source) = dispatched_telemetry_consent(Some(false));
|
||||
assert!(!on);
|
||||
assert_eq!(source, TelemetrySource::Config);
|
||||
|
||||
guard.set("false");
|
||||
guard.set_floor("1");
|
||||
guard.set_source("env");
|
||||
let (on, source) = dispatched_telemetry_consent(Some(false));
|
||||
assert!(!on);
|
||||
assert_eq!(source, TelemetrySource::Env);
|
||||
|
||||
guard.set("true");
|
||||
guard.set_floor("0");
|
||||
guard.set_source("default");
|
||||
let (on, source) = dispatched_telemetry_consent(None);
|
||||
assert!(on);
|
||||
assert_eq!(source, TelemetrySource::Default);
|
||||
|
||||
guard.set("false");
|
||||
guard.set_floor("1");
|
||||
guard.set_source("cli");
|
||||
let (on, source) = dispatched_telemetry_consent(None);
|
||||
assert!(!on);
|
||||
assert_eq!(source, TelemetrySource::Cli);
|
||||
|
||||
guard.clear();
|
||||
guard.set("0");
|
||||
guard.set_source("config");
|
||||
let (on, source) = dispatched_telemetry_consent(Some(false));
|
||||
assert!(!on);
|
||||
assert_eq!(
|
||||
source,
|
||||
TelemetrySource::Env,
|
||||
"a source with no dispatcher floor statement is not believed"
|
||||
);
|
||||
|
||||
guard.clear();
|
||||
guard.set("0");
|
||||
guard.set_floor("1");
|
||||
guard.set_source("nonsense");
|
||||
let (_, source) = dispatched_telemetry_consent(None);
|
||||
assert_eq!(source, TelemetrySource::Env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_runtime_options_mints_a_route_candidate() {
|
||||
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
@@ -10701,6 +10767,60 @@ fn blank_or_unrecognized_env_overrides_do_not_shadow_config_or_legacy_vars() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_primary_env_vars_fall_through_to_their_legacy_aliases() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let names = [
|
||||
"CODEWHALE_FORCE_HTTP1",
|
||||
"DEEPSEEK_FORCE_HTTP1",
|
||||
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
|
||||
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
|
||||
];
|
||||
let saved: Vec<(&str, Option<OsString>)> = names
|
||||
.iter()
|
||||
.map(|name| (*name, env::var_os(name)))
|
||||
.collect();
|
||||
// SAFETY: env mutation is serialized by `env_lock` and restored below.
|
||||
unsafe {
|
||||
env::set_var("CODEWHALE_MODEL", "");
|
||||
env::set_var("DEEPSEEK_MODEL", "legacy-model");
|
||||
env::set_var("CODEWHALE_BASE_URL", " ");
|
||||
env::set_var("DEEPSEEK_BASE_URL", "https://legacy.example/v1");
|
||||
env::set_var("MOONSHOT_BASE_URL", "");
|
||||
env::set_var("KIMI_BASE_URL", "https://kimi.example/v1");
|
||||
env::set_var("CODEWHALE_FORCE_HTTP1", "");
|
||||
env::set_var("DEEPSEEK_FORCE_HTTP1", "1");
|
||||
env::set_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "");
|
||||
env::set_var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS", "120");
|
||||
}
|
||||
|
||||
let env_overrides = EnvRuntimeOverrides::load();
|
||||
let force_http1 = crate::stream_timeouts::force_http1_from_env();
|
||||
let open_timeout = crate::stream_timeouts::resolve_stream_open_timeout(None);
|
||||
|
||||
unsafe {
|
||||
for (name, value) in saved {
|
||||
match value {
|
||||
Some(value) => env::set_var(name, value),
|
||||
None => env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(env_overrides.model.as_deref(), Some("legacy-model"));
|
||||
assert_eq!(
|
||||
env_overrides.active_route_base_url.as_deref(),
|
||||
Some("https://legacy.example/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
env_overrides.moonshot_base_url.as_deref(),
|
||||
Some("https://kimi.example/v1")
|
||||
);
|
||||
assert!(force_http1);
|
||||
assert_eq!(open_timeout, std::time::Duration::from_secs(120));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_legacy_state_copy_leaves_no_partial_primary() {
|
||||
|
||||
@@ -1183,7 +1183,7 @@ impl Git {
|
||||
overrides: Vec<(String, &'static str)>,
|
||||
) -> anyhow::Result<Command> {
|
||||
let mut command = Self::review_base(workspace)?;
|
||||
let mut count = 2;
|
||||
let mut count = 3;
|
||||
for (key, value) in overrides {
|
||||
// A subsection may contain '='; `-c key=value` would then
|
||||
// override a different key. Separate env fields preserve it.
|
||||
@@ -1195,7 +1195,7 @@ impl Git {
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
/// Git with fsmonitor, hooks, lazy fetch and replace objects disabled,
|
||||
/// Git with fsmonitor, hooks, lazy fetch, replace objects and path quoting disabled,
|
||||
/// running in `workspace`. [`Self::review_command`] adds filter overrides.
|
||||
pub fn review_base(workspace: &Path) -> anyhow::Result<Command> {
|
||||
use anyhow::Context;
|
||||
@@ -1257,7 +1257,7 @@ impl Git {
|
||||
// Both phases must observe the same effective repository config.
|
||||
.env_remove("GIT_CONFIG")
|
||||
.env_remove("GIT_CONFIG_PARAMETERS")
|
||||
.env("GIT_CONFIG_COUNT", "2")
|
||||
.env("GIT_CONFIG_COUNT", "3")
|
||||
.env("GIT_CONFIG_KEY_0", "core.fsmonitor")
|
||||
.env("GIT_CONFIG_VALUE_0", "false")
|
||||
.env("GIT_CONFIG_KEY_1", "core.hooksPath")
|
||||
@@ -1265,6 +1265,8 @@ impl Git {
|
||||
"GIT_CONFIG_VALUE_1",
|
||||
if cfg!(windows) { "NUL" } else { "/dev/null" },
|
||||
)
|
||||
.env("GIT_CONFIG_KEY_2", "core.quotePath")
|
||||
.env("GIT_CONFIG_VALUE_2", "false")
|
||||
.env("GIT_NO_LAZY_FETCH", "1")
|
||||
.env("GIT_NO_REPLACE_OBJECTS", "1");
|
||||
Ok(command)
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(here, '..')
|
||||
const SOURCES = {
|
||||
compatibility: join(root, 'src/dsh/compatibility.json'),
|
||||
hostRoot: join(root, 'src/root.ts'),
|
||||
shellHooks: join(root, 'src/dsh/shell-hooks.ts'),
|
||||
seatTable: join(root, '../../app-server/src/runtime_api/plugins.rs'),
|
||||
catalog: join(root, 'src/dsh/inspect-catalog.json'),
|
||||
}
|
||||
|
||||
const read = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n')
|
||||
|
||||
const SERVICES = {
|
||||
tools: {
|
||||
summary: 'The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'register', signature: 'register(definition: ToolDefinition): () => void', summary: 'Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer.' },
|
||||
{ name: 'get', signature: 'get(name: string, agent: Agent): { name: string } | undefined', summary: 'Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call\'s own agent.' },
|
||||
],
|
||||
},
|
||||
commands: {
|
||||
summary: 'Slash commands a person types. A handler runs only when the person types the command.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'register', signature: 'register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void', summary: 'Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer.' },
|
||||
],
|
||||
},
|
||||
storage: {
|
||||
summary: 'Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'get', signature: 'get(key: string): Promise<Json | undefined>', summary: 'Read one stored value.' },
|
||||
{ name: 'set', signature: 'set(key: string, value: Json): Promise<void>', summary: 'Store one JSON value, within the bounded key, value and owner limits.' },
|
||||
{ name: 'delete', signature: 'delete(key: string): Promise<void>', summary: 'Remove one stored value.' },
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
summary: 'Skill roots: reviewed folders of skills added to the Engine\'s skill catalog.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'registerRoot', signature: 'registerRoot(definition: { path: string }): () => void', summary: 'Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused.' },
|
||||
],
|
||||
},
|
||||
sessions: {
|
||||
summary: 'The session of the calling tool invocation.',
|
||||
availability: 'tool-invocation',
|
||||
methods: [
|
||||
{ name: 'flush', signature: 'flush(session: Session): Promise<boolean>', summary: 'Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available.' },
|
||||
],
|
||||
},
|
||||
userQuestions: {
|
||||
summary: 'Questions put to the person through the Engine\'s own question card.',
|
||||
availability: 'tool-invocation',
|
||||
methods: [
|
||||
{ name: 'ask', signature: 'ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>', summary: 'Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported.' },
|
||||
],
|
||||
},
|
||||
subagents: {
|
||||
summary: 'One independent child agent a root tool call may start.',
|
||||
availability: 'tool-invocation',
|
||||
methods: [
|
||||
{ name: 'getProvider', signature: 'getProvider(id: \'spawn\'): Provider', summary: 'Describe the one provider: a fresh child with no inherited conversation.' },
|
||||
{ name: 'startActivation', signature: 'startActivation(options: { provider: \'spawn\', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: \'caller\' | \'parent\' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>', summary: 'Start the child through the Engine\'s approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child.' },
|
||||
{ name: 'result', signature: 'activation.result: Promise<{ stopReason: string, output: { type: \'text\', text: string }[], diagnostic?: string }>', summary: 'A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks.' },
|
||||
{ name: 'dispose', signature: 'activation.dispose(): Promise<void>', summary: 'Stop that exact child and its own descendants.' },
|
||||
],
|
||||
},
|
||||
agents: {
|
||||
summary: 'Reserved. The host offers no methods on agents.',
|
||||
availability: 'none',
|
||||
methods: [],
|
||||
},
|
||||
systemPrompt: {
|
||||
summary: 'The child agent\'s persona slot, reachable only inside an agent/created listener.',
|
||||
availability: 'child-agent-hook',
|
||||
methods: [
|
||||
{ name: 'section', signature: 'agent.ctx.systemPrompt.section({ name: \'deployment:persona-prefix\', order: agent.ctx.systemPrompt.getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\'), text: string }): () => void', summary: 'Set the child\'s persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine\'s own system prompt cannot be replaced.' },
|
||||
],
|
||||
},
|
||||
logger: {
|
||||
summary: 'Cordis logging. Every line becomes a host log notification attributed to the plugin.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'logger', signature: 'ctx.logger(name?: string): Logger', summary: 'A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info.' },
|
||||
],
|
||||
},
|
||||
events: {
|
||||
summary: 'Cordis event dispatch. Query the host Event provider for the events this host fires.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'on', signature: 'ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider).' },
|
||||
{ name: 'once', signature: 'ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event once.' },
|
||||
{ name: 'emit', signature: 'ctx.emit(name: string, ...args: any[]): void', summary: 'Dispatch an event to its listeners.' },
|
||||
],
|
||||
},
|
||||
reflect: {
|
||||
summary: 'Cordis service reflection. A plugin may provide its own services but not the Engine\'s.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'provide', signature: 'ctx.reflect.provide(name: string, value?: any): () => void', summary: 'Provide a service under a name. Names in refusedServices are refused: the Engine owns them.' },
|
||||
{ name: 'get', signature: 'ctx.reflect.get(name: string, strict?: boolean): any', summary: 'Read a provided service.' },
|
||||
],
|
||||
},
|
||||
registry: {
|
||||
summary: 'Cordis plugin registry.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'get', signature: 'ctx.registry.get(plugin: Plugin): Runtime | undefined', summary: 'Look up the runtime record of a plugin.' },
|
||||
{ name: 'resolve', signature: 'ctx.registry.resolve(plugin: Plugin): Function | undefined', summary: 'Resolve a plugin shape to its callback.' },
|
||||
],
|
||||
},
|
||||
prompt: {
|
||||
summary: 'Attributed sections of the Engine\'s runtime prompt. A plugin cannot replace the system prompt.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'registerSection', signature: 'registerSection(definition: { id: string, text: string, interpolate?: \'model-cwd\' }): () => void', summary: 'Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded.' },
|
||||
],
|
||||
},
|
||||
avatars: {
|
||||
summary: 'Avatar packs for the terminal.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'registerPack', signature: 'registerPack(definition: { path: string }): () => void', summary: 'Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin.' },
|
||||
],
|
||||
},
|
||||
mcp: {
|
||||
summary: 'Proposals of MCP servers. The Engine owns connection, credentials and tool calls.',
|
||||
availability: 'native-review',
|
||||
methods: [
|
||||
{ name: 'registerServer', signature: 'registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void', summary: 'Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition.' },
|
||||
],
|
||||
},
|
||||
shellHooks: {
|
||||
summary: 'Claude Code and Codex style command hooks, run by the Engine.',
|
||||
availability: 'native-review',
|
||||
methods: [
|
||||
{ name: 'register', signature: 'register(definition: { dialect: \'claude-code\' | \'codex\', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void', summary: 'Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported.' },
|
||||
],
|
||||
},
|
||||
remote: {
|
||||
summary: 'Remote pairing, served by the Engine.',
|
||||
availability: 'review-grant',
|
||||
methods: [
|
||||
{ name: 'status', signature: 'status(): Promise<Status>', summary: 'Whether remote access is on.' },
|
||||
{ name: 'pair', signature: 'pair(options?: { label?: string }): Promise<Pairing>', summary: 'Start a pairing.' },
|
||||
{ name: 'clients', signature: 'clients(): Promise<Client[]>', summary: 'List paired clients.' },
|
||||
{ name: 'revoke', signature: 'revoke(id: string): Promise<void>', summary: 'Revoke one grant by the id pair returned.' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const AVAILABILITY = {
|
||||
always: 'Available to every plugin that injects it.',
|
||||
'tool-invocation': 'Answers only inside a live tool call of the plugin.',
|
||||
'child-agent-hook': 'Answers only inside an agent/created listener, on the child agent.',
|
||||
'native-review': 'Available to a reviewed native plugin.',
|
||||
'review-grant': 'Served by the Engine only when the plugin\'s review granted it.',
|
||||
none: 'No methods.',
|
||||
}
|
||||
|
||||
const EVENTS_SUPPORTED = [
|
||||
{
|
||||
name: 'tools/pre-execute',
|
||||
status: 'supported',
|
||||
mode: 'ordered listeners, native hooks first, then plugin listeners in registration order',
|
||||
signature: 'ctx.on(\'tools/pre-execute\', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)',
|
||||
summary: 'Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: \'deny\', reason }, { kind: \'ask\', reason? }, { kind: \'revise\', input }, { kind: \'annotate\', text } or { kind: \'abstain\' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.',
|
||||
options: 'prepend and global are refused.',
|
||||
},
|
||||
{
|
||||
name: 'agent/created',
|
||||
status: 'supported',
|
||||
mode: 'listeners of the owning plugin only',
|
||||
signature: 'ctx.on(\'agent/created\', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)',
|
||||
summary: 'Fires for a child agent that this plugin\'s own subagents.startActivation creates, before the child starts. The listener may set the child\'s persona and deny tools to it; the window closes when the listeners return.',
|
||||
options: 'global is refused.',
|
||||
},
|
||||
]
|
||||
|
||||
const REFUSED_EVENT_REASON = 'Not projected by this host: no core projection exists, so a listener would never fire.'
|
||||
|
||||
const ROW_PACKAGES = {
|
||||
'@deepseek-ai/dsh-mcp-client': {
|
||||
summary: 'An MCP server row. The Engine proposes the server and owns the connection.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['transport', 'serverName'],
|
||||
properties: {
|
||||
transport: { type: 'string', enum: ['stdio', 'streamable-http', 'sse'] },
|
||||
serverName: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$' },
|
||||
command: { type: 'string', description: 'stdio only: the program to run.' },
|
||||
args: { type: 'array', items: { type: 'string' }, description: 'stdio only.' },
|
||||
env: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
|
||||
cwd: { type: 'string', description: 'stdio only: a bundle-relative directory, no .. components.' },
|
||||
url: { type: 'string', description: 'streamable-http and sse: the endpoint, with no credentials, query or fragment.' },
|
||||
headers: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
|
||||
toolCallTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000 },
|
||||
failOnStartupError: { type: 'boolean', const: false, description: 'Only false: the Engine connects lazily and reports failures through /mcp.' },
|
||||
},
|
||||
},
|
||||
limits: ['stdio rows name a command; streamable-http and sse rows name a url.', 'Reconnect and instruction bounds belong to the Engine and are not configurable here.'],
|
||||
example: { transport: 'stdio', serverName: 'docs', command: 'node', args: ['server.js'], cwd: '.' },
|
||||
},
|
||||
'@deepseek-ai/dsh-skill-filesystem': {
|
||||
summary: 'A skill folder row, limited to reviewed roots inside the bundle.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['includeDefaultRoots', 'watch', 'customSkillDirs'],
|
||||
properties: {
|
||||
providerName: { type: 'string', maxLength: 64 },
|
||||
includeDefaultRoots: { type: 'boolean', const: false, description: 'Must be false: ambient roots are not read.' },
|
||||
watch: { type: 'boolean', const: false, description: 'Must be false: there is no file watcher.' },
|
||||
customSkillDirs: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'string' }, description: 'Bundle-relative skill roots, no duplicates.' },
|
||||
},
|
||||
},
|
||||
limits: ['Both includeDefaultRoots and watch must be written out as false.'],
|
||||
example: { includeDefaultRoots: false, watch: false, customSkillDirs: ['skills'] },
|
||||
},
|
||||
'@deepseek-ai/dsh-hooks-claude-code': {
|
||||
summary: 'Claude Code style command hooks read from a reviewed file in the bundle.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
required: ['configPath'],
|
||||
properties: {
|
||||
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
|
||||
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
|
||||
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
|
||||
},
|
||||
},
|
||||
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
|
||||
example: { configPath: 'hooks/hooks.json' },
|
||||
},
|
||||
'@deepseek-ai/dsh-hooks-codex': {
|
||||
summary: 'Codex style command hooks read from a reviewed file in the bundle.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
required: ['configPath'],
|
||||
properties: {
|
||||
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
|
||||
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
|
||||
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
|
||||
},
|
||||
},
|
||||
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
|
||||
example: { configPath: 'hooks/hooks.json' },
|
||||
},
|
||||
'@deepseek-ai/dsh-persona': {
|
||||
summary: 'Text added before and after the Engine\'s own prompt for this plugin\'s entry.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['prefix'],
|
||||
properties: {
|
||||
prefix: { type: 'string', description: 'At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded.' },
|
||||
suffix: { type: 'string', description: 'Same limits as prefix.' },
|
||||
complete: { type: 'boolean', const: false, description: 'Replacing the whole prompt belongs to the Engine.' },
|
||||
includeRuntimeContext: { type: 'boolean', const: true, description: 'Suppressing the runtime context belongs to the Engine.' },
|
||||
},
|
||||
},
|
||||
limits: ['Needs an exact Engine-selected entry scope.'],
|
||||
example: { prefix: 'Answer in short sentences.' },
|
||||
},
|
||||
}
|
||||
|
||||
const THEME = {
|
||||
tokens: [],
|
||||
colorSchemes: ['light', 'dark'],
|
||||
scheme: 'The frame sets data-theme and color-scheme on its document to the app\'s current scheme and follows changes.',
|
||||
api: [
|
||||
{ name: 'ctx.theme.getTheme', signature: 'getTheme(): { active: { colorScheme: \'light\' | \'dark\' } }', summary: 'The scheme in use now.' },
|
||||
{ name: 'ctx.theme.overrideTokens', signature: 'overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void', summary: 'Set custom properties on the frame\'s document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer.' },
|
||||
],
|
||||
notes: [
|
||||
'The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.',
|
||||
'The frame\'s background is transparent and its margin is zero, so the plugin sits on the app\'s own surface.',
|
||||
],
|
||||
}
|
||||
|
||||
const SEAT_NOTES = {
|
||||
'customize.plugins.detail': { drawn: true, where: 'The plugin\'s own page under Customize, Plugins.' },
|
||||
'conversation.composer.dock': { drawn: false, where: 'Under the composer. The desktop app does not place it yet.' },
|
||||
'conversation.chat.node': { drawn: false, where: 'Inside the conversation. The desktop app does not place it yet.' },
|
||||
'workpane.panel': { drawn: false, where: 'The work pane. The desktop app does not place it yet.' },
|
||||
'sidebar.section': { drawn: false, where: 'The sidebar. The desktop app does not place it yet.' },
|
||||
'command.row': { drawn: false, where: 'The command list. The desktop app does not place it yet.' },
|
||||
}
|
||||
|
||||
function between(text, open, close, what) {
|
||||
const start = text.indexOf(open)
|
||||
if (start < 0) throw new Error(`${what}: cannot find ${open}`)
|
||||
const end = text.indexOf(close, start + open.length)
|
||||
if (end < 0) throw new Error(`${what}: cannot find the end of ${open}`)
|
||||
return text.slice(start + open.length, end)
|
||||
}
|
||||
|
||||
export function parseSeatTable(source) {
|
||||
const text = source.replace(/\r\n/g, '\n')
|
||||
const constants = Object.fromEntries([...text.matchAll(/const ([A-Z_]+): &str = "([^"]+)";/g)].map((m) => [m[1], m[2]]))
|
||||
const resolve = (literal, name) => literal !== undefined ? literal : constants[name]
|
||||
const seatBlock = between(text, 'const PLUGIN_UI_SEATS: [&str; ', '];', 'seat list')
|
||||
const declaredSeats = Number(seatBlock.slice(0, seatBlock.indexOf(']')))
|
||||
const seatBody = seatBlock.slice(seatBlock.indexOf('= [') + 3)
|
||||
const seats = [...seatBody.matchAll(/"([^"]+)"|([A-Z_]+)/g)].map((m) => resolve(m[1], m[2]))
|
||||
if (seats.length !== declaredSeats || seats.some((seat) => seat === undefined)) throw new Error('the seat list does not match its declared length')
|
||||
const tableBlock = between(text, 'const DSH_SEAT_TABLE: [(&str, DshSeat); ', '];\n', 'slot table')
|
||||
const declaredSlots = Number(tableBlock.slice(0, tableBlock.indexOf(']')))
|
||||
const tableBody = tableBlock.slice(tableBlock.indexOf('= [') + 3)
|
||||
const slots = [...tableBody.matchAll(/\(\s*"([^"]+)",\s*DshSeat::(Seat|Unsupported)\(\s*(?:"([^"]*)"|([A-Z_]+))\s*\)\s*,?\s*\)/g)].map((m) => {
|
||||
const value = resolve(m[3], m[4])
|
||||
return m[2] === 'Seat' ? { slot: m[1], seat: value, supported: true } : { slot: m[1], seat: null, supported: false, reason: value }
|
||||
})
|
||||
if (slots.length !== declaredSlots) throw new Error(`the slot table declares ${declaredSlots} rows and ${slots.length} were read`)
|
||||
for (const slot of slots) if (slot.supported && !seats.includes(slot.seat)) throw new Error(`slot ${slot.slot} maps to ${slot.seat}, which is not a seat`)
|
||||
return { seats, slots }
|
||||
}
|
||||
|
||||
export function parseRefusedEvents(source) {
|
||||
const text = source.replace(/\r\n/g, '\n')
|
||||
const body = between(text, 'const unsupportedDshLifecycle = new Set([', '])', 'refused events')
|
||||
return [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
}
|
||||
|
||||
export function parseHookEvents(source) {
|
||||
const text = source.replace(/\r\n/g, '\n')
|
||||
const body = between(text, 'const EVENTS:Record<string,string>={', '}', 'hook events')
|
||||
return [...body.matchAll(/([A-Za-z]+):'([a-z_]+)'/g)].map((m) => ({ event: m[1], point: m[2] }))
|
||||
}
|
||||
|
||||
export function buildCatalog(paths = SOURCES) {
|
||||
const compatibility = JSON.parse(read(paths.compatibility))
|
||||
const provided = Object.entries(compatibility.provided_services).flatMap(([group, names]) => names.map((name) => ({ group, name })))
|
||||
const known = new Set(Object.keys(SERVICES))
|
||||
const missing = provided.filter(({ name }) => !known.has(name)).map(({ name }) => name)
|
||||
if (missing.length) throw new Error(`compatibility.json provides services with no entry in the method table: ${missing.join(', ')}`)
|
||||
const extra = [...known].filter((name) => !provided.some((entry) => entry.name === name))
|
||||
if (extra.length) throw new Error(`the method table names services the host does not provide: ${extra.join(', ')}`)
|
||||
|
||||
const services = provided.map(({ group, name }) => {
|
||||
const entry = SERVICES[name]
|
||||
return {
|
||||
key: name,
|
||||
group,
|
||||
summary: entry.summary,
|
||||
availability: entry.availability,
|
||||
availabilityText: AVAILABILITY[entry.availability],
|
||||
methods: entry.methods,
|
||||
}
|
||||
})
|
||||
|
||||
const refusedEvents = parseRefusedEvents(read(paths.hostRoot))
|
||||
const hookEvents = parseHookEvents(read(paths.shellHooks))
|
||||
const events = [
|
||||
...EVENTS_SUPPORTED,
|
||||
...refusedEvents.map((name) => ({ name, status: 'refused', summary: REFUSED_EVENT_REASON })),
|
||||
]
|
||||
|
||||
const rowPackages = Object.keys(compatibility.row_bridges)
|
||||
const unknownRows = rowPackages.filter((name) => !ROW_PACKAGES[name])
|
||||
const staleRows = Object.keys(ROW_PACKAGES).filter((name) => !rowPackages.includes(name))
|
||||
if (unknownRows.length || staleRows.length) throw new Error(`row packages differ from compatibility.json: missing ${unknownRows.join(', ')}; stale ${staleRows.join(', ')}`)
|
||||
const rows = rowPackages.map((name) => ({
|
||||
id: `row:${name}`,
|
||||
name,
|
||||
bridge: compatibility.row_bridges[name],
|
||||
summary: ROW_PACKAGES[name].summary,
|
||||
limits: ROW_PACKAGES[name].limits,
|
||||
example: ROW_PACKAGES[name].example,
|
||||
...(name.includes('hooks-') ? { hookEvents } : {}),
|
||||
schema: { $schema: 'https://json-schema.org/draft/2020-12/schema', title: name, ...ROW_PACKAGES[name].schema },
|
||||
}))
|
||||
|
||||
const table = parseSeatTable(read(paths.seatTable))
|
||||
const unknownSeatNotes = table.seats.filter((seat) => !SEAT_NOTES[seat])
|
||||
const staleSeatNotes = Object.keys(SEAT_NOTES).filter((seat) => !table.seats.includes(seat))
|
||||
if (unknownSeatNotes.length || staleSeatNotes.length) throw new Error(`seat notes differ from the Engine seat list: missing ${unknownSeatNotes.join(', ')}; stale ${staleSeatNotes.join(', ')}`)
|
||||
const seats = table.seats.map((seat) => ({ seat, drawn: SEAT_NOTES[seat].drawn, where: SEAT_NOTES[seat].where }))
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
targetDshVersion: compatibility.target_dsh_version,
|
||||
host: {
|
||||
services,
|
||||
refusedServices: compatibility.refused_services,
|
||||
events,
|
||||
cordisInternalEvents: 'Cordis fires its own internal/* lifecycle events. They are not part of this host\'s supported contract and are not listed.',
|
||||
config: rows,
|
||||
},
|
||||
client: {
|
||||
seats,
|
||||
slots: table.slots,
|
||||
theme: THEME,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCatalog(catalog) {
|
||||
return `${JSON.stringify(catalog, null, 2)}\n`
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const text = renderCatalog(buildCatalog())
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = existsOrEmpty(SOURCES.catalog)
|
||||
if (current !== text) {
|
||||
process.stderr.write('inspect-catalog.json is out of date: run node scripts/gen-inspect-catalog.mjs\n')
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
writeFileSync(SOURCES.catalog, text)
|
||||
}
|
||||
}
|
||||
|
||||
function existsOrEmpty(path) {
|
||||
try {
|
||||
return read(path)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
{
|
||||
"version": 1,
|
||||
"targetDshVersion": "0.2.2-rc.1",
|
||||
"host": {
|
||||
"services": [
|
||||
{
|
||||
"key": "tools",
|
||||
"group": "dsh",
|
||||
"summary": "The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "register",
|
||||
"signature": "register(definition: ToolDefinition): () => void",
|
||||
"summary": "Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer."
|
||||
},
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "get(name: string, agent: Agent): { name: string } | undefined",
|
||||
"summary": "Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call's own agent."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "commands",
|
||||
"group": "dsh",
|
||||
"summary": "Slash commands a person types. A handler runs only when the person types the command.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "register",
|
||||
"signature": "register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void",
|
||||
"summary": "Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "storage",
|
||||
"group": "dsh",
|
||||
"summary": "Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "get(key: string): Promise<Json | undefined>",
|
||||
"summary": "Read one stored value."
|
||||
},
|
||||
{
|
||||
"name": "set",
|
||||
"signature": "set(key: string, value: Json): Promise<void>",
|
||||
"summary": "Store one JSON value, within the bounded key, value and owner limits."
|
||||
},
|
||||
{
|
||||
"name": "delete",
|
||||
"signature": "delete(key: string): Promise<void>",
|
||||
"summary": "Remove one stored value."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "skills",
|
||||
"group": "dsh",
|
||||
"summary": "Skill roots: reviewed folders of skills added to the Engine's skill catalog.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerRoot",
|
||||
"signature": "registerRoot(definition: { path: string }): () => void",
|
||||
"summary": "Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "sessions",
|
||||
"group": "dsh",
|
||||
"summary": "The session of the calling tool invocation.",
|
||||
"availability": "tool-invocation",
|
||||
"availabilityText": "Answers only inside a live tool call of the plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "flush",
|
||||
"signature": "flush(session: Session): Promise<boolean>",
|
||||
"summary": "Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "userQuestions",
|
||||
"group": "dsh",
|
||||
"summary": "Questions put to the person through the Engine's own question card.",
|
||||
"availability": "tool-invocation",
|
||||
"availabilityText": "Answers only inside a live tool call of the plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "ask",
|
||||
"signature": "ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>",
|
||||
"summary": "Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "subagents",
|
||||
"group": "dsh",
|
||||
"summary": "One independent child agent a root tool call may start.",
|
||||
"availability": "tool-invocation",
|
||||
"availabilityText": "Answers only inside a live tool call of the plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "getProvider",
|
||||
"signature": "getProvider(id: 'spawn'): Provider",
|
||||
"summary": "Describe the one provider: a fresh child with no inherited conversation."
|
||||
},
|
||||
{
|
||||
"name": "startActivation",
|
||||
"signature": "startActivation(options: { provider: 'spawn', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: 'caller' | 'parent' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>",
|
||||
"summary": "Start the child through the Engine's approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child."
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"signature": "activation.result: Promise<{ stopReason: string, output: { type: 'text', text: string }[], diagnostic?: string }>",
|
||||
"summary": "A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks."
|
||||
},
|
||||
{
|
||||
"name": "dispose",
|
||||
"signature": "activation.dispose(): Promise<void>",
|
||||
"summary": "Stop that exact child and its own descendants."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "agents",
|
||||
"group": "dsh",
|
||||
"summary": "Reserved. The host offers no methods on agents.",
|
||||
"availability": "none",
|
||||
"availabilityText": "No methods.",
|
||||
"methods": []
|
||||
},
|
||||
{
|
||||
"key": "systemPrompt",
|
||||
"group": "dsh",
|
||||
"summary": "The child agent's persona slot, reachable only inside an agent/created listener.",
|
||||
"availability": "child-agent-hook",
|
||||
"availabilityText": "Answers only inside an agent/created listener, on the child agent.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "section",
|
||||
"signature": "agent.ctx.systemPrompt.section({ name: 'deployment:persona-prefix', order: agent.ctx.systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_PREFIX'), text: string }): () => void",
|
||||
"summary": "Set the child's persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder('DEPLOYMENT_PERSONA_PREFIX') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine's own system prompt cannot be replaced."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "logger",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis logging. Every line becomes a host log notification attributed to the plugin.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "logger",
|
||||
"signature": "ctx.logger(name?: string): Logger",
|
||||
"summary": "A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "events",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis event dispatch. Query the host Event provider for the events this host fires.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "on",
|
||||
"signature": "ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
|
||||
"summary": "Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider)."
|
||||
},
|
||||
{
|
||||
"name": "once",
|
||||
"signature": "ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
|
||||
"summary": "Listen to an event once."
|
||||
},
|
||||
{
|
||||
"name": "emit",
|
||||
"signature": "ctx.emit(name: string, ...args: any[]): void",
|
||||
"summary": "Dispatch an event to its listeners."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "reflect",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis service reflection. A plugin may provide its own services but not the Engine's.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "provide",
|
||||
"signature": "ctx.reflect.provide(name: string, value?: any): () => void",
|
||||
"summary": "Provide a service under a name. Names in refusedServices are refused: the Engine owns them."
|
||||
},
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "ctx.reflect.get(name: string, strict?: boolean): any",
|
||||
"summary": "Read a provided service."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "registry",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis plugin registry.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "ctx.registry.get(plugin: Plugin): Runtime | undefined",
|
||||
"summary": "Look up the runtime record of a plugin."
|
||||
},
|
||||
{
|
||||
"name": "resolve",
|
||||
"signature": "ctx.registry.resolve(plugin: Plugin): Function | undefined",
|
||||
"summary": "Resolve a plugin shape to its callback."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "prompt",
|
||||
"group": "codewhale",
|
||||
"summary": "Attributed sections of the Engine's runtime prompt. A plugin cannot replace the system prompt.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerSection",
|
||||
"signature": "registerSection(definition: { id: string, text: string, interpolate?: 'model-cwd' }): () => void",
|
||||
"summary": "Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "avatars",
|
||||
"group": "codewhale",
|
||||
"summary": "Avatar packs for the terminal.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerPack",
|
||||
"signature": "registerPack(definition: { path: string }): () => void",
|
||||
"summary": "Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "mcp",
|
||||
"group": "codewhale",
|
||||
"summary": "Proposals of MCP servers. The Engine owns connection, credentials and tool calls.",
|
||||
"availability": "native-review",
|
||||
"availabilityText": "Available to a reviewed native plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerServer",
|
||||
"signature": "registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void",
|
||||
"summary": "Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "shellHooks",
|
||||
"group": "codewhale",
|
||||
"summary": "Claude Code and Codex style command hooks, run by the Engine.",
|
||||
"availability": "native-review",
|
||||
"availabilityText": "Available to a reviewed native plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "register",
|
||||
"signature": "register(definition: { dialect: 'claude-code' | 'codex', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void",
|
||||
"summary": "Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "remote",
|
||||
"group": "codewhale",
|
||||
"summary": "Remote pairing, served by the Engine.",
|
||||
"availability": "review-grant",
|
||||
"availabilityText": "Served by the Engine only when the plugin's review granted it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "status",
|
||||
"signature": "status(): Promise<Status>",
|
||||
"summary": "Whether remote access is on."
|
||||
},
|
||||
{
|
||||
"name": "pair",
|
||||
"signature": "pair(options?: { label?: string }): Promise<Pairing>",
|
||||
"summary": "Start a pairing."
|
||||
},
|
||||
{
|
||||
"name": "clients",
|
||||
"signature": "clients(): Promise<Client[]>",
|
||||
"summary": "List paired clients."
|
||||
},
|
||||
{
|
||||
"name": "revoke",
|
||||
"signature": "revoke(id: string): Promise<void>",
|
||||
"summary": "Revoke one grant by the id pair returned."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"refusedServices": [
|
||||
"shellHooks",
|
||||
"loader",
|
||||
"approval",
|
||||
"agents",
|
||||
"subagents",
|
||||
"sessions",
|
||||
"llm",
|
||||
"sandboxPolicy",
|
||||
"credentials",
|
||||
"fs",
|
||||
"subprocess",
|
||||
"systemPrompt",
|
||||
"runtimeLoop",
|
||||
"tools",
|
||||
"commands",
|
||||
"prompt",
|
||||
"storage",
|
||||
"skills",
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"name": "tools/pre-execute",
|
||||
"status": "supported",
|
||||
"mode": "ordered listeners, native hooks first, then plugin listeners in registration order",
|
||||
"signature": "ctx.on('tools/pre-execute', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)",
|
||||
"summary": "Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: 'deny', reason }, { kind: 'ask', reason? }, { kind: 'revise', input }, { kind: 'annotate', text } or { kind: 'abstain' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.",
|
||||
"options": "prepend and global are refused."
|
||||
},
|
||||
{
|
||||
"name": "agent/created",
|
||||
"status": "supported",
|
||||
"mode": "listeners of the owning plugin only",
|
||||
"signature": "ctx.on('agent/created', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)",
|
||||
"summary": "Fires for a child agent that this plugin's own subagents.startActivation creates, before the child starts. The listener may set the child's persona and deny tools to it; the window closes when the listeners return.",
|
||||
"options": "global is refused."
|
||||
},
|
||||
{
|
||||
"name": "agent/pre-step",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "agent/turn-stopping",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "tools/post-execute",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "subagent/start",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "subagent/end",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
}
|
||||
],
|
||||
"cordisInternalEvents": "Cordis fires its own internal/* lifecycle events. They are not part of this host's supported contract and are not listed.",
|
||||
"config": [
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-mcp-client",
|
||||
"name": "@deepseek-ai/dsh-mcp-client",
|
||||
"bridge": "mcp",
|
||||
"summary": "An MCP server row. The Engine proposes the server and owns the connection.",
|
||||
"limits": [
|
||||
"stdio rows name a command; streamable-http and sse rows name a url.",
|
||||
"Reconnect and instruction bounds belong to the Engine and are not configurable here."
|
||||
],
|
||||
"example": {
|
||||
"transport": "stdio",
|
||||
"serverName": "docs",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"server.js"
|
||||
],
|
||||
"cwd": "."
|
||||
},
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-mcp-client",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"transport",
|
||||
"serverName"
|
||||
],
|
||||
"properties": {
|
||||
"transport": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stdio",
|
||||
"streamable-http",
|
||||
"sse"
|
||||
]
|
||||
},
|
||||
"serverName": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "stdio only: the program to run."
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "stdio only."
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"maxProperties": 0,
|
||||
"description": "Must be empty or absent: credentials stay with the Engine."
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "stdio only: a bundle-relative directory, no .. components."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "streamable-http and sse: the endpoint, with no credentials, query or fragment."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"maxProperties": 0,
|
||||
"description": "Must be empty or absent: credentials stay with the Engine."
|
||||
},
|
||||
"toolCallTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"multipleOf": 1000
|
||||
},
|
||||
"failOnStartupError": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Only false: the Engine connects lazily and reports failures through /mcp."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-skill-filesystem",
|
||||
"name": "@deepseek-ai/dsh-skill-filesystem",
|
||||
"bridge": "skills",
|
||||
"summary": "A skill folder row, limited to reviewed roots inside the bundle.",
|
||||
"limits": [
|
||||
"Both includeDefaultRoots and watch must be written out as false."
|
||||
],
|
||||
"example": {
|
||||
"includeDefaultRoots": false,
|
||||
"watch": false,
|
||||
"customSkillDirs": [
|
||||
"skills"
|
||||
]
|
||||
},
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-skill-filesystem",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"includeDefaultRoots",
|
||||
"watch",
|
||||
"customSkillDirs"
|
||||
],
|
||||
"properties": {
|
||||
"providerName": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"includeDefaultRoots": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Must be false: ambient roots are not read."
|
||||
},
|
||||
"watch": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Must be false: there is no file watcher."
|
||||
},
|
||||
"customSkillDirs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Bundle-relative skill roots, no duplicates."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-hooks-claude-code",
|
||||
"name": "@deepseek-ai/dsh-hooks-claude-code",
|
||||
"bridge": "hooks-claude-code",
|
||||
"summary": "Claude Code style command hooks read from a reviewed file in the bundle.",
|
||||
"limits": [
|
||||
"projectDir is refused: each process uses the current workspace.",
|
||||
"Only command hooks run; non-command and asynchronous hooks fail the row.",
|
||||
"At most 128 commands per row."
|
||||
],
|
||||
"example": {
|
||||
"configPath": "hooks/hooks.json"
|
||||
},
|
||||
"hookEvents": [
|
||||
{
|
||||
"event": "SessionStart",
|
||||
"point": "session_start"
|
||||
},
|
||||
{
|
||||
"event": "UserPromptSubmit",
|
||||
"point": "message_submit"
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"point": "tool_call_before"
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"point": "tool_call_after"
|
||||
},
|
||||
{
|
||||
"event": "Stop",
|
||||
"point": "turn_end"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStart",
|
||||
"point": "subagent_spawn"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStop",
|
||||
"point": "subagent_complete"
|
||||
}
|
||||
],
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-hooks-claude-code",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"configPath"
|
||||
],
|
||||
"properties": {
|
||||
"configPath": {
|
||||
"type": "string",
|
||||
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
|
||||
},
|
||||
"pluginRoot": {
|
||||
"type": "string",
|
||||
"description": "Absent, \".\", or this reviewed bundle root."
|
||||
},
|
||||
"defaultTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"multipleOf": 1000,
|
||||
"description": "Whole seconds, used when a hook sets none; default 600000."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-hooks-codex",
|
||||
"name": "@deepseek-ai/dsh-hooks-codex",
|
||||
"bridge": "hooks-codex",
|
||||
"summary": "Codex style command hooks read from a reviewed file in the bundle.",
|
||||
"limits": [
|
||||
"projectDir is refused: each process uses the current workspace.",
|
||||
"Only command hooks run; non-command and asynchronous hooks fail the row.",
|
||||
"At most 128 commands per row."
|
||||
],
|
||||
"example": {
|
||||
"configPath": "hooks/hooks.json"
|
||||
},
|
||||
"hookEvents": [
|
||||
{
|
||||
"event": "SessionStart",
|
||||
"point": "session_start"
|
||||
},
|
||||
{
|
||||
"event": "UserPromptSubmit",
|
||||
"point": "message_submit"
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"point": "tool_call_before"
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"point": "tool_call_after"
|
||||
},
|
||||
{
|
||||
"event": "Stop",
|
||||
"point": "turn_end"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStart",
|
||||
"point": "subagent_spawn"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStop",
|
||||
"point": "subagent_complete"
|
||||
}
|
||||
],
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-hooks-codex",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"configPath"
|
||||
],
|
||||
"properties": {
|
||||
"configPath": {
|
||||
"type": "string",
|
||||
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
|
||||
},
|
||||
"pluginRoot": {
|
||||
"type": "string",
|
||||
"description": "Absent, \".\", or this reviewed bundle root."
|
||||
},
|
||||
"defaultTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"multipleOf": 1000,
|
||||
"description": "Whole seconds, used when a hook sets none; default 600000."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-persona",
|
||||
"name": "@deepseek-ai/dsh-persona",
|
||||
"bridge": "persona",
|
||||
"summary": "Text added before and after the Engine's own prompt for this plugin's entry.",
|
||||
"limits": [
|
||||
"Needs an exact Engine-selected entry scope."
|
||||
],
|
||||
"example": {
|
||||
"prefix": "Answer in short sentences."
|
||||
},
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-persona",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"prefix"
|
||||
],
|
||||
"properties": {
|
||||
"prefix": {
|
||||
"type": "string",
|
||||
"description": "At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded."
|
||||
},
|
||||
"suffix": {
|
||||
"type": "string",
|
||||
"description": "Same limits as prefix."
|
||||
},
|
||||
"complete": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Replacing the whole prompt belongs to the Engine."
|
||||
},
|
||||
"includeRuntimeContext": {
|
||||
"type": "boolean",
|
||||
"const": true,
|
||||
"description": "Suppressing the runtime context belongs to the Engine."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"client": {
|
||||
"seats": [
|
||||
{
|
||||
"seat": "customize.plugins.detail",
|
||||
"drawn": true,
|
||||
"where": "The plugin's own page under Customize, Plugins."
|
||||
},
|
||||
{
|
||||
"seat": "conversation.composer.dock",
|
||||
"drawn": false,
|
||||
"where": "Under the composer. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "conversation.chat.node",
|
||||
"drawn": false,
|
||||
"where": "Inside the conversation. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "workpane.panel",
|
||||
"drawn": false,
|
||||
"where": "The work pane. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "sidebar.section",
|
||||
"drawn": false,
|
||||
"where": "The sidebar. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "command.row",
|
||||
"drawn": false,
|
||||
"where": "The command list. The desktop app does not place it yet."
|
||||
}
|
||||
],
|
||||
"slots": [
|
||||
{
|
||||
"slot": "plugins.bundle.config",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "plugins.row.config",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "plugins.tab",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "plugins.detail.section",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.section",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.plugin.item",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.general.item",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.plugins.tab",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.node",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.turnTail",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.assistant-actions",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.text-interaction",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.user.text.node",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "tool.call.toolview",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.view",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.input.dock",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.input.overlay",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.input.right",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.composer.dock",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.right.pane.tab",
|
||||
"seat": "workpane.panel",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "main.conversation",
|
||||
"seat": "workpane.panel",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "main.page-menu",
|
||||
"seat": "workpane.panel",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.panellist",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.workspaces",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.footer.action",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.session.row.leading",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.workspaces.session.row.action",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.session.header.actions",
|
||||
"seat": null,
|
||||
"supported": false,
|
||||
"reason": "no ADE seat yet"
|
||||
},
|
||||
{
|
||||
"slot": "conversation.session.header.utilities",
|
||||
"seat": null,
|
||||
"supported": false,
|
||||
"reason": "no ADE seat yet"
|
||||
},
|
||||
{
|
||||
"slot": "contextMenu.icon",
|
||||
"seat": null,
|
||||
"supported": false,
|
||||
"reason": "no ADE seat yet"
|
||||
}
|
||||
],
|
||||
"theme": {
|
||||
"tokens": [],
|
||||
"colorSchemes": [
|
||||
"light",
|
||||
"dark"
|
||||
],
|
||||
"scheme": "The frame sets data-theme and color-scheme on its document to the app's current scheme and follows changes.",
|
||||
"api": [
|
||||
{
|
||||
"name": "ctx.theme.getTheme",
|
||||
"signature": "getTheme(): { active: { colorScheme: 'light' | 'dark' } }",
|
||||
"summary": "The scheme in use now."
|
||||
},
|
||||
{
|
||||
"name": "ctx.theme.overrideTokens",
|
||||
"signature": "overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void",
|
||||
"summary": "Set custom properties on the frame's document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer."
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.",
|
||||
"The frame's background is transparent and its margin is zero, so the plugin sits on the app's own surface."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { isJson } from '../json.ts'
|
||||
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './dsh-tools-compat.js'
|
||||
import catalogData from './inspect-catalog.json' with { type: 'json' }
|
||||
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
export type InspectPlatform = 'host' | 'client'
|
||||
|
||||
export interface InspectMethod {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: JsonValue
|
||||
outputSchema: JsonValue
|
||||
}
|
||||
|
||||
export interface InspectProviderView {
|
||||
platform: InspectPlatform
|
||||
id: string
|
||||
description: string
|
||||
methods: InspectMethod[]
|
||||
}
|
||||
|
||||
export interface InstalledRow {
|
||||
id: string
|
||||
name: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface ClientState {
|
||||
connected: boolean
|
||||
drawnSeats?: readonly string[]
|
||||
}
|
||||
|
||||
export interface InspectSources {
|
||||
tools?: (agent: unknown, signal: AbortSignal) => Promise<readonly JsonValue[]> | readonly JsonValue[]
|
||||
installed?: (signal: AbortSignal) => Promise<readonly InstalledRow[]> | readonly InstalledRow[]
|
||||
client?: () => ClientState
|
||||
}
|
||||
|
||||
export interface CordisInspect {
|
||||
list(): InspectProviderView[]
|
||||
query(platform: string, provider: string, method: string, input: JsonValue | undefined, agent?: unknown, signal?: AbortSignal): Promise<JsonValue>
|
||||
}
|
||||
|
||||
interface Provider {
|
||||
platform: InspectPlatform
|
||||
id: string
|
||||
description: string
|
||||
methods: InspectMethod[]
|
||||
run(method: string, input: Record<string, JsonValue>, agent: unknown, signal: AbortSignal): Promise<any> | any
|
||||
}
|
||||
|
||||
const catalog = catalogData as any
|
||||
const EMPTY_INPUT: JsonValue = { type: 'object', properties: {}, additionalProperties: false }
|
||||
const ANY_OUTPUT: JsonValue = { description: 'JSON data owned by this inspect provider.' }
|
||||
const NO_CLIENT = 'No client is connected. Plugin screens are drawn by the desktop app; in a terminal nothing is drawn, so every slot reports drawn: false.'
|
||||
|
||||
const exactInput = (field: string, description: string): JsonValue => ({
|
||||
type: 'object',
|
||||
properties: { [field]: { type: 'string', description } },
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
const method = (name: string, description: string, inputSchema: JsonValue = EMPTY_INPUT, outputSchema: JsonValue = ANY_OUTPUT): InspectMethod =>
|
||||
({ name, description, inputSchema, outputSchema })
|
||||
|
||||
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
||||
|
||||
function serviceView(service: any): JsonValue {
|
||||
return {
|
||||
key: service.key,
|
||||
group: service.group,
|
||||
summary: service.summary,
|
||||
availability: service.availability,
|
||||
availabilityText: service.availabilityText,
|
||||
methods: service.methods,
|
||||
}
|
||||
}
|
||||
|
||||
function serviceProvider(): Provider {
|
||||
return {
|
||||
platform: 'host', id: 'Service',
|
||||
description: 'The services this host gives plugins, with their methods, and the services a plugin may not provide. A name that is not listed does not exist here.',
|
||||
methods: [method('listService', 'Without input: the directory of services with one-line summaries and method signatures. With service: that service\'s exact contract, with availability. These are services a plugin injects, not DSH\'s full catalogue.',
|
||||
exactInput('service', 'Exact service key. Omit it for the directory.'))],
|
||||
run(_name, input) {
|
||||
const key = input.service
|
||||
if (key === undefined) {
|
||||
return {
|
||||
services: catalog.host.services.map((s: any) => ({ key: s.key, group: s.group, summary: s.summary, availability: s.availability, signatures: s.methods.map((m: any) => m.signature) })),
|
||||
refusedServices: catalog.host.refusedServices,
|
||||
note: 'refusedServices are names a plugin may not provide: the Engine owns them. Service methods named by the host Event and Config providers are separate.',
|
||||
targetDshVersion: catalog.targetDshVersion,
|
||||
}
|
||||
}
|
||||
const found = catalog.host.services.find((s: any) => s.key === key)
|
||||
if (!found) throw new Error(`Host service "${String(key)}" is not provided by this host. Call without service for the directory.`)
|
||||
return { service: serviceView(found), targetDshVersion: catalog.targetDshVersion }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function eventProvider(): Provider {
|
||||
return {
|
||||
platform: 'host', id: 'Event',
|
||||
description: 'The events a plugin may listen to in this host, and the ones the host refuses.',
|
||||
methods: [method('listEvents', 'Without input: the directory of events with status supported or refused. With event: its exact contract.',
|
||||
exactInput('event', 'Exact event name. Omit it for the directory.'))],
|
||||
run(_name, input) {
|
||||
const key = input.event
|
||||
if (key === undefined) {
|
||||
return {
|
||||
events: catalog.host.events.map((e: any) => ({ name: e.name, status: e.status, summary: e.summary })),
|
||||
note: catalog.host.cordisInternalEvents,
|
||||
}
|
||||
}
|
||||
const found = catalog.host.events.find((e: any) => e.name === key)
|
||||
if (!found) throw new Error(`Host event "${String(key)}" is not known to this host. Call without event for the directory.`)
|
||||
return { event: found }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIG_INPUT: JsonValue = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entry: { type: 'string', description: 'Exact entry id from the directory; returns that entry\'s Config schema.' },
|
||||
name: { type: 'string', description: 'Exact package or plugin name; limits the directory to it.' },
|
||||
offset: { type: 'integer', description: 'Zero-based directory offset; defaults to 0.' },
|
||||
limit: { type: 'integer', description: 'Directory page size, from 1 to 100; defaults to 25.' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
function configProvider(sources: InspectSources): Provider {
|
||||
return {
|
||||
platform: 'host', id: 'Config',
|
||||
description: 'Config a composition row may carry: the packages a row may name, each with the config it accepts, and the plugins installed on this host with their state.',
|
||||
methods: [method('listConfigs', 'Without entry: a page of the directory (row packages first, then installed plugins) with total and nextOffset. With entry: one row package\'s exact JSON Schema, limits and a valid example. Installed plugins are listed with their state; their own config is not described here.', CONFIG_INPUT)],
|
||||
async run(_name, input, _agent, signal) {
|
||||
const rows: any[] = catalog.host.config.map((r: any) => ({ id: r.id, name: r.name, kind: 'row-package', status: 'schema', bridge: r.bridge, summary: r.summary }))
|
||||
const installed = sources.installed ? await sources.installed(signal) : []
|
||||
for (const row of installed) rows.push({ id: `plugin:${row.id}`, name: row.name, kind: 'installed-plugin', status: row.state })
|
||||
if (input.entry !== undefined) {
|
||||
const pkg = catalog.host.config.find((r: any) => r.id === input.entry)
|
||||
if (pkg) return { entry: pkg.id, name: pkg.name, kind: 'row-package', bridge: pkg.bridge, summary: pkg.summary, limits: pkg.limits, example: pkg.example, ...pkg.hookEvents ? { hookEvents: pkg.hookEvents } : {}, schema: pkg.schema }
|
||||
const live = rows.find((r) => r.id === input.entry)
|
||||
if (live) return { entry: live.id, name: live.name, kind: live.kind, status: live.status, schema: null, note: 'An installed plugin\'s own Config is not described by this host.' }
|
||||
throw new Error(`Host Config entry "${String(input.entry)}" is not listed. Call without entry for the directory.`)
|
||||
}
|
||||
const filtered = input.name === undefined ? rows : rows.filter((r) => r.name === input.name)
|
||||
const offset = typeof input.offset === 'number' ? input.offset : 0
|
||||
const limit = typeof input.limit === 'number' ? input.limit : 25
|
||||
if (offset < 0 || limit < 1 || limit > 100) throw new Error('Host Config directory needs offset >= 0 and 1 <= limit <= 100')
|
||||
const page = filtered.slice(offset, offset + limit)
|
||||
const next = offset + page.length
|
||||
return { entries: page, total: filtered.length, ...next < filtered.length ? { nextOffset: next } : {} }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toolProvider(sources: InspectSources): Provider | undefined {
|
||||
const source = sources.tools
|
||||
if (!source) return undefined
|
||||
return {
|
||||
platform: 'host', id: 'Tool',
|
||||
description: 'Tools visible to the requesting agent.',
|
||||
methods: [method('listTools', 'Return every tool schema currently callable by this agent.')],
|
||||
async run(_name, _input, agent, signal) {
|
||||
return { tools: [...await source(agent, signal)] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const SUBTREE_INPUT = exactInput('root', 'Exact slot name to select. Omit it for every slot.')
|
||||
|
||||
function slotsProvider(sources: InspectSources): Provider {
|
||||
return {
|
||||
platform: 'client', id: 'Slots',
|
||||
description: 'The DSH slot names this host accepts, the seat each maps to, and whether the connected client draws that seat.',
|
||||
methods: [method('listSubTree', 'Return every slot with its seat and drawn state; with root, also that slot selected. Slots are static names here, not a live tree, and their props are not described by this host: a plugin registers on a slot, the Engine serves it on the mapped seat, and the client draws only the seats it places.', SUBTREE_INPUT)],
|
||||
run(_name, input) {
|
||||
const state = sources.client?.() ?? { connected: false }
|
||||
const drawn = new Set(state.connected ? state.drawnSeats ?? catalog.client.seats.filter((s: any) => s.drawn).map((s: any) => s.seat) : [])
|
||||
const slots = catalog.client.slots.map((s: any) => ({
|
||||
type: 'slot', name: s.slot, seat: s.seat, supported: s.supported,
|
||||
drawn: s.supported && drawn.has(s.seat),
|
||||
...s.reason ? { reason: s.reason } : {},
|
||||
}))
|
||||
const seats = catalog.client.seats.map((s: any) => ({ seat: s.seat, drawn: drawn.has(s.seat), where: s.where }))
|
||||
const out: Record<string, JsonValue> = {
|
||||
client: state.connected ? { connected: true } : { connected: false, message: NO_CLIENT },
|
||||
seats, trees: slots, referencedTypes: [],
|
||||
}
|
||||
if (input.root !== undefined) {
|
||||
const selected = slots.find((s: any) => s.name === input.root)
|
||||
out.requestedRoot = { name: input.root, available: selected !== undefined }
|
||||
if (selected) out.selected = selected
|
||||
}
|
||||
return out
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function themeProvider(): Provider {
|
||||
return {
|
||||
platform: 'client', id: 'Theme',
|
||||
description: 'What the plugin frame provides for theming.',
|
||||
methods: [method('listTokens', 'Return the named tokens the frame provides (none), the colour schemes, and the theme calls a plugin has.')],
|
||||
run() {
|
||||
return { tokens: catalog.client.theme.tokens, colorSchemes: catalog.client.theme.colorSchemes, scheme: catalog.client.theme.scheme, api: catalog.client.theme.api, notes: catalog.client.theme.notes, referencedTypes: [] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createCordisInspect(sources: InspectSources = {}): CordisInspect {
|
||||
const providers: Provider[] = [serviceProvider(), eventProvider(), configProvider(sources), toolProvider(sources), slotsProvider(sources), themeProvider()]
|
||||
.filter((p): p is Provider => p !== undefined)
|
||||
for (const provider of providers) {
|
||||
if (provider.id.trim() === '' || provider.description.trim() === '') throw new Error('Cordis inspect provider needs an id and a description')
|
||||
for (const m of provider.methods) {
|
||||
assertSupportedJsonSchema(m.inputSchema)
|
||||
assertSupportedJsonSchema(m.outputSchema)
|
||||
}
|
||||
}
|
||||
const label = (platform: InspectPlatform) => platform === 'host' ? 'Host' : 'Client'
|
||||
return {
|
||||
list() {
|
||||
return providers.map((p) => ({ platform: p.platform, id: p.id, description: p.description, methods: copy(p.methods) }))
|
||||
},
|
||||
async query(platform, providerId, methodName, input, agent, signal = new AbortController().signal) {
|
||||
if (platform !== 'host' && platform !== 'client') throw new Error(`Cordis inspect platform must be "host" or "client", not "${String(platform)}"`)
|
||||
const provider = providers.find((p) => p.platform === platform && p.id === providerId)
|
||||
if (!provider) throw new Error(`${label(platform)} Cordis inspect provider "${providerId}" is not registered`)
|
||||
const found = provider.methods.find((m) => m.name === methodName)
|
||||
if (!found) throw new Error(`Cordis inspect provider "${provider.id}" has no method "${methodName}"`)
|
||||
const violations = validateJsonSchemaValue(found.inputSchema, input ?? {}, 'input') as string[]
|
||||
if (violations.length > 0) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} rejected input: ${violations.join('; ')}`)
|
||||
signal.throwIfAborted()
|
||||
const data = await provider.run(methodName, (input ?? {}) as Record<string, JsonValue>, agent, signal)
|
||||
signal.throwIfAborted()
|
||||
if (!isJson(data)) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} returned a non-JSON value`)
|
||||
return copy(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { build } from 'esbuild'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { after, test } from 'node:test'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { buildCatalog, parseSeatTable, renderCatalog } from '../scripts/gen-inspect-catalog.mjs'
|
||||
import { createCordisInspect } from '../src/dsh/inspect.ts'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const src = (path) => join(here, '..', 'src', path)
|
||||
const text = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n')
|
||||
const compatibility = JSON.parse(text(src('dsh/compatibility.json')))
|
||||
const catalog = JSON.parse(text(src('dsh/inspect-catalog.json')))
|
||||
const seatSource = text(join(here, '..', '..', '..', 'app-server', 'src', 'runtime_api', 'plugins.rs'))
|
||||
const rootSource = text(src('root.ts'))
|
||||
const inspect = createCordisInspect()
|
||||
const ask = (platform, provider, method, input) => inspect.query(platform, provider, method, input)
|
||||
|
||||
test('the committed catalog is what the generator writes now', () => {
|
||||
assert.equal(text(src('dsh/inspect-catalog.json')), renderCatalog(buildCatalog()))
|
||||
})
|
||||
|
||||
test('list names the providers DSH calls and leaves out Tool until a source exists', async () => {
|
||||
const ids = inspect.list().map((p) => `${p.platform}.${p.id}.${p.methods.map((m) => m.name).join(',')}`)
|
||||
assert.deepEqual(ids, ['host.Service.listService', 'host.Event.listEvents', 'host.Config.listConfigs', 'client.Slots.listSubTree', 'client.Theme.listTokens'])
|
||||
const withTools = createCordisInspect({ tools: () => [{ name: 'read_file' }] })
|
||||
assert.ok(withTools.list().some((p) => p.id === 'Tool'))
|
||||
assert.deepEqual(await withTools.query('host', 'Tool', 'listTools', undefined, {}), { tools: [{ name: 'read_file' }] })
|
||||
})
|
||||
|
||||
test('errors use DSH registry wording', async () => {
|
||||
await assert.rejects(ask('host', 'Nope', 'x'), /Host Cordis inspect provider "Nope" is not registered/)
|
||||
await assert.rejects(ask('client', 'Nope', 'x'), /Client Cordis inspect provider "Nope" is not registered/)
|
||||
await assert.rejects(ask('host', 'Service', 'nope'), /Cordis inspect provider "Service" has no method "nope"/)
|
||||
await assert.rejects(ask('host', 'Service', 'listService', { service: 3 }), /Host Cordis inspect Service.listService rejected input/)
|
||||
await assert.rejects(ask('host', 'Tool', 'listTools'), /not registered/)
|
||||
await assert.rejects(ask('page', 'Service', 'listService'), /platform must be/)
|
||||
})
|
||||
|
||||
test('every service the catalog names is provided, and no provided service is missing', () => {
|
||||
const provided = Object.values(compatibility.provided_services).flat().sort()
|
||||
assert.deepEqual(catalog.host.services.map((s) => s.key).sort(), provided)
|
||||
assert.deepEqual(catalog.host.refusedServices, compatibility.refused_services)
|
||||
})
|
||||
|
||||
test('methods match the compatibility tables where it has them', () => {
|
||||
const tables = { sessions: 'session_methods', userQuestions: 'user_question_methods', subagents: 'subagent_methods', agents: 'agents_methods', systemPrompt: 'system_prompt_methods' }
|
||||
for (const [service, table] of Object.entries(tables)) {
|
||||
const named = catalog.host.services.find((s) => s.key === service).methods.map((m) => m.name).sort()
|
||||
assert.deepEqual(named, Object.keys(compatibility[table]).sort(), service)
|
||||
}
|
||||
})
|
||||
|
||||
const classBody = (source, name) => {
|
||||
const start = source.indexOf(`class ${name} extends Service`)
|
||||
assert.ok(start >= 0, `class ${name}`)
|
||||
const rest = source.slice(start + 1)
|
||||
const next = rest.search(/\n\s*(export )?(class |function )/)
|
||||
return next < 0 ? rest : rest.slice(0, next)
|
||||
}
|
||||
const defines = (body, name) => new RegExp(`^\\s*(async\\s+)?${name}\\s*\\(`, 'm').test(body)
|
||||
|
||||
test('every curated method is defined on the class that serves it', () => {
|
||||
const served = {
|
||||
tools: ['root.ts', 'ToolsShim'], commands: ['shims/commands.ts', 'CommandsShim'], storage: ['root.ts', 'StorageShim'],
|
||||
skills: ['shims/skills.ts', 'SkillsShim'], prompt: ['shims/prompt.ts', 'PromptShim'], avatars: ['shims/avatars.ts', 'AvatarsShim'],
|
||||
mcp: ['shims/mcp.ts', 'McpShim'], shellHooks: ['shims/shell-hooks.ts', 'ShellHooks'], remote: ['dsh/remote.ts', 'RemoteService'],
|
||||
sessions: ['dsh/session-prompt.ts', 'SessionsService'], userQuestions: ['root.ts', 'UserQuestionsShim'],
|
||||
subagents: ['dsh/agents.ts', 'SubagentsShim'], systemPrompt: ['dsh/session-prompt.ts', 'SystemPromptService'],
|
||||
}
|
||||
const cordis = { events: ['events', ['on', 'once', 'emit']], reflect: ['reflect', ['provide', 'get']], registry: ['registry', ['get', 'resolve']] }
|
||||
const agentsSource = text(src('dsh/agents.ts'))
|
||||
for (const service of catalog.host.services) {
|
||||
if (service.key === 'logger' || service.key === 'agents') continue
|
||||
if (cordis[service.key]) {
|
||||
const [file, names] = cordis[service.key]
|
||||
const body = text(join(here, '..', 'node_modules', '@deepseek-ai', 'cordis', 'lib', 'types', `${file}.d.ts`))
|
||||
assert.deepEqual(service.methods.map((m) => m.name).sort(), [...names].sort(), service.key)
|
||||
for (const name of names) assert.ok(defines(body, name), `${service.key}.${name}`)
|
||||
continue
|
||||
}
|
||||
const [file, cls] = served[service.key]
|
||||
const body = classBody(text(src(file)), cls)
|
||||
for (const m of service.methods) {
|
||||
if (service.key === 'subagents' && m.name === 'result') assert.match(agentsSource, /^\s*get result\(\)/m, 'subagents.result is a getter')
|
||||
else if (service.key === 'subagents' && m.name === 'dispose') assert.match(agentsSource, /^\s*dispose\(\)/m, 'subagents.dispose')
|
||||
else assert.ok(defines(body, m.name), `${service.key}.${m.name}`)
|
||||
}
|
||||
}
|
||||
assert.match(agentsSource, /^\s*section\(section: unknown\)/m)
|
||||
assert.match(agentsSource, /^\s*getSectionOrder\(name: unknown\)/m)
|
||||
})
|
||||
|
||||
test('the catalog states the contracts the host actually enforces', () => {
|
||||
const agentsSource = text(src('dsh/agents.ts'))
|
||||
const personaName = text(src('dsh/dsh-message-compat.js')).match(/PERSONA_PREFIX_SECTION = "([^"]+)"/)[1]
|
||||
const section = catalog.host.services.find((s) => s.key === 'systemPrompt').methods[0]
|
||||
assert.ok(section.signature.includes(`name: '${personaName}'`))
|
||||
assert.ok(section.signature.includes("getSectionOrder('DEPLOYMENT_PERSONA_PREFIX')"))
|
||||
assert.match(agentsSource, /PERSONA_ORDER = 0/)
|
||||
const subagents = catalog.host.services.find((s) => s.key === 'subagents').methods
|
||||
const result = subagents.find((m) => m.name === 'result')
|
||||
assert.match(result.signature, /^activation\.result: Promise<\{ stopReason: string, output: \{ type: 'text', text: string \}\[\]/)
|
||||
assert.ok(!result.signature.includes('result()'))
|
||||
assert.match(subagents.find((m) => m.name === 'startActivation').signature, /Promise<\{ childId: string, messageId: string, result: Promise</)
|
||||
})
|
||||
|
||||
test('an output that is not JSON is refused as DSH refuses it', async () => {
|
||||
const bad = createCordisInspect({ tools: () => [{ name: 'x', extra: undefined }] })
|
||||
await assert.rejects(bad.query('host', 'Tool', 'listTools', undefined, {}), /returned a non-JSON value/)
|
||||
})
|
||||
|
||||
test('every slot and its seat equal the Engine seat table', () => {
|
||||
const block = seatSource.slice(seatSource.indexOf('const DSH_SEAT_TABLE'), seatSource.indexOf('];', seatSource.indexOf('const DSH_SEAT_TABLE')))
|
||||
const names = [...block.matchAll(/\(\s*"([^"]+)",\s*DshSeat::/g)].map((m) => m[1])
|
||||
assert.equal(names.length, 30)
|
||||
assert.deepEqual(catalog.client.slots.map((s) => s.slot), names)
|
||||
const unsupported = [...block.matchAll(/\(\s*"([^"]+)",\s*DshSeat::Unsupported/g)].map((m) => m[1])
|
||||
assert.deepEqual(catalog.client.slots.filter((s) => !s.supported).map((s) => s.slot), unsupported)
|
||||
const seats = [...seatSource.slice(seatSource.indexOf('const PLUGIN_UI_SEATS'), seatSource.indexOf('];', seatSource.indexOf('const PLUGIN_UI_SEATS'))).matchAll(/"([^"]+)"/g)].map((m) => m[1])
|
||||
for (const slot of catalog.client.slots.filter((s) => s.supported)) assert.ok(slot.seat === 'customize.plugins.detail' || seats.includes(slot.seat), slot.slot)
|
||||
assert.equal(catalog.client.seats.length, 6)
|
||||
assert.deepEqual(parseSeatTable(seatSource.replace(/\n/g, '\r\n')), parseSeatTable(seatSource))
|
||||
})
|
||||
|
||||
test('the refused events equal the list in root.ts', () => {
|
||||
const body = rootSource.slice(rootSource.indexOf('unsupportedDshLifecycle = new Set(['), rootSource.indexOf('])', rootSource.indexOf('unsupportedDshLifecycle = new Set([')))
|
||||
const refused = [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
assert.equal(refused.length, 5)
|
||||
assert.deepEqual(catalog.host.events.filter((e) => e.status === 'refused').map((e) => e.name), refused)
|
||||
for (const name of catalog.host.events.filter((e) => e.status === 'supported').map((e) => e.name)) assert.ok(rootSource.includes(`'${name}'`) || text(src('dsh/agents.ts')).includes(`'${name}'`), name)
|
||||
})
|
||||
|
||||
test('the Service provider answers a directory, one contract, and refuses a name the host lacks', async () => {
|
||||
const directory = await ask('host', 'Service', 'listService')
|
||||
assert.equal(directory.services.length, 18)
|
||||
assert.ok(!directory.services.some((s) => s.key === 'fs' || s.key === 'llm'))
|
||||
const one = await ask('host', 'Service', 'listService', { service: 'prompt' })
|
||||
assert.equal(one.service.methods[0].name, 'registerSection')
|
||||
await assert.rejects(ask('host', 'Service', 'listService', { service: 'llm' }), /not provided by this host/)
|
||||
})
|
||||
|
||||
test('the Event provider separates supported from refused', async () => {
|
||||
const { events } = await ask('host', 'Event', 'listEvents')
|
||||
assert.deepEqual(events.map((e) => e.status), ['supported', 'supported', 'refused', 'refused', 'refused', 'refused', 'refused'])
|
||||
assert.equal((await ask('host', 'Event', 'listEvents', { event: 'tools/pre-execute' })).event.name, 'tools/pre-execute')
|
||||
await assert.rejects(ask('host', 'Event', 'listEvents', { event: 'subagent/start-x' }), /not known/)
|
||||
})
|
||||
|
||||
test('the Config provider lists exactly the row packages, pages, and shows installed rows', async () => {
|
||||
const page = await ask('host', 'Config', 'listConfigs')
|
||||
assert.deepEqual(page.entries.map((e) => e.name).sort(), Object.keys(compatibility.row_bridges).sort())
|
||||
assert.equal(page.total, 5)
|
||||
const paged = await ask('host', 'Config', 'listConfigs', { limit: 2 })
|
||||
assert.equal(paged.entries.length, 2)
|
||||
assert.equal(paged.nextOffset, 2)
|
||||
const schema = await ask('host', 'Config', 'listConfigs', { entry: 'row:@deepseek-ai/dsh-persona' })
|
||||
assert.deepEqual(schema.schema.required, ['prefix'])
|
||||
await assert.rejects(ask('host', 'Config', 'listConfigs', { limit: 500 }), /limit/)
|
||||
await assert.rejects(ask('host', 'Config', 'listConfigs', { entry: 'row:nope' }), /not listed/)
|
||||
const live = createCordisInspect({ installed: () => [{ id: 'abc', name: 'hello', state: 'active' }] })
|
||||
const after = await live.query('host', 'Config', 'listConfigs', { name: 'hello' })
|
||||
assert.deepEqual(after.entries, [{ id: 'plugin:abc', name: 'hello', kind: 'installed-plugin', status: 'active' }])
|
||||
})
|
||||
|
||||
test('each row example passes the bridge that reads it', async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-inspect-'))
|
||||
after(() => rmSync(dir, { recursive: true, force: true }))
|
||||
const bridges = join(dir, 'bridges.mjs')
|
||||
await build({
|
||||
stdin: { contents: "export { normalizePersonaConfig } from './src/dsh/persona.ts'\nexport { reviewedMcpModule } from './src/dsh/mcp.ts'\nexport { reviewedSkillModule } from './src/dsh/skill-filesystem.ts'", resolveDir: join(here, '..'), loader: 'ts' },
|
||||
outfile: bridges, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent',
|
||||
})
|
||||
const { normalizePersonaConfig, reviewedMcpModule, reviewedSkillModule } = await import(pathToFileURL(bridges).href)
|
||||
const example = (name) => catalog.host.config.find((r) => r.name === name).example
|
||||
assert.doesNotThrow(() => normalizePersonaConfig(example('@deepseek-ai/dsh-persona')))
|
||||
assert.throws(() => normalizePersonaConfig({ prefix: 'x', complete: true }))
|
||||
const servers = []
|
||||
reviewedMcpModule().apply({ mcp: { registerServer: (d) => servers.push(d) } }, example('@deepseek-ai/dsh-mcp-client'))
|
||||
assert.equal(servers[0].serverName, 'docs')
|
||||
const roots = []
|
||||
reviewedSkillModule().apply({ skills: { registerRoot: (d) => roots.push(d) } }, example('@deepseek-ai/dsh-skill-filesystem'))
|
||||
assert.equal(roots.length, 1)
|
||||
})
|
||||
|
||||
test('Slots says no client is connected in a terminal and marks no slot drawn', async () => {
|
||||
const out = await ask('client', 'Slots', 'listSubTree')
|
||||
assert.equal(out.client.connected, false)
|
||||
assert.match(out.client.message, /No client is connected/)
|
||||
assert.equal(out.trees.length, 30)
|
||||
assert.ok(out.trees.every((s) => s.drawn === false))
|
||||
assert.deepEqual((await ask('client', 'Slots', 'listSubTree', { root: 'conversation.composer.dock' })).selected.seat, 'conversation.composer.dock')
|
||||
assert.equal((await ask('client', 'Slots', 'listSubTree', { root: 'nope' })).requestedRoot.available, false)
|
||||
})
|
||||
|
||||
test('Slots marks only the drawn seats when a client is connected', async () => {
|
||||
const connected = createCordisInspect({ client: () => ({ connected: true }) })
|
||||
const out = await connected.query('client', 'Slots', 'listSubTree')
|
||||
const drawn = out.trees.filter((s) => s.drawn).map((s) => s.name)
|
||||
assert.deepEqual(drawn, ['plugins.bundle.config', 'plugins.row.config', 'plugins.tab', 'plugins.detail.section', 'settings.section', 'settings.plugin.item', 'settings.general.item', 'settings.plugins.tab'])
|
||||
const all = createCordisInspect({ client: () => ({ connected: true, drawnSeats: ['conversation.composer.dock'] }) })
|
||||
const composer = (await all.query('client', 'Slots', 'listSubTree')).trees.filter((s) => s.drawn).map((s) => s.name)
|
||||
assert.ok(composer.includes('conversation.input.dock') && !composer.includes('plugins.tab'))
|
||||
assert.ok((await ask('client', 'Slots', 'listSubTree')).trees.filter((s) => !s.supported).every((s) => s.seat === null && s.reason))
|
||||
})
|
||||
|
||||
test('Theme reports that the frame defines no named tokens', async () => {
|
||||
const out = await ask('client', 'Theme', 'listTokens')
|
||||
assert.deepEqual(out.tokens, [])
|
||||
assert.deepEqual(out.colorSchemes, ['light', 'dark'])
|
||||
assert.ok(out.api.some((a) => a.name === 'ctx.theme.overrideTokens'))
|
||||
})
|
||||
@@ -44,8 +44,9 @@ use super::CodewhaleClient;
|
||||
use super::prepared::WireDialect;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
extract_sse_data_value, from_api_tool_name, next_sse_line, push_sse_event_data,
|
||||
to_api_tool_name,
|
||||
ToolNameWire, check_history_tool_names, documented_tool_name_limit, extract_sse_data_value,
|
||||
from_api_tool_name, next_sse_line, over_limit_tool_name_message, push_sse_event_data,
|
||||
to_api_tool_name, tool_name_exceeds_limit, tool_name_limit,
|
||||
};
|
||||
|
||||
/// Maximum `cache_control` breakpoints Anthropic accepts per request.
|
||||
@@ -103,6 +104,11 @@ impl CodewhaleClient {
|
||||
repair_dangling_tool_uses(&mut messages);
|
||||
body["messages"] = Value::Array(messages);
|
||||
|
||||
let tool_name_limit = tool_name_limit(self.api_provider, ToolNameWire::AnthropicMessages);
|
||||
check_history_tool_names(
|
||||
request,
|
||||
documented_tool_name_limit(self.api_provider, ToolNameWire::AnthropicMessages),
|
||||
)?;
|
||||
let mut omitted_tool_names = Vec::new();
|
||||
let mut omitted_logical_names = Vec::new();
|
||||
if let Some(tools) = request.tools.as_ref()
|
||||
@@ -113,6 +119,11 @@ impl CodewhaleClient {
|
||||
};
|
||||
let mut wire_tools = Vec::new();
|
||||
for tool in tools {
|
||||
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
|
||||
omitted_logical_names.push(tool.name.clone());
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
continue;
|
||||
}
|
||||
match project_tool_schema(route_projection, &tool.input_schema, tool.strict) {
|
||||
ToolSchemaProjection::Exact {
|
||||
parameters,
|
||||
@@ -155,6 +166,9 @@ impl CodewhaleClient {
|
||||
if let Some(name) = forced_tool_name(tool_choice)
|
||||
&& omitted_logical_names.iter().any(|omitted| omitted == name)
|
||||
{
|
||||
if tool_name_exceeds_limit(name, tool_name_limit) {
|
||||
anyhow::bail!(over_limit_tool_name_message(name, tool_name_limit));
|
||||
}
|
||||
anyhow::bail!(
|
||||
"cannot force an omitted tool because its input schema is incompatible with this route"
|
||||
);
|
||||
@@ -1305,6 +1319,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_encoded_tool_names_are_withheld_at_128() {
|
||||
let client = anthropic_test_client(None);
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let mid = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
let long = format!(
|
||||
"mcp__tracker__{}.v2.issues.list-by-assignee",
|
||||
"a".repeat(70)
|
||||
);
|
||||
assert!(to_api_tool_name(mid).len() > 64 && to_api_tool_name(mid).len() <= 128);
|
||||
assert!(to_api_tool_name(&long).len() > 128);
|
||||
|
||||
let mut request = request_with("claude-sonnet-4-6", None, None, None);
|
||||
request.tools = Some(vec![tool("read_file"), tool(mid), tool(&long)]);
|
||||
let (body, omitted) = client
|
||||
.build_anthropic_wire_body(&request, true)
|
||||
.expect("body builds");
|
||||
let sent: Vec<&str> = body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(sent, vec!["read_file".to_string(), to_api_tool_name(mid)]);
|
||||
assert_eq!(omitted, vec![to_api_tool_name(&long)]);
|
||||
|
||||
request.tool_choice = Some(json!({"type": "tool", "name": long}));
|
||||
let error = client
|
||||
.build_anthropic_wire_body(&request, true)
|
||||
.expect_err("forcing a withheld tool is refused");
|
||||
assert!(error.to_string().contains("at most 128"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_100_character_encoded_name_is_offered_on_an_anthropic_wire_route_without_a_documented_limit()
|
||||
{
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let name = "a".repeat(100);
|
||||
assert_eq!(to_api_tool_name(&name).len(), 100);
|
||||
let client = minimax_test_client();
|
||||
let mut request = request_with("MiniMax-M2", None, None, None);
|
||||
request.tools = Some(vec![tool(&name)]);
|
||||
let (body, omitted) = client
|
||||
.build_anthropic_wire_body(&request, true)
|
||||
.expect("body builds");
|
||||
assert_eq!(body["tools"][0]["name"], name);
|
||||
assert!(omitted.is_empty());
|
||||
}
|
||||
|
||||
fn request_with(
|
||||
model: &str,
|
||||
reasoning_effort: Option<&str>,
|
||||
|
||||
@@ -41,8 +41,10 @@ use super::reasoning_effort::apply_reasoning_effort;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
ERROR_BODY_MAX_BYTES, SSE_BACKPRESSURE_HIGH_WATERMARK, SSE_BACKPRESSURE_SLEEP_MS,
|
||||
SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer, bounded_error_text, from_api_tool_name,
|
||||
parse_usage, release_stream_buffer, system_to_instructions, to_api_tool_name,
|
||||
SSE_MAX_LINES_PER_CHUNK, ToolNameWire, acquire_stream_buffer, bounded_error_text,
|
||||
check_history_tool_names, documented_tool_name_limit, from_api_tool_name,
|
||||
over_limit_tool_name_message, parse_usage, release_stream_buffer, system_to_instructions,
|
||||
to_api_tool_name, tool_name_exceeds_limit, tool_name_limit,
|
||||
};
|
||||
use super::wire::{extract_sse_data_value, flush_sse_line, push_sse_event_data, take_sse_line};
|
||||
use codewhale_config::route::RouteLimits;
|
||||
@@ -1208,11 +1210,20 @@ pub fn build_chat_wire_body_with_window(
|
||||
body["top_p"] = json!(top_p);
|
||||
}
|
||||
let mut omitted_tool_names = Vec::new();
|
||||
let tool_name_limit = tool_name_limit(provider, ToolNameWire::OpenAiCompatible);
|
||||
check_history_tool_names(
|
||||
request,
|
||||
documented_tool_name_limit(provider, ToolNameWire::OpenAiCompatible),
|
||||
)?;
|
||||
if let Some(tools) = request.tools.as_ref() {
|
||||
let route_projection =
|
||||
chat_route_tool_projection(provider, base_url, strict_tools_disabled);
|
||||
let mut chat_tools = Vec::new();
|
||||
for tool in tools {
|
||||
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
continue;
|
||||
}
|
||||
match project_tool_schema(route_projection, &tool.input_schema, tool.strict) {
|
||||
ToolSchemaProjection::Exact {
|
||||
parameters,
|
||||
@@ -1247,6 +1258,9 @@ pub fn build_chat_wire_body_with_window(
|
||||
.iter()
|
||||
.any(|omitted| omitted == name || *omitted == to_api_tool_name(name))
|
||||
{
|
||||
if tool_name_exceeds_limit(name, tool_name_limit) {
|
||||
bail!(over_limit_tool_name_message(name, tool_name_limit));
|
||||
}
|
||||
bail!(
|
||||
"cannot force an omitted tool because its input schema is incompatible with this route"
|
||||
);
|
||||
@@ -3255,11 +3269,25 @@ fn deepseek_base_url_supports_strict_tools(base_url: &str) -> bool {
|
||||
|
||||
fn map_tool_choice_for_chat(choice: &Value) -> Option<Value> {
|
||||
if let Some(choice_str) = choice.as_str() {
|
||||
return Some(json!(choice_str));
|
||||
return Some(match forced_tool_name(choice) {
|
||||
Some(name) => json!({
|
||||
"type": "function",
|
||||
"function": { "name": to_api_tool_name(name) }
|
||||
}),
|
||||
None => json!(choice_str),
|
||||
});
|
||||
}
|
||||
let Some(choice_type) = choice.get("type").and_then(Value::as_str) else {
|
||||
return Some(choice.clone());
|
||||
};
|
||||
if choice_type == "function"
|
||||
&& let Some(name) = forced_tool_name(choice)
|
||||
{
|
||||
return Some(json!({
|
||||
"type": "function",
|
||||
"function": { "name": to_api_tool_name(name) }
|
||||
}));
|
||||
}
|
||||
|
||||
match choice_type {
|
||||
"auto" | "none" => Some(json!(choice_type)),
|
||||
@@ -6664,9 +6692,13 @@ mod image_block_wire_tests {
|
||||
//! most of them at once. The shape is fixed by OpenAI's spec: a `user`
|
||||
//! message whose `content` is an array of parts, with the image as
|
||||
//! `{"type":"image_url","image_url":{"url":…}}`.
|
||||
use super::{ProviderKind, build_chat_messages, build_chat_wire_body};
|
||||
use super::{
|
||||
ChatWireBody, ProviderKind, build_chat_messages, build_chat_wire_body,
|
||||
map_tool_choice_for_chat, to_api_tool_name,
|
||||
};
|
||||
use codewhale_models::Role;
|
||||
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest};
|
||||
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest, Tool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const DATA_URL: &str = "data:image/png;base64,QUJD";
|
||||
|
||||
@@ -6973,6 +7005,220 @@ mod image_block_wire_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn named_tool(name: &str) -> Tool {
|
||||
Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_object_tool_choice_encodes_the_tool_name() {
|
||||
let encoded = to_api_tool_name("web.run");
|
||||
assert_eq!(encoded, "web-x00002E-run");
|
||||
let expected = json!({"type": "function", "function": {"name": encoded}});
|
||||
for choice in [
|
||||
json!({"type": "function", "function": {"name": "web.run"}}),
|
||||
json!({"type": "function", "name": "web.run"}),
|
||||
json!({"type": "tool", "name": "web.run"}),
|
||||
json!("web.run"),
|
||||
] {
|
||||
assert_eq!(
|
||||
map_tool_choice_for_chat(&choice),
|
||||
Some(expected.clone()),
|
||||
"{choice}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
map_tool_choice_for_chat(&json!("required")),
|
||||
Some(json!("required"))
|
||||
);
|
||||
|
||||
let mut request = request_with_image();
|
||||
request.tools = Some(vec![named_tool("read_file"), named_tool("web.run")]);
|
||||
request.tool_choice = Some(json!({"type": "function", "function": {"name": "web.run"}}));
|
||||
let body = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("wire body");
|
||||
assert_eq!(body.body["tool_choice"], expected);
|
||||
let declared: Vec<&str> = body.body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
|
||||
.collect();
|
||||
assert!(declared.contains(&"web-x00002E-run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_encoded_tool_names_are_withheld_by_provider_limit() {
|
||||
let mid = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
let long = format!(
|
||||
"mcp__tracker__{}.v2.issues.list-by-assignee",
|
||||
"a".repeat(70)
|
||||
);
|
||||
let mid_len = to_api_tool_name(mid).len();
|
||||
assert!(mid_len > 64 && mid_len <= 128, "{mid_len}");
|
||||
assert!(to_api_tool_name(&long).len() > 128);
|
||||
|
||||
let build = |provider, base_url: &str, choice: Option<Value>| {
|
||||
let mut request = request_with_image();
|
||||
request.tools = Some(vec![
|
||||
named_tool("read_file"),
|
||||
named_tool(mid),
|
||||
named_tool(&long),
|
||||
]);
|
||||
request.tool_choice = choice;
|
||||
build_chat_wire_body(&request, provider, base_url, false, None)
|
||||
};
|
||||
let declared = |body: &ChatWireBody| -> Vec<String> {
|
||||
body.body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let openai = build(ProviderKind::Openai, "https://api.openai.com/v1", None).expect("body");
|
||||
assert_eq!(declared(&openai), vec!["read_file".to_string()]);
|
||||
assert_eq!(
|
||||
openai.omitted_tool_names,
|
||||
vec![to_api_tool_name(mid), to_api_tool_name(&long)]
|
||||
);
|
||||
|
||||
let deepseek =
|
||||
build(ProviderKind::Deepseek, "https://api.deepseek.com", None).expect("body");
|
||||
assert_eq!(
|
||||
declared(&deepseek),
|
||||
vec!["read_file".to_string(), to_api_tool_name(mid)]
|
||||
);
|
||||
assert_eq!(deepseek.omitted_tool_names, vec![to_api_tool_name(&long)]);
|
||||
|
||||
let forced = json!({"type": "function", "function": {"name": mid}});
|
||||
let error = build(
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
Some(forced),
|
||||
)
|
||||
.err()
|
||||
.expect("forcing a withheld tool is refused");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains(&format!("{mid_len} characters")),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("at most 64"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_tool_name_in_history_is_refused_with_the_limit() {
|
||||
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
let mut request = request_with_image();
|
||||
request.messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
execution_id: None,
|
||||
id: "call_1".to_string(),
|
||||
name: long.to_string(),
|
||||
input: json!({}),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
});
|
||||
let error = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.err()
|
||||
.expect("history name over the limit is refused");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("at most 64") && message.contains(long),
|
||||
"{message}"
|
||||
);
|
||||
build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Deepseek,
|
||||
"https://api.deepseek.com",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("the same history fits a 128-character route");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_name_of_65_to_128_characters_is_sent_unchanged_on_undocumented_routes() {
|
||||
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
assert!(to_api_tool_name(long).len() > 64);
|
||||
let mut request = request_with_image();
|
||||
request.messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
execution_id: None,
|
||||
id: "call_1".to_string(),
|
||||
name: long.to_string(),
|
||||
input: json!({}),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
});
|
||||
for (provider, base_url) in [
|
||||
(ProviderKind::Ollama, "http://localhost:11434/v1"),
|
||||
(ProviderKind::Custom, "http://localhost:8000/v1"),
|
||||
(ProviderKind::Minimax, "https://api.minimax.io/v1"),
|
||||
] {
|
||||
build_chat_wire_body(&request, provider, base_url, false, None)
|
||||
.unwrap_or_else(|error| panic!("{provider:?}: {error}"));
|
||||
}
|
||||
let error = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.err()
|
||||
.expect("documented 64-character route refuses the history name");
|
||||
assert!(
|
||||
error.to_string().contains("earlier in this session"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_100_character_encoded_name_is_withheld_on_a_chat_wire_route() {
|
||||
let name = "a".repeat(100);
|
||||
let mut request = request_with_image();
|
||||
request.tools = Some(vec![named_tool("read_file"), named_tool(&name)]);
|
||||
let body = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Minimax,
|
||||
"https://api.minimax.io/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("wire body");
|
||||
assert_eq!(body.omitted_tool_names, vec![name]);
|
||||
assert_eq!(body.body["tools"].as_array().expect("tools").len(), 1);
|
||||
}
|
||||
|
||||
fn request_with_image() -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
|
||||
@@ -35,8 +35,10 @@ use super::chat::REASONING_REPLAY_PLACEHOLDER;
|
||||
use super::prepared::WireDialect;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
ERROR_BODY_MAX_BYTES, bounded_error_text, from_api_tool_name, system_to_instructions,
|
||||
to_api_tool_name,
|
||||
ERROR_BODY_MAX_BYTES, TOOL_NAME_LIMIT_64, ToolNameWire, bounded_error_text,
|
||||
check_history_tool_names, documented_tool_name_limit, from_api_tool_name,
|
||||
over_limit_tool_name_message, system_to_instructions, to_api_tool_name,
|
||||
tool_name_exceeds_limit, tool_name_limit,
|
||||
};
|
||||
use super::wire::{extract_sse_data_value, next_sse_line, push_sse_event_data};
|
||||
use crate::llm_client::LlmError;
|
||||
@@ -135,6 +137,7 @@ pub(super) fn build_responses_wire_body(
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.unwrap_or_else(|| "You are a helpful assistant.".to_string());
|
||||
|
||||
let provider_kind = ProviderKind::parse(context.provider_id);
|
||||
let mut input = convert_messages_to_responses_input(request, profile, context);
|
||||
if profile.request.instructions == InstructionsPlacement::LeadingSystemInput {
|
||||
input.insert(
|
||||
@@ -150,12 +153,26 @@ pub(super) fn build_responses_wire_body(
|
||||
}
|
||||
body["input"] = json!(input);
|
||||
|
||||
let tool_name_limit = provider_kind.map_or(TOOL_NAME_LIMIT_64, |provider| {
|
||||
tool_name_limit(provider, ToolNameWire::OpenAiCompatible)
|
||||
});
|
||||
check_history_tool_names(
|
||||
request,
|
||||
provider_kind.and_then(|provider| {
|
||||
documented_tool_name_limit(provider, ToolNameWire::OpenAiCompatible)
|
||||
}),
|
||||
)?;
|
||||
let mut omitted_tool_names = Vec::new();
|
||||
let mut omitted_logical_names = Vec::new();
|
||||
// Convert tools to Responses function tools.
|
||||
if let Some(tools) = request.tools.as_ref() {
|
||||
let mut responses_tools = Vec::new();
|
||||
for tool in tools {
|
||||
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
|
||||
omitted_logical_names.push(tool.name.clone());
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
continue;
|
||||
}
|
||||
match project_tool_schema(
|
||||
RouteToolProjection::RelaxedNonStrict,
|
||||
&tool.input_schema,
|
||||
@@ -185,6 +202,9 @@ pub(super) fn build_responses_wire_body(
|
||||
if let Some(name) = request.tool_choice.as_ref().and_then(forced_tool_name)
|
||||
&& omitted_logical_names.iter().any(|omitted| omitted == name)
|
||||
{
|
||||
if tool_name_exceeds_limit(name, tool_name_limit) {
|
||||
anyhow::bail!(over_limit_tool_name_message(name, tool_name_limit));
|
||||
}
|
||||
anyhow::bail!(
|
||||
"cannot force an omitted tool because its input schema is incompatible with this route"
|
||||
);
|
||||
|
||||
@@ -755,6 +755,43 @@ async fn responses_stream_inserts_boundary_between_reasoning_summary_parts() {
|
||||
assert_eq!(thinking, "partA\n\npartB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_encoded_tool_names_are_withheld_from_responses_at_64() {
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
assert!(to_api_tool_name(long).len() > 64);
|
||||
let mut request = minimal_responses_request();
|
||||
request.tools = Some(vec![tool("read_file"), tool(long)]);
|
||||
let (body, omitted) = build_responses_wire_body(
|
||||
&request,
|
||||
profile_for_provider(ProviderKind::Openai),
|
||||
ResponsesReplayContext {
|
||||
provider_id: ProviderKind::Openai.as_str(),
|
||||
wire_model_id: request.model.as_str(),
|
||||
reasoning_api_id: None,
|
||||
},
|
||||
)
|
||||
.expect("body builds");
|
||||
let sent: Vec<&str> = body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(sent, vec!["read_file"]);
|
||||
assert_eq!(omitted, vec![to_api_tool_name(long)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_reasoning_effort_uses_responses_labels() {
|
||||
assert_eq!(codex_responses_reasoning_effort("max"), Some("max"));
|
||||
|
||||
@@ -1449,9 +1449,10 @@ fn tool_name_scenario() {
|
||||
}
|
||||
// from tool_name_decode_bare_hex_no_trailing_dash
|
||||
{
|
||||
let mangled = "foo_x00002Ebar";
|
||||
let decoded = from_api_tool_name(mangled);
|
||||
assert_eq!(decoded, "foo_.bar");
|
||||
let exact = "foo_x00002Ebar";
|
||||
assert_eq!(from_api_tool_name(exact), exact);
|
||||
let mangled = "foo_.x00002Ebar";
|
||||
assert_eq!(from_api_tool_name(mangled), "foo_..bar");
|
||||
}
|
||||
// from tool_name_bare_hex_preserves_alnum
|
||||
{
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
use codewhale_models::{ServerToolUsage, SystemPrompt, Usage};
|
||||
use codewhale_config::ProviderKind;
|
||||
use codewhale_models::{ContentBlock, MessageRequest, ServerToolUsage, SystemPrompt, Usage};
|
||||
use std::sync::{Mutex as StdMutex, OnceLock};
|
||||
|
||||
pub fn saturating_u32(value: u64) -> u32 {
|
||||
@@ -315,7 +316,7 @@ pub fn to_api_tool_name(name: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn from_api_tool_name(name: &str) -> String {
|
||||
fn decode_delimited_tool_name(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut iter = name.chars().peekable();
|
||||
while let Some(ch) = iter.next() {
|
||||
@@ -357,12 +358,87 @@ pub fn from_api_tool_name(name: &str) -> String {
|
||||
}
|
||||
out.push('-');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Second pass: decode bare hex escapes (e.g. `x00002E`) that the model
|
||||
// may produce when it mangles the `-x00002E-` delimiter form. Only
|
||||
// decode when the resulting character is one that `to_api_tool_name`
|
||||
// would have encoded (not alphanumeric, not `_`, not `-`).
|
||||
decode_bare_hex_escapes(&out)
|
||||
pub fn from_api_tool_name(name: &str) -> String {
|
||||
let exact = decode_delimited_tool_name(name);
|
||||
if to_api_tool_name(&exact) == name {
|
||||
return exact;
|
||||
}
|
||||
decode_bare_hex_escapes(&exact)
|
||||
}
|
||||
|
||||
pub const TOOL_NAME_LIMIT_64: usize = 64;
|
||||
pub const TOOL_NAME_LIMIT_128: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolNameWire {
|
||||
AnthropicMessages,
|
||||
OpenAiCompatible,
|
||||
}
|
||||
|
||||
pub fn documented_tool_name_limit(provider: ProviderKind, wire: ToolNameWire) -> Option<usize> {
|
||||
match (provider, wire) {
|
||||
(ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic, _) => Some(TOOL_NAME_LIMIT_128),
|
||||
(ProviderKind::Anthropic, ToolNameWire::AnthropicMessages) => Some(TOOL_NAME_LIMIT_128),
|
||||
(ProviderKind::Openai | ProviderKind::OpenaiCodex, ToolNameWire::OpenAiCompatible) => {
|
||||
Some(TOOL_NAME_LIMIT_64)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_name_limit(provider: ProviderKind, wire: ToolNameWire) -> usize {
|
||||
if let Some(limit) = documented_tool_name_limit(provider, wire) {
|
||||
return limit;
|
||||
}
|
||||
match (provider, wire) {
|
||||
(_, ToolNameWire::AnthropicMessages) => TOOL_NAME_LIMIT_128,
|
||||
(
|
||||
ProviderKind::Ollama
|
||||
| ProviderKind::OllamaCloud
|
||||
| ProviderKind::Vllm
|
||||
| ProviderKind::Sglang
|
||||
| ProviderKind::Custom,
|
||||
_,
|
||||
) => TOOL_NAME_LIMIT_128,
|
||||
(_, ToolNameWire::OpenAiCompatible) => TOOL_NAME_LIMIT_64,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_history_tool_names(request: &MessageRequest, documented: Option<usize>) -> Result<()> {
|
||||
let Some(limit) = documented else {
|
||||
return Ok(());
|
||||
};
|
||||
for message in &request.messages {
|
||||
for block in &message.content {
|
||||
if let ContentBlock::ToolUse { name, .. } = block
|
||||
&& tool_name_exceeds_limit(name, limit)
|
||||
{
|
||||
anyhow::bail!(over_limit_history_tool_name_message(name, limit));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn tool_name_exceeds_limit(name: &str, limit: usize) -> bool {
|
||||
to_api_tool_name(name).len() > limit
|
||||
}
|
||||
|
||||
pub fn over_limit_tool_name_message(name: &str, limit: usize) -> String {
|
||||
format!(
|
||||
"tool name `{name}` encodes to {} characters but this provider accepts at most {limit}; rename the tool (shorter name, fewer dots, hyphens or other special characters) or remove it",
|
||||
to_api_tool_name(name).len()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn over_limit_history_tool_name_message(name: &str, limit: usize) -> String {
|
||||
format!(
|
||||
"tool `{name}` was called earlier in this session and its encoded name is {} characters, but this provider accepts at most {limit}; switch to a route with a larger limit or start a new session",
|
||||
to_api_tool_name(name).len()
|
||||
)
|
||||
}
|
||||
|
||||
pub const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 1024 * 1024; // 1 MB
|
||||
@@ -465,6 +541,98 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tool_name_encoding_is_collision_free_for_hostile_names() {
|
||||
let names = [
|
||||
"web.run",
|
||||
"web-x00002E-run",
|
||||
"web--x00002E--run",
|
||||
"webx00002Erun",
|
||||
"web_x00002Erun",
|
||||
"web_.run",
|
||||
"a-b",
|
||||
"a--b",
|
||||
"a_b",
|
||||
"a.b",
|
||||
"ax00002Eb",
|
||||
"a-x00002E-b",
|
||||
"mcp__srv__tool.name",
|
||||
"mcp__srv__tool-x00002E-name",
|
||||
];
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for name in names {
|
||||
let encoded = to_api_tool_name(name);
|
||||
assert!(
|
||||
encoded
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
|
||||
"{encoded}"
|
||||
);
|
||||
if let Some(previous) = seen.insert(encoded.clone(), name) {
|
||||
panic!("{previous} and {name} both encode to {encoded}");
|
||||
}
|
||||
assert_eq!(from_api_tool_name(&encoded), name, "round trip of {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_name_limit_follows_the_wire_unless_the_provider_documents_one() {
|
||||
use ToolNameWire::{AnthropicMessages, OpenAiCompatible};
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::Anthropic, AnthropicMessages),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::MinimaxAnthropic, AnthropicMessages),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(
|
||||
ProviderKind::ModelstudioTokenPlanAnthropic,
|
||||
AnthropicMessages
|
||||
),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::Deepseek, OpenAiCompatible),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::DeepseekAnthropic, AnthropicMessages),
|
||||
128
|
||||
);
|
||||
assert_eq!(tool_name_limit(ProviderKind::Openai, OpenAiCompatible), 64);
|
||||
assert_eq!(tool_name_limit(ProviderKind::Minimax, OpenAiCompatible), 64);
|
||||
assert_eq!(tool_name_limit(ProviderKind::Ollama, OpenAiCompatible), 128);
|
||||
assert_eq!(
|
||||
documented_tool_name_limit(ProviderKind::Ollama, OpenAiCompatible),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
documented_tool_name_limit(ProviderKind::Custom, OpenAiCompatible),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_name_limit_counts_the_encoded_form() {
|
||||
let dotted = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
assert!(dotted.len() <= 64);
|
||||
assert!(tool_name_exceeds_limit(dotted, TOOL_NAME_LIMIT_64));
|
||||
assert!(!tool_name_exceeds_limit(dotted, TOOL_NAME_LIMIT_128));
|
||||
let plain = "a".repeat(64);
|
||||
assert!(!tool_name_exceeds_limit(&plain, TOOL_NAME_LIMIT_64));
|
||||
assert!(tool_name_exceeds_limit(
|
||||
&format!("{plain}a"),
|
||||
TOOL_NAME_LIMIT_64
|
||||
));
|
||||
let message = over_limit_tool_name_message(dotted, TOOL_NAME_LIMIT_64);
|
||||
assert!(
|
||||
message.contains("at most 64") && message.contains(dotted),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_line_limit_rejects_oversized_pending_complete_and_final_lines() {
|
||||
let mut decoder = SseLineDecoder::new();
|
||||
|
||||
@@ -2152,10 +2152,7 @@ fn git_push_refspec_is_protected(refspec: &str) -> bool {
|
||||
// Tag-like names (`v1`, `v0.9.1`): git resolves branch-vs-tag on the
|
||||
// server, so treat them as publishes.
|
||||
let mut chars = lower.chars();
|
||||
if chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn git_tag_tokens_are_publish_like(tokens: &[&str]) -> bool {
|
||||
|
||||
@@ -3504,6 +3504,7 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4503,6 +4504,7 @@ impl Engine {
|
||||
model,
|
||||
workspace,
|
||||
mode,
|
||||
agent_preset,
|
||||
} => {
|
||||
if expected
|
||||
.as_deref()
|
||||
@@ -4523,6 +4525,9 @@ 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
|
||||
@@ -4589,6 +4594,14 @@ impl Engine {
|
||||
self.session.model = model;
|
||||
self.session.workspace = workspace.clone();
|
||||
self.current_mode = mode;
|
||||
if keep_conversation_mode {
|
||||
tracing::warn!(
|
||||
target: "conversation_mode",
|
||||
"ignored a conversation mode change on a conversation that already has messages"
|
||||
);
|
||||
} else {
|
||||
self.session.agent_preset = agent_preset;
|
||||
}
|
||||
self.config.model.clone_from(&self.session.model);
|
||||
self.config.workspace = workspace.clone();
|
||||
if plugin_workspace_changed {
|
||||
|
||||
@@ -643,6 +643,7 @@ 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();
|
||||
@@ -664,6 +665,111 @@ 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,6 +1044,7 @@ 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");
|
||||
@@ -1239,6 +1240,7 @@ 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");
|
||||
@@ -1321,6 +1323,7 @@ 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");
|
||||
@@ -1422,6 +1425,7 @@ 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");
|
||||
@@ -1444,6 +1448,7 @@ 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");
|
||||
@@ -1487,6 +1492,7 @@ 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");
|
||||
@@ -1512,6 +1518,7 @@ 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");
|
||||
@@ -1570,6 +1577,7 @@ 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,6 +36,7 @@ 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");
|
||||
@@ -176,6 +177,7 @@ 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");
|
||||
@@ -334,6 +336,7 @@ 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");
|
||||
@@ -451,6 +454,7 @@ 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");
|
||||
@@ -569,6 +573,7 @@ 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");
|
||||
|
||||
@@ -1044,7 +1044,7 @@ pub fn tool_projection_warning_message(
|
||||
omitted_tool_count: usize,
|
||||
) -> String {
|
||||
format!(
|
||||
"Warning: {provider} omitted incompatible tools for this request: {}",
|
||||
"Warning: {provider} omitted tools for this request: {}. A tool is omitted when its input schema is incompatible with this route or when its encoded name is longer than the provider's tool-name limit (64 characters on OpenAI-style routes, 128 on Anthropic-style routes and DeepSeek); a name ending in … is cut for display. Shorten the tool's name (dots, hyphens and other special characters each add characters) or remove it.",
|
||||
tool_projection_warning_tool_list(omitted_tool_names, omitted_tool_count)
|
||||
)
|
||||
}
|
||||
@@ -1251,6 +1251,15 @@ mod tool_projection_warning_tests {
|
||||
bounded_tool_projection_warning_names,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn projection_warning_message_names_the_tool_and_the_name_limit() {
|
||||
let names = vec!["web-x00002E-run".to_string()];
|
||||
let message = tool_projection_warning_message("OpenAI", &names, 1);
|
||||
assert!(message.contains("web-x00002E-run"), "{message}");
|
||||
assert!(message.contains("64 characters"), "{message}");
|
||||
assert!(message.contains("128 on Anthropic-style"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_warning_names_are_count_and_length_bounded() {
|
||||
let names: Vec<String> = (0..12)
|
||||
|
||||
@@ -117,6 +117,8 @@ 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
|
||||
@@ -468,6 +470,7 @@ 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,6 +1261,7 @@ 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),
|
||||
@@ -1271,6 +1272,7 @@ 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, ..
|
||||
@@ -1739,6 +1741,7 @@ mod tests {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
};
|
||||
let (tx, mut receive) = tokio::sync::oneshot::channel();
|
||||
let op = Op::RewindConversation {
|
||||
|
||||
@@ -408,6 +408,8 @@ 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.
|
||||
@@ -481,6 +483,7 @@ impl Session {
|
||||
tool_activation_cache: ToolActivationCache::default(),
|
||||
messages_revision: 0,
|
||||
latest_parent_input_tokens: None,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1295,6 +1295,7 @@ fn the_wire_result_is_the_tools_text_and_json_and_says_when_it_was_cut() {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let hash = SessionCheckpointRequest::prefix_hash(&snapshot.messages).unwrap();
|
||||
let make = |projection| {
|
||||
|
||||
@@ -203,10 +203,13 @@ impl Drop for TestOverlay {
|
||||
|
||||
fn overlay_granted() -> bool {
|
||||
#[cfg(test)]
|
||||
if TEST_OVERLAY.with(std::cell::Cell::get) {
|
||||
return true;
|
||||
{
|
||||
TEST_OVERLAY.with(std::cell::Cell::get)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
false
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
|
||||
|
||||
@@ -187,6 +187,19 @@ pub const AGENTS: &[ExternalAgent] = &[
|
||||
adapter_binary: None,
|
||||
adapter_cli_env: None,
|
||||
},
|
||||
ExternalAgent {
|
||||
id: "qwen-code",
|
||||
name: "Qwen Code",
|
||||
binary: "qwen",
|
||||
acp_args: Some(&["--acp"]),
|
||||
website: "https://github.com/QwenLM/qwen-code",
|
||||
min_version: Some("0.6.1"),
|
||||
upgrade: Some("npm i -g @qwen-code/qwen-code@latest"),
|
||||
login: None,
|
||||
adapter: None,
|
||||
adapter_binary: None,
|
||||
adapter_cli_env: None,
|
||||
},
|
||||
];
|
||||
|
||||
const FIXTURE_AGENT: ExternalAgent = ExternalAgent {
|
||||
|
||||
@@ -1727,9 +1727,10 @@ fn ssh_client_env() -> BTreeMap<String, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn process_base_env() -> BTreeMap<String, String> {
|
||||
base_env_from(std::env::vars_os())
|
||||
}
|
||||
const MANAGED_CONFIG_ENV: [&str; 2] = [
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
];
|
||||
|
||||
/// The worker's inherited environment: the repository's scrubbed child-env
|
||||
/// allowlist (`child_env`), so proxy routing, CA bundles, temp directories,
|
||||
@@ -1742,11 +1743,22 @@ where
|
||||
K: AsRef<std::ffi::OsStr>,
|
||||
V: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let parent: Vec<(std::ffi::OsString, std::ffi::OsString)> = parent
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.as_ref().to_os_string(), value.as_ref().to_os_string()))
|
||||
.collect();
|
||||
let mut env: BTreeMap<String, String> =
|
||||
codewhale_runtime::child_env::sanitized_runtime_env_from(parent)
|
||||
codewhale_runtime::child_env::sanitized_runtime_env_from(parent.iter().cloned())
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
|
||||
.collect();
|
||||
for (key, value) in &parent {
|
||||
if MANAGED_CONFIG_ENV.iter().any(|name| key == name)
|
||||
&& let (Some(key), Some(value)) = (key.to_str(), value.to_str())
|
||||
{
|
||||
env.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
force_worker_telemetry_off(&mut env);
|
||||
env
|
||||
}
|
||||
@@ -1754,7 +1766,7 @@ where
|
||||
/// Fleet workers are an implementation detail of the parent session, not
|
||||
/// sessions of their own — the parent already accounts for the dispatch.
|
||||
///
|
||||
/// The spawn path `env_clear()`s and rebuilds from [`process_base_env`], so an
|
||||
/// The spawn path `env_clear()`s and rebuilds from [`base_env_from`], so an
|
||||
/// operator's opt-out would otherwise never reach a worker at all. Hard-off
|
||||
/// here means a worker can never emit, can never inherit an ambient "on", and
|
||||
/// can never write telemetry state into the operator's home.
|
||||
@@ -1772,8 +1784,24 @@ fn worker_env(
|
||||
request_env: &BTreeMap<String, String>,
|
||||
allowlist: &BTreeSet<String>,
|
||||
) -> FleetHostResult<BTreeMap<String, String>> {
|
||||
let mut env = process_base_env();
|
||||
env.extend(filtered_env(request_env, allowlist)?);
|
||||
worker_env_from(std::env::vars_os(), request_env, allowlist)
|
||||
}
|
||||
|
||||
fn worker_env_from<K, V>(
|
||||
parent: impl IntoIterator<Item = (K, V)>,
|
||||
request_env: &BTreeMap<String, String>,
|
||||
allowlist: &BTreeSet<String>,
|
||||
) -> FleetHostResult<BTreeMap<String, String>>
|
||||
where
|
||||
K: AsRef<std::ffi::OsStr>,
|
||||
V: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let mut env = base_env_from(parent);
|
||||
let mut requested = filtered_env(request_env, allowlist)?;
|
||||
for key in MANAGED_CONFIG_ENV {
|
||||
requested.remove(key);
|
||||
}
|
||||
env.extend(requested);
|
||||
force_worker_telemetry_off(&mut env);
|
||||
Ok(env)
|
||||
}
|
||||
@@ -2770,11 +2798,47 @@ mod tests {
|
||||
assert_eq!(config.codewhale_binary, "/usr/local/bin/codewhale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_env_keeps_the_parent_managed_config_path_and_a_request_cannot_replace_it() {
|
||||
let parent = [
|
||||
("CODEWHALE_MANAGED_CONFIG_PATH", "/etc/example/managed.toml"),
|
||||
(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
"/etc/deepseek/managed_config.toml",
|
||||
),
|
||||
("USER", "fleet"),
|
||||
];
|
||||
let request_env = BTreeMap::from([
|
||||
(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH".to_string(),
|
||||
"/nonexistent/managed.toml".to_string(),
|
||||
),
|
||||
(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH".to_string(),
|
||||
"/nonexistent/managed.toml".to_string(),
|
||||
),
|
||||
]);
|
||||
let allowlist: BTreeSet<String> = request_env.keys().cloned().collect();
|
||||
let env = worker_env_from(parent, &request_env, &allowlist).expect("worker env");
|
||||
for (key, value) in &parent[..2] {
|
||||
assert_eq!(env.get(*key).map(String::as_str), Some(*value), "{key}");
|
||||
}
|
||||
|
||||
let env =
|
||||
worker_env_from([("USER", "fleet")], &request_env, &allowlist).expect("worker env");
|
||||
for key in MANAGED_CONFIG_ENV {
|
||||
assert!(
|
||||
!env.contains_key(key),
|
||||
"{key} must come only from the parent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_env_forces_telemetry_off_even_when_the_allowlist_says_otherwise() {
|
||||
// The spawn path env_clear()s and rebuilds from this map, so anything
|
||||
// absent here simply does not exist inside the worker.
|
||||
let base = process_base_env();
|
||||
let base = base_env_from(std::env::vars_os());
|
||||
assert_eq!(
|
||||
base.get("CODEWHALE_TELEMETRY").map(String::as_str),
|
||||
Some("false")
|
||||
|
||||
@@ -1748,11 +1748,7 @@ impl FleetManager {
|
||||
return Err(start_error);
|
||||
}
|
||||
};
|
||||
if !started {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
Ok(started)
|
||||
}
|
||||
|
||||
fn start_leased_workers(
|
||||
|
||||
@@ -1669,6 +1669,10 @@ pub trait McpTransport: Send + Sync {
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>>;
|
||||
|
||||
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
self.send(msg).await
|
||||
}
|
||||
|
||||
/// Record the protocol revision negotiated at `initialize`. Only the
|
||||
/// Streamable HTTP transport uses it (the `MCP-Protocol-Version` header on
|
||||
/// subsequent requests); stdio and legacy SSE have no header channel, so
|
||||
@@ -3125,6 +3129,30 @@ impl McpConnection {
|
||||
result
|
||||
}
|
||||
|
||||
async fn answer_server_request(&mut self, reply: &serde_json::Value) {
|
||||
let result = match serde_json::to_vec(reply) {
|
||||
Ok(bytes) => {
|
||||
let cancel_token = self.cancel_token.clone();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => {
|
||||
Err(anyhow::anyhow!("MCP connection was cancelled"))
|
||||
}
|
||||
result = self.transport.send_reply(bytes) => result,
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
};
|
||||
if let Err(err) = result {
|
||||
tracing::debug!(
|
||||
target: "mcp",
|
||||
server = %self.name,
|
||||
error = %err,
|
||||
"could not answer an MCP server request"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handshake and discovery receive: each frame wait is bounded by the read
|
||||
/// knob, and a server that stays silent past it is treated as dead.
|
||||
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
|
||||
@@ -3202,6 +3230,22 @@ impl McpConnection {
|
||||
{
|
||||
self.catalog_stale = true;
|
||||
}
|
||||
if let Some(method) = value.get("method") {
|
||||
if let Some(id) = value.get("id") {
|
||||
let method = method.as_str().unwrap_or_default();
|
||||
let reply = if method == "ping" {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {"code": -32601, "message": format!("Method not found: {method}")}
|
||||
})
|
||||
};
|
||||
self.answer_server_request(&reply).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if response_id_matches(value.get("id"), &expected_id) {
|
||||
// Marks the connection stale so it is rebuilt, but this is a
|
||||
// reply to the request, so it never qualifies for a replay.
|
||||
|
||||
@@ -177,6 +177,21 @@ impl McpTransport for HttpTransport {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
match &mut self.mode {
|
||||
HttpTransportMode::Streamable(transport) => {
|
||||
transport.send(msg).await.map_err(|err| match err {
|
||||
StreamableSendError::Incompatible(detail)
|
||||
| StreamableSendError::StaleSession(detail) => anyhow::anyhow!(
|
||||
"MCP Streamable HTTP refused a reply to a server request: {detail}"
|
||||
),
|
||||
StreamableSendError::Other(err) => err,
|
||||
})
|
||||
}
|
||||
HttpTransportMode::Sse(transport) => transport.send(msg).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
match &mut self.mode {
|
||||
HttpTransportMode::Streamable(transport) => transport.recv().await,
|
||||
|
||||
@@ -3314,6 +3314,80 @@ async fn call_method_skips_notifications_and_unmatched_responses() {
|
||||
assert_eq!(sent[0]["method"], "tools/call");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_answers_server_requests_instead_of_taking_them_as_the_reply() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let transport = ScriptedValueTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
responses: VecDeque::from([
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": "1", "method": "roots/list"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
|
||||
]),
|
||||
};
|
||||
let mut conn = test_connection(Box::new(transport));
|
||||
|
||||
let result = conn
|
||||
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, serde_json::json!({"ok": true}));
|
||||
let sent = sent.lock().unwrap();
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert_eq!(
|
||||
sent[1],
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}})
|
||||
);
|
||||
assert_eq!(sent[2]["id"], "1");
|
||||
assert_eq!(sent[2]["error"]["code"], -32601);
|
||||
}
|
||||
|
||||
struct ReplyRefusingTransport {
|
||||
sent: Arc<Mutex<Vec<serde_json::Value>>>,
|
||||
responses: VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpTransport for ReplyRefusingTransport {
|
||||
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
let msg: serde_json::Value = serde_json::from_slice(&msg)?;
|
||||
anyhow::ensure!(msg.get("method").is_some(), "status=405 Method Not Allowed");
|
||||
self.sent.lock().unwrap().push(msg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
self.responses
|
||||
.pop_front()
|
||||
.context("scripted transport exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_keeps_its_reply_when_answering_a_server_request_fails() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let transport = ReplyRefusingTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
responses: VecDeque::from([
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
|
||||
]),
|
||||
};
|
||||
let mut conn = test_connection(Box::new(transport));
|
||||
|
||||
let result = conn
|
||||
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, serde_json::json!({"ok": true}));
|
||||
assert_eq!(conn.state, ConnectionState::Ready);
|
||||
let sent = sent.lock().unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0]["method"], "tools/call");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_invalid_json_includes_server_output_preview() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -6770,6 +6844,60 @@ async fn custom_headers_applied_to_get_preflight() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_reply_to_a_server_request_does_not_fall_back_to_sse() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _lock = lock_mcp_loopback_tests().await;
|
||||
let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*,127.0.0.1");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let requests_srv = Arc::clone(&requests);
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = socket.read(&mut buf).await.unwrap();
|
||||
let request_line = String::from_utf8_lossy(&buf[..n])
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
requests_srv.lock().unwrap().push(request_line);
|
||||
socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!("http://{addr}/mcp");
|
||||
let mut transport = HttpTransport::new(
|
||||
test_mcp_http_client(&url),
|
||||
url,
|
||||
tokio_util::sync::CancellationToken::new(),
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
let result = transport
|
||||
.send_reply(json_frame(
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}}),
|
||||
))
|
||||
.await;
|
||||
|
||||
server.abort();
|
||||
assert!(result.is_err(), "a refused reply reports the refusal");
|
||||
let requests = requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1, "requests seen: {requests:?}");
|
||||
assert!(requests[0].starts_with("POST "), "{requests:?}");
|
||||
}
|
||||
|
||||
// === add_runtime_server_config conflict tests ===
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9161,6 +9161,7 @@ impl RuntimeThreadManager {
|
||||
thread.auto_approve,
|
||||
)
|
||||
.mode,
|
||||
agent_preset: thread.agent_preset.clone(),
|
||||
})
|
||||
.await?;
|
||||
let observed = engine.get_session_snapshot().await?;
|
||||
@@ -14916,6 +14917,7 @@ 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,6 +19625,7 @@ fn answer_session_snapshot(
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: String::new(),
|
||||
agent_preset: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19704,6 +19705,7 @@ 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();
|
||||
@@ -22037,6 +22039,7 @@ 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,5 +1,7 @@
|
||||
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
|
||||
@@ -132,3 +134,58 @@ async fn creator_is_listed_unavailable_until_it_loads_its_tools() -> Result<()>
|
||||
assert_eq!(refusal_code(&refused), ModeRefusal::MODE_UNAVAILABLE);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_runtime_thread_sync_keeps_its_conversation_mode() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let manager = test_manager(tmp.path().to_path_buf())?;
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let thread = manager.create_thread(create_request(Some(CREATOR))).await?;
|
||||
let mock = Arc::new(MockLlmClient::new(Vec::new()));
|
||||
let (engine, handle) = Engine::new_with_model_client(
|
||||
EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
model: thread.model.clone(),
|
||||
subagents_enabled: false,
|
||||
snapshots_enabled: false,
|
||||
memory_enabled: false,
|
||||
terminal_chrome_enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
&Config::default(),
|
||||
mock,
|
||||
);
|
||||
manager
|
||||
.install_test_engine(&thread.id, handle.clone())
|
||||
.await?;
|
||||
let run = tokio::spawn(engine.run());
|
||||
let messages = vec![Message {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "first".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}];
|
||||
let session = crate::session_manager::create_saved_session(
|
||||
&messages,
|
||||
&thread.model,
|
||||
&thread.workspace,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
manager
|
||||
.synchronize_history_operation(&thread.id, &session)
|
||||
.await?;
|
||||
let observed = handle.get_session_snapshot().await?;
|
||||
assert_eq!(observed.agent_preset.as_deref(), Some(CREATOR));
|
||||
manager
|
||||
.synchronize_history_operation(&thread.id, &session)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
handle.get_session_snapshot().await?.agent_preset.as_deref(),
|
||||
Some(CREATOR)
|
||||
);
|
||||
handle.send(Op::Shutdown).await?;
|
||||
tokio::time::timeout(Duration::from_secs(10), run).await??;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2090,9 +2090,27 @@ 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")]
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "read_saved_agent_preset"
|
||||
)]
|
||||
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,3 +362,25 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -4988,14 +4988,16 @@ mod tests {
|
||||
Arc::new(MockExecutor),
|
||||
)
|
||||
.await?;
|
||||
sleep(STORE_IDLE_POLL_INTERVAL + Duration::from_millis(300)).await;
|
||||
settle_queue_fixture(&manager).await?;
|
||||
inspect_settled_workers(&manager).await?;
|
||||
manager.fingerprint_reads.store(0, Ordering::Relaxed);
|
||||
manager.store_loads.store(0, Ordering::Relaxed);
|
||||
|
||||
let window = Duration::from_secs(3);
|
||||
sleep(window).await;
|
||||
let started = Instant::now();
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
let reads = manager.fingerprint_reads.load(Ordering::Relaxed);
|
||||
let loads = manager.store_loads.load(Ordering::Relaxed);
|
||||
let window = started.elapsed();
|
||||
let ticks = (window.as_millis() / STORE_SETTLED_NAP.as_millis()) as usize + 1;
|
||||
assert!(
|
||||
reads <= workers * ticks,
|
||||
|
||||
@@ -133,14 +133,9 @@ impl WriteScopeClaim {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.contracts
|
||||
self.contracts
|
||||
.iter()
|
||||
.any(|contract| other.contracts.iter().any(|other| other == contract))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "endormiscat",
|
||||
"PetWatchUnavailable": "La telemetria de la mascota està en pausa. /pet on ho torna a intentar.",
|
||||
"ConversationChangedBeforeUndo": "La conversa ha canviat abans de completar l’acció de desfer. No s’ha desfet res ni s’ha enviat cap nou intent. Revisa la conversa actual abans de tornar-ho a provar.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Mode de conversa",
|
||||
"ConversationModeGroupPermissions": "Permisos",
|
||||
"ConversationModeStandardName": "Mode estàndard",
|
||||
"ConversationModeStandardDescription": "L'agent de cada dia amb les seves eines i habilitats habituals.",
|
||||
"ConversationModeCreatorName": "Mode creador",
|
||||
"ConversationModeCreatorDescription": "Crea i prova connectors en aquesta conversa.",
|
||||
"ConversationModeNotReady": "Encara no està a punt en aquesta versió.",
|
||||
"ConversationModeLockedNote": "Fix després del primer missatge. Comença'n una altra amb {command}.",
|
||||
"ConversationModeLockedRefusal": "El mode de conversa queda fix quan s'envia el primer missatge. Comença una conversa nova amb {command}.",
|
||||
"ConversationModeUnknown": "Mode de conversa desconegut «{id}». Disponibles: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} no està disponible: {reason}",
|
||||
"ConversationModeSet": "Mode de conversa: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (no disponible)",
|
||||
"ConversationModeSavedUnavailableNotice": "Aquesta conversa es va desar en {name}, que no està disponible aquí ({reason}). Se'n mostra l'historial.",
|
||||
"ConversationModeResumeUnreadable": "No s'ha pogut llegir la conversa desada per comprovar-ne el mode; no s'ha iniciat ni canviat res.",
|
||||
"SessionArchiveExported": "Sessió exportada",
|
||||
"SessionArchiveSizes": "elements / mida sense comprimir / arxiu",
|
||||
"SessionArchiveNoArtifacts": "No s’han trobat fitxers d’artefactes regulars en aquesta sessió.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "dösend",
|
||||
"PetWatchUnavailable": "Die Haustier-Telemetrie pausiert. /pet on versucht es erneut.",
|
||||
"ConversationChangedBeforeUndo": "Die Unterhaltung wurde geändert, bevor das Rückgängigmachen abgeschlossen war. Es wurde nichts rückgängig gemacht und kein erneuter Versuch gesendet. Prüfe die aktuelle Unterhaltung, bevor du es erneut versuchst.",
|
||||
"ConversationModePickerTitle": "Modus",
|
||||
"ConversationModeGroupConversation": "Gesprächsmodus",
|
||||
"ConversationModeGroupPermissions": "Berechtigungen",
|
||||
"ConversationModeStandardName": "Standardmodus",
|
||||
"ConversationModeStandardDescription": "Der Alltagsagent mit seinen üblichen Werkzeugen und Skills.",
|
||||
"ConversationModeCreatorName": "Creator-Modus",
|
||||
"ConversationModeCreatorDescription": "Plugins in diesem Gespräch bauen und testen.",
|
||||
"ConversationModeNotReady": "In dieser Version noch nicht bereit.",
|
||||
"ConversationModeLockedNote": "Nach der ersten Nachricht festgelegt. Ein anderes Gespräch starten mit {command}.",
|
||||
"ConversationModeLockedRefusal": "Der Gesprächsmodus ist festgelegt, sobald die erste Nachricht gesendet wurde. Starte ein neues Gespräch mit {command}.",
|
||||
"ConversationModeUnknown": "Unbekannter Gesprächsmodus „{id}“. Verfügbar: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} ist nicht verfügbar: {reason}",
|
||||
"ConversationModeSet": "Gesprächsmodus: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (nicht verfügbar)",
|
||||
"ConversationModeSavedUnavailableNotice": "Dieses Gespräch wurde im {name} gespeichert, der hier nicht verfügbar ist ({reason}). Sein Verlauf wird angezeigt.",
|
||||
"ConversationModeResumeUnreadable": "Das gespeicherte Gespräch konnte nicht gelesen werden, um seinen Modus zu prüfen. Es wurde nichts gestartet oder geändert.",
|
||||
"SessionArchiveExported": "Sitzung exportiert",
|
||||
"SessionArchiveSizes": "Einträge / unkomprimierte Größe / Archivgröße",
|
||||
"SessionArchiveNoArtifacts": "Keine regulären Artefaktdateien für diese Sitzung gefunden.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"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.",
|
||||
@@ -235,14 +251,14 @@
|
||||
"TelemetryNoticeActionChoose": "choose",
|
||||
"TelemetryNoticeActionConfirm": "confirm",
|
||||
"TelemetryNoticeActionExit": "exit",
|
||||
"TelemetryNoticeReceiptEnabled": "Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeReceiptDisabled": "Anonymous usage counting is off. You will not be asked again.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Anonymous usage counts are on for this session. Codewhale could not save this notice, so it will appear again. Change this in /settings; schema: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Anonymous usage counting is off for this session. Codewhale could not save the choice, so it will ask again next launch.",
|
||||
"TelemetryPreferenceEnabledNextLaunch": "Anonymous usage counts will be on next launch. Conversations and code are never collected.",
|
||||
"TelemetryPreferenceDisabled": "Anonymous usage counts are off. Local telemetry state was erased.",
|
||||
"TelemetryPreferenceDisabledWithWarning": "Anonymous usage counts are off, but part of the cleanup needs attention: {detail}",
|
||||
"TelemetryPreferenceDisabledForSession": "Anonymous usage counts are off for this session, but the preference was not saved and may return next launch: {detail}",
|
||||
"TelemetryNoticeReceiptEnabled": "Usage reporting is on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeReceiptDisabled": "Usage reporting is off. You will not be asked again.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Usage reporting is on for this session. Codewhale could not save this notice, so it will appear again. Change this in /settings; schema: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Usage reporting is off for this session. Codewhale could not save the choice, so it will ask again next launch.",
|
||||
"TelemetryPreferenceEnabledNextLaunch": "Usage reporting will be on next launch. Conversations and code are never collected.",
|
||||
"TelemetryPreferenceDisabled": "Usage reporting is off. Local telemetry state was erased.",
|
||||
"TelemetryPreferenceDisabledWithWarning": "Usage reporting is off, but part of the cleanup needs attention: {detail}",
|
||||
"TelemetryPreferenceDisabledForSession": "Usage reporting is off for this session, but the preference was not saved and may return next launch: {detail}",
|
||||
"TelemetryPreferenceSaveFailed": "The telemetry preference could not be changed safely: {detail}",
|
||||
"StatusPickerTitle": " Status line ",
|
||||
"StatusPickerInstruction": "Choose what the footer shows:",
|
||||
@@ -373,7 +389,7 @@
|
||||
"ConfigLabelDefaultMode": "Startup mode",
|
||||
"ConfigLabelAllowShell": "Shell access",
|
||||
"ConfigLabelManagedAllowShell": "Shell access (managed)",
|
||||
"ConfigLabelTelemetry": "Anonymous usage counts",
|
||||
"ConfigLabelTelemetry": "Usage reporting",
|
||||
"ConfigHintTelemetry": "Aggregate version, session, feature and error counts to Codewhale and PostHog; never content or IP. docs/TELEMETRY.md",
|
||||
"ConfigValueTelemetryOn": "On",
|
||||
"ConfigValueTelemetryOff": "Off",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "dormitando",
|
||||
"PetWatchUnavailable": "La telemetría de la mascota está en pausa. /pet on reintenta.",
|
||||
"ConversationChangedBeforeUndo": "La conversación cambió antes de completar la acción de deshacer. No se deshizo nada ni se envió un nuevo intento. Revisa la conversación actual antes de volver a intentarlo.",
|
||||
"ConversationModePickerTitle": "Modo",
|
||||
"ConversationModeGroupConversation": "Modo de conversación",
|
||||
"ConversationModeGroupPermissions": "Permisos",
|
||||
"ConversationModeStandardName": "Modo estándar",
|
||||
"ConversationModeStandardDescription": "El agente de todos los días con sus herramientas y habilidades habituales.",
|
||||
"ConversationModeCreatorName": "Modo creador",
|
||||
"ConversationModeCreatorDescription": "Crea y prueba plugins en esta conversación.",
|
||||
"ConversationModeNotReady": "Aún no está listo en esta versión.",
|
||||
"ConversationModeLockedNote": "Fijo tras el primer mensaje. Inicia otra con {command}.",
|
||||
"ConversationModeLockedRefusal": "El modo de conversación queda fijo al enviar el primer mensaje. Inicia una conversación nueva con {command}.",
|
||||
"ConversationModeUnknown": "Modo de conversación desconocido «{id}». Disponibles: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} no está disponible: {reason}",
|
||||
"ConversationModeSet": "Modo de conversación: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (no disponible)",
|
||||
"ConversationModeSavedUnavailableNotice": "Esta conversación se guardó en {name}, que no está disponible aquí ({reason}). Se muestra su historial.",
|
||||
"ConversationModeResumeUnreadable": "No se pudo leer la conversación guardada para comprobar su modo, así que no se inició ni se cambió nada.",
|
||||
"SessionArchiveExported": "Sesión exportada",
|
||||
"SessionArchiveSizes": "elementos / tamaño sin comprimir / archivo",
|
||||
"SessionArchiveNoArtifacts": "No se encontraron archivos de artefactos regulares en esta sesión.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "assoupi",
|
||||
"PetWatchUnavailable": "La télémétrie de la mascotte est en pause. /pet on réessaie.",
|
||||
"ConversationChangedBeforeUndo": "La conversation a changé avant la fin de l’annulation. Rien n’a été annulé et aucune nouvelle tentative n’a été envoyée. Vérifiez la conversation actuelle avant de réessayer.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Mode de conversation",
|
||||
"ConversationModeGroupPermissions": "Autorisations",
|
||||
"ConversationModeStandardName": "Mode standard",
|
||||
"ConversationModeStandardDescription": "L'agent du quotidien avec ses outils et compétences habituels.",
|
||||
"ConversationModeCreatorName": "Mode créateur",
|
||||
"ConversationModeCreatorDescription": "Créer et tester des plugins dans cette conversation.",
|
||||
"ConversationModeNotReady": "Pas encore prêt dans cette version.",
|
||||
"ConversationModeLockedNote": "Fixé après le premier message. Pour en démarrer une autre : {command}.",
|
||||
"ConversationModeLockedRefusal": "Le mode de conversation est fixé dès l'envoi du premier message. Démarrez une nouvelle conversation avec {command}.",
|
||||
"ConversationModeUnknown": "Mode de conversation inconnu « {id} ». Disponibles : {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} n'est pas disponible : {reason}",
|
||||
"ConversationModeSet": "Mode de conversation : {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (indisponible)",
|
||||
"ConversationModeSavedUnavailableNotice": "Cette conversation a été enregistrée en {name}, indisponible ici ({reason}). Son historique est affiché.",
|
||||
"ConversationModeResumeUnreadable": "Impossible de lire la conversation enregistrée pour vérifier son mode ; rien n'a été démarré ni modifié.",
|
||||
"SessionArchiveExported": "Session exportée",
|
||||
"SessionArchiveSizes": "éléments / taille non compressée / archive",
|
||||
"SessionArchiveNoArtifacts": "Aucun fichier d’artefact ordinaire trouvé pour cette session.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "ऊँघ रहा है",
|
||||
"PetWatchUnavailable": "पालतू की टेलीमेट्री रुकी हुई है। /pet on से फिर कोशिश करें।",
|
||||
"ConversationChangedBeforeUndo": "पूर्ववत करने की प्रक्रिया पूरी होने से पहले बातचीत बदल गई। कुछ भी पूर्ववत नहीं किया गया और दोबारा अनुरोध नहीं भेजा गया। फिर से कोशिश करने से पहले मौजूदा बातचीत की समीक्षा करें।",
|
||||
"ConversationModePickerTitle": "मोड",
|
||||
"ConversationModeGroupConversation": "बातचीत मोड",
|
||||
"ConversationModeGroupPermissions": "अनुमतियाँ",
|
||||
"ConversationModeStandardName": "मानक मोड",
|
||||
"ConversationModeStandardDescription": "रोज़ के उपयोग वाला एजेंट, अपने सामान्य टूल और स्किल के साथ।",
|
||||
"ConversationModeCreatorName": "क्रिएटर मोड",
|
||||
"ConversationModeCreatorDescription": "इस बातचीत में प्लगइन बनाएँ और परखें।",
|
||||
"ConversationModeNotReady": "इस संस्करण में अभी तैयार नहीं है।",
|
||||
"ConversationModeLockedNote": "पहले संदेश के बाद तय हो जाता है। दूसरी शुरू करने के लिए {command} चलाएँ।",
|
||||
"ConversationModeLockedRefusal": "पहला संदेश भेजते ही बातचीत का मोड तय हो जाता है। {command} से नई बातचीत शुरू करें।",
|
||||
"ConversationModeUnknown": "अज्ञात बातचीत मोड \"{id}\"। उपलब्ध: {modes}।",
|
||||
"ConversationModeUnavailableRefusal": "{name} उपलब्ध नहीं है: {reason}",
|
||||
"ConversationModeSet": "बातचीत मोड: {name}।",
|
||||
"ConversationModeLabelUnavailable": "{name} (उपलब्ध नहीं)",
|
||||
"ConversationModeSavedUnavailableNotice": "यह बातचीत {name} में सहेजी गई थी, जो यहाँ उपलब्ध नहीं है ({reason})। इसका इतिहास दिखाया गया है।",
|
||||
"ConversationModeResumeUnreadable": "इसका मोड जाँचने के लिए सहेजी गई बातचीत नहीं पढ़ी जा सकी, इसलिए कुछ शुरू या बदला नहीं गया।",
|
||||
"SessionArchiveExported": "सत्र निर्यात किया गया",
|
||||
"SessionArchiveSizes": "प्रविष्टियाँ / असंपीड़ित आकार / संग्रह का आकार",
|
||||
"SessionArchiveNoArtifacts": "इस सत्र में कोई सामान्य आर्टिफ़ैक्ट फ़ाइल नहीं मिली।",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "terlelap",
|
||||
"PetWatchUnavailable": "Telemetri hewan peliharaan dijeda. /pet on mencoba lagi.",
|
||||
"ConversationChangedBeforeUndo": "Percakapan berubah sebelum pembatalan selesai. Tidak ada yang dibatalkan dan tidak ada permintaan coba lagi yang dikirim. Periksa percakapan saat ini sebelum mencoba lagi.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Mode percakapan",
|
||||
"ConversationModeGroupPermissions": "Izin",
|
||||
"ConversationModeStandardName": "Mode standar",
|
||||
"ConversationModeStandardDescription": "Agen sehari-hari dengan alat dan skill yang biasa.",
|
||||
"ConversationModeCreatorName": "Mode kreator",
|
||||
"ConversationModeCreatorDescription": "Bangun dan uji plugin dalam percakapan ini.",
|
||||
"ConversationModeNotReady": "Belum siap di versi ini.",
|
||||
"ConversationModeLockedNote": "Tetap setelah pesan pertama. Mulai yang lain dengan {command}.",
|
||||
"ConversationModeLockedRefusal": "Mode percakapan tetap setelah pesan pertama dikirim. Mulai percakapan baru dengan {command}.",
|
||||
"ConversationModeUnknown": "Mode percakapan \"{id}\" tidak dikenal. Tersedia: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} tidak tersedia: {reason}",
|
||||
"ConversationModeSet": "Mode percakapan: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (tidak tersedia)",
|
||||
"ConversationModeSavedUnavailableNotice": "Percakapan ini disimpan dalam {name}, yang tidak tersedia di sini ({reason}). Riwayatnya ditampilkan.",
|
||||
"ConversationModeResumeUnreadable": "Percakapan tersimpan tidak dapat dibaca untuk memeriksa modenya, jadi tidak ada yang dimulai atau diubah.",
|
||||
"SessionArchiveExported": "Sesi diekspor",
|
||||
"SessionArchiveSizes": "jumlah entri / ukuran tanpa kompresi / arsip",
|
||||
"SessionArchiveNoArtifacts": "Tidak ditemukan berkas artefak biasa untuk sesi ini.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "うたた寝",
|
||||
"PetWatchUnavailable": "ペットの計測が一時停止しました。/pet on で再試行します。",
|
||||
"ConversationChangedBeforeUndo": "取り消しが完了する前に会話が変更されました。取り消しも再試行の送信も行っていません。現在の会話を確認してから、もう一度お試しください。",
|
||||
"ConversationModePickerTitle": "モード",
|
||||
"ConversationModeGroupConversation": "会話モード",
|
||||
"ConversationModeGroupPermissions": "権限",
|
||||
"ConversationModeStandardName": "標準モード",
|
||||
"ConversationModeStandardDescription": "いつもの道具とスキルを備えた日常用のエージェントです。",
|
||||
"ConversationModeCreatorName": "クリエイターモード",
|
||||
"ConversationModeCreatorDescription": "この会話でプラグインを作成・テストします。",
|
||||
"ConversationModeNotReady": "このバージョンではまだ使えません。",
|
||||
"ConversationModeLockedNote": "最初のメッセージ後は固定されます。別の会話は {command} で始めます。",
|
||||
"ConversationModeLockedRefusal": "会話モードは最初のメッセージを送ると固定されます。{command} で新しい会話を始めてください。",
|
||||
"ConversationModeUnknown": "会話モード「{id}」は不明です。使用可能: {modes}。",
|
||||
"ConversationModeUnavailableRefusal": "{name} は使えません: {reason}",
|
||||
"ConversationModeSet": "会話モード: {name}。",
|
||||
"ConversationModeLabelUnavailable": "{name}(利用不可)",
|
||||
"ConversationModeSavedUnavailableNotice": "この会話は {name} で保存されましたが、ここでは使えません({reason})。履歴は表示されています。",
|
||||
"ConversationModeResumeUnreadable": "保存された会話を読み取れず、モードを確認できませんでした。何も開始・変更していません。",
|
||||
"SessionArchiveExported": "セッションをエクスポートしました",
|
||||
"SessionArchiveSizes": "項目数 / 非圧縮サイズ / アーカイブサイズ",
|
||||
"SessionArchiveNoArtifacts": "このセッションに通常のアーティファクトファイルはありません。",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "졸고 있음",
|
||||
"PetWatchUnavailable": "펫 원격 측정이 일시 중지되었습니다. /pet on 으로 재시도합니다.",
|
||||
"ConversationChangedBeforeUndo": "실행 취소가 완료되기 전에 대화가 변경되었습니다. 취소된 내용이 없으며 재시도 요청도 보내지 않았습니다. 현재 대화를 확인한 후 다시 시도하세요.",
|
||||
"ConversationModePickerTitle": "모드",
|
||||
"ConversationModeGroupConversation": "대화 모드",
|
||||
"ConversationModeGroupPermissions": "권한",
|
||||
"ConversationModeStandardName": "표준 모드",
|
||||
"ConversationModeStandardDescription": "평소 도구와 스킬을 갖춘 일상용 에이전트입니다.",
|
||||
"ConversationModeCreatorName": "크리에이터 모드",
|
||||
"ConversationModeCreatorDescription": "이 대화에서 플러그인을 만들고 테스트합니다.",
|
||||
"ConversationModeNotReady": "이 버전에서는 아직 준비되지 않았습니다.",
|
||||
"ConversationModeLockedNote": "첫 메시지 이후에는 고정됩니다. 다른 대화는 {command} 로 시작하세요.",
|
||||
"ConversationModeLockedRefusal": "대화 모드는 첫 메시지를 보내면 고정됩니다. {command} 로 새 대화를 시작하세요.",
|
||||
"ConversationModeUnknown": "알 수 없는 대화 모드 \"{id}\". 사용 가능: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} 사용 불가: {reason}",
|
||||
"ConversationModeSet": "대화 모드: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (사용 불가)",
|
||||
"ConversationModeSavedUnavailableNotice": "이 대화는 {name}에서 저장되었지만 여기서는 사용할 수 없습니다({reason}). 기록은 표시됩니다.",
|
||||
"ConversationModeResumeUnreadable": "저장된 대화를 읽어 모드를 확인할 수 없어 아무것도 시작하거나 변경하지 않았습니다.",
|
||||
"SessionArchiveExported": "세션을 내보냈습니다",
|
||||
"SessionArchiveSizes": "항목 수 / 압축 전 크기 / 아카이브 크기",
|
||||
"SessionArchiveNoArtifacts": "이 세션에 일반 산출물 파일이 없습니다.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "cochilando",
|
||||
"PetWatchUnavailable": "A telemetria do pet foi pausada. /pet on tenta novamente.",
|
||||
"ConversationChangedBeforeUndo": "A conversa mudou antes de concluir a ação de desfazer. Nada foi desfeito e nenhuma nova tentativa foi enviada. Revise a conversa atual antes de tentar novamente.",
|
||||
"ConversationModePickerTitle": "Modo",
|
||||
"ConversationModeGroupConversation": "Modo de conversa",
|
||||
"ConversationModeGroupPermissions": "Permissões",
|
||||
"ConversationModeStandardName": "Modo padrão",
|
||||
"ConversationModeStandardDescription": "O agente do dia a dia, com suas ferramentas e habilidades habituais.",
|
||||
"ConversationModeCreatorName": "Modo criador",
|
||||
"ConversationModeCreatorDescription": "Crie e teste plugins nesta conversa.",
|
||||
"ConversationModeNotReady": "Ainda não está pronto nesta versão.",
|
||||
"ConversationModeLockedNote": "Fixo após a primeira mensagem. Inicie outra com {command}.",
|
||||
"ConversationModeLockedRefusal": "O modo de conversa fica fixo depois que a primeira mensagem é enviada. Inicie uma nova conversa com {command}.",
|
||||
"ConversationModeUnknown": "Modo de conversa desconhecido \"{id}\". Disponíveis: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} não está disponível: {reason}",
|
||||
"ConversationModeSet": "Modo de conversa: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (indisponível)",
|
||||
"ConversationModeSavedUnavailableNotice": "Esta conversa foi salva no {name}, que não está disponível aqui ({reason}). O histórico dela é exibido.",
|
||||
"ConversationModeResumeUnreadable": "Não foi possível ler a conversa salva para verificar o modo, então nada foi iniciado ou alterado.",
|
||||
"SessionArchiveExported": "Sessão exportada",
|
||||
"SessionArchiveSizes": "itens / tamanho sem compressão / arquivo",
|
||||
"SessionArchiveNoArtifacts": "Nenhum artefato regular encontrado nesta sessão.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "дремлет",
|
||||
"PetWatchUnavailable": "Телеметрия питомца приостановлена. /pet on повторит попытку.",
|
||||
"ConversationChangedBeforeUndo": "Разговор изменился до завершения отмены. Ничего не отменено, повторный запрос не отправлен. Проверьте текущий разговор, прежде чем повторять попытку.",
|
||||
"ConversationModePickerTitle": "Режим",
|
||||
"ConversationModeGroupConversation": "Режим беседы",
|
||||
"ConversationModeGroupPermissions": "Разрешения",
|
||||
"ConversationModeStandardName": "Стандартный режим",
|
||||
"ConversationModeStandardDescription": "Повседневный агент с обычными инструментами и навыками.",
|
||||
"ConversationModeCreatorName": "Режим создателя",
|
||||
"ConversationModeCreatorDescription": "Создавайте и проверяйте плагины в этой беседе.",
|
||||
"ConversationModeNotReady": "В этой версии пока не готово.",
|
||||
"ConversationModeLockedNote": "Фиксируется после первого сообщения. Начните другую командой {command}.",
|
||||
"ConversationModeLockedRefusal": "Режим беседы фиксируется после отправки первого сообщения. Начните новую беседу командой {command}.",
|
||||
"ConversationModeUnknown": "Неизвестный режим беседы «{id}». Доступны: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} недоступен: {reason}",
|
||||
"ConversationModeSet": "Режим беседы: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (недоступен)",
|
||||
"ConversationModeSavedUnavailableNotice": "Эта беседа сохранена в режиме «{name}», недоступном здесь ({reason}). Её история показана.",
|
||||
"ConversationModeResumeUnreadable": "Не удалось прочитать сохранённый разговор, чтобы проверить его режим. Ничего не запущено и не изменено.",
|
||||
"SessionArchiveExported": "Сеанс экспортирован",
|
||||
"SessionArchiveSizes": "записи / размер без сжатия / размер архива",
|
||||
"SessionArchiveNoArtifacts": "Обычные файлы артефактов для этого сеанса не найдены.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "дрімає",
|
||||
"PetWatchUnavailable": "Телеметрію улюбленця призупинено. /pet on повторить спробу.",
|
||||
"ConversationChangedBeforeUndo": "Розмова змінилася до завершення скасування. Нічого не скасовано, повторний запит не надіслано. Перевірте поточну розмову, перш ніж спробувати знову.",
|
||||
"ConversationModePickerTitle": "Режим",
|
||||
"ConversationModeGroupConversation": "Режим розмови",
|
||||
"ConversationModeGroupPermissions": "Дозволи",
|
||||
"ConversationModeStandardName": "Стандартний режим",
|
||||
"ConversationModeStandardDescription": "Щоденний агент зі звичними інструментами та навичками.",
|
||||
"ConversationModeCreatorName": "Режим творця",
|
||||
"ConversationModeCreatorDescription": "Створюйте й перевіряйте плагіни в цій розмові.",
|
||||
"ConversationModeNotReady": "У цій версії ще не готово.",
|
||||
"ConversationModeLockedNote": "Фіксується після першого повідомлення. Почніть іншу командою {command}.",
|
||||
"ConversationModeLockedRefusal": "Режим розмови фіксується після надсилання першого повідомлення. Почніть нову розмову командою {command}.",
|
||||
"ConversationModeUnknown": "Невідомий режим розмови «{id}». Доступні: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} недоступний: {reason}",
|
||||
"ConversationModeSet": "Режим розмови: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (недоступний)",
|
||||
"ConversationModeSavedUnavailableNotice": "Цю розмову збережено в режимі «{name}», недоступному тут ({reason}). Її історію показано.",
|
||||
"ConversationModeResumeUnreadable": "Не вдалося прочитати збережену розмову, щоб перевірити її режим. Нічого не запущено й не змінено.",
|
||||
"SessionArchiveExported": "Сеанс експортовано",
|
||||
"SessionArchiveSizes": "записи / розмір без стиснення / розмір архіву",
|
||||
"SessionArchiveNoArtifacts": "Звичайних файлів артефактів для цього сеансу не знайдено.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "ngủ gật",
|
||||
"PetWatchUnavailable": "Đã tạm dừng dữ liệu thú cưng. /pet on sẽ thử lại.",
|
||||
"ConversationChangedBeforeUndo": "Cuộc trò chuyện đã thay đổi trước khi hoàn tất hoàn tác. Không có nội dung nào bị hoàn tác và không gửi yêu cầu thử lại. Hãy kiểm tra cuộc trò chuyện hiện tại trước khi thử lại.",
|
||||
"ConversationModePickerTitle": "Chế độ",
|
||||
"ConversationModeGroupConversation": "Chế độ cuộc trò chuyện",
|
||||
"ConversationModeGroupPermissions": "Quyền",
|
||||
"ConversationModeStandardName": "Chế độ tiêu chuẩn",
|
||||
"ConversationModeStandardDescription": "Tác nhân hằng ngày với các công cụ và kỹ năng quen thuộc.",
|
||||
"ConversationModeCreatorName": "Chế độ sáng tạo",
|
||||
"ConversationModeCreatorDescription": "Xây dựng và thử plugin trong cuộc trò chuyện này.",
|
||||
"ConversationModeNotReady": "Phiên bản này chưa sẵn sàng.",
|
||||
"ConversationModeLockedNote": "Cố định sau tin nhắn đầu tiên. Bắt đầu cuộc khác bằng {command}.",
|
||||
"ConversationModeLockedRefusal": "Chế độ cuộc trò chuyện được cố định sau khi gửi tin nhắn đầu tiên. Hãy bắt đầu cuộc trò chuyện mới bằng {command}.",
|
||||
"ConversationModeUnknown": "Không rõ chế độ cuộc trò chuyện \"{id}\". Hiện có: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} không khả dụng: {reason}",
|
||||
"ConversationModeSet": "Chế độ cuộc trò chuyện: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (không khả dụng)",
|
||||
"ConversationModeSavedUnavailableNotice": "Cuộc trò chuyện này được lưu ở {name}, hiện không khả dụng ở đây ({reason}). Lịch sử vẫn được hiển thị.",
|
||||
"ConversationModeResumeUnreadable": "Không đọc được cuộc trò chuyện đã lưu để kiểm tra chế độ, nên không có gì được bắt đầu hoặc thay đổi.",
|
||||
"SessionArchiveExported": "Đã xuất phiên",
|
||||
"SessionArchiveSizes": "số mục / dung lượng chưa nén / tệp lưu trữ",
|
||||
"SessionArchiveNoArtifacts": "Không tìm thấy tệp sản phẩm thông thường trong phiên này.",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "打盹",
|
||||
"PetWatchUnavailable": "宠物遥测已暂停。/pet on 重试。",
|
||||
"ConversationChangedBeforeUndo": "撤销完成前,会话已发生变化。未撤销任何内容,也未发送重试请求。请先检查当前会话,再重试。",
|
||||
"ConversationModePickerTitle": "模式",
|
||||
"ConversationModeGroupConversation": "对话模式",
|
||||
"ConversationModeGroupPermissions": "权限",
|
||||
"ConversationModeStandardName": "标准模式",
|
||||
"ConversationModeStandardDescription": "日常使用的智能体,带有常用工具和技能。",
|
||||
"ConversationModeCreatorName": "创作者模式",
|
||||
"ConversationModeCreatorDescription": "在此对话中构建并测试插件。",
|
||||
"ConversationModeNotReady": "此版本尚未就绪。",
|
||||
"ConversationModeLockedNote": "发送第一条消息后即固定。用 {command} 开始另一个对话。",
|
||||
"ConversationModeLockedRefusal": "发送第一条消息后,对话模式即固定。请用 {command} 开始新对话。",
|
||||
"ConversationModeUnknown": "未知的对话模式“{id}”。可用:{modes}。",
|
||||
"ConversationModeUnavailableRefusal": "{name}不可用:{reason}",
|
||||
"ConversationModeSet": "对话模式:{name}。",
|
||||
"ConversationModeLabelUnavailable": "{name}(不可用)",
|
||||
"ConversationModeSavedUnavailableNotice": "此对话保存时使用{name},但这里无法使用({reason})。已显示其历史记录。",
|
||||
"ConversationModeResumeUnreadable": "无法读取已保存的对话以检查其模式,因此没有开始或更改任何内容。",
|
||||
"SessionArchiveExported": "会话已导出",
|
||||
"SessionArchiveSizes": "条目数 / 未压缩大小 / 归档大小",
|
||||
"SessionArchiveNoArtifacts": "此会话没有常规产物文件。",
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"PetDozing": "打盹",
|
||||
"PetWatchUnavailable": "寵物遙測已暫停。/pet on 重試。",
|
||||
"ConversationChangedBeforeUndo": "復原完成前,對話已發生變更。未復原任何內容,也未送出重試請求。請先檢查目前的對話,再重試。",
|
||||
"ConversationModePickerTitle": "模式",
|
||||
"ConversationModeGroupConversation": "對話模式",
|
||||
"ConversationModeGroupPermissions": "權限",
|
||||
"ConversationModeStandardName": "標準模式",
|
||||
"ConversationModeStandardDescription": "日常使用的代理,具備常用工具與技能。",
|
||||
"ConversationModeCreatorName": "創作者模式",
|
||||
"ConversationModeCreatorDescription": "在此對話中建置並測試外掛。",
|
||||
"ConversationModeNotReady": "此版本尚未就緒。",
|
||||
"ConversationModeLockedNote": "傳送第一則訊息後即固定。用 {command} 開始另一個對話。",
|
||||
"ConversationModeLockedRefusal": "傳送第一則訊息後,對話模式即固定。請用 {command} 開始新對話。",
|
||||
"ConversationModeUnknown": "未知的對話模式「{id}」。可用:{modes}。",
|
||||
"ConversationModeUnavailableRefusal": "{name}無法使用:{reason}",
|
||||
"ConversationModeSet": "對話模式:{name}。",
|
||||
"ConversationModeLabelUnavailable": "{name}(無法使用)",
|
||||
"ConversationModeSavedUnavailableNotice": "此對話儲存時使用{name},但這裡無法使用({reason})。已顯示其歷史記錄。",
|
||||
"ConversationModeResumeUnreadable": "無法讀取已儲存的對話以檢查其模式,因此沒有開始或變更任何內容。",
|
||||
"SessionArchiveExported": "工作階段已匯出",
|
||||
"SessionArchiveSizes": "項目數 / 未壓縮大小 / 封存大小",
|
||||
"SessionArchiveNoArtifacts": "此工作階段沒有一般產物檔案。",
|
||||
|
||||
@@ -171,6 +171,22 @@ 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,
|
||||
@@ -3114,6 +3130,22 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::MobileStreamReconnecting,
|
||||
MessageId::MobileStreamConnected,
|
||||
MessageId::ConversationChangedBeforeUndo,
|
||||
MessageId::ConversationModePickerTitle,
|
||||
MessageId::ConversationModeGroupConversation,
|
||||
MessageId::ConversationModeGroupPermissions,
|
||||
MessageId::ConversationModeStandardName,
|
||||
MessageId::ConversationModeStandardDescription,
|
||||
MessageId::ConversationModeCreatorName,
|
||||
MessageId::ConversationModeCreatorDescription,
|
||||
MessageId::ConversationModeNotReady,
|
||||
MessageId::ConversationModeLockedNote,
|
||||
MessageId::ConversationModeLockedRefusal,
|
||||
MessageId::ConversationModeUnknown,
|
||||
MessageId::ConversationModeUnavailableRefusal,
|
||||
MessageId::ConversationModeSet,
|
||||
MessageId::ConversationModeLabelUnavailable,
|
||||
MessageId::ConversationModeSavedUnavailableNotice,
|
||||
MessageId::ConversationModeResumeUnreadable,
|
||||
MessageId::SessionArchiveExported,
|
||||
MessageId::SessionArchiveSizes,
|
||||
MessageId::SessionArchiveNoArtifacts,
|
||||
|
||||
@@ -255,11 +255,12 @@ impl StdioLspTransport {
|
||||
let child = Arc::new(AsyncMutex::new(Some(child)));
|
||||
let dispatcher_child = child.clone();
|
||||
let dispatcher_pending = pending.clone();
|
||||
let dispatcher_outbound = tx_outbound.clone();
|
||||
let dispatcher_task = spawn_supervised(
|
||||
"lsp-dispatcher",
|
||||
std::panic::Location::caller(),
|
||||
async move {
|
||||
dispatcher_task(rx_inbound, tx_diag, dispatcher_pending).await;
|
||||
dispatcher_task(rx_inbound, tx_diag, dispatcher_outbound, dispatcher_pending).await;
|
||||
// EOF, malformed frames, or an overflowing diagnostics queue
|
||||
// terminate the producer too, so its pipes cannot stay stuck.
|
||||
if let Some(mut child) = dispatcher_child.lock().await.take() {
|
||||
@@ -536,15 +537,19 @@ impl LspTransport for StdioLspTransport {
|
||||
|
||||
/// Send a JSON value as one Content-Length-framed JSON-RPC message.
|
||||
async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
|
||||
tx.send(frame_message(value)?)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frame_message(value: &Value) -> Result<Vec<u8>> {
|
||||
let body = serde_json::to_vec(value).context("serialize LSP message")?;
|
||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||
let mut frame = Vec::with_capacity(header.len() + body.len());
|
||||
frame.extend_from_slice(header.as_bytes());
|
||||
frame.extend_from_slice(&body);
|
||||
tx.send(frame)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Background task that drains the outbound queue and writes each frame to
|
||||
@@ -638,6 +643,7 @@ fn parse_header(buf: &[u8]) -> Result<Option<(usize, usize)>> {
|
||||
async fn dispatcher_task(
|
||||
mut rx: mpsc::Receiver<Value>,
|
||||
tx_diag: mpsc::Sender<DiagnosticMessage>,
|
||||
tx_outbound: mpsc::Sender<Vec<u8>>,
|
||||
pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
|
||||
) {
|
||||
while let Some(value) = rx.recv().await {
|
||||
@@ -654,6 +660,19 @@ async fn dispatcher_task(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(method) = method {
|
||||
if let Some(id) = value.get("id") {
|
||||
let reply = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {"code": -32601, "message": format!("unsupported method {method}")},
|
||||
});
|
||||
if let Ok(frame) = frame_message(&reply) {
|
||||
let _ = tx_outbound.try_send(frame);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Replies have an `id` and a `result` or `error`.
|
||||
if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
|
||||
let mut map = pending.lock().await;
|
||||
@@ -780,6 +799,31 @@ pub(super) mod tests {
|
||||
assert_eq!(diags[0].message, "missing semicolon");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_request_sharing_a_pending_id_is_answered_not_taken_as_the_reply() {
|
||||
let (tx_in, rx_in) = mpsc::channel(4);
|
||||
let (tx_out, mut rx_out) = mpsc::channel::<Vec<u8>>(4);
|
||||
let (tx_diag, _rx_diag) = mpsc::channel(4);
|
||||
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
pending.lock().await.insert(2, reply_tx);
|
||||
let dispatcher = tokio::spawn(dispatcher_task(rx_in, tx_diag, tx_out, pending));
|
||||
tx_in
|
||||
.send(json!({"jsonrpc":"2.0","id":2,"method":"workspace/configuration","params":{"items":[]}}))
|
||||
.await
|
||||
.unwrap();
|
||||
tx_in
|
||||
.send(json!({"jsonrpc":"2.0","id":2,"result":"definition"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reply_rx.await.unwrap()["result"], "definition");
|
||||
let answer = next_document(&mut rx_out).await;
|
||||
assert_eq!(answer["id"], 2);
|
||||
assert_eq!(answer["error"]["code"], -32601);
|
||||
drop(tx_in);
|
||||
dispatcher.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_uri_path() {
|
||||
let path = fixture_path("example/foo.rs");
|
||||
@@ -1309,11 +1353,12 @@ while True:
|
||||
let (mut producer, reader) = tokio::io::duplex(MAX_LSP_HEADER_BYTES * 2);
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let (diag, _diagnostics) = mpsc::channel(8);
|
||||
let (outbound, _outbound_rx) = mpsc::channel(8);
|
||||
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
|
||||
let (reply, receiver) = oneshot::channel();
|
||||
pending.lock().await.insert(1, reply);
|
||||
let read_task = tokio::spawn(reader_task(reader, tx));
|
||||
let dispatch = tokio::spawn(dispatcher_task(rx, diag, pending.clone()));
|
||||
let dispatch = tokio::spawn(dispatcher_task(rx, diag, outbound, pending.clone()));
|
||||
producer.write_all(&frame).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_secs(1), receiver)
|
||||
|
||||
@@ -444,6 +444,8 @@ 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
|
||||
@@ -787,6 +789,7 @@ 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": []}),
|
||||
|
||||
@@ -3446,15 +3446,10 @@ impl Config {
|
||||
return Some("project runtime configuration");
|
||||
}
|
||||
|
||||
let managed_path = self
|
||||
.managed_config_path
|
||||
.as_deref()
|
||||
.map(expand_path)
|
||||
.or_else(default_managed_config_path);
|
||||
if let Some(path) = managed_path
|
||||
&& path.exists()
|
||||
{
|
||||
match load_single_config_file(&path) {
|
||||
match managed_config_file(self.managed_config_path.as_deref()) {
|
||||
Err(_) => return Some("an unreadable managed runtime configuration"),
|
||||
Ok(None) => {}
|
||||
Ok(Some(path)) => match load_single_config_file(&path) {
|
||||
Ok(managed)
|
||||
if managed.approval_policy.is_some()
|
||||
|| managed.sandbox_mode.is_some()
|
||||
@@ -3464,7 +3459,7 @@ impl Config {
|
||||
}
|
||||
Err(_) => return Some("an unreadable managed runtime configuration"),
|
||||
Ok(_) => {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let env_controls_runtime = || {
|
||||
@@ -3550,21 +3545,16 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
let managed_path = self
|
||||
.managed_config_path
|
||||
.as_deref()
|
||||
.map(expand_path)
|
||||
.or_else(default_managed_config_path);
|
||||
if let Some(path) = managed_path
|
||||
&& path.exists()
|
||||
{
|
||||
match load_single_config_file(&path) {
|
||||
match managed_config_file(self.managed_config_path.as_deref()) {
|
||||
Err(_) => return ApprovalPolicyControl::Ambiguous,
|
||||
Ok(None) => {}
|
||||
Ok(Some(path)) => match load_single_config_file(&path) {
|
||||
Ok(managed) if managed.approval_policy.is_some() => {
|
||||
return ApprovalPolicyControl::ManagedConfig;
|
||||
}
|
||||
Err(_) => return ApprovalPolicyControl::Ambiguous,
|
||||
Ok(_) => {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if approval_policy_env_is_set() {
|
||||
@@ -3630,21 +3620,16 @@ impl Config {
|
||||
return ShellAccessControl::ProjectConfig;
|
||||
}
|
||||
|
||||
let managed_path = self
|
||||
.managed_config_path
|
||||
.as_deref()
|
||||
.map(expand_path)
|
||||
.or_else(default_managed_config_path);
|
||||
if let Some(path) = managed_path
|
||||
&& path.exists()
|
||||
{
|
||||
match load_single_config_file(&path) {
|
||||
match managed_config_file(self.managed_config_path.as_deref()) {
|
||||
Err(_) => return ShellAccessControl::Ambiguous,
|
||||
Ok(None) => {}
|
||||
Ok(Some(path)) => match load_single_config_file(&path) {
|
||||
Ok(managed) if managed.allow_shell.is_some() => {
|
||||
return ShellAccessControl::ManagedConfig;
|
||||
}
|
||||
Err(_) => return ShellAccessControl::Ambiguous,
|
||||
Ok(_) => {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if allow_shell_env_is_set() {
|
||||
@@ -8932,9 +8917,10 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo
|
||||
{
|
||||
config.sandbox_api_key = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
|
||||
{
|
||||
if let Ok(value) = codewhale_env_var(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
) {
|
||||
config.managed_config_path = Some(value);
|
||||
}
|
||||
if policy.permits_secret_bearing_values()
|
||||
@@ -10718,26 +10704,25 @@ fn warn_on_misplaced_top_level_keys(raw: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn apply_managed_overrides(config: &mut Config) -> Result<()> {
|
||||
let path = managed_path_from_env()
|
||||
.or_else(|| config.managed_config_path.as_deref().map(expand_path))
|
||||
.or_else(default_managed_config_path);
|
||||
let Some(path) = path else {
|
||||
let Some(path) = managed_config_file(config.managed_config_path.as_deref())? else {
|
||||
return Ok(());
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut managed = load_single_config_file(&path)?;
|
||||
let mut managed = load_single_config_file(&path).with_context(|| {
|
||||
format!(
|
||||
"the managed configuration {} cannot be loaded, and Codewhale does not start without a managed configuration it was told to use",
|
||||
codewhale_config::quote_os_path(&path)
|
||||
)
|
||||
})?;
|
||||
strip_external_credential_consent(&mut managed);
|
||||
managed.tool_launcher = managed_tool_launcher(&path)?;
|
||||
let prior_route = config
|
||||
.active_provider_identity()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
.map_err(|error| managed_route_error(config, &path, error))?;
|
||||
let mut merged = merge_config(config.clone(), managed.clone());
|
||||
apply_layer_root_model(&mut merged, &managed);
|
||||
let merged_route = merged
|
||||
.active_provider_identity()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
.map_err(|error| managed_route_error(&merged, &path, error))?;
|
||||
if managed.provider.is_some()
|
||||
|| managed.default_text_model.is_some()
|
||||
|| managed.legacy_model.is_some()
|
||||
@@ -10888,10 +10873,62 @@ fn managed_tool_launcher(path: &Path) -> Result<Option<Vec<String>>> {
|
||||
}
|
||||
|
||||
fn managed_path_from_env() -> Option<PathBuf> {
|
||||
std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
|
||||
.ok()
|
||||
.map(|value| expand_path(&value))
|
||||
codewhale_env_var(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
)
|
||||
.ok()
|
||||
.map(|value| expand_path(&value))
|
||||
}
|
||||
|
||||
fn managed_config_file(configured: Option<&str>) -> Result<Option<PathBuf>> {
|
||||
if let Some(path) = managed_path_from_env() {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
let default = default_managed_config_path().filter(|path| {
|
||||
fs::symlink_metadata(path).map_or_else(
|
||||
|error| error.kind() != std::io::ErrorKind::NotFound,
|
||||
|_| true,
|
||||
)
|
||||
});
|
||||
let Some(configured) = configured
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(expand_path)
|
||||
else {
|
||||
return Ok(default);
|
||||
};
|
||||
if let Some(default) = default
|
||||
&& default != configured
|
||||
{
|
||||
anyhow::bail!(
|
||||
"`managed_config_path` names {}, but the default managed configuration {} exists, and a config file cannot replace it; name another managed file with CODEWHALE_MANAGED_CONFIG_PATH instead",
|
||||
codewhale_config::quote_os_path(&configured),
|
||||
codewhale_config::quote_os_path(&default)
|
||||
);
|
||||
}
|
||||
Ok(Some(configured))
|
||||
}
|
||||
|
||||
fn managed_route_error(config: &Config, path: &Path, error: String) -> anyhow::Error {
|
||||
let selected = config.provider.as_deref().unwrap_or_default();
|
||||
let undeclared = !selected.is_empty()
|
||||
&& ProviderKind::parse(selected).is_none()
|
||||
&& config
|
||||
.providers
|
||||
.as_ref()
|
||||
.and_then(|providers| providers.custom_provider_config(selected))
|
||||
.is_none();
|
||||
if undeclared {
|
||||
anyhow::anyhow!(
|
||||
"provider '{selected}' has no `[providers.{selected}]` table in the configuration, and the managed configuration {} applies: a provider that only a plugin declares cannot be used under a managed configuration. Declare `[providers.{selected}]` in the config file, or select a built-in provider",
|
||||
codewhale_config::quote_os_path(path)
|
||||
)
|
||||
} else {
|
||||
anyhow::anyhow!(
|
||||
"{error} (while applying the managed configuration {})",
|
||||
codewhale_config::quote_os_path(path)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_path_is_named_by_host(path: &Path) -> bool {
|
||||
|
||||
@@ -588,12 +588,11 @@ pub fn session_custom_provider_config(name: &str, kind: &str, base_url: &str) ->
|
||||
// provider is contacted: the assertions are about which host string a route
|
||||
// resolves to, and about the classifications derived from it.
|
||||
|
||||
/// A managed-config guard pointing at a path that does not exist, so an
|
||||
/// A managed-config guard pointing at an empty managed file, so an
|
||||
/// operator-installed managed file on the developer's machine cannot leak into
|
||||
/// these route assertions.
|
||||
pub fn no_managed_config(root: &std::path::Path) -> EnvVarGuard {
|
||||
EnvVarGuard::set(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
root.join("absent-managed.toml"),
|
||||
)
|
||||
let path = root.join("empty-managed.toml");
|
||||
std::fs::write(&path, "").expect("write empty managed config");
|
||||
EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", path)
|
||||
}
|
||||
@@ -3108,7 +3108,8 @@ fn policy_control_waits_for_foreign_test_env_overrides_to_restore() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = temp.path().join("config.toml");
|
||||
let workspace = temp.path().join("workspace");
|
||||
let managed_config_path = temp.path().join("missing-managed.toml");
|
||||
let managed_config_path = temp.path().join("empty-managed.toml");
|
||||
fs::write(&managed_config_path, "").expect("empty managed config");
|
||||
let requirements_path = temp.path().join("missing-requirements.toml");
|
||||
let (started_tx, started_rx) = mpsc::channel();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
@@ -6476,10 +6477,7 @@ fn deepseek_model_env_overrides_default_text_model() -> Result<()> {
|
||||
fn retired_deepseek_aliases_from_env_are_migrated_before_runtime() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _managed_config = crate::test_support::EnvVarGuard::set(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
temp_root.path().join("missing-managed.toml"),
|
||||
);
|
||||
let _managed_config = no_managed_config(temp_root.path());
|
||||
|
||||
for (provider, alias, expected_effort) in [
|
||||
("deepseek", "deepseek-chat", "off"),
|
||||
@@ -12198,10 +12196,7 @@ fn tool_launcher_is_refused_outside_the_managed_layer() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = EnvVarGuard::set(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
temp_root.path().join("absent-managed.toml"),
|
||||
);
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
for document in [
|
||||
"tool_launcher = [\"/usr/bin/sudo\", \"-n\", \"-u\", \"agent\", \"--\"]\n",
|
||||
@@ -12372,17 +12367,62 @@ fn the_managed_file_named_by_the_host_survives_a_provider_that_resolves_late() -
|
||||
ConfigEnvironmentPolicy::Runtime,
|
||||
&mut late_provider,
|
||||
);
|
||||
if let Ok(config) = loaded {
|
||||
assert_eq!(
|
||||
config.tool_launcher.as_deref(),
|
||||
Some(["/usr/bin/sudo", "-n"].map(str::to_string).as_slice()),
|
||||
"{user_file}"
|
||||
);
|
||||
}
|
||||
let error = loaded.expect_err("a plugin provider does not load under a managed file");
|
||||
assert!(
|
||||
format!("{error:#}").contains("a provider that only a plugin declares"),
|
||||
"{user_file}: {error:#}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_managed_file_that_cannot_be_read_is_a_load_error() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _legacy_managed = EnvVarGuard::remove("DEEPSEEK_MANAGED_CONFIG_PATH");
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, "")?;
|
||||
let missing = temp_root.path().join("missing-managed.toml");
|
||||
for named in [missing.clone(), temp_root.path().to_path_buf()] {
|
||||
let _managed = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", &named);
|
||||
let error = Config::load(Some(config_path.clone()), None).expect_err("closed");
|
||||
assert!(
|
||||
format!("{error:#}").contains("does not start without a managed configuration"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
|
||||
let _managed = EnvVarGuard::remove("CODEWHALE_MANAGED_CONFIG_PATH");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"managed_config_path = {}\n",
|
||||
toml::Value::String(missing.display().to_string())
|
||||
),
|
||||
)?;
|
||||
let error = Config::load(Some(config_path.clone()), None).expect_err("closed");
|
||||
assert!(
|
||||
format!("{error:#}").contains("does not start without a managed configuration"),
|
||||
"{error:#}"
|
||||
);
|
||||
|
||||
fs::write(&config_path, "")?;
|
||||
let empty = temp_root.path().join("empty-managed.toml");
|
||||
fs::write(&empty, "")?;
|
||||
let _blank = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", "");
|
||||
let _legacy = EnvVarGuard::set("DEEPSEEK_MANAGED_CONFIG_PATH", &missing);
|
||||
let error = Config::load(Some(config_path.clone()), None).expect_err("blank defers");
|
||||
assert!(
|
||||
format!("{error:#}").contains("missing-managed.toml"),
|
||||
"{error:#}"
|
||||
);
|
||||
let _legacy = EnvVarGuard::set("DEEPSEEK_MANAGED_CONFIG_PATH", &empty);
|
||||
Config::load(Some(config_path), None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
#[test]
|
||||
fn tool_launcher_is_refused_from_a_managed_file_the_user_config_names() -> Result<()> {
|
||||
|
||||
@@ -1371,6 +1371,9 @@ fn relativize_candidate(
|
||||
.map(fs::Metadata::is_dir)
|
||||
.unwrap_or_else(|| candidate.ends_with('/'));
|
||||
|
||||
if rel_path.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let rel_string = path_to_string(&rel_path)?;
|
||||
Some((rel_string, exists, is_dir))
|
||||
}
|
||||
@@ -1655,6 +1658,19 @@ mod tests {
|
||||
assert!(!entry.is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_root_is_not_an_active_path() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let root = tmp.path().canonicalize().expect("canonical root");
|
||||
fs::write(root.join("笔记 📝.md"), "旧").expect("write");
|
||||
|
||||
let mut ws = WorkingSet::default();
|
||||
let mention = format!("<file path=\"{}/笔记 📝.md\">", root.display());
|
||||
ws.observe_user_message(&mention, &root);
|
||||
|
||||
assert!(!ws.entries.contains_key(""), "{:?}", ws.entries.keys());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_tool_call_extracts_paths_from_input() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"in_model_catalogue": "true when the skill renders as an ambient catalogue line",
|
||||
"shadowed_aliases": "aliases that collide with another canonical bundled name; the canonical skill wins resolution"
|
||||
},
|
||||
"generation": "21",
|
||||
"generation": "22",
|
||||
"skills": [
|
||||
{
|
||||
"name": "skill-creator",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: plugin-creator
|
||||
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review.
|
||||
---
|
||||
|
||||
# Plugin Creator
|
||||
|
||||
Use this skill when a user wants a local Codewhale plugin bundle. Trusted and
|
||||
enabled bundles may add declarative Skills, commands, agents, hooks, and MCP
|
||||
servers (stdio and remote) through the existing engines. LSP, filesystem
|
||||
roots, and lifecycle mutation are inventory-only. Native extensions (host
|
||||
code) are inventory-only unless the user has turned on the experimental
|
||||
`[features] extension_host` flag.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Pick a Codewhale-owned location:
|
||||
- User bundle: `~/.codewhale/plugins/<plugin-name>/`
|
||||
- Workspace bundle: `<workspace>/.codewhale/plugins/<plugin-name>/`
|
||||
2. Normalize the bundle name to lowercase hyphen-case.
|
||||
3. Create `plugin.json` (Agent Plugins v1.0.0; a legacy `plugin.toml` stays
|
||||
readable, but new bundles use `plugin.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/plugin.json",
|
||||
"name": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "What this bundle provides"
|
||||
}
|
||||
```
|
||||
|
||||
4. Put each Skill under `skills/<skill-name>/SKILL.md`; Codewhale finds
|
||||
`skills/` automatically and exposes each as `my-plugin:<skill-name>`,
|
||||
never as an unqualified command.
|
||||
5. Add MCP servers in a sibling `mcp.json` only when the bundle needs an
|
||||
existing MCP engine. Keep stdio commands and paths inside the bundle. Map local
|
||||
environment values only as exact `${SOURCE_ENV}` references. For remote MCP,
|
||||
use HTTPS (or loopback HTTP), forbid URL user information/query/fragment,
|
||||
use only environment-backed headers or bearer tokens, and declare the exact
|
||||
normalized endpoint host set in `capabilities.network_hosts` under
|
||||
`extensions["net.codewhale"]`. Never place credentials in the manifest.
|
||||
6. Commands (`commands/*.md`), agents (`agents/*.toml`), and hooks
|
||||
(`hooks/*.toml`), declared under `extensions["net.codewhale"]`, activate
|
||||
under the current policy — workspace bundles win same-name collisions over
|
||||
user and built-in bundles. LSP, filesystem roots, and lifecycle mutation
|
||||
are inventory-only: declare them only when inventorying future work. A
|
||||
`native` entry runs only under the experimental extension host; there it
|
||||
must be one `.mjs`, `.js` or `.mts` ES module file, `/plugin validate` rejects
|
||||
anything else, and its tools always use `Required` approval, never a
|
||||
plugin's read-only hint. Full Access, Bypass, or an exact session grant
|
||||
for the reviewed build can satisfy that gate without a prompt. A bundle
|
||||
that declares only unsupported surfaces cannot be enabled.
|
||||
7. Validate and review without executing bundle content:
|
||||
- `/plugin validate <plugin-name>`
|
||||
- `/plugin show <plugin-name>`
|
||||
- stop and present these results; the person runs `/plugin enable <plugin-name>`
|
||||
to open the content/capability review, reviews it, runs the exact
|
||||
`/plugin trust ...` confirmation shown, then enables the bundle
|
||||
8. Verify `/skills inspect` reports plugin provenance and `/plugin list`
|
||||
reports the expected trust and activation state. Trust stages the reviewed
|
||||
content but does not activate it. After enablement, follow the host's
|
||||
reload notice: use `/reload` or a new session to apply changes to a live
|
||||
session's pinned skills and tools.
|
||||
|
||||
Every user and workspace bundle starts untrusted and disabled. Reuse the
|
||||
existing `/plugin marketplace`, install, update, review and reload surfaces;
|
||||
do not add a parallel installer, registry or automatic trust flow. Catalog
|
||||
membership alone never installs, trusts or enables a plugin.
|
||||
|
||||
## Experimental host code
|
||||
|
||||
Only scaffold host code when the person explicitly uses the experimental
|
||||
extension-host feature. Start from the tested `hello-extension` example and
|
||||
`docs/EXTENSIONS.md` in the Codewhale repository. A typed `.mts` entry may use
|
||||
Node's erasable TypeScript syntax; bundle dependencies locally. Register tools
|
||||
with a plugin-specific prefix and an object input schema, propagate
|
||||
`exec.signal`, and use `ctx.effect` for bounded asynchronous cleanup. The
|
||||
current execution context exposes `signal`, `callId` and `args`; it does not
|
||||
expose the calling workspace path. Do not change the shared process cwd.
|
||||
|
||||
Stop after install, validate and show; never automate the trust token. A
|
||||
person reviews, trusts and enables the bundle. `/plugin show <name>` reports
|
||||
owner state, live tools and recent attributed diagnostics. Recovery may create
|
||||
fresh registrations, but never replays an interrupted tool call. Explain the
|
||||
shared-process and current platform sandbox limits without claiming isolation.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: plugin-creator
|
||||
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review.
|
||||
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and a trust review that goes through the session's approval.
|
||||
---
|
||||
|
||||
# Plugin Creator
|
||||
@@ -54,9 +54,24 @@ code) are inventory-only unless the user has turned on the experimental
|
||||
7. Validate and review without executing bundle content:
|
||||
- `/plugin validate <plugin-name>`
|
||||
- `/plugin show <plugin-name>`
|
||||
- stop and present these results; the person runs `/plugin enable <plugin-name>`
|
||||
- present these results; the person runs `/plugin enable <plugin-name>`
|
||||
to open the content/capability review, reviews it, runs the exact
|
||||
`/plugin trust ...` confirmation shown, then enables the bundle
|
||||
- exception: in Full Access, for a plugin you wrote yourself, you may
|
||||
trust and enable it without asking the person. Write it in an ordinary
|
||||
folder inside the current project (for example
|
||||
`<workspace>/plugins/<plugin-name>/`), not under `.codewhale/plugins/`
|
||||
as in step 1, because `codewhale install` copies it into place and
|
||||
refuses a name that is already in use. Then run, in this order:
|
||||
`codewhale install <folder>`, `codewhale plugins trust <name>` (it prints
|
||||
the review and a review token), `codewhale plugins trust <name>
|
||||
--review-token <token> --yes` with the token from your own run, and
|
||||
`codewhale plugins enable <name>`. These are shell commands, so the
|
||||
session's approval applies to them like any other (Ask asks the person,
|
||||
Auto-Review's reviewer judges, Full Access runs them). The trust is
|
||||
recorded as approved by the agent, for this project only. A bundle that
|
||||
was not installed this way from inside the project is refused. Never do
|
||||
this for a plugin you did not write.
|
||||
8. Verify `/skills inspect` reports plugin provenance and `/plugin list`
|
||||
reports the expected trust and activation state. Trust stages the reviewed
|
||||
content but does not activate it. After enablement, follow the host's
|
||||
@@ -65,22 +80,18 @@ code) are inventory-only unless the user has turned on the experimental
|
||||
|
||||
Every user and workspace bundle starts untrusted and disabled. Reuse the
|
||||
existing `/plugin marketplace`, install, update, review and reload surfaces;
|
||||
do not add a parallel installer, registry or automatic trust flow. Catalog
|
||||
membership alone never installs, trusts or enables a plugin.
|
||||
do not add a parallel installer or registry. Catalog membership alone never
|
||||
installs, trusts or enables a plugin.
|
||||
|
||||
## Experimental host code
|
||||
## Plugins with code or a screen
|
||||
|
||||
Only scaffold host code when the person explicitly uses the experimental
|
||||
extension-host feature. Start from the tested `hello-extension` example and
|
||||
`docs/EXTENSIONS.md` in the Codewhale repository. A typed `.mts` entry may use
|
||||
Node's erasable TypeScript syntax; bundle dependencies locally. Register tools
|
||||
with a plugin-specific prefix and an object input schema, propagate
|
||||
`exec.signal`, and use `ctx.effect` for bounded asynchronous cleanup. The
|
||||
current execution context exposes `signal`, `callId` and `args`; it does not
|
||||
expose the calling workspace path. Do not change the shared process cwd.
|
||||
|
||||
Stop after install, validate and show; never automate the trust token. A
|
||||
person reviews, trusts and enables the bundle. `/plugin show <name>` reports
|
||||
owner state, live tools and recent attributed diagnostics. Recovery may create
|
||||
fresh registrations, but never replays an interrupted tool call. Explain the
|
||||
shared-process and current platform sandbox limits without claiming isolation.
|
||||
For a plugin that runs code, adds tools of its own, or shows a screen, use the
|
||||
`cordis-plugin-development` skill when it is offered: it teaches the format
|
||||
that also loads in DeepSeek Harness. Host code runs only under the
|
||||
experimental `[features] extension_host` flag; start from the tested
|
||||
`hello-extension` example and `docs/EXTENSIONS.md` in the Codewhale repository,
|
||||
add a `plugin.json` beside the package for Codewhale's own kinds, and never
|
||||
change the shared process cwd. A person reviews and turns on a plugin as step 7
|
||||
says. `/plugin show <name>` reports owner state, live tools and recent
|
||||
attributed diagnostics. Explain the shared-process and current platform sandbox
|
||||
limits without claiming isolation.
|
||||
@@ -42,7 +42,7 @@ use sha2::{Digest, Sha256};
|
||||
/// person; the exact generation-16 body allows a safe upgrade.
|
||||
/// Generation 18 recognizes previously shipped bodies by SHA-256 as well as by
|
||||
/// retained bytes, so an untouched copy from any earlier generation upgrades.
|
||||
const BUNDLED_SKILL_VERSION: &str = "21";
|
||||
const BUNDLED_SKILL_VERSION: &str = "22";
|
||||
|
||||
mod dsh_creator;
|
||||
use dsh_creator::{BundledFile, CREATOR_SKILLS};
|
||||
@@ -176,6 +176,10 @@ const SUPERSEDED_BODIES: &[(&str, &str)] = &[
|
||||
"plugin-creator",
|
||||
include_str!("../assets/skills/plugin-creator/SKILL.generation-16.md"),
|
||||
),
|
||||
(
|
||||
"plugin-creator",
|
||||
include_str!("../assets/skills/plugin-creator/SKILL.generation-19.md"),
|
||||
),
|
||||
(
|
||||
"help",
|
||||
include_str!("../assets/skills/help/SKILL.generation-14.md"),
|
||||
|
||||
@@ -70,6 +70,7 @@ plugin-creator 935a64e72137a2420910a3365f8d4000038987fa0753648e9751dd79eae5cc30
|
||||
plugin-creator a22e9b25e4ba8eac43007899547bf4471e79a18d3b55f60df4cf073271529104
|
||||
plugin-creator b150b79446d876a2cd572aacd7be7712fd9914c340b51969a29186f85db4e18a
|
||||
plugin-creator c20a792055a4b5601e58b2fb721cd896a34f572a4acdabd1a706d46ce77eb639
|
||||
plugin-creator c761d6774b751a985ffed2894f595d8f871430b0a05a8e94bb7245399d811de7
|
||||
plugin-creator ce156ea9c6e2896b9c6c0da08f3d78c27bb793f685bcd2afbbffd712a5f64387
|
||||
plugin-creator e91aa6dfde62abd9548dda7a43bc097a82aa19385335117e86cb204edc9e294d
|
||||
presentations 2b6d20a00280f3de7814ebd8870e6e1af08c11bd115603b265fc31b9004f9d35
|
||||
|
||||
@@ -874,7 +874,8 @@ fn generation_17_refreshes_previous_plugin_creator_bodies() {
|
||||
.find(|skill| skill.name == "plugin-creator")
|
||||
.unwrap();
|
||||
assert!(skill.body.contains("Create `plugin.json`"));
|
||||
assert!(skill.body.contains("## Experimental host code"));
|
||||
assert!(skill.body.contains("cordis-plugin-development"));
|
||||
assert!(!skill.body.contains("never automate the trust token"));
|
||||
for (generation, old) in [
|
||||
(
|
||||
"15",
|
||||
@@ -884,9 +885,16 @@ fn generation_17_refreshes_previous_plugin_creator_bodies() {
|
||||
"16",
|
||||
include_str!("../../assets/skills/plugin-creator/SKILL.generation-16.md"),
|
||||
),
|
||||
(
|
||||
"19",
|
||||
include_str!("../../assets/skills/plugin-creator/SKILL.generation-19.md"),
|
||||
),
|
||||
] {
|
||||
assert!(is_superseded_shipped_body("plugin-creator", old));
|
||||
assert!(!old.contains("## Experimental host code"));
|
||||
assert_eq!(
|
||||
old.contains("## Experimental host code"),
|
||||
generation == "19"
|
||||
);
|
||||
for customized in [false, true] {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(skill_dir(&tmp, "plugin-creator")).unwrap();
|
||||
|
||||
@@ -1457,6 +1457,7 @@ impl SnapshotRepo {
|
||||
.arg(&self.git_dir)
|
||||
.arg("--work-tree")
|
||||
.arg(&self.work_tree)
|
||||
.args(["-c", "core.quotePath=false"])
|
||||
.args(args);
|
||||
let output = run_bounded_git(&mut cmd, subcommand)?;
|
||||
if !output.status.success() {
|
||||
@@ -1850,7 +1851,7 @@ impl SnapshotRepo {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| io_other(format!("clock error: {e}")))?
|
||||
.as_secs() as i64;
|
||||
let cutoff = now - max_age.as_secs() as i64;
|
||||
let cutoff = now.saturating_sub(i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX));
|
||||
|
||||
let snapshots = self.list(usize::MAX)?;
|
||||
if snapshots.is_empty() {
|
||||
@@ -3948,6 +3949,22 @@ mod tests {
|
||||
assert_eq!(list[0].label, "turn:1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_with_an_age_beyond_i64_keeps_every_snapshot() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (repo, _home) = make_repo(tmp.path());
|
||||
std::fs::write(repo.work_tree().join("f.txt"), "v0").unwrap();
|
||||
repo.snapshot("turn:0").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
|
||||
let removed = repo
|
||||
.prune_older_than(Duration::from_secs(u64::MAX))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(repo.list(10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// The 2026-08-04 regression: with a cut in the MIDDLE of history,
|
||||
/// `prune_older_than` used to `update-ref HEAD <oldest survivor>`, which
|
||||
/// orphaned (and gc destroyed) the NEWEST snapshots while keeping the
|
||||
@@ -4892,6 +4909,24 @@ mod tests {
|
||||
assert_eq!(none.patch, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_tree_changes_since_names_non_ascii_paths_as_written() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (repo, _home) = make_repo(tmp.path());
|
||||
let ws = repo.work_tree().to_path_buf();
|
||||
std::fs::write(ws.join("笔记 📝.md"), b"old\n").unwrap();
|
||||
let before = repo.snapshot("pre-turn:1").expect("snapshot");
|
||||
git_output(&repo, &["config", "core.quotePath", "true"]);
|
||||
std::fs::write(ws.join("笔记 📝.md"), b"new\n").unwrap();
|
||||
|
||||
let changes = repo
|
||||
.work_tree_changes_since(&before, 1 << 20)
|
||||
.expect("changes");
|
||||
assert_eq!(changes.names, "笔记 📝.md\n");
|
||||
assert!(changes.stat.contains("笔记 📝.md"), "{}", changes.stat);
|
||||
assert!(changes.patch.contains("b/笔记 📝.md"), "{}", changes.patch);
|
||||
}
|
||||
|
||||
/// A tree the repo no longer holds — the receipt outlived the object —
|
||||
/// is reported as absent rather than as a diff failure, so a caller can
|
||||
/// tell pruning from a broken repo without reading git's stderr.
|
||||
|
||||
+37
-2
@@ -11,7 +11,7 @@ mod runtime_aliases;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex, MutexGuard};
|
||||
|
||||
@@ -1453,6 +1453,7 @@ impl StateStore {
|
||||
self.with_session_index_lock(|| {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.append(true)
|
||||
.open(&self.session_index_path)
|
||||
.with_context(|| {
|
||||
@@ -1461,7 +1462,14 @@ impl StateStore {
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
writeln!(file, "{encoded}").context("failed to append session index entry")?;
|
||||
let mut last = *b"\n";
|
||||
if file.metadata()?.len() > 0 {
|
||||
file.seek(SeekFrom::End(-1))?;
|
||||
file.read_exact(&mut last)?;
|
||||
}
|
||||
let separator = if last[0] == b'\n' { "" } else { "\n" };
|
||||
writeln!(file, "{separator}{encoded}")
|
||||
.context("failed to append session index entry")?;
|
||||
// Durability: without this a crash mid-write can leave a torn
|
||||
// final line. Reads tolerate one (see `session_index_map`), but
|
||||
// not losing the entry beats recovering from having lost it.
|
||||
@@ -2922,6 +2930,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_index_append_after_an_unterminated_torn_line_is_kept() {
|
||||
let store = temp_state_store("session-index-unterminated");
|
||||
store
|
||||
.append_thread_name("thread-1", Some("first".to_string()), 1, None)
|
||||
.expect("append first entry");
|
||||
{
|
||||
let mut file = OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&store.session_index_path)
|
||||
.expect("open session index");
|
||||
write!(file, "{{\"thread_id\":\"thread-2\",\"thread_na").expect("write torn line");
|
||||
}
|
||||
|
||||
store
|
||||
.append_thread_name("thread-3", Some("third".to_string()), 3, None)
|
||||
.expect("append third entry");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.find_thread_name_by_id("thread-3")
|
||||
.expect("lookup thread-3")
|
||||
.as_deref(),
|
||||
Some("third"),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hook fired by compaction between writing the snapshot and renaming it
|
||||
/// over the live index — the window a concurrent append can be lost in.
|
||||
/// Only the store whose path a test registered is affected, so tests
|
||||
|
||||
+17
-1
@@ -7,7 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
|
||||
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
|
||||
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
|
||||
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
|
||||
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
|
||||
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
|
||||
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
|
||||
- **Paused stream:** A paused stream now shows the thinking it has already received instead of waiting for the next token.
|
||||
- **Sign-in notice:** The "Account not loaded" notice now appears once per sign-in in the footer, and Esc dismisses it; it no longer lands in the transcript.
|
||||
- **Usage reporting:** Installers now run their closing diagnostic with usage reporting and update checks off and print how to turn usage reporting off. `codewhale doctor` now says a saved opt-out comes from the config file, not the environment, and help and English notices call the feature "usage reporting".
|
||||
- **Long tool names:** Tools whose encoded name is longer than the route's limit are now left out with a visible warning instead of failing every request with a 400. The limit is 64 characters on OpenAI and on other OpenAI-compatible providers that document no larger limit, and 128 on Anthropic-style routes, DeepSeek, Ollama, Ollama Cloud, vLLM, SGLang and custom routes. Forcing such a tool gives an error that names the tool and the limit, and a session that already called one on a route with a documented limit (Anthropic, OpenAI, Codex, DeepSeek) is refused with a message saying to switch route or start a new session. A forced tool_choice in any chat form now names the encoded tool, and a tool whose real name looks like an encoded escape can no longer be mistaken for another tool.
|
||||
- **Start after a hard kill:** The Engine now also starts after a hard kill when its home was spelled through a symbolic link (for example under /tmp on macOS) or, on macOS and Windows, in a different letter case: a dead owner's record for the deleted data directory is discarded instead of refusing every start.
|
||||
- **Context window labels:** the model picker and fleet capability badges now show `1.5M` and `2.1M` for 1,500,000- and 2,100,000-token windows instead of `1.50M` and `2.10M`. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7295).
|
||||
- **REPL output limit:** REPL output is now cut to its byte limit, so Chinese, Japanese, Korean or emoji output no longer comes back up to three times longer than the limit, and the cut never splits a character. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7296).
|
||||
- **Git status with renamed files:** the Git and Changes views no longer fail with "git status failed" or show a phantom row when a file is renamed on the worktree side (for example a moved file marked with `git add -N`), including when the original name starts with non-ASCII text such as Chinese. Thanks [@luochen211](https://github.com/luochen211) (PR 7310).
|
||||
@@ -22,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Simplified Chinese punctuation and /status:** in the zh-Hans interface, the `/router` setup and test screens, label prompts and usage lines now use full-width punctuation (`:;,()`) instead of ASCII marks next to Chinese text, and `/status` values start in one column under Chinese labels instead of starting at a different column for each label. Thanks [@luochen211](https://github.com/luochen211) (PR 7314).
|
||||
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
|
||||
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
|
||||
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
|
||||
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` or `managed_config_path` is applied on every load, and one that is missing or cannot be read stops the load instead of loading with no managed layer; a blank `CODEWHALE_MANAGED_CONFIG_PATH` is now ignored instead of switching the managed layer off, and a config file's `managed_config_path` can no longer point away from an existing file at the default managed path. Fleet local workers inherit the managed-config variable, and `codewhale serve --mcp` does not expose shell tools when the managed configuration it loads sets `tool_launcher`. A configuration whose provider is declared only by a plugin fails to load under a managed configuration, with an error that says to declare the provider table or pick a built-in provider.
|
||||
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
|
||||
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
|
||||
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
|
||||
|
||||
@@ -41,6 +41,7 @@ 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,
|
||||
@@ -100,6 +101,7 @@ pub(crate) fn app_action_to_config_action(action: AppAction) -> Option<ConfigAct
|
||||
AppAction::StartOrcarouterRevoke => ConfigAction::StartOrcarouterRevoke,
|
||||
AppAction::OpenConfigView => ConfigAction::OpenConfigView,
|
||||
AppAction::OpenModePicker => ConfigAction::OpenModePicker,
|
||||
AppAction::SetConversationMode { id } => ConfigAction::SetConversationMode { id },
|
||||
AppAction::OpenThemePicker => ConfigAction::OpenThemePicker,
|
||||
AppAction::OpenStatusPicker => ConfigAction::OpenStatusPicker,
|
||||
AppAction::OpenProviderPicker => ConfigAction::OpenProviderPicker,
|
||||
|
||||
@@ -438,6 +438,7 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: to_command_mode(app.mode),
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -734,6 +735,7 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -796,6 +798,7 @@ 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);
|
||||
@@ -834,12 +837,36 @@ 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) -> Result<SessionNewReceipt, String> {
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> 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() {
|
||||
@@ -871,6 +898,7 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode,
|
||||
agent_preset,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1723,6 +1751,7 @@ fn import_session_container(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: to_command_mode(app.mode),
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -7433,7 +7462,9 @@ 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).expect_err("busy work state");
|
||||
let err = facet
|
||||
.fresh_session(true, None)
|
||||
.expect_err("busy work state");
|
||||
assert!(err.contains("Work state is busy"), "{err}");
|
||||
}
|
||||
assert_eq!(app.api_messages.len(), 1);
|
||||
@@ -7452,7 +7483,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).expect_err("blocker text");
|
||||
let err = facet.fresh_session(false, None).expect_err("blocker text");
|
||||
assert!(err.contains("/new --force"), "{err}");
|
||||
}
|
||||
assert_eq!(app.input, "draft text");
|
||||
@@ -7463,7 +7494,7 @@ mod tests {
|
||||
let mut parts = bundle.parts();
|
||||
let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
|
||||
let ok = facet
|
||||
.fresh_session(true)
|
||||
.fresh_session(true, None)
|
||||
.expect("force prepares a new session");
|
||||
assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
|
||||
assert_eq!(app.input, "draft text");
|
||||
|
||||
@@ -157,7 +157,8 @@ fn session_diff(app: &App) -> std::io::Result<Option<DebugDiffObservation>> {
|
||||
/// The workspace repository's own uncommitted changes: what `/diff` shows
|
||||
/// before the session has a restore point to compare against.
|
||||
fn workspace_git_diff(workspace: &std::path::Path) -> DebugDiffObservation {
|
||||
let git = |args: &[&str]| Git::output(args, workspace);
|
||||
let git =
|
||||
|args: &[&str]| Git::output(&[&["-c", "core.quotePath=false"], args].concat(), workspace);
|
||||
// Outside a repository `git diff` prints nothing on stdout, which would
|
||||
// read as "no changes".
|
||||
match git(&["rev-parse", "--is-inside-work-tree"]) {
|
||||
@@ -214,6 +215,7 @@ fn session_sync_payload(app: &App) -> SessionSyncPayload {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: super::to_command_mode(app.mode),
|
||||
agent_preset: app.agent_preset(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ fn sync_session(sync: codewhale_command_contract::facets::SessionSyncPayload) ->
|
||||
model: sync.model,
|
||||
workspace: sync.workspace,
|
||||
mode: super::contract::from_command_mode(sync.mode),
|
||||
agent_preset: sync.agent_preset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -608,12 +608,26 @@ 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]";
|
||||
pub(super) const MODE_USAGE: &str = "/mode [work|plan|1|2|<conversation mode>]";
|
||||
|
||||
/// `/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,6 +58,7 @@ pub fn clear(app: &mut App) -> CommandResult {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -260,16 +260,30 @@ 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,
|
||||
@@ -313,8 +327,21 @@ 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,6 +32,7 @@ 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)>,
|
||||
@@ -58,6 +59,7 @@ 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(),
|
||||
@@ -74,6 +76,7 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +110,13 @@ impl CommandSessionLifecycleContext for CannedLifecycle {
|
||||
self.fork_sources.push(id.to_string());
|
||||
self.fork_from.clone()
|
||||
}
|
||||
fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> Result<SessionNewReceipt, String> {
|
||||
self.fresh_forces.push(force);
|
||||
self.fresh_modes.push(conversation_mode.map(str::to_string));
|
||||
self.fresh.clone()
|
||||
}
|
||||
fn load_session(&mut self, path: &str) -> Result<PathBuf, String> {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(in crate::commands) struct NewCmd;
|
||||
pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo {
|
||||
name: "new",
|
||||
aliases: &[],
|
||||
usage: "/new [--force]",
|
||||
usage: "/new [<mode>] [--force]",
|
||||
description_key: "cmd_new_description",
|
||||
};
|
||||
|
||||
@@ -51,15 +51,21 @@ pub(in crate::commands) fn new_portable(
|
||||
lifecycle: &mut dyn CommandSessionLifecycleContext,
|
||||
arg: Option<&str>,
|
||||
) -> CommandResult {
|
||||
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}"
|
||||
));
|
||||
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}"
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
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
|
||||
@@ -69,7 +75,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) {
|
||||
match lifecycle.fresh_session(force, conversation_mode) {
|
||||
Ok(receipt) => CommandResult::action(
|
||||
codewhale_command_contract::outcome::SessionAction::NewSession {
|
||||
sync: receipt.sync,
|
||||
|
||||
@@ -94,6 +94,7 @@ 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,6 +77,7 @@ 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");
|
||||
@@ -1141,3 +1142,131 @@ 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());
|
||||
}
|
||||
+103
-1
@@ -129,6 +129,10 @@ 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 {
|
||||
@@ -473,6 +477,10 @@ 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,
|
||||
@@ -3010,6 +3018,19 @@ 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()
|
||||
@@ -3141,6 +3162,7 @@ async fn run_async_main_dispatch(
|
||||
args.hooks,
|
||||
std::sync::Arc::clone(&plugin_registry),
|
||||
!tool_surface_requested,
|
||||
exec_conversation_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -3216,6 +3238,7 @@ async fn run_async_main_dispatch(
|
||||
workspace,
|
||||
skills_dir,
|
||||
skills_config,
|
||||
config.tool_launcher.is_some(),
|
||||
)
|
||||
.await
|
||||
} else if http_selected {
|
||||
@@ -3303,6 +3326,7 @@ 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,
|
||||
@@ -7867,7 +7891,7 @@ fn doctor_control_socket_posture_line(config: &Config) -> String {
|
||||
/// Report the durable opt-out and environment preference without arming or
|
||||
/// touching telemetry state. Notice presentation is not a permission gate.
|
||||
fn doctor_runtime_telemetry(config: &Config) -> (bool, &'static str) {
|
||||
let (on, source) = codewhale_config::resolved_telemetry_consent(config.telemetry);
|
||||
let (on, source) = codewhale_config::dispatched_telemetry_consent(config.telemetry);
|
||||
if !on {
|
||||
return (false, source.as_str());
|
||||
}
|
||||
@@ -14502,6 +14526,7 @@ 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,
|
||||
@@ -14526,6 +14551,28 @@ 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,
|
||||
@@ -14584,6 +14631,32 @@ 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 {
|
||||
@@ -14731,6 +14804,7 @@ async fn run_interactive_prepared(
|
||||
},
|
||||
plugin_registry,
|
||||
pending_telemetry_notice,
|
||||
launch_conversation_mode,
|
||||
))
|
||||
.await
|
||||
}
|
||||
@@ -16558,6 +16632,8 @@ mod doctor_setup_state_tests {
|
||||
let _telemetry_alias_env = crate::test_support::EnvVarGuard::remove("DEEPSEEK_TELEMETRY");
|
||||
let _telemetry_floor =
|
||||
crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_FLOOR");
|
||||
let _telemetry_source =
|
||||
crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_SOURCE");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
|
||||
@@ -16608,6 +16684,32 @@ mod doctor_setup_state_tests {
|
||||
let report = doctor_setup_report_json(&config, &workspace);
|
||||
assert_eq!(report["runtime_posture"]["telemetry"]["value"], false);
|
||||
assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
|
||||
|
||||
{
|
||||
let _handed_down =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY", "false");
|
||||
let _floor = crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_FLOOR", "0");
|
||||
let _source =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_SOURCE", "config");
|
||||
let line = doctor_runtime_posture_line(&config, &workspace);
|
||||
assert!(
|
||||
line.contains("telemetry=off (config)"),
|
||||
"a saved opt-out the dispatcher handed down is not the environment: {line}"
|
||||
);
|
||||
let report = doctor_setup_report_json(&config, &workspace);
|
||||
assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
|
||||
}
|
||||
{
|
||||
let _by_hand = crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY", "0");
|
||||
let _floor = crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_FLOOR", "1");
|
||||
let _source =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_SOURCE", "env");
|
||||
let line = doctor_runtime_posture_line(&config, &workspace);
|
||||
assert!(
|
||||
line.contains("telemetry=off (env)"),
|
||||
"a kill switch the person set is the environment: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -304,6 +304,106 @@ fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSess
|
||||
saved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_conversation_mode_flag_sets_a_new_session_mode() {
|
||||
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
|
||||
let cli = parse_cli(&["codewhale", "exec", "--conversation-mode", "creator", "hi"]);
|
||||
let Some(Commands::Exec(args)) = cli.command else {
|
||||
panic!("exec command");
|
||||
};
|
||||
assert_eq!(args.conversation_mode.as_deref(), Some("creator"));
|
||||
let none = parse_cli(&["codewhale", "exec", "hi"]);
|
||||
let Some(Commands::Exec(args_none)) = none.command else {
|
||||
panic!("exec command");
|
||||
};
|
||||
assert_eq!(args_none.conversation_mode, None);
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let config = codewhale_runtime::config::Config::default();
|
||||
let locale = codewhale_localization::Locale::En;
|
||||
let resolve = |flag: Option<&str>| {
|
||||
runtime.block_on(tui::conversation_mode::launch_conversation_mode(
|
||||
&config, locale, flag, None,
|
||||
))
|
||||
};
|
||||
{
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
assert_eq!(resolve(Some("creator")), Ok(Some("creator".to_string())));
|
||||
assert_eq!(resolve(Some("standard")), Ok(None));
|
||||
assert_eq!(resolve(None), Ok(None));
|
||||
let unknown = resolve(Some("minimal")).expect_err("unknown id");
|
||||
assert!(unknown.contains("standard, creator"), "{unknown}");
|
||||
}
|
||||
{
|
||||
let _unavailable = CreatorAvailabilityGuard::set(Some(
|
||||
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON,
|
||||
));
|
||||
let refused = resolve(Some("creator")).expect_err("unavailable");
|
||||
assert!(refused.contains("Not ready yet in this version."), "{refused}");
|
||||
}
|
||||
|
||||
let mut saved = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
saved.agent_preset = resolve_stored_for_test(&runtime, &config, "creator");
|
||||
let raw = serde_json::to_value(&saved).unwrap();
|
||||
assert_eq!(raw["agent_preset"], "creator");
|
||||
let standard = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
assert!(serde_json::to_value(&standard).unwrap().get("agent_preset").is_none());
|
||||
}
|
||||
|
||||
fn resolve_stored_for_test(
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
config: &codewhale_runtime::config::Config,
|
||||
flag: &str,
|
||||
) -> Option<String> {
|
||||
let _available = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(None);
|
||||
runtime
|
||||
.block_on(tui::conversation_mode::launch_conversation_mode(
|
||||
config,
|
||||
codewhale_localization::Locale::En,
|
||||
Some(flag),
|
||||
None,
|
||||
))
|
||||
.expect("resolves")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_conversation_mode_flag_that_differs_from_a_resumed_session_is_refused_before_any_model_call() {
|
||||
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let config = codewhale_runtime::config::Config::default();
|
||||
let locale = codewhale_localization::Locale::En;
|
||||
let resolve = |flag: &str, saved: &session_manager::SavedSession| {
|
||||
runtime.block_on(tui::conversation_mode::launch_conversation_mode(
|
||||
&config,
|
||||
locale,
|
||||
Some(flag),
|
||||
Some(saved),
|
||||
))
|
||||
};
|
||||
let _unavailable = CreatorAvailabilityGuard::set(Some(
|
||||
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON,
|
||||
));
|
||||
let standard = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
let mut creator = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
creator.agent_preset = Some("creator".to_string());
|
||||
|
||||
let refused = resolve("creator", &standard).expect_err("Standard session, creator flag");
|
||||
assert!(refused.contains("fixed") && refused.contains("/new creator"), "{refused}");
|
||||
let refused = resolve("standard", &creator).expect_err("creator session, standard flag");
|
||||
assert!(refused.contains("fixed") && refused.contains("/new"), "{refused}");
|
||||
assert!(resolve("minimal", &creator).is_err());
|
||||
assert_eq!(resolve("standard", &standard), Ok(None));
|
||||
assert_eq!(
|
||||
resolve("creator", &creator),
|
||||
Ok(Some("creator".to_string())),
|
||||
"a matching flag resumes even while the mode is unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
|
||||
let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
|
||||
|
||||
@@ -2277,6 +2277,8 @@ 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>,
|
||||
@@ -5590,6 +5592,37 @@ impl App {
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn agent_preset(&self) -> Option<String> {
|
||||
self.conversation_mode.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn conversation_mode_locked(&self) -> bool {
|
||||
!self.api_messages.is_empty()
|
||||
|| self.is_loading
|
||||
|| !self.queued_messages.is_empty()
|
||||
|| self.queued_draft.is_some()
|
||||
|| self.pending_fresh_session.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn install_conversation_mode(
|
||||
&mut self,
|
||||
stored: Option<String>,
|
||||
unavailable: Option<String>,
|
||||
) {
|
||||
self.conversation_mode = stored;
|
||||
self.conversation_mode_unavailable = unavailable;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub(crate) fn conversation_mode_label(&self) -> Option<String> {
|
||||
crate::tui::conversation_mode::label(
|
||||
self.ui_locale,
|
||||
self.conversation_mode.as_deref(),
|
||||
self.conversation_mode_unavailable.as_deref(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn set_current_session_id(&mut self, session_id: Option<String>) {
|
||||
if self.current_session_id != session_id {
|
||||
self.view_stack.remove_owned_evidence_pagers();
|
||||
|
||||
@@ -1062,6 +1062,8 @@ impl App {
|
||||
backtrack: crate::tui::backtrack::BacktrackState::new(),
|
||||
undo_selection: false,
|
||||
current_session_id: None,
|
||||
conversation_mode: None,
|
||||
conversation_mode_unavailable: None,
|
||||
session_write_receipt: None,
|
||||
session_write_owner: None,
|
||||
offline_queue_lease: None,
|
||||
|
||||
@@ -559,6 +559,10 @@ 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
|
||||
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user