mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 20:08:04 -10:00
Compare commits
No files matched your search
@@ -75,6 +75,7 @@ security boundaries and adds bridge-specific controls.
|
||||
- **Token gate**: All runtime API calls carry `CODEWHALE_RUNTIME_TOKEN`
|
||||
- **Chat allowlist**: Only chats/users listed in `WECOM_CHAT_ALLOWLIST` can interact. First-pairing mode (`WECOM_ALLOW_UNLISTED=true`) is meant for onboarding only
|
||||
- **Approval required**: Tool calls from WeCom sessions must be approved — either via explicit `/allow <id>` commands or natural-language keywords (`允许`, `yes`, `ok`, etc.)
|
||||
- **Thread ownership**: `/threads` and `/resume` cover only the threads the chat itself created; any other thread ID is refused with one fixed reply
|
||||
- **No workspace exposure**: Only prompts, status summaries, and approval requests are sent to WeCom. Workspace contents, shell output, and runtime internals stay on the local machine
|
||||
|
||||
### Reporting WeCom Bridge vulnerabilities
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-java@v6.1.0
|
||||
- uses: actions/setup-java@v6.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
mirror: true
|
||||
- name: Install Java for the OpenHarmony binary signer
|
||||
if: matrix.platform == 'ohos-arm64'
|
||||
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v4
|
||||
uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
@@ -13,8 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Usage-reporting collector:** accepts runtime schema 6 alongside existing versions and keeps schema 6 reports first-party only.
|
||||
|
||||
- **Source-build toolchain:** Rust 1.100.0 is pinned consistently across local source builds, CI, Docker, Dev Containers and CNB recipes. The minimum supported Rust version remains 1.89.
|
||||
- **Terminal input recovery:** retains a wake event from a replaced reader, suppresses retired events observed during a child-terminal handoff, and closes only the handoff window owned by that scope.
|
||||
|
||||
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
|
||||
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
|
||||
- **Delayed-message workspaces and shell stdin approvals:** `send_later` `schedule` now keeps its workspace inside the session's reachable paths instead of accepting an arbitrary directory, and `send_later` approval prompts show the requested workspace; session shell grants now match exact inputs when `stdin` (`stdin`, `input`, `data`) is supplied instead of reusing a command-only grant across different `stdin`.
|
||||
|
||||
- **Installers no longer say Computer Use comes bundled:** the macOS install script and the install guide no longer print or state that Computer Use ships with the binary or needs Node.js 20. Computer Use is released separately and stays off until you turn it on.
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and every value is also in words, so the model and other clients get the same facts as text. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and a plain-text summary accompanies each chart for the model and other clients. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Terminal widgets:** narrow action hints wrap, scrollbars reach the final row, progress bars show fractional cells, focused tabs and panels are clearer, and the shared terminal kit supports measured ocean themes and reduced-motion-aware water drift.
|
||||
|
||||
- **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).
|
||||
@@ -28,8 +36,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
|
||||
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
|
||||
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
|
||||
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
|
||||
- **Chat bridges thread ownership:** Chat bridges now list and resume only the threads the chat itself created: `/threads` shows owned threads, `/resume` refuses any other thread id with one fixed message, and `/new` always starts a new thread. In an existing thread map each chat keeps only the thread it is bound to, so threads it created earlier and left are no longer listed or resumable; a thread that several chats already pointed at is usable from none of them (send `/new`). Review `thread-map.json` after upgrading.
|
||||
- **Skill and plugin installs:** skill and plugin installs now enforce the uncompressed size cap on the whole decompressed archive, and reject archive entries with names longer than 4096 bytes.
|
||||
|
||||
- **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.
|
||||
- **Website `/app` captures:** the `/app` page's desktop build screenshots (`conversation.webp`, `approval.webp`, `model-picker.webp`) and their alt and caption text have been refreshed from the current 0.10.3 desktop UI.
|
||||
- **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.
|
||||
@@ -110,6 +123,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Child processes now inherit only the standard locale variables (`LC_ALL`, `LC_CTYPE` and the other locale categories, plus `LC_TERMINAL` and `LC_TERMINAL_VERSION`) and a listed set of `DOTNET_` host, SDK and runtime settings. Other `DOTNET_` variables, including application configuration keys, are no longer passed to child processes or MCP servers.
|
||||
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
|
||||
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
|
||||
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
|
||||
|
||||
Generated
+1
@@ -1196,6 +1196,7 @@ dependencies = [
|
||||
"codewhale-paths",
|
||||
"codewhale-plugins",
|
||||
"codewhale-protocol",
|
||||
"codewhale-ratatui",
|
||||
"codewhale-release",
|
||||
"codewhale-repl",
|
||||
"codewhale-runtime",
|
||||
|
||||
@@ -119,12 +119,9 @@ Connect [MCP servers](docs/MCP.md), add [skills](docs/SKILLS.md) and
|
||||
[plugins](docs/PLUGINS.md), and run [hooks](docs/HOOKS.md) on session and tool
|
||||
events. Code mode (experimental, on by default) lets the model write one
|
||||
`execute_tools` program that calls several MCP and plugin tools; each call
|
||||
still meets your approval rules and keeps its receipt. The Computer Use plugin
|
||||
bundled with v0.10.2 (experimental, macOS only) can observe and operate other
|
||||
apps, and it stays off until you review its access and enable it
|
||||
([guide](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)).
|
||||
Coming in 0.10.3: the plugin is released
|
||||
[separately](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
still meets your approval rules and keeps its receipt. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin)
|
||||
is a separate plugin released on its own (experimental, macOS only), and stays
|
||||
off until you review its access and turn it on.
|
||||
|
||||
### Ways to run it
|
||||
|
||||
|
||||
@@ -605,7 +605,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
def(
|
||||
"show_thinking",
|
||||
SettingKind::Bool(SHOW_THINKING),
|
||||
"false",
|
||||
"true",
|
||||
ui(
|
||||
TAB_APPEARANCE,
|
||||
"display",
|
||||
@@ -1546,6 +1546,12 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"false",
|
||||
None,
|
||||
),
|
||||
def(
|
||||
"account_preferences_notice_shown",
|
||||
SettingKind::String,
|
||||
"",
|
||||
None,
|
||||
),
|
||||
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
|
||||
def(
|
||||
"work_surface_bottom_migrated",
|
||||
|
||||
@@ -171,6 +171,7 @@ sha2.workspace = true
|
||||
# Reuse the locked cryptographic implementation for exact-call user decisions.
|
||||
ring = "0.17"
|
||||
codewhale-localization = { path = "../localization", version = "0.10.3" }
|
||||
codewhale-ratatui = { path = "../../vendor/codewhale-ratatui", version = "0.2.0" }
|
||||
shell-words = "1.1.1"
|
||||
|
||||
wiremock = { version = "0.6", optional = true }
|
||||
|
||||
@@ -110,6 +110,7 @@ impl Engine {
|
||||
// the ToolContext carries ShellPolicy::None.
|
||||
.with_file_tools()
|
||||
.with_present_tool()
|
||||
.with_show_data_tool()
|
||||
// Foreground-only shell registration: never add terminal/*
|
||||
// lifecycle tools to Plan merely to keep the `bash` identity
|
||||
// stable across modes.
|
||||
|
||||
@@ -244,22 +244,38 @@ pub async fn capture(
|
||||
if let Some(snapshot) = supplied {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
// Unit tests never read an operator's account credentials. Transport tests
|
||||
// exercise load_from with an explicitly isolated store instead.
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
if let Some(snapshot) = load(profile).await? {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
{
|
||||
let _ = profile;
|
||||
if let Some(message) = TEST_ACCOUNT_LOAD_FAILURE.get() {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
if !account_api_base_is_loopback() {
|
||||
return Ok(crate::prompts::load_user_constitution_block());
|
||||
}
|
||||
}
|
||||
if let Some(snapshot) = load(profile).await? {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
Ok(crate::prompts::load_user_constitution_block())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn account_api_base_is_loopback() -> bool {
|
||||
std::env::var(codewhale_secrets::account::ACCOUNT_API_BASE_ENV)
|
||||
.ok()
|
||||
.and_then(|value| normalize_runtime_account_api_base(&value))
|
||||
.and_then(|base| reqwest::Url::parse(&base).ok())
|
||||
.and_then(|url| url.host_str().map(str::to_owned))
|
||||
.is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_loopback())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
static TEST_ACCOUNT_LOAD_FAILURE: std::cell::Cell<Option<&'static str>> =
|
||||
|
||||
@@ -82,13 +82,7 @@ pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> Approva
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "exec_wait"
|
||||
| "exec_interact" => {
|
||||
let mut execution_input = input.clone();
|
||||
if let Some(fields) = execution_input.as_object_mut() {
|
||||
fields.remove("description");
|
||||
}
|
||||
format!("shell:{tool_name}:{}", hash_json_value(&execution_input))
|
||||
}
|
||||
| "exec_interact" => exact_shell_call_key(tool_name, input),
|
||||
"fetch_url" | "web.fetch" | "web_fetch" => {
|
||||
let host = parse_host(input);
|
||||
format!("net:{host}")
|
||||
@@ -113,6 +107,9 @@ pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -
|
||||
let paths_hash = hash_patch_paths(input);
|
||||
format!("patch:{paths_hash}")
|
||||
}
|
||||
"exec_shell" | "task_shell_start" if shell_call_carries_stdin(input) => {
|
||||
exact_shell_call_key(tool_name, input)
|
||||
}
|
||||
"exec_shell" | "task_shell_start" => shell_command_grant_scope(input),
|
||||
// Interact and wait calls carry no command, only input for a live
|
||||
// session. Keying them on the (empty) command prefix gave every one
|
||||
@@ -447,6 +444,20 @@ pub fn computer_use_batch_hidden_gate(tool_name: &str, input: &Value) -> Option<
|
||||
})
|
||||
}
|
||||
|
||||
fn exact_shell_call_key(tool_name: &str, input: &serde_json::Value) -> String {
|
||||
let mut execution_input = input.clone();
|
||||
if let Some(fields) = execution_input.as_object_mut() {
|
||||
fields.remove("description");
|
||||
}
|
||||
format!("shell:{tool_name}:{}", hash_json_value(&execution_input))
|
||||
}
|
||||
|
||||
fn shell_call_carries_stdin(input: &serde_json::Value) -> bool {
|
||||
["stdin", "input", "data"]
|
||||
.iter()
|
||||
.any(|key| input.get(*key).is_some_and(|value| !value.is_null()))
|
||||
}
|
||||
|
||||
/// The session-grant scope for a shell command.
|
||||
///
|
||||
/// A simple command whose family is in the arity dictionary keeps the
|
||||
@@ -1021,6 +1032,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_run_grant_does_not_cover_a_different_stdin() {
|
||||
for tool in ["Bash", "bash", "exec_shell", "task_shell_start"] {
|
||||
let plain = json!({"command": "python3 -"});
|
||||
let hello = json!({"command": "python3 -", "stdin": "print(1)"});
|
||||
let other = json!({"command": "python3 -", "stdin": "print(2)"});
|
||||
let granted = build_approval_grouping_key(tool, &plain);
|
||||
let with_stdin = build_approval_grouping_key(tool, &hello);
|
||||
assert_ne!(granted, with_stdin, "{tool}: stdin is part of what runs");
|
||||
assert_ne!(
|
||||
with_stdin,
|
||||
build_approval_grouping_key(tool, &other),
|
||||
"{tool}: different stdin must not share a grant"
|
||||
);
|
||||
assert_eq!(
|
||||
with_stdin,
|
||||
build_approval_grouping_key(tool, &hello),
|
||||
"{tool}: the same call keeps its grant"
|
||||
);
|
||||
for alias in ["input", "data"] {
|
||||
let aliased = json!({"command": "python3 -", alias: "print(2)"});
|
||||
assert_ne!(
|
||||
granted,
|
||||
build_approval_grouping_key(tool, &aliased),
|
||||
"{tool}: `{alias}` carries stdin too"
|
||||
);
|
||||
assert_ne!(
|
||||
with_stdin,
|
||||
build_approval_grouping_key(tool, &aliased),
|
||||
"{tool}: `{alias}` input is not the `stdin` input of another call"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
granted,
|
||||
build_approval_grouping_key(tool, &json!({"command": "python3 -", "stdin": null})),
|
||||
"{tool}: a null stdin is no stdin"
|
||||
);
|
||||
}
|
||||
let family_plain = build_approval_grouping_key("Bash", &json!({"command": "git status"}));
|
||||
let family_stdin =
|
||||
build_approval_grouping_key("Bash", &json!({"command": "git status", "stdin": "x"}));
|
||||
assert_ne!(family_plain, family_stdin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_key_grants_mcp_tools_as_reviewed_kinds() {
|
||||
// A session grant for a reviewed plugin MCP tool is the kind
|
||||
|
||||
@@ -165,7 +165,7 @@ fn generic_summary(locale: Locale, name: &str, input: &Value, workspace: Option<
|
||||
fn is_delegated_work_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"task_create" | "automation_create" | "automation_update"
|
||||
"task_create" | "automation_create" | "automation_update" | "send_later"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -728,6 +728,13 @@ mod tests {
|
||||
assert!(summary.contains("Auto-approve: on"), "{summary}");
|
||||
assert!(summary.contains("Workspace: /x"), "{summary}");
|
||||
|
||||
let summary = approval_summary(
|
||||
"send_later",
|
||||
&json!({"action": "schedule", "delay_minutes": 5, "message": "m", "workspace": "/elsewhere"}),
|
||||
None,
|
||||
);
|
||||
assert!(summary.contains("Workspace: /elsewhere"), "{summary}");
|
||||
|
||||
let zh = approval_summary_in(
|
||||
Locale::ZhHans,
|
||||
"automation",
|
||||
|
||||
@@ -76,6 +76,7 @@ pub mod send_later;
|
||||
pub mod session;
|
||||
pub mod shell;
|
||||
pub mod shell_output;
|
||||
pub mod show_data;
|
||||
pub mod skill;
|
||||
pub mod spec;
|
||||
pub mod speech;
|
||||
|
||||
@@ -1043,6 +1043,11 @@ impl ToolRegistryBuilder {
|
||||
self.with_tool(Arc::new(PresentTool))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_show_data_tool(self) -> Self {
|
||||
self.with_tool(Arc::new(super::show_data::ShowDataTool))
|
||||
}
|
||||
|
||||
/// Include shell execution tools.
|
||||
///
|
||||
/// New turns expose lowercase `bash`; uppercase `Bash` remains a hidden
|
||||
@@ -1540,6 +1545,7 @@ impl ToolRegistryBuilder {
|
||||
let builder = self
|
||||
.with_file_tools()
|
||||
.with_present_tool()
|
||||
.with_show_data_tool()
|
||||
.with_note_tool()
|
||||
.with_search_tools()
|
||||
.with_user_input_tool(user_input_limits)
|
||||
|
||||
@@ -1032,6 +1032,7 @@ fn machine_verifier_catalog_and_dispatch_add_only_bounded_run() {
|
||||
"read_media",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"show_data",
|
||||
"todo_write",
|
||||
"tui_help",
|
||||
"validate_data",
|
||||
@@ -1915,6 +1916,7 @@ fn machine_readonly_catalog_is_exactly_the_evidence_profile() {
|
||||
"read_media",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"show_data",
|
||||
"todo_write",
|
||||
"tui_help",
|
||||
"validate_data",
|
||||
|
||||
@@ -294,16 +294,17 @@ async fn execute_schedule(input: &Value, context: &ToolContext) -> Result<ToolRe
|
||||
})?,
|
||||
};
|
||||
|
||||
let workspace: Option<PathBuf> = optional_str(input, "workspace")?
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
let workspace: Option<PathBuf> = match optional_str(input, "workspace")? {
|
||||
Some(raw) => Some(context.resolve_path(raw)?),
|
||||
None => {
|
||||
let ws = &context.workspace;
|
||||
if ws == std::path::Path::new(".") {
|
||||
None
|
||||
} else {
|
||||
Some(ws.clone())
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let parent_trigger_id = optional_str(input, "parent_trigger_id")?.map(String::from);
|
||||
|
||||
@@ -501,6 +502,104 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn make_context_in_workspace(tmp: &TempDir, workspace: &std::path::Path) -> ToolContext {
|
||||
let manager = AutomationManager::open_for_test(tmp.path().to_path_buf()).unwrap();
|
||||
ToolContext::new(workspace)
|
||||
.with_state_namespace("test-session")
|
||||
.with_runtime_services(RuntimeToolServices {
|
||||
automations: Some(Arc::new(Mutex::new(manager))),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn stored_triggers(tmp: &TempDir) -> Vec<crate::automation_manager::DelayedTriggerRecord> {
|
||||
AutomationManager::open_for_test(tmp.path().to_path_buf())
|
||||
.unwrap()
|
||||
.list_triggers(None, None)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_refuses_a_workspace_the_session_cannot_reach() {
|
||||
let store = TempDir::new().unwrap();
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let ctx = make_context_in_workspace(&store, workspace.path());
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_minutes": 5,
|
||||
"message": "look around",
|
||||
"workspace": outside.path().to_string_lossy()
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a directory outside the session workspace must be refused"
|
||||
);
|
||||
assert!(stored_triggers(&store).is_empty(), "nothing is scheduled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_stores_a_reachable_workspace_resolved() {
|
||||
let store = TempDir::new().unwrap();
|
||||
let workspace = TempDir::new().unwrap();
|
||||
std::fs::create_dir(workspace.path().join("sub")).unwrap();
|
||||
let ctx = make_context_in_workspace(&store, workspace.path());
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
tool.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_minutes": 5,
|
||||
"message": "work in sub",
|
||||
"workspace": "sub"
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let triggers = stored_triggers(&store);
|
||||
assert_eq!(triggers.len(), 1);
|
||||
assert_eq!(
|
||||
triggers[0]
|
||||
.workspace
|
||||
.as_deref()
|
||||
.map(|p| p.canonicalize().unwrap()),
|
||||
Some(workspace.path().join("sub").canonicalize().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_allows_any_workspace_in_trust_mode() {
|
||||
let store = TempDir::new().unwrap();
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let elsewhere = TempDir::new().unwrap();
|
||||
let ctx = make_context_in_workspace(&store, workspace.path()).with_trust_mode(true);
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
tool.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_minutes": 5,
|
||||
"message": "trusted",
|
||||
"workspace": elsewhere.path().to_string_lossy()
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored_triggers(&store).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_with_delay_minutes_succeeds() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use async_trait::async_trait;
|
||||
use codewhale_ratatui::{DATA_DISPLAY_SCHEMA, DataDisplay, DataWords};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec};
|
||||
|
||||
pub struct ShowDataTool;
|
||||
|
||||
pub fn parse_data_display(input: &Value) -> Result<DataDisplay, ToolError> {
|
||||
DataDisplay::from_json(&input.to_string())
|
||||
.map_err(|error| ToolError::invalid_input(error.to_string()))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for ShowDataTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"show_data"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Show the person numbers as a small terminal chart: `sparkline` (one row, newest value \
|
||||
on the right), `bars` (up to 24 labelled bars) or `trend` (a multi-row line chart). \
|
||||
Read-only: nothing is written or sent. Values must be finite numbers, oldest first. \
|
||||
The result repeats the same facts as plain text, so say what matters in words too."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
serde_json::from_str(DATA_DISPLAY_SCHEMA).expect("kit DATA_DISPLAY_SCHEMA is valid JSON")
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let display = parse_data_display(&input)?;
|
||||
let summary = format!("Shown: {}", display.describe(&DataWords::default()));
|
||||
Ok(ToolResult::success(summary).with_metadata(json!({ "data_display": display })))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_parses_and_bad_input_names_the_broken_limit() {
|
||||
assert_eq!(ShowDataTool.input_schema()["type"], "object");
|
||||
|
||||
let shown = parse_data_display(&json!({
|
||||
"kind": "bars",
|
||||
"label": "Tokens",
|
||||
"unit": "tok",
|
||||
"items": [{ "label": "opus", "value": 100.0 }]
|
||||
}))
|
||||
.expect("valid bars");
|
||||
let words = shown.describe(&DataWords::default());
|
||||
assert!(words.starts_with("Tokens: opus 100"), "{words}");
|
||||
|
||||
let context = ToolContext::new(".");
|
||||
let result = ShowDataTool
|
||||
.execute(serde_json::to_value(&shown).unwrap(), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.content, format!("Shown: {words}"));
|
||||
assert_eq!(
|
||||
result.metadata.as_ref().unwrap()["data_display"],
|
||||
serde_json::to_value(shown).unwrap()
|
||||
);
|
||||
|
||||
let too_many: Vec<Value> = (0..25)
|
||||
.map(|i| json!({ "label": format!("b{i}"), "value": i }))
|
||||
.collect();
|
||||
let error = ShowDataTool
|
||||
.execute(
|
||||
json!({
|
||||
"kind": "bars",
|
||||
"label": "Too many",
|
||||
"items": too_many
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect_err("25 bars is over the limit");
|
||||
assert!(error.to_string().contains("25 bars; at most 24"), "{error}");
|
||||
|
||||
let error =
|
||||
parse_data_display(&json!({ "kind": "pie", "label": "x" })).expect_err("unknown kind");
|
||||
assert!(error.to_string().contains("not a data display"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -8401,6 +8401,7 @@ fn every_named_role_has_one_complete_capability_based_surface() {
|
||||
"retrieve_tool_result",
|
||||
"session_get",
|
||||
"session_search",
|
||||
"show_data",
|
||||
"todo_write",
|
||||
"tui_help",
|
||||
"validate_data",
|
||||
@@ -8435,6 +8436,7 @@ fn every_named_role_has_one_complete_capability_based_surface() {
|
||||
"review",
|
||||
"session_get",
|
||||
"session_search",
|
||||
"show_data",
|
||||
"todo_write",
|
||||
"tui_help",
|
||||
"validate_data",
|
||||
@@ -8473,6 +8475,7 @@ fn every_named_role_has_one_complete_capability_based_surface() {
|
||||
"review",
|
||||
"session_get",
|
||||
"session_search",
|
||||
"show_data",
|
||||
"tasks",
|
||||
"todo_write",
|
||||
"tui_help",
|
||||
@@ -8519,6 +8522,7 @@ fn every_named_role_has_one_complete_capability_based_surface() {
|
||||
"send_later",
|
||||
"session_get",
|
||||
"session_search",
|
||||
"show_data",
|
||||
"speech",
|
||||
"task_shell_wait",
|
||||
"tasks",
|
||||
@@ -9849,6 +9853,7 @@ async fn read_only_roles_expose_and_dispatch_lowercase_bash_only() {
|
||||
"retrieve_tool_result",
|
||||
"session_get",
|
||||
"session_search",
|
||||
"show_data",
|
||||
"todo_write",
|
||||
"tui_help",
|
||||
"validate_data",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "actiu",
|
||||
"ExtensionPromptUnavailable": "Les instruccions de les extensions no estan disponibles; consulta /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "No s'han pogut carregar les preferències del compte de Codewhale; aquest torn fa servir la configuració local. La clau del proveïdor continua funcionant; executa codewhale login per recuperar les preferències del compte.",
|
||||
"AccountPreferencesNotLoaded": "Compte no carregat. Executa {command}. La clau del proveïdor continua funcionant.",
|
||||
"ComposerOversizedSubmitHeld": "No s’ha enviat: el missatge supera els {limit} caràcters i no s’ha pogut desar en un fitxer de text enganxat ({error}). El text complet continua al camp d’entrada: escurça’l o habilita l’escriptura a .codewhale/pastes i torna a prémer Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "No s’ha enviat: supera els {limit} caràcters; no s’ha desat el fitxer d’enganxat.",
|
||||
"CmdPluginOwnerReport": "Amfitrió d’extensions:\n Estat: {state}\n Eines actives ({count}): {tools}",
|
||||
@@ -1556,7 +1557,7 @@
|
||||
"PhaseIdle": "inactiu",
|
||||
"PhaseDraft": "esborrany",
|
||||
"PhaseWorking": "treballant",
|
||||
"PhaseReasoning": "raonant",
|
||||
"PhaseReasoning": "pensant",
|
||||
"PhaseReading": "llegint",
|
||||
"PhaseUsingTool": "usant una eina",
|
||||
"PhaseSubagents": "subagents en execució",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "aturat",
|
||||
"ToolReceiptIssue": "incidència",
|
||||
"ToolReceiptFailed": "ha fallat",
|
||||
"DataChartNow": "ara",
|
||||
"DataChartMin": "mín.",
|
||||
"DataChartMax": "màx.",
|
||||
"DataChartEmpty": "encara no hi ha dades",
|
||||
"DataChartPoints": "punts",
|
||||
"AgentActivityChecking": "comprovant",
|
||||
"AgentActivityChecked": "comprovat",
|
||||
"AgentActivityWaiting": "esperant",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "aktiv",
|
||||
"ExtensionPromptUnavailable": "Anweisungen von Erweiterungen sind nicht verfügbar; prüfe /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Die Codewhale-Kontoeinstellungen konnten nicht geladen werden; dieser Durchlauf nutzt deine lokalen Einstellungen. Dein Anbieter-Schlüssel funktioniert weiter; führe codewhale login aus, um die Kontoeinstellungen wiederherzustellen.",
|
||||
"AccountPreferencesNotLoaded": "Konto nicht geladen. Führe {command} aus. Dein Anbieter-Schlüssel funktioniert weiter.",
|
||||
"ComposerOversizedSubmitHeld": "Nicht gesendet: Diese Nachricht enthält mehr als {limit} Zeichen und konnte nicht als Einfügedatei gespeichert werden ({error}). Der vollständige Text steht noch im Eingabefeld: Kürze ihn oder mache .codewhale/pastes beschreibbar und drücke dann erneut Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Nicht gesendet: über {limit} Zeichen; Einfügedatei nicht gespeichert.",
|
||||
"CmdPluginOwnerReport": "Erweiterungshost:\n Status: {state}\n Aktive Werkzeuge ({count}): {tools}",
|
||||
@@ -1556,7 +1557,7 @@
|
||||
"PhaseIdle": "ruhend",
|
||||
"PhaseDraft": "entwurf",
|
||||
"PhaseWorking": "arbeitet",
|
||||
"PhaseReasoning": "überlegt",
|
||||
"PhaseReasoning": "denkt nach",
|
||||
"PhaseReading": "liest",
|
||||
"PhaseUsingTool": "nutzt Tool",
|
||||
"PhaseSubagents": "Subagenten laufen",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "gestoppt",
|
||||
"ToolReceiptIssue": "Problem",
|
||||
"ToolReceiptFailed": "fehlgeschlagen",
|
||||
"DataChartNow": "aktuell",
|
||||
"DataChartMin": "Min.",
|
||||
"DataChartMax": "Max.",
|
||||
"DataChartEmpty": "noch keine Daten",
|
||||
"DataChartPoints": "Punkte",
|
||||
"AgentActivityChecking": "wird geprüft",
|
||||
"AgentActivityChecked": "geprüft",
|
||||
"AgentActivityWaiting": "wartet",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "live",
|
||||
"ExtensionPromptUnavailable": "Extension instructions are unavailable; inspect /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale account preferences could not be loaded, so this turn uses your local settings. Your provider key still works; run codewhale login to restore account preferences.",
|
||||
"AccountPreferencesNotLoaded": "Account not loaded. Run {command}. Your provider key still works.",
|
||||
"ComposerOversizedSubmitHeld": "Not sent: this message is over {limit} characters and could not be saved as a paste file ({error}). The full text is still in the composer: shorten it, or make .codewhale/pastes writable, then press Enter again.",
|
||||
"ComposerOversizedSubmitHeldShort": "Not sent: over {limit} characters; paste file not saved.",
|
||||
"CmdPluginOwnerReport": "Extension host:\n State: {state}\n Live tools ({count}): {tools}",
|
||||
@@ -1575,7 +1576,7 @@
|
||||
"PhaseIdle": "idle",
|
||||
"PhaseDraft": "draft",
|
||||
"PhaseWorking": "working",
|
||||
"PhaseReasoning": "reasoning",
|
||||
"PhaseReasoning": "thinking",
|
||||
"PhaseReading": "reading",
|
||||
"PhaseUsingTool": "using tool",
|
||||
"PhaseSubagents": "agents underway",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "stopped",
|
||||
"ToolReceiptIssue": "issue",
|
||||
"ToolReceiptFailed": "failed",
|
||||
"DataChartNow": "now",
|
||||
"DataChartMin": "min",
|
||||
"DataChartMax": "max",
|
||||
"DataChartEmpty": "no data yet",
|
||||
"DataChartPoints": "points",
|
||||
"AgentActivityChecking": "checking",
|
||||
"AgentActivityChecked": "checked",
|
||||
"AgentActivityWaiting": "waiting",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "activo",
|
||||
"ExtensionPromptUnavailable": "Las instrucciones de las extensiones no están disponibles; revisa /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "No se pudieron cargar las preferencias de la cuenta de Codewhale; este turno usa tu configuración local. Tu clave del proveedor sigue funcionando; ejecuta codewhale login para restaurar las preferencias de la cuenta.",
|
||||
"AccountPreferencesNotLoaded": "Cuenta no cargada. Ejecuta {command}. Tu clave del proveedor sigue funcionando.",
|
||||
"ComposerOversizedSubmitHeld": "No se envió: este mensaje supera los {limit} caracteres y no se pudo guardar en un archivo de texto pegado ({error}). El texto completo sigue en el campo de entrada: acórtalo o habilita la escritura en .codewhale/pastes y vuelve a presionar Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "No se envió: supera los {limit} caracteres; no se guardó el archivo pegado.",
|
||||
"CmdPluginOwnerReport": "Host de extensiones:\n Estado: {state}\n Herramientas activas ({count}): {tools}",
|
||||
@@ -1575,7 +1576,7 @@
|
||||
"PhaseIdle": "inactivo",
|
||||
"PhaseDraft": "borrador",
|
||||
"PhaseWorking": "trabajando",
|
||||
"PhaseReasoning": "razonando",
|
||||
"PhaseReasoning": "pensando",
|
||||
"PhaseReading": "leyendo",
|
||||
"PhaseUsingTool": "usando herramienta",
|
||||
"PhaseSubagents": "subagentes en curso",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "detenido",
|
||||
"ToolReceiptIssue": "problema",
|
||||
"ToolReceiptFailed": "falló",
|
||||
"DataChartNow": "actual",
|
||||
"DataChartMin": "mín.",
|
||||
"DataChartMax": "máx.",
|
||||
"DataChartEmpty": "sin datos aún",
|
||||
"DataChartPoints": "puntos",
|
||||
"AgentActivityChecking": "comprobando",
|
||||
"AgentActivityChecked": "comprobado",
|
||||
"AgentActivityWaiting": "esperando",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "actif",
|
||||
"ExtensionPromptUnavailable": "Les instructions des extensions sont indisponibles ; consultez /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Les préférences du compte Codewhale n'ont pas pu être chargées ; ce tour utilise vos réglages locaux. Votre clé de fournisseur fonctionne toujours ; lancez codewhale login pour rétablir les préférences du compte.",
|
||||
"AccountPreferencesNotLoaded": "Compte non chargé. Lancez {command}. Votre clé de fournisseur fonctionne toujours.",
|
||||
"ComposerOversizedSubmitHeld": "Non envoyé : ce message dépasse {limit} caractères et n’a pas pu être enregistré dans un fichier de texte collé ({error}). Le texte intégral reste dans la zone de saisie : raccourcissez-le ou autorisez l’écriture dans .codewhale/pastes, puis appuyez à nouveau sur Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Non envoyé : plus de {limit} caractères ; fichier collé non enregistré.",
|
||||
"CmdPluginOwnerReport": "Hôte des extensions :\n État : {state}\n Outils actifs ({count}) : {tools}",
|
||||
@@ -1556,7 +1557,7 @@
|
||||
"PhaseIdle": "inactif",
|
||||
"PhaseDraft": "brouillon",
|
||||
"PhaseWorking": "travail en cours",
|
||||
"PhaseReasoning": "raisonnement",
|
||||
"PhaseReasoning": "réflexion",
|
||||
"PhaseReading": "lecture",
|
||||
"PhaseUsingTool": "utilisation d'outil",
|
||||
"PhaseSubagents": "sous-agents en cours",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "arrêté",
|
||||
"ToolReceiptIssue": "problème",
|
||||
"ToolReceiptFailed": "échec",
|
||||
"DataChartNow": "actuel",
|
||||
"DataChartMin": "min.",
|
||||
"DataChartMax": "max.",
|
||||
"DataChartEmpty": "pas encore de données",
|
||||
"DataChartPoints": "points",
|
||||
"AgentActivityChecking": "vérification en cours",
|
||||
"AgentActivityChecked": "vérifié",
|
||||
"AgentActivityWaiting": "en attente",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "सक्रिय",
|
||||
"ExtensionPromptUnavailable": "एक्सटेंशन के निर्देश उपलब्ध नहीं हैं; /plugin show से जाँचें।",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale खाते की प्राथमिकताएँ लोड नहीं हो सकीं, इसलिए यह टर्न आपकी स्थानीय सेटिंग्स का उपयोग करता है। आपकी प्रदाता कुंजी काम करती रहेगी; खाते की प्राथमिकताएँ वापस लाने के लिए codewhale login चलाएँ।",
|
||||
"AccountPreferencesNotLoaded": "खाता लोड नहीं हुआ। {command} चलाएँ। आपकी प्रदाता कुंजी काम करती रहेगी।",
|
||||
"ComposerOversizedSubmitHeld": "नहीं भेजा गया: इस संदेश में {limit} से अधिक वर्ण हैं और इसे पेस्ट फ़ाइल के रूप में सहेजा नहीं जा सका ({error})। पूरा पाठ अभी भी इनपुट बॉक्स में है: इसे छोटा करें या .codewhale/pastes में लिखने की अनुमति दें, फिर Enter दबाएँ।",
|
||||
"ComposerOversizedSubmitHeldShort": "नहीं भेजा गया: {limit} से अधिक वर्ण; पेस्ट फ़ाइल सहेजी नहीं गई।",
|
||||
"CmdPluginOwnerReport": "एक्सटेंशन होस्ट:\n स्थिति: {state}\n सक्रिय टूल ({count}): {tools}",
|
||||
@@ -1556,7 +1557,7 @@
|
||||
"PhaseIdle": "निष्क्रिय",
|
||||
"PhaseDraft": "मसौदा",
|
||||
"PhaseWorking": "काम चल रहा",
|
||||
"PhaseReasoning": "तर्क चल रहा",
|
||||
"PhaseReasoning": "सोच रहा",
|
||||
"PhaseReading": "पढ़ रहा",
|
||||
"PhaseUsingTool": "टूल इस्तेमाल हो रहा",
|
||||
"PhaseSubagents": "सबएजेंट चल रहे हैं",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "रोका गया",
|
||||
"ToolReceiptIssue": "समस्या",
|
||||
"ToolReceiptFailed": "विफल",
|
||||
"DataChartNow": "अभी",
|
||||
"DataChartMin": "न्यूनतम",
|
||||
"DataChartMax": "अधिकतम",
|
||||
"DataChartEmpty": "अभी कोई डेटा नहीं",
|
||||
"DataChartPoints": "बिंदु",
|
||||
"AgentActivityChecking": "जाँच जारी",
|
||||
"AgentActivityChecked": "जाँच पूरी",
|
||||
"AgentActivityWaiting": "प्रतीक्षा जारी",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "aktif",
|
||||
"ExtensionPromptUnavailable": "Instruksi ekstensi tidak tersedia; periksa /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Preferensi akun Codewhale tidak dapat dimuat, jadi giliran ini memakai pengaturan lokal. Kunci penyedia tetap berfungsi; jalankan codewhale login untuk memulihkan preferensi akun.",
|
||||
"AccountPreferencesNotLoaded": "Akun tidak dimuat. Jalankan {command}. Kunci penyedia tetap berfungsi.",
|
||||
"ComposerOversizedSubmitHeld": "Belum terkirim: pesan ini melebihi {limit} karakter dan tidak dapat disimpan sebagai berkas teks tempelan ({error}). Teks lengkap masih ada di kolom input: persingkat teks atau izinkan penulisan ke .codewhale/pastes, lalu tekan Enter lagi.",
|
||||
"ComposerOversizedSubmitHeldShort": "Belum terkirim: melebihi {limit} karakter; berkas tempelan tidak tersimpan.",
|
||||
"CmdPluginOwnerReport": "Host ekstensi:\n Status: {state}\n Alat aktif ({count}): {tools}",
|
||||
@@ -1556,7 +1557,7 @@
|
||||
"PhaseIdle": "diam",
|
||||
"PhaseDraft": "draf",
|
||||
"PhaseWorking": "bekerja",
|
||||
"PhaseReasoning": "menalar",
|
||||
"PhaseReasoning": "berpikir",
|
||||
"PhaseReading": "membaca",
|
||||
"PhaseUsingTool": "memakai tool",
|
||||
"PhaseSubagents": "subagen berjalan",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "dihentikan",
|
||||
"ToolReceiptIssue": "masalah",
|
||||
"ToolReceiptFailed": "gagal",
|
||||
"DataChartNow": "kini",
|
||||
"DataChartMin": "min.",
|
||||
"DataChartMax": "maks.",
|
||||
"DataChartEmpty": "belum ada data",
|
||||
"DataChartPoints": "titik",
|
||||
"AgentActivityChecking": "memeriksa",
|
||||
"AgentActivityChecked": "diperiksa",
|
||||
"AgentActivityWaiting": "menunggu",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "稼働中",
|
||||
"ExtensionPromptUnavailable": "拡張機能の指示を利用できません。/plugin show で確認してください。",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale アカウントの設定を読み込めなかったため、このターンはローカル設定を使います。プロバイダーキーはそのまま使えます。アカウント設定を戻すには codewhale login を実行してください。",
|
||||
"AccountPreferencesNotLoaded": "アカウントを読み込めませんでした。{command} を実行してください。プロバイダーキーはそのまま使えます。",
|
||||
"ComposerOversizedSubmitHeld": "未送信:メッセージが {limit} 文字を超えており、貼り付けファイルに保存できませんでした({error})。全文は入力欄に残っています。短くするか .codewhale/pastes を書き込み可能にしてから、もう一度 Enter を押してください。",
|
||||
"ComposerOversizedSubmitHeldShort": "未送信:{limit} 文字超。貼り付けファイルは未保存です。",
|
||||
"CmdPluginOwnerReport": "拡張機能ホスト:\n 状態: {state}\n 有効なツール ({count}): {tools}",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "停止",
|
||||
"ToolReceiptIssue": "問題あり",
|
||||
"ToolReceiptFailed": "失敗",
|
||||
"DataChartNow": "現在",
|
||||
"DataChartMin": "最小",
|
||||
"DataChartMax": "最大",
|
||||
"DataChartEmpty": "データなし",
|
||||
"DataChartPoints": "点",
|
||||
"AgentActivityChecking": "確認中",
|
||||
"AgentActivityChecked": "確認済み",
|
||||
"AgentActivityWaiting": "待機中",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "실행 중",
|
||||
"ExtensionPromptUnavailable": "확장 기능의 지침을 사용할 수 없습니다. /plugin show에서 확인하세요.",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale 계정 설정을 불러오지 못해 이번 턴은 로컬 설정을 사용합니다. 공급자 키는 계속 사용할 수 있으며, 계정 설정을 복원하려면 codewhale login을 실행하세요.",
|
||||
"AccountPreferencesNotLoaded": "계정을 불러오지 못했습니다. {command}을 실행하세요. 공급자 키는 계속 사용할 수 있습니다.",
|
||||
"ComposerOversizedSubmitHeld": "전송하지 못했습니다. 메시지가 {limit}자를 초과하며 붙여넣기 파일로 저장할 수 없습니다({error}). 전체 텍스트는 입력란에 그대로 있습니다. 내용을 줄이거나 .codewhale/pastes에 쓰기 권한을 부여한 뒤 Enter를 다시 누르세요.",
|
||||
"ComposerOversizedSubmitHeldShort": "전송 안 됨: {limit}자 초과, 붙여넣기 파일 저장 실패.",
|
||||
"CmdPluginOwnerReport": "확장 호스트:\n 상태: {state}\n 활성 도구 ({count}): {tools}",
|
||||
@@ -1575,7 +1576,7 @@
|
||||
"PhaseIdle": "유휴",
|
||||
"PhaseDraft": "초안",
|
||||
"PhaseWorking": "작업 중",
|
||||
"PhaseReasoning": "추론 중",
|
||||
"PhaseReasoning": "생각 중",
|
||||
"PhaseReading": "읽는 중",
|
||||
"PhaseUsingTool": "도구 사용 중",
|
||||
"PhaseSubagents": "서브에이전트 실행 중",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "중지됨",
|
||||
"ToolReceiptIssue": "문제 있음",
|
||||
"ToolReceiptFailed": "실패",
|
||||
"DataChartNow": "현재",
|
||||
"DataChartMin": "최소",
|
||||
"DataChartMax": "최대",
|
||||
"DataChartEmpty": "아직 데이터 없음",
|
||||
"DataChartPoints": "개 점",
|
||||
"AgentActivityChecking": "확인 중",
|
||||
"AgentActivityChecked": "확인됨",
|
||||
"AgentActivityWaiting": "대기 중",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "ativo",
|
||||
"ExtensionPromptUnavailable": "As instruções das extensões não estão disponíveis; confira /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Não foi possível carregar as preferências da conta Codewhale; este turno usa suas configurações locais. Sua chave do provedor continua funcionando; execute codewhale login para restaurar as preferências da conta.",
|
||||
"AccountPreferencesNotLoaded": "Conta não carregada. Execute {command}. Sua chave do provedor continua funcionando.",
|
||||
"ComposerOversizedSubmitHeld": "Não enviado: esta mensagem excede {limit} caracteres e não pôde ser salva em um arquivo de texto colado ({error}). O texto completo continua no campo de entrada: reduza-o ou permita a gravação em .codewhale/pastes e pressione Enter novamente.",
|
||||
"ComposerOversizedSubmitHeldShort": "Não enviado: acima de {limit} caracteres; arquivo colado não salvo.",
|
||||
"CmdPluginOwnerReport": "Host de extensões:\n Estado: {state}\n Ferramentas ativas ({count}): {tools}",
|
||||
@@ -1575,7 +1576,7 @@
|
||||
"PhaseIdle": "ocioso",
|
||||
"PhaseDraft": "rascunho",
|
||||
"PhaseWorking": "trabalhando",
|
||||
"PhaseReasoning": "raciocinando",
|
||||
"PhaseReasoning": "pensando",
|
||||
"PhaseReading": "lendo",
|
||||
"PhaseUsingTool": "usando ferramenta",
|
||||
"PhaseSubagents": "subagentes em andamento",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "parado",
|
||||
"ToolReceiptIssue": "problema",
|
||||
"ToolReceiptFailed": "falhou",
|
||||
"DataChartNow": "atual",
|
||||
"DataChartMin": "mín.",
|
||||
"DataChartMax": "máx.",
|
||||
"DataChartEmpty": "ainda sem dados",
|
||||
"DataChartPoints": "pontos",
|
||||
"AgentActivityChecking": "verificando",
|
||||
"AgentActivityChecked": "verificado",
|
||||
"AgentActivityWaiting": "aguardando",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "активен",
|
||||
"ExtensionPromptUnavailable": "Инструкции расширений недоступны; проверьте /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Не удалось загрузить настройки аккаунта Codewhale, поэтому этот ход использует локальные настройки. Ключ провайдера продолжает работать; выполните codewhale login, чтобы вернуть настройки аккаунта.",
|
||||
"AccountPreferencesNotLoaded": "Аккаунт не загружен. Выполните {command}. Ключ провайдера продолжает работать.",
|
||||
"ComposerOversizedSubmitHeld": "Не отправлено: сообщение длиннее {limit} символов, и его не удалось сохранить в файл вставленного текста ({error}). Полный текст остался в поле ввода: сократите его или разрешите запись в .codewhale/pastes, затем снова нажмите Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Не отправлено: больше {limit} символов; файл вставки не сохранён.",
|
||||
"CmdPluginOwnerReport": "Хост расширений:\n Состояние: {state}\n Активные инструменты ({count}): {tools}",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "остановлено",
|
||||
"ToolReceiptIssue": "проблема",
|
||||
"ToolReceiptFailed": "ошибка",
|
||||
"DataChartNow": "сейчас",
|
||||
"DataChartMin": "мин.",
|
||||
"DataChartMax": "макс.",
|
||||
"DataChartEmpty": "данных пока нет",
|
||||
"DataChartPoints": "точек",
|
||||
"AgentActivityChecking": "проверка",
|
||||
"AgentActivityChecked": "проверено",
|
||||
"AgentActivityWaiting": "ожидание",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "активний",
|
||||
"ExtensionPromptUnavailable": "Інструкції розширень недоступні; перевірте /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Не вдалося завантажити налаштування облікового запису Codewhale, тому цей хід використовує локальні налаштування. Ключ провайдера й далі працює; виконайте codewhale login, щоб повернути налаштування облікового запису.",
|
||||
"AccountPreferencesNotLoaded": "Обліковий запис не завантажено. Виконайте {command}. Ключ провайдера й далі працює.",
|
||||
"ComposerOversizedSubmitHeld": "Не надіслано: повідомлення перевищує {limit} символів, і його не вдалося зберегти у файл вставленого тексту ({error}). Повний текст залишається в полі введення: скоротіть його або дозвольте запис у .codewhale/pastes, а потім знову натисніть Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Не надіслано: понад {limit} символів; файл вставки не збережено.",
|
||||
"CmdPluginOwnerReport": "Хост розширень:\n Стан: {state}\n Активні інструменти ({count}): {tools}",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "зупинено",
|
||||
"ToolReceiptIssue": "проблема",
|
||||
"ToolReceiptFailed": "помилка",
|
||||
"DataChartNow": "зараз",
|
||||
"DataChartMin": "мін.",
|
||||
"DataChartMax": "макс.",
|
||||
"DataChartEmpty": "даних ще немає",
|
||||
"DataChartPoints": "точок",
|
||||
"AgentActivityChecking": "перевірка",
|
||||
"AgentActivityChecked": "перевірено",
|
||||
"AgentActivityWaiting": "очікування",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "đang chạy",
|
||||
"ExtensionPromptUnavailable": "Không thể tải hướng dẫn của tiện ích mở rộng; kiểm tra /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Không thể tải tùy chọn tài khoản Codewhale nên lượt này dùng cài đặt cục bộ. Khóa nhà cung cấp của bạn vẫn hoạt động; chạy codewhale login để khôi phục tùy chọn tài khoản.",
|
||||
"AccountPreferencesNotLoaded": "Chưa tải được tài khoản. Chạy {command}. Khóa nhà cung cấp của bạn vẫn hoạt động.",
|
||||
"ComposerOversizedSubmitHeld": "Chưa gửi: tin nhắn vượt quá {limit} ký tự và không thể lưu thành tệp văn bản đã dán ({error}). Toàn bộ nội dung vẫn còn trong ô nhập: hãy rút ngắn hoặc cho phép ghi vào .codewhale/pastes, rồi nhấn Enter lần nữa.",
|
||||
"ComposerOversizedSubmitHeldShort": "Chưa gửi: vượt quá {limit} ký tự; chưa lưu tệp dán.",
|
||||
"CmdPluginOwnerReport": "Máy chủ tiện ích mở rộng:\n Trạng thái: {state}\n Công cụ đang hoạt động ({count}): {tools}",
|
||||
@@ -1575,7 +1576,7 @@
|
||||
"PhaseIdle": "rảnh",
|
||||
"PhaseDraft": "nháp",
|
||||
"PhaseWorking": "đang xử lý",
|
||||
"PhaseReasoning": "đang suy luận",
|
||||
"PhaseReasoning": "đang suy nghĩ",
|
||||
"PhaseReading": "đang đọc",
|
||||
"PhaseUsingTool": "đang dùng công cụ",
|
||||
"PhaseSubagents": "đang chạy tác tử con",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "đã dừng",
|
||||
"ToolReceiptIssue": "có vấn đề",
|
||||
"ToolReceiptFailed": "thất bại",
|
||||
"DataChartNow": "hiện tại",
|
||||
"DataChartMin": "nhỏ nhất",
|
||||
"DataChartMax": "lớn nhất",
|
||||
"DataChartEmpty": "chưa có dữ liệu",
|
||||
"DataChartPoints": "điểm",
|
||||
"AgentActivityChecking": "đang kiểm tra",
|
||||
"AgentActivityChecked": "đã kiểm tra",
|
||||
"AgentActivityWaiting": "đang chờ",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "运行中",
|
||||
"ExtensionPromptUnavailable": "扩展指令不可用;请通过 /plugin show 查看。",
|
||||
"ProfileConstitutionUnavailableLocal": "无法加载 Codewhale 账号偏好,本轮改用本地设置。你的提供商密钥仍可正常使用;运行 codewhale login 可恢复账号偏好。",
|
||||
"AccountPreferencesNotLoaded": "账号未加载。请运行 {command}。你的提供商密钥仍可正常使用。",
|
||||
"ComposerOversizedSubmitHeld": "未发送:消息超过 {limit} 个字符,无法保存为粘贴文本文件({error})。完整文本仍保留在输入框中:请缩短内容,或允许写入 .codewhale/pastes,然后再次按 Enter。",
|
||||
"ComposerOversizedSubmitHeldShort": "未发送:超过 {limit} 个字符;粘贴文件未保存。",
|
||||
"CmdPluginOwnerReport": "扩展宿主:\n 状态:{state}\n 活动工具({count}):{tools}",
|
||||
@@ -1575,7 +1576,7 @@
|
||||
"PhaseIdle": "空闲",
|
||||
"PhaseDraft": "草稿",
|
||||
"PhaseWorking": "工作中",
|
||||
"PhaseReasoning": "推理中",
|
||||
"PhaseReasoning": "思考中",
|
||||
"PhaseReading": "读取中",
|
||||
"PhaseUsingTool": "使用工具中",
|
||||
"PhaseSubagents": "智能体工作中",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "已停止",
|
||||
"ToolReceiptIssue": "存在问题",
|
||||
"ToolReceiptFailed": "失败",
|
||||
"DataChartNow": "当前",
|
||||
"DataChartMin": "最小",
|
||||
"DataChartMax": "最大",
|
||||
"DataChartEmpty": "暂无数据",
|
||||
"DataChartPoints": "个数据点",
|
||||
"AgentActivityChecking": "检查中",
|
||||
"AgentActivityChecked": "已检查",
|
||||
"AgentActivityWaiting": "等待中",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "執行中",
|
||||
"ExtensionPromptUnavailable": "擴充功能指令無法使用;請透過 /plugin show 查看。",
|
||||
"ProfileConstitutionUnavailableLocal": "無法載入 Codewhale 帳號偏好,本輪改用本機設定。你的服務商金鑰仍可正常使用;執行 codewhale login 可恢復帳號偏好。",
|
||||
"AccountPreferencesNotLoaded": "帳號未載入。請執行 {command}。你的服務商金鑰仍可正常使用。",
|
||||
"ComposerOversizedSubmitHeld": "未傳送:訊息超過 {limit} 個字元,無法儲存為貼上文字檔案({error})。完整文字仍保留在輸入框中:請縮短內容,或允許寫入 .codewhale/pastes,然後再次按 Enter。",
|
||||
"ComposerOversizedSubmitHeldShort": "未傳送:超過 {limit} 個字元;貼上檔案未儲存。",
|
||||
"CmdPluginOwnerReport": "擴充功能主機:\n 狀態:{state}\n 活動工具({count}):{tools}",
|
||||
@@ -1573,7 +1574,7 @@
|
||||
"PhaseIdle": "閒置",
|
||||
"PhaseDraft": "草稿",
|
||||
"PhaseWorking": "工作中",
|
||||
"PhaseReasoning": "推理中",
|
||||
"PhaseReasoning": "思考中",
|
||||
"PhaseReading": "讀取中",
|
||||
"PhaseUsingTool": "使用工具中",
|
||||
"PhaseSubagents": "代理程式工作中",
|
||||
@@ -2571,6 +2572,11 @@
|
||||
"ToolReceiptStopped": "已停止",
|
||||
"ToolReceiptIssue": "發生問題",
|
||||
"ToolReceiptFailed": "失敗",
|
||||
"DataChartNow": "目前",
|
||||
"DataChartMin": "最小",
|
||||
"DataChartMax": "最大",
|
||||
"DataChartEmpty": "暫無資料",
|
||||
"DataChartPoints": "個資料點",
|
||||
"AgentActivityChecking": "檢查中",
|
||||
"AgentActivityChecked": "已檢查",
|
||||
"AgentActivityWaiting": "等待中",
|
||||
|
||||
@@ -672,6 +672,7 @@ pub enum MessageId {
|
||||
ExtensionsInventorySkills,
|
||||
ExtensionPromptUnavailable,
|
||||
ProfileConstitutionUnavailableLocal,
|
||||
AccountPreferencesNotLoaded,
|
||||
ExtensionsMarketplaceDetail,
|
||||
ExtensionsMarketplaceUnavailable,
|
||||
ExtensionsMcpEmpty,
|
||||
@@ -2874,6 +2875,11 @@ pub enum MessageId {
|
||||
ToolReceiptStopped,
|
||||
ToolReceiptIssue,
|
||||
ToolReceiptFailed,
|
||||
DataChartNow,
|
||||
DataChartMin,
|
||||
DataChartMax,
|
||||
DataChartEmpty,
|
||||
DataChartPoints,
|
||||
AgentActivityChecking,
|
||||
AgentActivityChecked,
|
||||
AgentActivityWaiting,
|
||||
@@ -3450,6 +3456,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::ExtensionsInventorySkills,
|
||||
MessageId::ExtensionPromptUnavailable,
|
||||
MessageId::ProfileConstitutionUnavailableLocal,
|
||||
MessageId::AccountPreferencesNotLoaded,
|
||||
MessageId::ExtensionsMarketplaceDetail,
|
||||
MessageId::ExtensionsMarketplaceUnavailable,
|
||||
MessageId::ExtensionsMcpEmpty,
|
||||
@@ -5540,6 +5547,11 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::ToolReceiptStopped,
|
||||
MessageId::ToolReceiptIssue,
|
||||
MessageId::ToolReceiptFailed,
|
||||
MessageId::DataChartNow,
|
||||
MessageId::DataChartMin,
|
||||
MessageId::DataChartMax,
|
||||
MessageId::DataChartEmpty,
|
||||
MessageId::DataChartPoints,
|
||||
MessageId::AgentActivityChecking,
|
||||
MessageId::AgentActivityChecked,
|
||||
MessageId::AgentActivityWaiting,
|
||||
|
||||
@@ -26,14 +26,12 @@ codewhale-secrets = { path = "../secrets", version = "0.10.3" }
|
||||
codewhale-skill-install = { path = "../skill-install", version = "0.10.3" }
|
||||
codewhale-util = { path = "../util", version = "0.10.3" }
|
||||
fd-lock = "4.0.4"
|
||||
flate2 = "1.1"
|
||||
regex = "1.11"
|
||||
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2"] }
|
||||
semver.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
|
||||
sha2.workspace = true
|
||||
tar = "0.4"
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -55,3 +53,5 @@ codewhale-config = { path = "../config", version = "0.10.3", features = ["schema
|
||||
codewhale-runtime = { path = "../runtime", version = "0.10.3", features = ["test-support", "test-thread-scoped-state"] }
|
||||
codewhale-skill-install = { path = "../skill-install", version = "0.10.3", features = ["test-support"] }
|
||||
codewhale-util = { path = "../util", version = "0.10.3", features = ["test-support"] }
|
||||
flate2 = "1.1"
|
||||
tar = "0.4"
|
||||
@@ -368,6 +368,8 @@ pub enum PluginInstallError {
|
||||
PathTraversal(String),
|
||||
#[error("bundle is too large; uncompressed total would exceed {limit} bytes")]
|
||||
OversizedBundle { limit: u64 },
|
||||
#[error("archive entry name is longer than {limit} bytes")]
|
||||
EntryNameTooLong { limit: usize },
|
||||
#[error(
|
||||
"archive must contain exactly one plugin bundle root (a directory holding plugin.json, kimi.plugin.json, or plugin.toml); found {0} (install a single plugin bundle, not a mono-repo)"
|
||||
)]
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use flate2::read::GzDecoder;
|
||||
|
||||
use codewhale_skill_install::bounded_tar::{
|
||||
MAX_ENTRY_NAME_BYTES, entry_names_within_bound, map_stream_limit, open_bounded_tarball,
|
||||
read_body_within,
|
||||
};
|
||||
use codewhale_skill_install::install::is_safe_path;
|
||||
|
||||
use super::PluginInstallError;
|
||||
@@ -65,14 +67,27 @@ pub(super) fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
scan_tarball_for_bundle(bytes, max_size, None)
|
||||
}
|
||||
|
||||
fn oversized_if_stream_limit(err: anyhow::Error, max_size: u64) -> anyhow::Error {
|
||||
map_stream_limit(err, || {
|
||||
PluginInstallError::OversizedBundle { limit: max_size }.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_tarball_for_bundle(
|
||||
bytes: &[u8],
|
||||
max_size: u64,
|
||||
bundle_path: Option<&str>,
|
||||
) -> Result<TarballScan> {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
scan_bundle_entries(bytes, max_size, bundle_path)
|
||||
.map_err(|err| oversized_if_stream_limit(err, max_size))
|
||||
}
|
||||
|
||||
fn scan_bundle_entries(
|
||||
bytes: &[u8],
|
||||
max_size: u64,
|
||||
bundle_path: Option<&str>,
|
||||
) -> Result<TarballScan> {
|
||||
let mut archive = open_bounded_tarball(bytes, max_size);
|
||||
|
||||
let mut total_size: u64 = 0;
|
||||
let mut manifest_paths: Vec<String> = Vec::new();
|
||||
@@ -83,6 +98,12 @@ fn scan_tarball_for_bundle(
|
||||
.context("failed to read tar entries (corrupt archive?)")?
|
||||
{
|
||||
let mut entry = entry.context("failed to read tar entry")?;
|
||||
if !entry_names_within_bound(&entry) {
|
||||
return Err(PluginInstallError::EntryNameTooLong {
|
||||
limit: MAX_ENTRY_NAME_BYTES,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let header = entry.header().clone();
|
||||
let path = entry
|
||||
.path()
|
||||
@@ -92,11 +113,9 @@ fn scan_tarball_for_bundle(
|
||||
if !is_safe_path(&path) {
|
||||
return Err(PluginInstallError::PathTraversal(path_str).into());
|
||||
}
|
||||
if let Ok(size) = header.size() {
|
||||
total_size = total_size.saturating_add(size);
|
||||
if total_size > max_size {
|
||||
return Err(PluginInstallError::OversizedBundle { limit: max_size }.into());
|
||||
}
|
||||
total_size = total_size.saturating_add(entry.size());
|
||||
if total_size > max_size {
|
||||
return Err(PluginInstallError::OversizedBundle { limit: max_size }.into());
|
||||
}
|
||||
if header.entry_type().is_file() {
|
||||
if path.file_name().is_some_and(|name| {
|
||||
@@ -110,8 +129,9 @@ fn scan_tarball_for_bundle(
|
||||
.components()
|
||||
.any(|part| part.as_os_str() == std::ffi::OsStr::new("node_modules"))
|
||||
{
|
||||
let mut raw = Vec::new();
|
||||
if entry.by_ref().take(65_536).read_to_end(&mut raw).is_ok()
|
||||
let raw = read_body_within(&mut entry, 65_536)
|
||||
.context("failed to read package.json from archive")?;
|
||||
if let Some(raw) = raw
|
||||
&& let Ok(text) = std::str::from_utf8(&raw)
|
||||
&& let Ok(parsed) = super::dsh::parse_json(text)
|
||||
&& parsed
|
||||
@@ -167,9 +187,12 @@ fn scan_tarball_for_bundle(
|
||||
|
||||
/// Second pass: extract only entries under the scanned bundle root.
|
||||
fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
extract_entries(scan, bytes, dest, max_size)
|
||||
.map_err(|err| oversized_if_stream_limit(err, max_size))
|
||||
}
|
||||
|
||||
fn extract_entries(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
|
||||
let mut archive = open_bounded_tarball(bytes, max_size);
|
||||
let mut total_size: u64 = 0;
|
||||
|
||||
for entry in archive
|
||||
@@ -177,6 +200,12 @@ fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) ->
|
||||
.context("failed to read tar entries (corrupt archive?)")?
|
||||
{
|
||||
let mut entry = entry.context("failed to read tar entry")?;
|
||||
if !entry_names_within_bound(&entry) {
|
||||
return Err(PluginInstallError::EntryNameTooLong {
|
||||
limit: MAX_ENTRY_NAME_BYTES,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let header = entry.header().clone();
|
||||
let entry_type = header.entry_type();
|
||||
let path = entry
|
||||
@@ -230,14 +259,10 @@ fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) ->
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create dir {}", parent.display()))?;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
entry
|
||||
.read_to_end(&mut buf)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let buf = read_body_within(&mut entry, max_size.saturating_sub(total_size))
|
||||
.with_context(|| format!("failed to read {}", path.display()))?
|
||||
.ok_or(PluginInstallError::OversizedBundle { limit: max_size })?;
|
||||
total_size = total_size.saturating_add(buf.len() as u64);
|
||||
if total_size > max_size {
|
||||
return Err(PluginInstallError::OversizedBundle { limit: max_size }.into());
|
||||
}
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.create_new(true).write(true);
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
// `scan_tarball` lives in the sibling stage-reader module and is not needed
|
||||
// by the verbs, so it is not in `super`'s namespace.
|
||||
use super::tarball::scan_tarball;
|
||||
use codewhale_skill_install::bounded_tar::test_archives;
|
||||
|
||||
#[path = "tests/place_recovery.rs"]
|
||||
mod place_recovery;
|
||||
@@ -189,6 +190,90 @@ fn scan_enforces_size_cap() {
|
||||
);
|
||||
}
|
||||
|
||||
const MANIFEST: &[u8] = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
|
||||
const SMALL_CAP: u64 = 64 * 1024;
|
||||
const HUGE_BODY: u64 = 16 * 1024 * 1024;
|
||||
|
||||
fn assert_oversized(err: &anyhow::Error) {
|
||||
assert!(
|
||||
matches!(
|
||||
err.downcast_ref::<PluginInstallError>(),
|
||||
Some(PluginInstallError::OversizedBundle { .. })
|
||||
),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_counts_the_size_a_pax_record_gives_an_entry() {
|
||||
let bytes = test_archives::pax_size_record_archive(("plugin.toml", MANIFEST), HUGE_BODY);
|
||||
assert_oversized(&scan_tarball(&bytes, SMALL_CAP).unwrap_err());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_oversized(
|
||||
&tarball::stage_tarball(&bytes, tmp.path(), SMALL_CAP, None)
|
||||
.map(|staged| staged.name)
|
||||
.unwrap_err(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_and_extract_bound_the_decoded_stream_for_extension_entries() {
|
||||
for kind in [tar::EntryType::GNULongName, tar::EntryType::XHeader] {
|
||||
let bytes =
|
||||
test_archives::extension_body_archive(("plugin.toml", MANIFEST), kind, HUGE_BODY);
|
||||
assert_oversized(&scan_tarball(&bytes, SMALL_CAP).unwrap_err());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_oversized(
|
||||
&tarball::stage_tarball(&bytes, tmp.path(), SMALL_CAP, None)
|
||||
.map(|staged| staged.name)
|
||||
.unwrap_err(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_name_too_long(err: &anyhow::Error) {
|
||||
assert!(
|
||||
matches!(
|
||||
err.downcast_ref::<PluginInstallError>(),
|
||||
Some(PluginInstallError::EntryNameTooLong { .. })
|
||||
),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_names_longer_than_the_bound_are_rejected_before_any_copy() {
|
||||
use codewhale_skill_install::bounded_tar::MAX_ENTRY_NAME_BYTES;
|
||||
let archives = [
|
||||
test_archives::long_name_archive(("plugin.toml", MANIFEST), MAX_ENTRY_NAME_BYTES + 1),
|
||||
test_archives::long_name_archive(("plugin.toml", MANIFEST), 100 * 1024),
|
||||
test_archives::long_link_target_archive(
|
||||
("plugin.toml", MANIFEST),
|
||||
MAX_ENTRY_NAME_BYTES + 1,
|
||||
),
|
||||
];
|
||||
for bytes in &archives {
|
||||
assert_name_too_long(&scan_tarball(bytes, SMALL_CAP).unwrap_err());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_name_too_long(
|
||||
&tarball::stage_tarball(bytes, tmp.path(), SMALL_CAP, None)
|
||||
.map(|staged| staged.name)
|
||||
.unwrap_err(),
|
||||
);
|
||||
}
|
||||
let at_bound =
|
||||
test_archives::long_name_archive(("plugin.toml", MANIFEST), MAX_ENTRY_NAME_BYTES);
|
||||
assert!(scan_tarball(&at_bound, SMALL_CAP).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_bounds_the_decoded_stream_for_many_empty_entries() {
|
||||
let bytes = test_archives::empty_entries_archive(("plugin.toml", MANIFEST), 600);
|
||||
assert_oversized(&scan_tarball(&bytes, SMALL_CAP).unwrap_err());
|
||||
let few = test_archives::empty_entries_archive(("plugin.toml", MANIFEST), 20);
|
||||
assert!(scan_tarball(&few, SMALL_CAP).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_requires_exactly_one_plugin_manifest_root() {
|
||||
let zero = tarball(&[("repo-main/README.md", b"no manifest here")]);
|
||||
|
||||
@@ -304,7 +304,19 @@ fn is_allowed_parent_env_key(key: &OsStr) -> bool {
|
||||
| "LANGUAGE"
|
||||
| "LC_ALL"
|
||||
| "LC_CTYPE"
|
||||
| "LC_COLLATE"
|
||||
| "LC_MESSAGES"
|
||||
| "LC_MONETARY"
|
||||
| "LC_NUMERIC"
|
||||
| "LC_TIME"
|
||||
| "LC_PAPER"
|
||||
| "LC_NAME"
|
||||
| "LC_ADDRESS"
|
||||
| "LC_TELEPHONE"
|
||||
| "LC_MEASUREMENT"
|
||||
| "LC_IDENTIFICATION"
|
||||
| "LC_TERMINAL"
|
||||
| "LC_TERMINAL_VERSION"
|
||||
| "TERM"
|
||||
| "COLORTERM"
|
||||
| "NO_COLOR"
|
||||
@@ -421,15 +433,90 @@ fn is_allowed_parent_env_key(key: &OsStr) -> bool {
|
||||
| "SSL_CERT_DIR"
|
||||
| "REQUESTS_CA_BUNDLE"
|
||||
| "CURL_CA_BUNDLE"
|
||||
) || normalized.starts_with("LC_")
|
||||
|| is_allowed_cargo_config_key(&normalized)
|
||||
// .NET CLI / SDK configuration (DOTNET_ROOT, DOTNET_CLI_*,
|
||||
// DOTNET_NOLOGO, DOTNET_CLI_TELEMETRY_OPTOUT, …). Paths and flags
|
||||
// only — no secret-shaped values (#1857).
|
||||
|| normalized.starts_with("DOTNET_")
|
||||
) || is_allowed_cargo_config_key(&normalized)
|
||||
|| is_allowed_dotnet_child_env_key(&normalized)
|
||||
|| is_allowed_platform_path_like_child_env_key(&normalized)
|
||||
}
|
||||
|
||||
fn is_allowed_dotnet_child_env_key(normalized: &str) -> bool {
|
||||
const SECRET_WORDS: [&str; 8] = [
|
||||
"KEY", "CONNSTR", "PWD", "PASS", "DSN", "AUTH", "BEARER", "COOKIE",
|
||||
];
|
||||
const SWITCHES: &[&str] = &[
|
||||
"ROOT",
|
||||
"ROOT(X86)",
|
||||
"HOST_PATH",
|
||||
"NOLOGO",
|
||||
"SKIP_FIRST_TIME_EXPERIENCE",
|
||||
"MULTILEVEL_LOOKUP",
|
||||
"ENVIRONMENT",
|
||||
"BUNDLE_EXTRACT_BASE_DIR",
|
||||
"NUGET_SIGNATURE_VERIFICATION",
|
||||
"ADD_GLOBAL_TOOLS_TO_PATH",
|
||||
"USE_POLLING_FILE_WATCHER",
|
||||
"RUNNING_IN_CONTAINER",
|
||||
"GENERATE_ASPNET_CERTIFICATE",
|
||||
"TOOLS_ALLOW_MANIFEST_IN_ROOT",
|
||||
"ADDITIONAL_DEPS",
|
||||
"SHARED_STORE",
|
||||
"STARTUP_HOOKS",
|
||||
"PROCESSOR_COUNT",
|
||||
"HTTPREPL_PREFERRED_EDITOR",
|
||||
"SDK_VERSION",
|
||||
"VERSION",
|
||||
"GCSERVER",
|
||||
"GCCONCURRENT",
|
||||
"GCGEN0SIZE",
|
||||
"GCHEAPHARDLIMIT",
|
||||
"GCHEAPHARDLIMITPERCENT",
|
||||
"GCHEAPCOUNT",
|
||||
"GCHEAPAFFINITIZEMASK",
|
||||
"GCNOAFFINITIZE",
|
||||
"GCCONSERVEMEMORY",
|
||||
"GCCPUGROUP",
|
||||
"TIEREDCOMPILATION",
|
||||
"TIEREDPGO",
|
||||
"TC_QUICKJIT",
|
||||
"TC_QUICKJITFORLOOPS",
|
||||
"TC_ONSTACKREPLACEMENT",
|
||||
"READYTORUN",
|
||||
"ENABLEDIAGNOSTICS",
|
||||
"ENABLEDIAGNOSTICS_IPC",
|
||||
"ENABLEDIAGNOSTICS_DEBUGGER",
|
||||
"ENABLEDIAGNOSTICS_PROFILER",
|
||||
"DBGENABLEMINIDUMP",
|
||||
"DBGMINIDUMPTYPE",
|
||||
"DBGMINIDUMPNAME",
|
||||
"CREATEDUMPDIAGNOSTICS",
|
||||
"DIAGNOSTICPORTS",
|
||||
"ENABLEEVENTPIPE",
|
||||
"EVENTPIPEOUTPUTPATH",
|
||||
"EVENTPIPECONFIG",
|
||||
"ENABLEWRITEXOREXECUTE",
|
||||
"THREAD_USEALLCPUGROUPS",
|
||||
];
|
||||
const FAMILIES: &[&str] = &[
|
||||
"ROOT_",
|
||||
"CLI_",
|
||||
"SYSTEM_",
|
||||
"ROLL_FORWARD",
|
||||
"WATCH_",
|
||||
"MSBUILD_SDK_RESOLVER_",
|
||||
];
|
||||
let Some(name) = normalized.strip_prefix("DOTNET_") else {
|
||||
return false;
|
||||
};
|
||||
if !SWITCHES.contains(&name) && !FAMILIES.iter().any(|family| name.starts_with(family)) {
|
||||
return false;
|
||||
}
|
||||
if name.contains("__") || name.contains(':') {
|
||||
return false;
|
||||
}
|
||||
let compact: String = name.chars().filter(|c| *c != '_').collect();
|
||||
!SECRET_WORDS.iter().any(|word| compact.contains(word))
|
||||
&& !is_secret_like_child_env_key(normalized)
|
||||
}
|
||||
|
||||
/// Cargo's `CARGO_*` configuration namespace (`CARGO_TARGET_DIR`,
|
||||
/// `CARGO_BUILD_JOBS`, `CARGO_INCREMENTAL`, ...) minus anything that can carry
|
||||
/// a credential: registry tokens and credential providers live under
|
||||
@@ -500,6 +587,7 @@ fn is_secret_like_child_env_key(normalized: &str) -> bool {
|
||||
|| normalized.contains("API_KEY")
|
||||
|| normalized.contains("ACCESS_KEY")
|
||||
|| normalized.contains("PRIVATE_KEY")
|
||||
|| normalized.contains("CONNECTIONSTRING")
|
||||
|| normalized.ends_with("_KEY")
|
||||
}
|
||||
|
||||
@@ -1278,6 +1366,214 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_and_dotnet_allowlists_drop_secret_shaped_names() {
|
||||
for key in [
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LC_MESSAGES",
|
||||
"LC_TIME",
|
||||
"LC_COLLATE",
|
||||
"LC_NUMERIC",
|
||||
"LC_MONETARY",
|
||||
"LC_PAPER",
|
||||
"LC_NAME",
|
||||
"LC_ADDRESS",
|
||||
"LC_TELEPHONE",
|
||||
"LC_MEASUREMENT",
|
||||
"LC_IDENTIFICATION",
|
||||
"LC_TERMINAL",
|
||||
"LC_TERMINAL_VERSION",
|
||||
"lc_time",
|
||||
"DOTNET_ROOT",
|
||||
"DOTNET_ROOT(x86)",
|
||||
"DOTNET_ROOT_X64",
|
||||
"DOTNET_HOST_PATH",
|
||||
"DOTNET_CLI_HOME",
|
||||
"DOTNET_NOLOGO",
|
||||
"DOTNET_CLI_TELEMETRY_OPTOUT",
|
||||
"DOTNET_SKIP_FIRST_TIME_EXPERIENCE",
|
||||
"DOTNET_MULTILEVEL_LOOKUP",
|
||||
"DOTNET_ROLL_FORWARD",
|
||||
"DOTNET_SYSTEM_GLOBALIZATION_INVARIANT",
|
||||
"DOTNET_CLI_UI_LANGUAGE",
|
||||
"DOTNET_ENVIRONMENT",
|
||||
"DOTNET_BUNDLE_EXTRACT_BASE_DIR",
|
||||
"DOTNET_NUGET_SIGNATURE_VERIFICATION",
|
||||
"DOTNET_ADD_GLOBAL_TOOLS_TO_PATH",
|
||||
"DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER",
|
||||
"DOTNET_USE_POLLING_FILE_WATCHER",
|
||||
"DOTNET_RUNNING_IN_CONTAINER",
|
||||
"DOTNET_TieredPGO",
|
||||
"DOTNET_TieredCompilation",
|
||||
"DOTNET_GCHeapHardLimit",
|
||||
"DOTNET_EnableDiagnostics",
|
||||
"dotnet_gcServer",
|
||||
"DOTNET_SDK_VERSION",
|
||||
"DOTNET_SYSTEM_NET_DISABLEIPV6",
|
||||
"DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE",
|
||||
"DOTNET_CLI_TELEMETRY_SESSIONID",
|
||||
"DOTNET_WATCH_RESTART_ON_RUDE_EDIT",
|
||||
"DOTNET_STARTUP_HOOKS",
|
||||
"DOTNET_EventPipeOutputPath",
|
||||
"DOTNET_DbgMiniDumpName",
|
||||
"DOTNET_DiagnosticPorts",
|
||||
] {
|
||||
assert!(
|
||||
is_allowed_parent_env_key(OsStr::new(key)),
|
||||
"child env should keep {key}"
|
||||
);
|
||||
assert!(
|
||||
is_allowed_mcp_env_key(OsStr::new(key)),
|
||||
"mcp env should keep {key}"
|
||||
);
|
||||
}
|
||||
for key in [
|
||||
"LC_X_TOKEN",
|
||||
"LC_PROBE_SECRET_TOKEN",
|
||||
"lc_probe_password",
|
||||
"LC_PROBE_API_KEY",
|
||||
"LC_PROBE_SIGNING_KEY",
|
||||
"LC_APIKEY",
|
||||
"LC_ApiKey",
|
||||
"LC_OPENAI_APIKEY",
|
||||
"LC_GH_PAT",
|
||||
"LC_PAT",
|
||||
"LC_PASS",
|
||||
"LC_X_PASS",
|
||||
"LC_PWD",
|
||||
"LC_AUTH",
|
||||
"LC_X_AUTH",
|
||||
"LC_BEARER",
|
||||
"LC_JWT",
|
||||
"LC_DB_URL",
|
||||
"LC_DSN",
|
||||
"LC_ACCESSKEY",
|
||||
"LC_PRIVATEKEY",
|
||||
"LC_SESSION_COOKIE",
|
||||
"LC_TIME_EXTRA",
|
||||
"DOTNET_X_API_KEY",
|
||||
"DOTNET_PROBE_API_KEY",
|
||||
"DOTNET_PROBE_ACCESS_KEY",
|
||||
"DOTNET_CONNECTIONSTRINGS__PROBE_PASSWORD",
|
||||
"DOTNET_ConnectionStrings__Default",
|
||||
"DOTNET_ConnectionStrings:Default",
|
||||
"DOTNET_Data__DefaultConnection__ConnectionString",
|
||||
"DOTNET_ConnStr",
|
||||
"DOTNET_ApiKey",
|
||||
"DOTNET_OpenAI__ApiKey",
|
||||
"DOTNET_OpenAI:ApiKey",
|
||||
"DOTNET_Stripe__ApiKey",
|
||||
"DOTNET_Jwt__SigningKey",
|
||||
"DOTNET_Jwt__Key",
|
||||
"DOTNET_Jwt__Keys__0",
|
||||
"DOTNET_Aws__AccessKey",
|
||||
"DOTNET_Signing__PrivateKey",
|
||||
"DOTNET_AzureStorage__AccountKey",
|
||||
"DOTNET_ApplicationInsights__InstrumentationKey",
|
||||
"DOTNET_Auth__ClientSecret",
|
||||
"DOTNET_Auth__Bearer",
|
||||
"DOTNET_Smtp__Pass",
|
||||
"DOTNET_Db__Pwd",
|
||||
"DOTNET_Database__ConnStr",
|
||||
"DOTNET_Database__Url",
|
||||
"DOTNET_Sentry__Dsn",
|
||||
"DOTNET_Redis__Configuration",
|
||||
"DOTNET_Azure__SasToken",
|
||||
"dotnet_probe_credential",
|
||||
"DOTNET_",
|
||||
"DOTNET__",
|
||||
"DOTNET_GH_PAT",
|
||||
"DOTNET_PAT",
|
||||
"DOTNET_NUGET_PAT",
|
||||
"DOTNET_AzureDevOpsPat",
|
||||
"DOTNET_JWT",
|
||||
"DOTNET_DATABASE_URL",
|
||||
"DOTNET_DB_URL",
|
||||
"DOTNET_DatabaseUrl",
|
||||
"DOTNET_DefaultConnection",
|
||||
"DOTNET_RedisConfiguration",
|
||||
"DOTNET_SentryUrl",
|
||||
"DOTNET_SlackWebhookUrl",
|
||||
"DOTNET_SasUrl",
|
||||
"DOTNET_Hmac",
|
||||
"DOTNET_Salt",
|
||||
"DOTNET_Signature",
|
||||
"DOTNET_Pfx",
|
||||
"DOTNET_Otp",
|
||||
"DOTNET_Creds",
|
||||
"DOTNET_Cred",
|
||||
"DOTNET_Pw",
|
||||
"DOTNET_Pin",
|
||||
"DOTNET_Mnemonic",
|
||||
"DOTNET_Seed",
|
||||
"DOTNET_SessionId",
|
||||
"DOTNET_Login",
|
||||
"DOTNET_License",
|
||||
"DOTNET_Api\u{212A}ey",
|
||||
"DOTNET_Api\u{FF2B}ey",
|
||||
"DOTNET_TieredPGO_X",
|
||||
"DOTNET_ROOT__X",
|
||||
] {
|
||||
assert!(
|
||||
!is_allowed_parent_env_key(OsStr::new(key)),
|
||||
"child env must not keep {key}"
|
||||
);
|
||||
assert!(
|
||||
!is_allowed_mcp_env_key(OsStr::new(key)),
|
||||
"mcp env must not keep {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_envs_drop_secret_shaped_locale_and_dotnet_names() {
|
||||
let _guard = crate::test_support::lock_test_env();
|
||||
let secrets = [
|
||||
"LC_PROBE_SECRET_TOKEN",
|
||||
"LC_APIKEY",
|
||||
"LC_GH_PAT",
|
||||
"LC_PASS",
|
||||
"DOTNET_PROBE_API_KEY",
|
||||
"DOTNET_OpenAI__ApiKey",
|
||||
"DOTNET_Jwt__SigningKey",
|
||||
"DOTNET_Smtp__Pass",
|
||||
"DOTNET_Db__Pwd",
|
||||
"DOTNET_GH_PAT",
|
||||
"DOTNET_DATABASE_URL",
|
||||
"DOTNET_DefaultConnection",
|
||||
"DOTNET_SlackWebhookUrl",
|
||||
];
|
||||
let _secret_guards: Vec<_> = secrets
|
||||
.iter()
|
||||
.map(|name| EnvVarGuard::set(name, "probe-fixture-value"))
|
||||
.collect();
|
||||
let _dotnet_root = EnvVarGuard::set("DOTNET_ROOT", "/opt/dotnet");
|
||||
let _lc_time = EnvVarGuard::set("LC_TIME", "C");
|
||||
|
||||
let parent: Vec<(OsString, OsString)> = std::env::vars_os().collect();
|
||||
for env in [
|
||||
sanitized_child_env(std::iter::empty::<(OsString, OsString)>()),
|
||||
sanitized_mcp_env(std::iter::empty::<(OsString, OsString)>()),
|
||||
sanitized_runtime_env_from(parent.clone()),
|
||||
sanitized_plugin_mcp_env_from(
|
||||
parent.clone(),
|
||||
std::iter::empty::<(OsString, OsString)>(),
|
||||
),
|
||||
] {
|
||||
let has = |name: &str| env.iter().any(|(key, _)| normalize_key(key) == name);
|
||||
for name in secrets {
|
||||
assert!(!has(name), "{name} reached the child env");
|
||||
}
|
||||
assert!(
|
||||
env.iter()
|
||||
.all(|(_, value)| !value.to_string_lossy().contains("probe-fixture"))
|
||||
);
|
||||
assert!(has("DOTNET_ROOT"));
|
||||
assert!(has("LC_TIME"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_userinfo_is_removed_before_reaching_a_child() {
|
||||
for (input, expected) in [
|
||||
|
||||
@@ -492,6 +492,8 @@ pub struct Settings {
|
||||
/// One-time Operate-retirement toast has been shown. Same suppression
|
||||
/// contract as `yolo_deprecation_shown`.
|
||||
pub operate_deprecation_shown: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account_preferences_notice_shown: Option<String>,
|
||||
/// Round 3 (2026-09-01) moved the work bar under the composer. Every
|
||||
/// settings.toml saved before that carries `work_surface_placement =
|
||||
/// "top"` — the old default, persisted verbatim by ordinary saves, not a
|
||||
@@ -650,9 +652,7 @@ impl Default for Settings {
|
||||
mention_menu_limit: 128,
|
||||
mention_walk_depth: 10,
|
||||
mention_menu_behavior: "fuzzy".to_string(),
|
||||
// Reasoning is useful when explicitly requested, but it should
|
||||
// never displace the actual conversation in the default TUI.
|
||||
show_thinking: false,
|
||||
show_thinking: true,
|
||||
thinking_default_expanded: false,
|
||||
thinking_preview_lines: default_thinking_preview_lines(),
|
||||
thinking_highlight: true,
|
||||
@@ -694,6 +694,7 @@ impl Default for Settings {
|
||||
feature_intro_shown: false,
|
||||
yolo_deprecation_shown: false,
|
||||
operate_deprecation_shown: false,
|
||||
account_preferences_notice_shown: None,
|
||||
legacy_operate_default: false,
|
||||
work_surface_bottom_migrated: false,
|
||||
behavioral_tip_impressions: std::collections::BTreeMap::new(),
|
||||
@@ -3747,6 +3748,36 @@ mod tests {
|
||||
assert_eq!(reloaded.permission_posture.as_deref(), Some("ask"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_shows_by_default_and_a_saved_choice_survives_reload_and_other_saves() {
|
||||
let _g = config_path_test_guard();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let settings_path = tmp.path().join("settings.toml");
|
||||
let _config_override =
|
||||
EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
|
||||
|
||||
let absent = Settings::load().expect("load without a settings file");
|
||||
assert!(absent.show_thinking);
|
||||
assert!(!absent.thinking_default_expanded);
|
||||
assert!(!absent.is_set("show_thinking"));
|
||||
|
||||
std::fs::write(&settings_path, "theme = \"light\"\n").expect("settings without the key");
|
||||
let unnamed = Settings::load().expect("load settings without the key");
|
||||
assert!(unnamed.show_thinking);
|
||||
assert!(!unnamed.is_set("show_thinking"));
|
||||
|
||||
for saved in [false, true] {
|
||||
std::fs::write(&settings_path, format!("show_thinking = {saved}\n"))
|
||||
.expect("saved choice");
|
||||
let loaded = Settings::load().expect("load the saved choice");
|
||||
assert_eq!(loaded.show_thinking, saved);
|
||||
assert!(loaded.is_set("show_thinking"));
|
||||
Settings::transact(|settings| settings.set("locale", "en")).expect("another save");
|
||||
let reloaded = Settings::load().expect("reload after another save");
|
||||
assert_eq!(reloaded.show_thinking, saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_load_keeps_top_placement_chosen_after_the_bottom_migration() {
|
||||
let _g = config_path_test_guard();
|
||||
@@ -3886,7 +3917,7 @@ mod tests {
|
||||
let mut settings = Settings::default();
|
||||
// Density is calm by default; motion is an independent axis.
|
||||
assert!(settings.calm_mode);
|
||||
assert!(!settings.show_thinking);
|
||||
settings.show_thinking = false;
|
||||
|
||||
let changed = settings.apply_preset("CALM").expect("calm preset applies");
|
||||
assert_eq!(
|
||||
@@ -3916,8 +3947,8 @@ mod tests {
|
||||
assert!(settings.fancy_animations);
|
||||
assert_eq!(settings.transcript_spacing, "spacious");
|
||||
assert_eq!(settings.tool_collapse_mode, "compact");
|
||||
// Thinking is opt-in so the transcript stays focused on the chat.
|
||||
assert!(!settings.show_thinking);
|
||||
assert!(settings.show_thinking);
|
||||
assert!(!settings.thinking_default_expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
use std::io::{self, Cursor, Read};
|
||||
|
||||
use flate2::read::GzDecoder;
|
||||
|
||||
const STREAM_CAP_FACTOR: u64 = 4;
|
||||
const STREAM_CAP_CEILING: u64 = 4 * 1024 * 1024 * 1024;
|
||||
|
||||
pub const MAX_ENTRY_NAME_BYTES: usize = 4096;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StreamLimitExceeded;
|
||||
|
||||
impl std::fmt::Display for StreamLimitExceeded {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("decompressed archive stream exceeds the size limit")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StreamLimitExceeded {}
|
||||
|
||||
pub struct BoundedReader<R> {
|
||||
inner: R,
|
||||
remaining: u64,
|
||||
tripped: bool,
|
||||
}
|
||||
|
||||
impl<R: Read> Read for BoundedReader<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if self.tripped {
|
||||
return Err(io::Error::other(StreamLimitExceeded));
|
||||
}
|
||||
let read = self.inner.read(buf)?;
|
||||
match self.remaining.checked_sub(read as u64) {
|
||||
Some(remaining) => {
|
||||
self.remaining = remaining;
|
||||
Ok(read)
|
||||
}
|
||||
None => {
|
||||
self.tripped = true;
|
||||
self.remaining = 0;
|
||||
Err(io::Error::other(StreamLimitExceeded))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type BoundedTarball<'a> = tar::Archive<BoundedReader<GzDecoder<Cursor<&'a [u8]>>>>;
|
||||
|
||||
pub fn stream_cap(max_size: u64) -> u64 {
|
||||
max_size
|
||||
.saturating_mul(STREAM_CAP_FACTOR)
|
||||
.min(STREAM_CAP_CEILING)
|
||||
}
|
||||
|
||||
pub fn open_bounded_tarball(bytes: &[u8], max_size: u64) -> BoundedTarball<'_> {
|
||||
tar::Archive::new(BoundedReader {
|
||||
inner: GzDecoder::new(Cursor::new(bytes)),
|
||||
remaining: stream_cap(max_size),
|
||||
tripped: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entry_names_within_bound<R: Read>(entry: &tar::Entry<'_, R>) -> bool {
|
||||
entry.path_bytes().len() <= MAX_ENTRY_NAME_BYTES
|
||||
&& entry
|
||||
.link_name_bytes()
|
||||
.is_none_or(|name| name.len() <= MAX_ENTRY_NAME_BYTES)
|
||||
}
|
||||
|
||||
pub fn stream_limit_exceeded(err: &anyhow::Error) -> bool {
|
||||
err.chain().any(|cause| {
|
||||
cause
|
||||
.downcast_ref::<io::Error>()
|
||||
.and_then(io::Error::get_ref)
|
||||
.is_some_and(|inner| inner.is::<StreamLimitExceeded>())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn map_stream_limit(
|
||||
err: anyhow::Error,
|
||||
oversized: impl FnOnce() -> anyhow::Error,
|
||||
) -> anyhow::Error {
|
||||
if stream_limit_exceeded(&err) {
|
||||
oversized()
|
||||
} else {
|
||||
err
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_body_within(reader: &mut impl Read, remaining: u64) -> io::Result<Option<Vec<u8>>> {
|
||||
let mut buf = Vec::new();
|
||||
reader
|
||||
.take(remaining.saturating_add(1))
|
||||
.read_to_end(&mut buf)?;
|
||||
Ok((buf.len() as u64 <= remaining).then_some(buf))
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_archives {
|
||||
use std::io::Read;
|
||||
|
||||
type Builder = tar::Builder<flate2::write::GzEncoder<Vec<u8>>>;
|
||||
|
||||
fn builder() -> Builder {
|
||||
tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::best(),
|
||||
))
|
||||
}
|
||||
|
||||
fn finish(builder: Builder) -> Vec<u8> {
|
||||
builder
|
||||
.into_inner()
|
||||
.expect("tar trailer")
|
||||
.finish()
|
||||
.expect("gzip trailer")
|
||||
}
|
||||
|
||||
fn add(
|
||||
builder: &mut Builder,
|
||||
kind: tar::EntryType,
|
||||
path: &str,
|
||||
declared: u64,
|
||||
body: impl Read,
|
||||
) {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(kind);
|
||||
header.set_path(path).expect("path");
|
||||
header.set_size(declared);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder.append(&header, body).expect("append");
|
||||
}
|
||||
|
||||
fn filler(byte: u8, len: u64) -> impl Read {
|
||||
std::io::repeat(byte).take(len)
|
||||
}
|
||||
|
||||
fn pax_size_record(size: u64) -> String {
|
||||
let body = format!(" size={size}\n");
|
||||
let mut total = body.len();
|
||||
while total != body.len() + total.to_string().len() {
|
||||
total = body.len() + total.to_string().len();
|
||||
}
|
||||
format!("{total}{body}")
|
||||
}
|
||||
|
||||
pub fn pax_size_record_archive(first: (&str, &[u8]), body_len: u64) -> Vec<u8> {
|
||||
let mut archive = builder();
|
||||
add(
|
||||
&mut archive,
|
||||
tar::EntryType::Regular,
|
||||
first.0,
|
||||
first.1.len() as u64,
|
||||
first.1,
|
||||
);
|
||||
let record = pax_size_record(body_len);
|
||||
add(
|
||||
&mut archive,
|
||||
tar::EntryType::XHeader,
|
||||
"PaxHeader/blob",
|
||||
record.len() as u64,
|
||||
record.as_bytes(),
|
||||
);
|
||||
add(
|
||||
&mut archive,
|
||||
tar::EntryType::Regular,
|
||||
"blob",
|
||||
0,
|
||||
filler(0, body_len),
|
||||
);
|
||||
finish(archive)
|
||||
}
|
||||
|
||||
pub fn extension_body_archive(
|
||||
first: (&str, &[u8]),
|
||||
kind: tar::EntryType,
|
||||
body_len: u64,
|
||||
) -> Vec<u8> {
|
||||
let mut archive = builder();
|
||||
add(
|
||||
&mut archive,
|
||||
tar::EntryType::Regular,
|
||||
first.0,
|
||||
first.1.len() as u64,
|
||||
first.1,
|
||||
);
|
||||
add(&mut archive, kind, "ext", body_len, filler(b'a', body_len));
|
||||
add(&mut archive, tar::EntryType::Regular, "blob", 0, &b""[..]);
|
||||
finish(archive)
|
||||
}
|
||||
|
||||
pub fn long_name_archive(first: (&str, &[u8]), name_len: usize) -> Vec<u8> {
|
||||
let mut archive = builder();
|
||||
add(
|
||||
&mut archive,
|
||||
tar::EntryType::Regular,
|
||||
first.0,
|
||||
first.1.len() as u64,
|
||||
first.1,
|
||||
);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Regular);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o644);
|
||||
let leaf = if name_len & 1 == 0 { "xx" } else { "x" };
|
||||
let name = format!("{}{leaf}", "a/".repeat(name_len.saturating_sub(1) / 2));
|
||||
archive
|
||||
.append_data(&mut header, &name, &b""[..])
|
||||
.expect("append long name");
|
||||
finish(archive)
|
||||
}
|
||||
|
||||
pub fn long_link_target_archive(first: (&str, &[u8]), target_len: usize) -> Vec<u8> {
|
||||
let mut archive = builder();
|
||||
add(
|
||||
&mut archive,
|
||||
tar::EntryType::Regular,
|
||||
first.0,
|
||||
first.1.len() as u64,
|
||||
first.1,
|
||||
);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Symlink);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o777);
|
||||
let target = "b".repeat(target_len);
|
||||
archive
|
||||
.append_link(&mut header, "link", &target)
|
||||
.expect("append long link");
|
||||
finish(archive)
|
||||
}
|
||||
|
||||
pub fn empty_entries_archive(first: (&str, &[u8]), count: usize) -> Vec<u8> {
|
||||
let mut archive = builder();
|
||||
add(
|
||||
&mut archive,
|
||||
tar::EntryType::Regular,
|
||||
first.0,
|
||||
first.1.len() as u64,
|
||||
first.1,
|
||||
);
|
||||
for index in 0..count {
|
||||
add(
|
||||
&mut archive,
|
||||
tar::EntryType::Regular,
|
||||
&format!("empty-{index}"),
|
||||
0,
|
||||
&b""[..],
|
||||
);
|
||||
}
|
||||
finish(archive)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn body_read_accepts_exactly_the_remaining_budget() {
|
||||
let data = [7u8; 10];
|
||||
assert_eq!(
|
||||
read_body_within(&mut &data[..], 10).unwrap().unwrap().len(),
|
||||
10
|
||||
);
|
||||
assert!(read_body_within(&mut &data[..], 9).unwrap().is_none());
|
||||
assert!(read_body_within(&mut &data[..], 0).unwrap().is_none());
|
||||
assert!(read_body_within(&mut &[][..], 0).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reader_errors_instead_of_truncating_past_the_limit() {
|
||||
let mut reader = BoundedReader {
|
||||
inner: &[1u8; 100][..],
|
||||
remaining: 64,
|
||||
tripped: false,
|
||||
};
|
||||
let mut sink = Vec::new();
|
||||
let err = reader.read_to_end(&mut sink).unwrap_err();
|
||||
assert!(
|
||||
err.get_ref()
|
||||
.is_some_and(|inner| inner.is::<StreamLimitExceeded>())
|
||||
);
|
||||
assert!(stream_limit_exceeded(&anyhow::Error::from(err)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reader_stays_failed_after_it_trips() {
|
||||
let mut reader = BoundedReader {
|
||||
inner: &[1u8; 100][..],
|
||||
remaining: 64,
|
||||
tripped: false,
|
||||
};
|
||||
let mut chunk = [0u8; 80];
|
||||
assert!(reader.read(&mut chunk).is_err());
|
||||
for _ in 0..3 {
|
||||
let err = reader.read(&mut chunk[..1]).unwrap_err();
|
||||
assert!(
|
||||
err.get_ref()
|
||||
.is_some_and(|inner| inner.is::<StreamLimitExceeded>())
|
||||
);
|
||||
}
|
||||
assert!(reader.read(&mut []).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_cap_is_four_times_the_cap_up_to_a_ceiling() {
|
||||
assert_eq!(stream_cap(5 * 1024 * 1024), 20 * 1024 * 1024);
|
||||
assert_eq!(stream_cap(u64::MAX), STREAM_CAP_CEILING);
|
||||
assert_eq!(stream_cap(0), 0);
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,14 @@
|
||||
//! does not execute `plugin.json` plugin runtimes or custom command bundles.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::io::Write;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::bounded_tar::{
|
||||
MAX_ENTRY_NAME_BYTES, entry_names_within_bound, map_stream_limit, open_bounded_tarball,
|
||||
read_body_within, stream_cap,
|
||||
};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use flate2::read::GzDecoder;
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -334,6 +337,8 @@ pub enum InstallError {
|
||||
PathTraversal(String),
|
||||
#[error("entry is too large; uncompressed total would exceed {limit} bytes")]
|
||||
OversizedTarball { limit: u64 },
|
||||
#[error("archive entry name is longer than {limit} bytes")]
|
||||
EntryNameTooLong { limit: usize },
|
||||
#[error("missing SKILL.md in archive")]
|
||||
MissingSkillMd,
|
||||
#[error("symlinks are not allowed in skill tarballs")]
|
||||
@@ -1230,8 +1235,9 @@ enum DownloadAttempt {
|
||||
}
|
||||
|
||||
/// Stream a URL into memory with a size cap. Aborts on the first read that
|
||||
/// would push the buffer over `max_size * 4` (the *4 accounts for compression;
|
||||
/// the unpack step still enforces `max_size` on the *uncompressed* bytes).
|
||||
/// would push the buffer over `(max_size * 4).min(4 GiB)` (accounting for
|
||||
/// compression while capping compressed downloads at 4 GiB; the unpack step
|
||||
/// still enforces `max_size` on the *uncompressed* bytes).
|
||||
async fn download_with_cap(
|
||||
url: &str,
|
||||
network: &NetworkPolicy,
|
||||
@@ -1257,7 +1263,7 @@ async fn download_with_cap(
|
||||
/// Soft cap on the *compressed* download — well above `max_size` to allow for
|
||||
/// highly compressible payloads but still bounded.
|
||||
fn compressed_download_cap(max_size: u64) -> usize {
|
||||
usize::try_from(max_size.saturating_mul(4)).unwrap_or(usize::MAX)
|
||||
usize::try_from(stream_cap(max_size)).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
struct StagedSkill {
|
||||
@@ -1320,9 +1326,17 @@ struct TarballScan {
|
||||
/// reject path-traversal entries and symlinks inside the selected install
|
||||
/// subtree. We do not write anything in this pass; that's the second pass's job.
|
||||
fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
scan_tarball_entries(bytes, max_size).map_err(|err| oversized_if_stream_limit(err, max_size))
|
||||
}
|
||||
|
||||
fn oversized_if_stream_limit(err: anyhow::Error, max_size: u64) -> anyhow::Error {
|
||||
map_stream_limit(err, || {
|
||||
InstallError::OversizedTarball { limit: max_size }.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_tarball_entries(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
let mut archive = open_bounded_tarball(bytes, max_size);
|
||||
|
||||
let mut total_size: u64 = 0;
|
||||
let mut prefix: Option<(String, bool)> = None;
|
||||
@@ -1337,6 +1351,12 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
.context("failed to read tar entries (corrupt archive?)")?
|
||||
{
|
||||
let mut entry = entry.context("failed to read tar entry")?;
|
||||
if !entry_names_within_bound(&entry) {
|
||||
return Err(InstallError::EntryNameTooLong {
|
||||
limit: MAX_ENTRY_NAME_BYTES,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let header = entry.header().clone();
|
||||
let entry_type = header.entry_type();
|
||||
let path = entry
|
||||
@@ -1351,15 +1371,9 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
has_claude_plugin_manifest = true;
|
||||
}
|
||||
|
||||
// Track total size against `max_size` (uncompressed). We honor `header
|
||||
// .size` rather than streaming-read every file; tar archives are
|
||||
// self-describing so this is reliable for non-malicious inputs and
|
||||
// catches the gzip-bomb case.
|
||||
if let Ok(size) = header.size() {
|
||||
total_size = total_size.saturating_add(size);
|
||||
if total_size > max_size {
|
||||
return Err(InstallError::OversizedTarball { limit: max_size }.into());
|
||||
}
|
||||
total_size = total_size.saturating_add(entry.size());
|
||||
if total_size > max_size {
|
||||
return Err(InstallError::OversizedTarball { limit: max_size }.into());
|
||||
}
|
||||
|
||||
if entry_type == tar::EntryType::XGlobalHeader {
|
||||
@@ -1394,10 +1408,9 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
|
||||
.file_name()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
|
||||
{
|
||||
let mut buf = Vec::new();
|
||||
entry
|
||||
.read_to_end(&mut buf)
|
||||
.context("failed to read SKILL.md from archive")?;
|
||||
let buf = read_body_within(&mut entry, max_size)
|
||||
.context("failed to read SKILL.md from archive")?
|
||||
.ok_or(InstallError::OversizedTarball { limit: max_size })?;
|
||||
skill_md_files.push((path_str, buf));
|
||||
}
|
||||
}
|
||||
@@ -1508,9 +1521,12 @@ fn is_claude_plugin_manifest_path(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
extract_entries(scan, bytes, dest, max_size)
|
||||
.map_err(|err| oversized_if_stream_limit(err, max_size))
|
||||
}
|
||||
|
||||
fn extract_entries(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
|
||||
let mut archive = open_bounded_tarball(bytes, max_size);
|
||||
|
||||
let mut total_size: u64 = 0;
|
||||
let prefix_with_root = if scan.skill_root.is_empty() {
|
||||
@@ -1526,6 +1542,12 @@ fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) ->
|
||||
.context("failed to read tar entries (corrupt archive?)")?
|
||||
{
|
||||
let mut entry = entry.context("failed to read tar entry")?;
|
||||
if !entry_names_within_bound(&entry) {
|
||||
return Err(InstallError::EntryNameTooLong {
|
||||
limit: MAX_ENTRY_NAME_BYTES,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let header = entry.header().clone();
|
||||
let entry_type = header.entry_type();
|
||||
let path = entry
|
||||
@@ -1583,14 +1605,10 @@ fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) ->
|
||||
}
|
||||
// Read into a buffer so we can enforce `max_size`. Files inside
|
||||
// a SKILL bundle are small; copying through a buffer is fine.
|
||||
let mut buf = Vec::new();
|
||||
entry
|
||||
.read_to_end(&mut buf)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let buf = read_body_within(&mut entry, max_size.saturating_sub(total_size))
|
||||
.with_context(|| format!("failed to read {}", path.display()))?
|
||||
.ok_or(InstallError::OversizedTarball { limit: max_size })?;
|
||||
total_size = total_size.saturating_add(buf.len() as u64);
|
||||
if total_size > max_size {
|
||||
return Err(InstallError::OversizedTarball { limit: max_size }.into());
|
||||
}
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.create_new(true).write(true);
|
||||
#[cfg(unix)]
|
||||
@@ -1754,6 +1772,7 @@ fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bounded_tar::test_archives;
|
||||
|
||||
fn skill_tarball(entries: &[(&str, &[u8], u32)]) -> Vec<u8> {
|
||||
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
@@ -1768,6 +1787,92 @@ mod tests {
|
||||
builder.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
const SKILL_MD: &[u8] = b"---\nname: demo\ndescription: test\n---\nbody";
|
||||
const SMALL_CAP: u64 = 64 * 1024;
|
||||
const HUGE_BODY: u64 = 16 * 1024 * 1024;
|
||||
|
||||
fn assert_oversized(err: &anyhow::Error) {
|
||||
assert!(
|
||||
matches!(
|
||||
err.downcast_ref::<InstallError>(),
|
||||
Some(InstallError::OversizedTarball { .. })
|
||||
),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_counts_the_size_a_pax_record_gives_an_entry() {
|
||||
let bytes = test_archives::pax_size_record_archive(("SKILL.md", SKILL_MD), HUGE_BODY);
|
||||
assert_oversized(&scan_tarball(&bytes, SMALL_CAP).err().unwrap());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_oversized(
|
||||
&stage_tarball(&bytes, tmp.path(), SMALL_CAP)
|
||||
.map(|staged| staged.skill_name)
|
||||
.unwrap_err(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_and_extract_bound_the_decoded_stream_for_extension_entries() {
|
||||
let benign = skill_tarball(&[("SKILL.md", SKILL_MD, 0o644)]);
|
||||
let benign_scan = scan_tarball(&benign, SMALL_CAP).unwrap();
|
||||
for kind in [tar::EntryType::GNULongName, tar::EntryType::XHeader] {
|
||||
let bytes =
|
||||
test_archives::extension_body_archive(("SKILL.md", SKILL_MD), kind, HUGE_BODY);
|
||||
assert_oversized(&scan_tarball(&bytes, SMALL_CAP).err().unwrap());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_oversized(
|
||||
&extract_into(&benign_scan, &bytes, tmp.path(), SMALL_CAP).unwrap_err(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_name_too_long(err: &anyhow::Error) {
|
||||
assert!(
|
||||
matches!(
|
||||
err.downcast_ref::<InstallError>(),
|
||||
Some(InstallError::EntryNameTooLong { .. })
|
||||
),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_names_longer_than_the_bound_are_rejected_before_any_copy() {
|
||||
let benign = skill_tarball(&[("SKILL.md", SKILL_MD, 0o644)]);
|
||||
let benign_scan = scan_tarball(&benign, SMALL_CAP).unwrap();
|
||||
let archives = [
|
||||
test_archives::long_name_archive(("SKILL.md", SKILL_MD), MAX_ENTRY_NAME_BYTES + 1),
|
||||
test_archives::long_name_archive(("SKILL.md", SKILL_MD), 100 * 1024),
|
||||
test_archives::long_link_target_archive(
|
||||
("SKILL.md", SKILL_MD),
|
||||
MAX_ENTRY_NAME_BYTES + 1,
|
||||
),
|
||||
];
|
||||
for bytes in &archives {
|
||||
assert_name_too_long(&scan_tarball(bytes, SMALL_CAP).err().unwrap());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_name_too_long(
|
||||
&extract_into(&benign_scan, bytes, tmp.path(), SMALL_CAP).unwrap_err(),
|
||||
);
|
||||
}
|
||||
let at_bound =
|
||||
test_archives::long_name_archive(("SKILL.md", SKILL_MD), MAX_ENTRY_NAME_BYTES);
|
||||
assert_eq!(
|
||||
scan_tarball(&at_bound, SMALL_CAP).unwrap().skill_name,
|
||||
"demo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_bounds_the_decoded_stream_for_many_empty_entries() {
|
||||
let bytes = test_archives::empty_entries_archive(("SKILL.md", SKILL_MD), 600);
|
||||
assert_oversized(&scan_tarball(&bytes, SMALL_CAP).err().unwrap());
|
||||
let few = test_archives::empty_entries_archive(("SKILL.md", SKILL_MD), 20);
|
||||
assert_eq!(scan_tarball(&few, SMALL_CAP).unwrap().skill_name, "demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_prefix_comes_from_every_entry_not_the_first_one() {
|
||||
let body = b"---\nname: demo\ndescription: test\n---\nbody";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod bounded_tar;
|
||||
pub mod frontmatter;
|
||||
pub mod install;
|
||||
pub mod package_digest;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codewhale_ratatui::{
|
||||
CountBar, InstrumentSurface, KeyHint, MotionMode, Ombre, Paint, Role, Segmented,
|
||||
SegmentedState, TranscriptScrollFacts, TranscriptViewport, WaterPalette,
|
||||
ocean::OceanRamp,
|
||||
testing::{Profile, render, text},
|
||||
};
|
||||
use ratatui::{buffer::Buffer, layout::Rect, style::Modifier, text::Line};
|
||||
|
||||
#[test]
|
||||
fn long_action_hints_wrap_without_losing_the_label() {
|
||||
let theme = Profile::DarkTrue.theme();
|
||||
let surface = InstrumentSurface::new("Actions")
|
||||
.actions(vec![KeyHint::new("Enter", "Open selected session")]);
|
||||
let area = Rect::new(0, 0, 18, 12);
|
||||
let layout = surface.areas(area, &theme);
|
||||
assert!(layout.footer.height > 1);
|
||||
let buf = render(18, 12, |area, buf| surface.paint(area, buf, &theme));
|
||||
let shown = text(&buf).split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(
|
||||
shown.replace(' ', "").contains("Openselectedsession"),
|
||||
"{shown}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_uses_fractional_cells_only_when_unicode_is_available() {
|
||||
let bar = CountBar::new(1, 7);
|
||||
let unicode = render(60, 1, |area, buf| {
|
||||
bar.paint(area, buf, &Profile::DarkTrue.theme())
|
||||
});
|
||||
let shown = text(&unicode);
|
||||
assert!(shown.chars().any(|c| "▏▎▍▌▋▊▉".contains(c)), "{shown}");
|
||||
let ascii = render(60, 1, |area, buf| {
|
||||
bar.paint(area, buf, &Profile::Ascii.theme())
|
||||
});
|
||||
assert!(text(&ascii).is_ascii());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_segment_covers_brackets_and_narrow_focus_is_visible() {
|
||||
let theme = Profile::DarkTrue.theme();
|
||||
let control = Segmented::new(["First", "Second"], SegmentedState::new(0)).focused(true);
|
||||
let wide = render(30, 1, |area, buf| control.paint(area, buf, &theme));
|
||||
for x in 0..7 {
|
||||
assert_eq!(wide[(x, 0)].bg, theme.bg(Role::Selected).bg.unwrap());
|
||||
}
|
||||
let narrow = render(4, 1, |area, buf| control.paint(area, buf, &theme));
|
||||
assert!(narrow[(0, 0)].modifier.contains(Modifier::UNDERLINED));
|
||||
assert_eq!(narrow[(0, 0)].fg, theme.fg(Role::Accent).fg.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_transcript_has_no_scrollbar_and_final_position_reaches_bottom() {
|
||||
let rows = vec![Line::from("row"); 5];
|
||||
let theme = Profile::DarkTrue.theme();
|
||||
let mut viewport = TranscriptViewport::new(&rows);
|
||||
viewport.scrollbar = Some(TranscriptScrollFacts {
|
||||
top: 0,
|
||||
visible: 5,
|
||||
total: 5,
|
||||
});
|
||||
let fits = render(20, 5, |area, buf| viewport.paint(area, buf, &theme));
|
||||
assert!((0..5).all(|y| fits[(19, y)].symbol() == " "));
|
||||
viewport.scrollbar = Some(TranscriptScrollFacts {
|
||||
top: 0,
|
||||
visible: 5,
|
||||
total: 6,
|
||||
});
|
||||
let start = render(20, 5, |area, buf| viewport.paint(area, buf, &theme));
|
||||
assert_eq!(start[(19, 0)].symbol(), "┃");
|
||||
assert_eq!(start[(19, 4)].symbol(), "│");
|
||||
viewport.scrollbar.as_mut().unwrap().top = 1;
|
||||
let end = render(20, 5, |area, buf| viewport.paint(area, buf, &theme));
|
||||
assert_eq!(end[(19, 0)].symbol(), "│");
|
||||
assert_eq!(end[(19, 4)].symbol(), "┃");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocean_supports_measured_rgb_themes_and_preserves_fallbacks() {
|
||||
for profile in [Profile::DarkTrue, Profile::DarkGraphite, Profile::LightTrue] {
|
||||
assert!(
|
||||
OceanRamp::for_theme(&profile.theme()).is_some(),
|
||||
"{profile:?}"
|
||||
);
|
||||
}
|
||||
for profile in [
|
||||
Profile::Dark256,
|
||||
Profile::Light256,
|
||||
Profile::Ansi16,
|
||||
Profile::UnknownGround,
|
||||
Profile::NoColor,
|
||||
Profile::Ascii,
|
||||
] {
|
||||
assert!(
|
||||
OceanRamp::for_theme(&profile.theme()).is_none(),
|
||||
"{profile:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_drift_keeps_reduced_motion_static_and_repeats_the_caller_clock() {
|
||||
let theme = Profile::DarkTrue.theme();
|
||||
let area = Rect::new(0, 0, 40, 10);
|
||||
let mut source = Buffer::empty(area);
|
||||
source.set_style(area, theme.bg(Role::Background));
|
||||
let pass = Ombre::new(WaterPalette::Lagoon);
|
||||
let mut still = source.clone();
|
||||
pass.apply(area, &mut still, &theme);
|
||||
for mode in [MotionMode::Reduced, MotionMode::Still] {
|
||||
let mut frame = source.clone();
|
||||
pass.apply_at(area, &mut frame, &theme, Duration::from_secs(6), mode);
|
||||
assert_eq!(frame, still);
|
||||
}
|
||||
let mut moving = source.clone();
|
||||
pass.apply_at(
|
||||
area,
|
||||
&mut moving,
|
||||
&theme,
|
||||
Duration::from_secs(6),
|
||||
MotionMode::Full,
|
||||
);
|
||||
assert_ne!(moving, still);
|
||||
let mut repeated = source;
|
||||
pass.apply_at(
|
||||
area,
|
||||
&mut repeated,
|
||||
&theme,
|
||||
Duration::from_secs(30),
|
||||
MotionMode::Full,
|
||||
);
|
||||
assert_eq!(moving, repeated);
|
||||
assert_eq!(moving[(0, 0)], still[(0, 0)]);
|
||||
assert_eq!(moving[(39, 9)], still[(39, 9)]);
|
||||
}
|
||||
@@ -13,8 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Usage-reporting collector:** accepts runtime schema 6 alongside existing versions and keeps schema 6 reports first-party only.
|
||||
|
||||
- **Source-build toolchain:** Rust 1.100.0 is pinned consistently across local source builds, CI, Docker, Dev Containers and CNB recipes. The minimum supported Rust version remains 1.89.
|
||||
- **Terminal input recovery:** retains a wake event from a replaced reader, suppresses retired events observed during a child-terminal handoff, and closes only the handoff window owned by that scope.
|
||||
|
||||
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
|
||||
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
|
||||
- **Delayed-message workspaces and shell stdin approvals:** `send_later` `schedule` now keeps its workspace inside the session's reachable paths instead of accepting an arbitrary directory, and `send_later` approval prompts show the requested workspace; session shell grants now match exact inputs when `stdin` (`stdin`, `input`, `data`) is supplied instead of reusing a command-only grant across different `stdin`.
|
||||
|
||||
- **Installers no longer say Computer Use comes bundled:** the macOS install script and the install guide no longer print or state that Computer Use ships with the binary or needs Node.js 20. Computer Use is released separately and stays off until you turn it on.
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and every value is also in words, so the model and other clients get the same facts as text. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and a plain-text summary accompanies each chart for the model and other clients. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Terminal widgets:** narrow action hints wrap, scrollbars reach the final row, progress bars show fractional cells, focused tabs and panels are clearer, and the shared terminal kit supports measured ocean themes and reduced-motion-aware water drift.
|
||||
|
||||
- **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).
|
||||
@@ -28,8 +36,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
|
||||
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
|
||||
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
|
||||
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
|
||||
- **Chat bridges thread ownership:** Chat bridges now list and resume only the threads the chat itself created: `/threads` shows owned threads, `/resume` refuses any other thread id with one fixed message, and `/new` always starts a new thread. In an existing thread map each chat keeps only the thread it is bound to, so threads it created earlier and left are no longer listed or resumable; a thread that several chats already pointed at is usable from none of them (send `/new`). Review `thread-map.json` after upgrading.
|
||||
- **Skill and plugin installs:** skill and plugin installs now enforce the uncompressed size cap on the whole decompressed archive, and reject archive entries with names longer than 4096 bytes.
|
||||
|
||||
- **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.
|
||||
- **Website `/app` captures:** the `/app` page's desktop build screenshots (`conversation.webp`, `approval.webp`, `model-picker.webp`) and their alt and caption text have been refreshed from the current 0.10.3 desktop UI.
|
||||
- **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.
|
||||
@@ -110,6 +123,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Child processes now inherit only the standard locale variables (`LC_ALL`, `LC_CTYPE` and the other locale categories, plus `LC_TERMINAL` and `LC_TERMINAL_VERSION`) and a listed set of `DOTNET_` host, SDK and runtime settings. Other `DOTNET_` variables, including application configuration keys, are no longer passed to child processes or MCP servers.
|
||||
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
|
||||
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
|
||||
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
|
||||
|
||||
@@ -421,12 +421,9 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
app.session_turn_outcomes = session.turn_outcomes.clone();
|
||||
app.session_artifacts = session.artifacts.clone();
|
||||
app.session_context_references = session.context_references.clone();
|
||||
app.extend_history(
|
||||
session
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(crate::tui::history::history_cells_from_message),
|
||||
);
|
||||
app.extend_history(session.messages.iter().flat_map(|message| {
|
||||
crate::tui::history::history_cells_from_message_with_results(message, &session.messages)
|
||||
}));
|
||||
app.scroll_to_bottom();
|
||||
Ok(SessionBranchOutcome {
|
||||
leaf_display,
|
||||
|
||||
@@ -26,7 +26,7 @@ use ratatui::{
|
||||
};
|
||||
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::history::{HistoryCell, history_cells_from_message};
|
||||
use crate::tui::history::{HistoryCell, history_cells_from_message_with_results};
|
||||
use codewhale_engine::tools::subagent::SubAgentStatus;
|
||||
use codewhale_localization::MessageId;
|
||||
use codewhale_models::Message;
|
||||
@@ -191,7 +191,7 @@ fn cells_for_messages(messages: &[Message], receipts: &[(String, String)]) -> Ve
|
||||
}
|
||||
let mut cells = Vec::new();
|
||||
for message in messages {
|
||||
cells.extend(history_cells_from_message(message));
|
||||
cells.extend(history_cells_from_message_with_results(message, messages));
|
||||
for block in &message.content {
|
||||
let Some(key) = block.tool_call_key() else {
|
||||
continue;
|
||||
|
||||
@@ -87,6 +87,27 @@ pub(crate) use types::{
|
||||
WORKFLOW_DRAFT_INSTRUCTION_PREFIX,
|
||||
};
|
||||
|
||||
const ACCOUNT_PREFERENCES_NOTICE: &str = "account-preferences-notice";
|
||||
|
||||
fn account_sign_in_marker(profile: Option<&str>) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let api_base = codewhale_engine::profile_constitution::runtime_account_api_base();
|
||||
let session = codewhale_secrets::account::AccountSessionStore::new(
|
||||
codewhale_secrets::Secrets::file_backed_read_only(),
|
||||
profile,
|
||||
&api_base,
|
||||
)
|
||||
.runtime_info_at(Utc::now())
|
||||
.ok()
|
||||
.and_then(|info| info.session_id)
|
||||
.unwrap_or_default();
|
||||
Sha256::digest(session.as_bytes())
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// === Types ===
|
||||
|
||||
/// One login owns one mailbox. A cancelled task can only write its abandoned
|
||||
@@ -2448,6 +2469,7 @@ pub struct App {
|
||||
/// re-render per character. `None` means "no bump since the last
|
||||
/// finalize" so the first chunk of a block always renders immediately.
|
||||
pub thinking_revision_last_bump_at: Option<Instant>,
|
||||
pub(crate) thinking_revision_owed: bool,
|
||||
/// Newline-gated streaming collector state.
|
||||
pub streaming_state: StreamingState,
|
||||
/// Live approximate output tokens for the current assistant stream.
|
||||
@@ -3791,6 +3813,38 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn notify_account_preferences_not_loaded(&mut self) {
|
||||
let sign_in = account_sign_in_marker(self.config_profile.as_deref());
|
||||
self.notify_account_preferences_not_loaded_for(&sign_in);
|
||||
}
|
||||
|
||||
fn notify_account_preferences_not_loaded_for(&mut self, sign_in: &str) {
|
||||
let first_for_sign_in = Settings::transact_opt(|settings| {
|
||||
if settings.account_preferences_notice_shown.as_deref() == Some(sign_in) {
|
||||
return Ok(None);
|
||||
}
|
||||
settings.account_preferences_notice_shown = Some(sign_in.to_string());
|
||||
Ok(Some(()))
|
||||
});
|
||||
if matches!(first_for_sign_in, Ok(None)) {
|
||||
return;
|
||||
}
|
||||
let text = self
|
||||
.tr(MessageId::AccountPreferencesNotLoaded)
|
||||
.replace("{command}", "codewhale login");
|
||||
self.push_status_toast_record(StatusToast::standing(
|
||||
text,
|
||||
StatusToastLevel::Warning,
|
||||
ACCOUNT_PREFERENCES_NOTICE,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn dismiss_account_preferences_notice(&mut self) -> bool {
|
||||
let before = self.status_toasts.len();
|
||||
self.retire_event_notices(ACCOUNT_PREFERENCES_NOTICE);
|
||||
self.status_toasts.len() != before
|
||||
}
|
||||
|
||||
/// One-release migration notice for the Shift+Tab/Ctrl+T rebinding: users
|
||||
/// pressing Shift+Tab expecting the old thinking cycle land here first.
|
||||
fn notify_keybinding_migration_once(&mut self) {
|
||||
|
||||
@@ -1142,6 +1142,7 @@ impl App {
|
||||
streaming_thinking_active_entry: None,
|
||||
pending_thinking_translation_cells: HashMap::new(),
|
||||
thinking_revision_last_bump_at: None,
|
||||
thinking_revision_owed: false,
|
||||
streaming_state: StreamingState::new(),
|
||||
streaming_output_token_estimate: 0,
|
||||
reasoning_buffer: String::new(),
|
||||
|
||||
@@ -184,6 +184,46 @@ fn feature_intro_shows_once_persists_then_is_idempotent() {
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_preferences_notice_shows_once_per_sign_in_and_can_be_dismissed() {
|
||||
let _env_lock = lock_test_env();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let _env = EnvVarGuard::set(
|
||||
"DEEPSEEK_CONFIG_PATH",
|
||||
config_path.to_string_lossy().as_ref(),
|
||||
);
|
||||
let shown = |app: &App| {
|
||||
let notice = app
|
||||
.tr(MessageId::AccountPreferencesNotLoaded)
|
||||
.replace("{command}", "codewhale login");
|
||||
app.status_toasts
|
||||
.iter()
|
||||
.filter(|toast| toast.text == notice && toast.kind == StatusToastKind::Standing)
|
||||
.count()
|
||||
};
|
||||
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.notify_account_preferences_not_loaded_for("first-sign-in");
|
||||
app.notify_account_preferences_not_loaded_for("first-sign-in");
|
||||
assert_eq!(shown(&app), 1);
|
||||
assert_eq!(
|
||||
Settings::load()
|
||||
.expect("settings should load")
|
||||
.account_preferences_notice_shown
|
||||
.as_deref(),
|
||||
Some("first-sign-in")
|
||||
);
|
||||
assert!(app.dismiss_account_preferences_notice());
|
||||
assert_eq!(shown(&app), 0);
|
||||
|
||||
let mut later_session = App::new(test_options(false), &Config::default());
|
||||
later_session.notify_account_preferences_not_loaded_for("first-sign-in");
|
||||
assert_eq!(shown(&later_session), 0, "the same sign-in is told once");
|
||||
later_session.notify_account_preferences_not_loaded_for("second-sign-in");
|
||||
assert_eq!(shown(&later_session), 1, "a later sign-in is told again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_input_scenario() {
|
||||
// Scenario consolidation of: initial_input_prefill_waits_for_manual_submit, initial_input_submit_marks_startup_dispatch
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
│ Background (default) ✎ │current terminal
|
||||
│ Pet mode Off [ ] │saved terminal
|
||||
│ Quiet transcript On [x] │startup terminal
|
||||
│ Model reasoning in chat Off [ ] │source settings.toml
|
||||
│ Model reasoning in chat On [x] │source settings.toml
|
||||
│ Thinking Default Expanded Off [ ] │scope SAVED
|
||||
│ Thinking Preview Lines 2 ✎ │apply applies on save
|
||||
│ Thinking background highlight On [x] │kind choice
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Background (default) ✎ SAVED █
|
||||
Pet mode Off [ ] SAVED █
|
||||
Quiet transcript On [x] SAVED █
|
||||
Model reasoning in chat Off [ ] SAVED █
|
||||
Model reasoning in chat On [x] SAVED █
|
||||
Thinking Default Expanded Off [ ] SAVED █
|
||||
Thinking Preview Lines 2 ✎ SAVED │
|
||||
Thinking background highlight On [x] SAVED │
|
||||
|
||||
@@ -24,6 +24,7 @@ mod archived_context;
|
||||
mod automation;
|
||||
mod checklist;
|
||||
mod constants;
|
||||
mod data_display;
|
||||
mod file_mutation;
|
||||
mod latex_render;
|
||||
mod message;
|
||||
@@ -40,6 +41,7 @@ use checklist::{
|
||||
parse_checklist_snapshot, parse_update_prefix, render_checklist_card,
|
||||
render_checklist_change_card,
|
||||
};
|
||||
pub use data_display::DataDisplayCell;
|
||||
|
||||
#[cfg(test)]
|
||||
use checklist::{ChecklistChange, ChecklistItemSnapshot, ChecklistSnapshot};
|
||||
@@ -747,6 +749,13 @@ impl HistoryCell {
|
||||
/// Convert a message into history cells for rendering.
|
||||
#[must_use]
|
||||
pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
|
||||
history_cells_from_message_with_results(msg, &[])
|
||||
}
|
||||
|
||||
pub fn history_cells_from_message_with_results(
|
||||
msg: &Message,
|
||||
messages: &[Message],
|
||||
) -> Vec<HistoryCell> {
|
||||
// Model-facing Operate contract; the live receipt is the goal line.
|
||||
if codewhale_engine::runtime_handoff::is_operate_contract_message(msg) {
|
||||
return Vec::new();
|
||||
@@ -897,6 +906,11 @@ pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
|
||||
status: ToolStatus::Success,
|
||||
})));
|
||||
}
|
||||
ContentBlock::ToolUse { name, input, .. } if name == "show_data" => {
|
||||
cells.push(HistoryCell::Tool(ToolCell::DataDisplay(
|
||||
DataDisplayCell::from_history(block, input, messages),
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -917,6 +931,7 @@ pub enum ToolCell {
|
||||
Mcp(McpToolCell),
|
||||
ViewImage(ViewImageCell),
|
||||
WebSearch(WebSearchCell),
|
||||
DataDisplay(DataDisplayCell),
|
||||
Generic(GenericToolCell),
|
||||
}
|
||||
|
||||
@@ -945,6 +960,7 @@ impl ToolCell {
|
||||
ToolCell::Review(cell) => Some(cell.status),
|
||||
ToolCell::Mcp(cell) => Some(cell.status),
|
||||
ToolCell::WebSearch(cell) => Some(cell.status),
|
||||
ToolCell::DataDisplay(cell) => Some(cell.status),
|
||||
ToolCell::Generic(cell) => Some(cell.status),
|
||||
ToolCell::ViewImage(_) => Some(ToolStatus::Success),
|
||||
}
|
||||
@@ -977,6 +993,7 @@ impl ToolCell {
|
||||
| ToolCell::PatchSummary(_)
|
||||
| ToolCell::Review(_)
|
||||
| ToolCell::PlanUpdate(_)
|
||||
| ToolCell::DataDisplay(_)
|
||||
)
|
||||
|| matches!(self, ToolCell::Generic(cell) if tool_run::generic_tool_name_is_collapse_guard(&cell.name) || cell.is_diff)
|
||||
}
|
||||
@@ -1045,6 +1062,9 @@ impl ToolCell {
|
||||
ToolCell::WebSearch(cell) => {
|
||||
cell.lines_with_motion_and_locale(width, low_motion, locale)
|
||||
}
|
||||
ToolCell::DataDisplay(cell) => {
|
||||
cell.lines_with_motion_and_locale(width, low_motion, locale)
|
||||
}
|
||||
ToolCell::Generic(cell) => {
|
||||
cell.lines_with_mode_and_locale(width, low_motion, mode, locale)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Transcript cell for `show_data`: the kit's `DataDisplay`, painted fully
|
||||
//! drawn. Known limits: no draw-in motion in the transcript, and the chart is
|
||||
//! painted with the dark truecolor role theme that the backend then adapts,
|
||||
//! like the other kit adapters here, so ASCII-only terminals get the
|
||||
//! Unicode glyphs.
|
||||
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_palette as palette;
|
||||
use codewhale_ratatui::color::ColorDepth;
|
||||
use codewhale_ratatui::detect::Appearance;
|
||||
use codewhale_ratatui::{Caps, DataDisplay, DataWords, Paint, Theme};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
use super::{ToolStatus, render_compact_kv, render_tool_header_with_summary};
|
||||
|
||||
const INDENT: &str = " ";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataDisplayCell {
|
||||
pub display: Result<DataDisplay, String>,
|
||||
pub status: ToolStatus,
|
||||
}
|
||||
|
||||
impl DataDisplayCell {
|
||||
#[must_use]
|
||||
pub fn from_input(input: &serde_json::Value, status: ToolStatus) -> Self {
|
||||
let display = codewhale_engine::tools::show_data::parse_data_display(input)
|
||||
.map_err(|error| error.to_string());
|
||||
let status = if display.is_err() {
|
||||
ToolStatus::Failed
|
||||
} else {
|
||||
status
|
||||
};
|
||||
Self { display, status }
|
||||
}
|
||||
|
||||
pub fn from_history(
|
||||
call: &codewhale_models::ContentBlock,
|
||||
input: &serde_json::Value,
|
||||
messages: &[codewhale_models::Message],
|
||||
) -> Self {
|
||||
use codewhale_models::ContentBlock;
|
||||
let mut cell = Self::from_input(input, ToolStatus::Stopped);
|
||||
let Some(key) = call
|
||||
.tool_call_key()
|
||||
.filter(|key| !key.as_str().trim().is_empty())
|
||||
else {
|
||||
return cell;
|
||||
};
|
||||
let matching: Vec<_> = messages
|
||||
.iter()
|
||||
.flat_map(|message| &message.content)
|
||||
.filter(|block| block.tool_call_key().as_ref() == Some(&key))
|
||||
.collect();
|
||||
if matching
|
||||
.iter()
|
||||
.filter(|block| matches!(block, ContentBlock::ToolUse { .. }))
|
||||
.count()
|
||||
!= 1
|
||||
{
|
||||
return cell;
|
||||
}
|
||||
let results: Vec<_> = matching
|
||||
.into_iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => Some((tool_use_id, content, is_error)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if let [(tool_use_id, content, is_error)] = results.as_slice() {
|
||||
let ContentBlock::ToolUse { id, .. } = call else {
|
||||
return cell;
|
||||
};
|
||||
if *tool_use_id != id {
|
||||
return cell;
|
||||
}
|
||||
if **is_error == Some(true) {
|
||||
cell.status = ToolStatus::Failed;
|
||||
cell.display = Err((*content).clone());
|
||||
} else if cell.display.is_ok() {
|
||||
cell.status = ToolStatus::Success;
|
||||
}
|
||||
}
|
||||
cell
|
||||
}
|
||||
|
||||
pub fn lines_with_motion_and_locale(
|
||||
&self,
|
||||
width: u16,
|
||||
low_motion: bool,
|
||||
locale: Locale,
|
||||
) -> Vec<Line<'static>> {
|
||||
let label = self
|
||||
.display
|
||||
.as_ref()
|
||||
.ok()
|
||||
.map(|display| codewhale_ratatui::text::display_safe(display.label()));
|
||||
let mut lines = vec![render_tool_header_with_summary(
|
||||
"show_data",
|
||||
label.as_deref().filter(|label| !label.trim().is_empty()),
|
||||
None,
|
||||
self.status,
|
||||
None,
|
||||
low_motion,
|
||||
locale,
|
||||
)];
|
||||
match &self.display {
|
||||
Ok(display) if self.status != ToolStatus::Stopped => {
|
||||
lines.extend(chart_lines(display, width, locale))
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => lines.extend(render_compact_kv(
|
||||
&tr(locale, MessageId::ToolReceiptFailed),
|
||||
&codewhale_ratatui::text::display_safe(error),
|
||||
Style::default().fg(palette::STATUS_ERROR),
|
||||
width,
|
||||
)),
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_lines(display: &DataDisplay, width: u16, locale: Locale) -> Vec<Line<'static>> {
|
||||
let inner = width.saturating_sub(INDENT.len() as u16).max(1);
|
||||
let theme = Theme::new(Caps {
|
||||
depth: ColorDepth::TrueColor,
|
||||
ascii: false,
|
||||
appearance: Appearance::Dark,
|
||||
});
|
||||
let words = DataWords {
|
||||
now: tr(locale, MessageId::DataChartNow),
|
||||
min: tr(locale, MessageId::DataChartMin),
|
||||
max: tr(locale, MessageId::DataChartMax),
|
||||
empty: tr(locale, MessageId::DataChartEmpty),
|
||||
points: tr(locale, MessageId::DataChartPoints),
|
||||
};
|
||||
let view = display.view().with_words(&words);
|
||||
let height = view.height(inner, &theme).max(1);
|
||||
let area = Rect::new(0, 0, inner, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.paint(area, &mut buf, &theme);
|
||||
|
||||
(0..height)
|
||||
.map(|y| {
|
||||
let mut spans = vec![Span::raw(INDENT)];
|
||||
let mut run = String::new();
|
||||
let mut run_style = Style::default();
|
||||
let mut x = 0;
|
||||
while x < inner {
|
||||
let cell = &buf[(x, y)];
|
||||
let style = cell.style();
|
||||
if style != run_style && !run.is_empty() {
|
||||
spans.push(Span::styled(std::mem::take(&mut run), run_style));
|
||||
}
|
||||
run_style = style;
|
||||
run.push_str(cell.symbol());
|
||||
x += u16::try_from(codewhale_ratatui::text::width(cell.symbol()).max(1))
|
||||
.unwrap_or(1);
|
||||
}
|
||||
let trimmed = run.trim_end();
|
||||
if !trimmed.is_empty() || run_style != Style::default() {
|
||||
let keep = if run_style == Style::default() {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
run
|
||||
};
|
||||
spans.push(Span::styled(keep, run_style));
|
||||
}
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn text(lines: &[Line<'_>]) -> String {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_data_input_paints_a_chart_or_names_the_refusal() {
|
||||
let bars = DataDisplayCell::from_input(
|
||||
&json!({
|
||||
"kind": "bars",
|
||||
"label": "Tokens by model",
|
||||
"unit": "tokens",
|
||||
"items": [
|
||||
{ "label": "opus", "value": 400.0 },
|
||||
{ "label": "haiku", "value": 100.0 }
|
||||
]
|
||||
}),
|
||||
ToolStatus::Success,
|
||||
);
|
||||
assert_eq!(bars.status, ToolStatus::Success);
|
||||
let rendered = text(&bars.lines_with_motion_and_locale(60, true, Locale::En));
|
||||
assert!(rendered.contains("Tokens by model"), "{rendered}");
|
||||
assert!(rendered.contains("opus"), "{rendered}");
|
||||
assert!(rendered.contains("haiku"), "{rendered}");
|
||||
assert!(rendered.contains('\u{2588}'), "{rendered}");
|
||||
|
||||
let refused = DataDisplayCell::from_input(
|
||||
&json!({ "kind": "trend", "label": "x", "values": [1.0], "rows": 40 }),
|
||||
ToolStatus::Running,
|
||||
);
|
||||
assert_eq!(refused.status, ToolStatus::Failed);
|
||||
let rendered = text(&refused.lines_with_motion_and_locale(60, true, Locale::En));
|
||||
assert!(rendered.contains("40 rows"), "{rendered}");
|
||||
}
|
||||
#[test]
|
||||
fn show_data_wide_labels_fit_and_survive_session_restore() {
|
||||
use crate::tui::history::{HistoryCell, ToolCell, history_cells_from_message_with_results};
|
||||
use codewhale_engine::session_manager::{SessionManager, create_saved_session};
|
||||
use codewhale_models::{ContentBlock, Message, Role};
|
||||
|
||||
let input = json!({
|
||||
"kind": "bars", "label": "Tokens",
|
||||
"items": [{ "label": "模型", "value": 42 }]
|
||||
});
|
||||
let message = Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
execution_id: None,
|
||||
id: "chart-1".into(),
|
||||
name: "show_data".into(),
|
||||
input: input.clone(),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let manager = SessionManager::new(tmp.path().join("sessions")).unwrap();
|
||||
let result = Message {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
execution_id: None,
|
||||
tool_use_id: "chart-1".into(),
|
||||
content: "Shown".into(),
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
};
|
||||
let session = create_saved_session(&[message, result], "test-model", tmp.path(), 0, None);
|
||||
manager.save_session(&session).unwrap();
|
||||
let loaded = manager.load_session(&session.metadata.id).unwrap();
|
||||
let cells = history_cells_from_message_with_results(&loaded.messages[0], &loaded.messages);
|
||||
let [HistoryCell::Tool(ToolCell::DataDisplay(restored))] = cells.as_slice() else {
|
||||
panic!("expected restored chart");
|
||||
};
|
||||
let call = &loaded.messages[0].content[0];
|
||||
let unfinished = DataDisplayCell::from_history(call, &input, &loaded.messages[..1]);
|
||||
assert_eq!(unfinished.status, ToolStatus::Stopped);
|
||||
assert!(
|
||||
!text(&unfinished.lines_with_motion_and_locale(60, true, Locale::En)).contains("模型")
|
||||
);
|
||||
let mut failed_messages = loaded.messages.clone();
|
||||
if let ContentBlock::ToolResult {
|
||||
is_error, content, ..
|
||||
} = &mut failed_messages[1].content[0]
|
||||
{
|
||||
*is_error = Some(true);
|
||||
*content = "Denied by policy".into();
|
||||
}
|
||||
let failed = DataDisplayCell::from_history(call, &input, &failed_messages);
|
||||
assert_eq!(failed.status, ToolStatus::Failed);
|
||||
assert!(
|
||||
text(&failed.lines_with_motion_and_locale(60, true, Locale::En))
|
||||
.contains("Denied by policy")
|
||||
);
|
||||
let mut ambiguous = loaded.messages.clone();
|
||||
ambiguous.push(loaded.messages[0].clone());
|
||||
assert_eq!(
|
||||
DataDisplayCell::from_history(call, &input, &ambiguous).status,
|
||||
ToolStatus::Stopped
|
||||
);
|
||||
let live = DataDisplayCell::from_input(&input, ToolStatus::Success);
|
||||
for width in [20, 40, 60, 100] {
|
||||
let lines = chart_lines(restored.display.as_ref().unwrap(), width, Locale::En);
|
||||
assert!(text(&lines).contains("模型"));
|
||||
assert!(lines.iter().all(|line| line.width() <= usize::from(width)));
|
||||
assert_eq!(
|
||||
restored.lines_with_motion_and_locale(width, true, Locale::En),
|
||||
live.lines_with_motion_and_locale(width, true, Locale::En)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,7 @@ fn tool_display_name(tool: &ToolCell) -> &str {
|
||||
ToolCell::Mcp(cell) => cell.tool.as_str(),
|
||||
ToolCell::WebSearch(_) => "web_search",
|
||||
ToolCell::ViewImage(_) => "view_image",
|
||||
ToolCell::DataDisplay(_) => "show_data",
|
||||
ToolCell::Exploring(_) => "explore",
|
||||
ToolCell::Exec(_) => "shell",
|
||||
ToolCell::PlanUpdate(_) => "update_plan",
|
||||
|
||||
@@ -345,6 +345,9 @@ impl OceanColumn {
|
||||
// Equality-matched opaque ground is actual pane evidence. A terminal
|
||||
// Reset/named ink supplies no RGB evidence; an OS hint cannot fill it.
|
||||
caps.appearance = codewhale_ratatui::detect::appearance_for_background(ground)?;
|
||||
if caps.appearance != codewhale_ratatui::detect::Appearance::Dark {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
codewhale_ratatui::Theme::new(caps)
|
||||
.ground(codewhale_ratatui::Ground::Ocean)
|
||||
|
||||
@@ -30,15 +30,17 @@ use crate::tui::history::HistoryCell;
|
||||
/// frame cap (see `frame_rate_limiter`) yet imperceptible as lag.
|
||||
///
|
||||
/// Correctness: this only skips *intermediate* repaints. Appended content is
|
||||
/// never dropped — it lands in the cell immediately — and finalize always
|
||||
/// forces a bump so the final reasoning text is fully rendered.
|
||||
/// never dropped — it lands in the cell immediately — the display beat renders
|
||||
/// a skipped bump once the window has passed, and finalize always forces a
|
||||
/// bump so the final reasoning text is fully rendered.
|
||||
const THINKING_REVISION_THROTTLE: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Bump the active-cell revision for a streaming thinking mutation, but at
|
||||
/// most once per [`THINKING_REVISION_THROTTLE`] window. Returns whether a bump
|
||||
/// was actually emitted. Skipped bumps coalesce into the next one (or into the
|
||||
/// forced finalize bump), so no content is ever lost — only redundant
|
||||
/// intermediate re-wraps are dropped.
|
||||
/// was actually emitted. Skipped bumps coalesce into the next one, into
|
||||
/// [`render_owed_revision`] on a later display beat, or into the forced
|
||||
/// finalize bump, so no content is ever lost — only redundant intermediate
|
||||
/// re-wraps are dropped.
|
||||
fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
|
||||
let due = app
|
||||
.thinking_revision_last_bump_at
|
||||
@@ -47,9 +49,17 @@ fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
|
||||
app.thinking_revision_last_bump_at = Some(now);
|
||||
app.bump_active_cell_revision();
|
||||
}
|
||||
app.thinking_revision_owed = !due;
|
||||
due
|
||||
}
|
||||
|
||||
pub(super) fn render_owed_revision(app: &mut App, now: Instant) -> bool {
|
||||
if app.streaming_thinking_active_entry.is_none() {
|
||||
app.thinking_revision_owed = false;
|
||||
}
|
||||
app.thinking_revision_owed && bump_thinking_revision_throttled(app, now)
|
||||
}
|
||||
|
||||
/// Ensure an in-flight Thinking entry exists in `active_cell` and return its
|
||||
/// entry index. If no thinking entry is currently streaming, push a fresh one.
|
||||
/// P2.3: thinking shares the active cell with subsequent tool calls so the
|
||||
@@ -76,7 +86,8 @@ pub(super) fn ensure_active_entry(app: &mut App) -> usize {
|
||||
/// committed to the cell immediately; the active-cell revision bump that
|
||||
/// triggers a re-wrap of the live tail is debounced to at most one per
|
||||
/// [`THINKING_REVISION_THROTTLE`] window (#1620). Skipped bumps coalesce into
|
||||
/// the next append or the forced finalize bump, so no content is ever lost.
|
||||
/// the next append, a later display beat or the forced finalize bump, so no
|
||||
/// content is ever lost.
|
||||
pub(super) fn append(app: &mut App, entry_idx: usize, text: &str) {
|
||||
append_at(app, entry_idx, text, Instant::now());
|
||||
}
|
||||
@@ -308,6 +319,7 @@ pub(super) fn finalize_active_entry(app: &mut App, duration: Option<f32>, remain
|
||||
// the debounce window so the next thinking block's first chunk renders
|
||||
// immediately rather than being coalesced into a stale window.
|
||||
app.thinking_revision_last_bump_at = None;
|
||||
app.thinking_revision_owed = false;
|
||||
app.bump_active_cell_revision();
|
||||
true
|
||||
}
|
||||
@@ -426,6 +438,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_stream_renders_the_reasoning_it_already_received() {
|
||||
let mut app = test_app();
|
||||
let entry = ensure_active_entry(&mut app);
|
||||
app.thinking_revision_last_bump_at = None;
|
||||
let t0 = Instant::now();
|
||||
append_at(&mut app, entry, "first slice ", t0);
|
||||
let shown = app.active_cell_revision;
|
||||
append_at(&mut app, entry, "held back", t0 + Duration::from_millis(16));
|
||||
assert_eq!(app.active_cell_revision, shown);
|
||||
|
||||
assert!(!render_owed_revision(
|
||||
&mut app,
|
||||
t0 + Duration::from_millis(32)
|
||||
));
|
||||
assert_eq!(app.active_cell_revision, shown);
|
||||
assert!(render_owed_revision(
|
||||
&mut app,
|
||||
t0 + THINKING_REVISION_THROTTLE
|
||||
));
|
||||
assert_eq!(app.active_cell_revision, shown.wrapping_add(1));
|
||||
assert!(!render_owed_revision(
|
||||
&mut app,
|
||||
t0 + THINKING_REVISION_THROTTLE * 3
|
||||
));
|
||||
assert_eq!(app.active_cell_revision, shown.wrapping_add(1));
|
||||
}
|
||||
|
||||
/// #7123: a reasoning block that opens while the display beat still holds
|
||||
/// an uncommitted slice of prose must not drop that slice. `start_block`
|
||||
/// resets `StreamingState`, so the tail has to be delivered first.
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::tui::active_cell::ActiveCell;
|
||||
use crate::tui::app::{App, ToolDetailRecord, ToolEvidence};
|
||||
use crate::tui::history::DataDisplayCell;
|
||||
use crate::tui::history::ExecCell;
|
||||
use crate::tui::history::ExecSource;
|
||||
use crate::tui::history::ExecWait;
|
||||
@@ -166,6 +167,20 @@ pub(super) fn handle_tool_call_started(
|
||||
return;
|
||||
}
|
||||
|
||||
if semantic_name == "show_data" {
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
name,
|
||||
input,
|
||||
HistoryCell::Tool(ToolCell::DataDisplay(DataDisplayCell::from_input(
|
||||
input,
|
||||
ToolStatus::Running,
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if semantic_name == "update_plan" {
|
||||
let snapshot = parse_plan_input(input);
|
||||
push_active_tool_cell(
|
||||
@@ -1002,6 +1017,20 @@ pub(super) fn handle_tool_call_complete(
|
||||
}
|
||||
app.mark_history_updated();
|
||||
}
|
||||
HistoryCell::Tool(ToolCell::DataDisplay(data)) => {
|
||||
data.status = status;
|
||||
match result.as_ref() {
|
||||
Ok(tool_result) if !tool_result.success => {
|
||||
data.display = Err(tool_result.content.clone());
|
||||
}
|
||||
Err(err) => data.display = Err(err.to_string()),
|
||||
Ok(_) => {}
|
||||
}
|
||||
if data.display.is_err() {
|
||||
data.status = ToolStatus::Failed;
|
||||
}
|
||||
app.mark_history_updated();
|
||||
}
|
||||
HistoryCell::Tool(ToolCell::WebSearch(search)) => {
|
||||
search.status = status;
|
||||
match result.as_ref() {
|
||||
@@ -1516,6 +1545,7 @@ fn history_cell_has_running_tool(cell: &HistoryCell) -> bool {
|
||||
ToolCell::Mcp(mcp) => mcp.status == ToolStatus::Running,
|
||||
ToolCell::ViewImage(_) => false,
|
||||
ToolCell::WebSearch(search) => search.status == ToolStatus::Running,
|
||||
ToolCell::DataDisplay(data) => data.status == ToolStatus::Running,
|
||||
ToolCell::Generic(generic) => generic.status == ToolStatus::Running,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +562,13 @@ pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option<String
|
||||
Some(format!("image {}", image.path.display()))
|
||||
}
|
||||
HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)),
|
||||
HistoryCell::Tool(ToolCell::DataDisplay(data)) => Some(match &data.display {
|
||||
Ok(display) => format!(
|
||||
"show_data {}",
|
||||
codewhale_ratatui::text::display_safe(display.label())
|
||||
),
|
||||
Err(_) => "show_data".to_string(),
|
||||
}),
|
||||
HistoryCell::Tool(ToolCell::Generic(generic)) => Some(
|
||||
crate::tui::widgets::tool_card::tool_activity_label_for_name(
|
||||
&generic.name,
|
||||
@@ -1214,6 +1221,18 @@ fn timeline_tool_summary(app: &App, idx: usize, tool: &ToolCell) -> (&'static st
|
||||
truncate_line_to_width(&image.path.display().to_string(), 88),
|
||||
),
|
||||
ToolCell::WebSearch(search) => ("web search", truncate_line_to_width(&search.query, 88)),
|
||||
ToolCell::DataDisplay(data) => (
|
||||
"show_data",
|
||||
match &data.display {
|
||||
Ok(display) => truncate_line_to_width(
|
||||
&codewhale_ratatui::text::display_safe(display.label()),
|
||||
88,
|
||||
),
|
||||
Err(error) => {
|
||||
truncate_line_to_width(&codewhale_ratatui::text::display_safe(error), 88)
|
||||
}
|
||||
},
|
||||
),
|
||||
ToolCell::Generic(generic) => {
|
||||
let mut label =
|
||||
detail_target_label(app, idx).unwrap_or_else(|| generic.name.replace('_', " "));
|
||||
|
||||
@@ -4718,7 +4718,8 @@ pub(crate) fn apply_loaded_session_with_goal(
|
||||
};
|
||||
replay_outcomes_through(app, 0, messages.is_empty());
|
||||
for (message_index, msg) in messages.iter().enumerate() {
|
||||
let mut cells = history_cells_from_message(msg);
|
||||
let mut cells =
|
||||
crate::tui::history::history_cells_from_message_with_results(msg, &messages);
|
||||
if msg.role == "user"
|
||||
&& session
|
||||
.context_references
|
||||
|
||||
@@ -7225,14 +7225,16 @@ pub(crate) async fn run_event_loop(
|
||||
continue;
|
||||
}
|
||||
// An idle operator can dismiss the persistent context warning
|
||||
// without affecting vim or attachment handling. While a turn
|
||||
// is active Esc retains its cancellation meaning.
|
||||
// or the account preferences notice without affecting vim or
|
||||
// attachment handling. While a turn is active Esc retains its
|
||||
// cancellation meaning.
|
||||
KeyCode::Esc
|
||||
if !app.is_loading
|
||||
&& app.input.is_empty()
|
||||
&& !slash_menu_open
|
||||
&& !mention_menu_open
|
||||
&& app.dismiss_context_pressure_warning() =>
|
||||
&& (app.dismiss_context_pressure_warning()
|
||||
|| app.dismiss_account_preferences_notice()) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -9319,14 +9321,10 @@ pub(super) fn apply_engine_status(app: &mut App, message: String) -> bool {
|
||||
app.add_message(HistoryCell::System { content: message });
|
||||
return true;
|
||||
}
|
||||
// The account-profile fallback notice is sent once per engine; keep it in
|
||||
// the transcript so the next turn's status line cannot erase it.
|
||||
let retain = app.tr(MessageId::ProfileConstitutionUnavailableLocal) == message.as_str();
|
||||
if retain {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: message.clone(),
|
||||
});
|
||||
if app.tr(MessageId::ProfileConstitutionUnavailableLocal) == message.as_str() {
|
||||
app.notify_account_preferences_not_loaded();
|
||||
return false;
|
||||
}
|
||||
app.status_message = Some(message);
|
||||
retain
|
||||
false
|
||||
}
|
||||
@@ -1564,7 +1564,10 @@ pub(crate) fn commit_streaming_display_tick(
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if app.streaming_state.has_pending_stream_text(0) {
|
||||
if streaming_thinking::render_owed_revision(app, now) {
|
||||
updated = true;
|
||||
}
|
||||
if app.streaming_state.has_pending_stream_text(0) || app.thinking_revision_owed {
|
||||
stream_display_clock.note_delta(now);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Terminal input ingestion and fairness controls for the TUI event loop.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::io;
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
@@ -19,6 +19,9 @@ pub(super) const TERMINAL_INPUT_STALL_TIMEOUT: Duration = Duration::from_secs(5)
|
||||
pub(super) const TERMINAL_INPUT_RECOVERY_COOLDOWN: Duration = Duration::from_secs(10);
|
||||
const TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL: Duration = Duration::from_millis(5);
|
||||
const MAX_RETIRED_INPUT_READERS: usize = 4;
|
||||
const RETIRED_INPUT_POLL_SLICE: Duration = Duration::from_millis(25);
|
||||
const MAX_CHILD_TERMINAL_WINDOWS: usize = 8;
|
||||
/// Upper bound on engine events processed before yielding to terminal input.
|
||||
pub(super) const MAX_ENGINE_EVENTS_PER_DRAIN: usize = 16;
|
||||
/// Wall-clock budget for one engine drain batch (#1830 / #2317 input fairness).
|
||||
@@ -30,7 +33,10 @@ pub(super) enum TerminalInputMessage {
|
||||
Error(io::Error),
|
||||
}
|
||||
|
||||
/// A terminal event paired with the instant the dedicated input thread read it.
|
||||
/// A terminal event paired with the instant the input thread's `event::read`
|
||||
/// returned. That is read completion, not the instant the key arrived and not
|
||||
/// what the user meant it for: crossterm hands back a buffered key only on the
|
||||
/// next read.
|
||||
///
|
||||
/// The event loop can lag behind this thread under load. Keeping receipt time
|
||||
/// prevents that backlog from collapsing a deliberate pause between a raw
|
||||
@@ -63,6 +69,72 @@ impl ObservedTerminalEvent {
|
||||
/// at the call sites that have them.
|
||||
static CHILD_TERMINAL_GATE: Mutex<Option<ChildTerminalGate>> = Mutex::new(None);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ChildTerminalWindow(u64);
|
||||
|
||||
struct ChildTerminalWindowRecord {
|
||||
id: u64,
|
||||
start: Instant,
|
||||
end: Option<Instant>,
|
||||
}
|
||||
|
||||
struct ChildTerminalWindows {
|
||||
next_id: u64,
|
||||
windows: Vec<ChildTerminalWindowRecord>,
|
||||
}
|
||||
|
||||
static CHILD_TERMINAL_WINDOWS: Mutex<ChildTerminalWindows> = Mutex::new(ChildTerminalWindows {
|
||||
next_id: 0,
|
||||
windows: Vec::new(),
|
||||
});
|
||||
|
||||
pub(super) fn child_terminal_window_begin() -> Option<ChildTerminalWindow> {
|
||||
let mut registry = CHILD_TERMINAL_WINDOWS.lock().ok()?;
|
||||
let id = registry.next_id;
|
||||
registry.next_id += 1;
|
||||
registry.windows.push(ChildTerminalWindowRecord {
|
||||
id,
|
||||
start: Instant::now(),
|
||||
end: None,
|
||||
});
|
||||
Some(ChildTerminalWindow(id))
|
||||
}
|
||||
|
||||
pub(super) fn child_terminal_window_end(window: Option<ChildTerminalWindow>) {
|
||||
let Some(ChildTerminalWindow(id)) = window else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut registry) = CHILD_TERMINAL_WINDOWS.lock()
|
||||
&& let Some(record) = registry
|
||||
.windows
|
||||
.iter_mut()
|
||||
.find(|record| record.id == id && record.end.is_none())
|
||||
{
|
||||
record.end = Some(Instant::now());
|
||||
let mut completed = registry
|
||||
.windows
|
||||
.iter()
|
||||
.filter(|window| window.end.is_some())
|
||||
.count();
|
||||
registry.windows.retain(|window| {
|
||||
if completed > MAX_CHILD_TERMINAL_WINDOWS && window.end.is_some() {
|
||||
completed -= 1;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn child_owned_terminal_at(observed_at: Instant) -> bool {
|
||||
CHILD_TERMINAL_WINDOWS.lock().is_ok_and(|registry| {
|
||||
registry.windows.iter().any(|record| {
|
||||
observed_at >= record.start && record.end.is_none_or(|end| observed_at <= end)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ChildTerminalGate {
|
||||
paused: Arc<AtomicBool>,
|
||||
@@ -98,6 +170,7 @@ fn retract_child_terminal_gate(paused: &Arc<AtomicBool>) {
|
||||
/// child that failed to spawn or a panic unwinding through it.
|
||||
pub(crate) struct ChildTerminalInputPause {
|
||||
gate: Option<ChildTerminalGate>,
|
||||
window: Option<ChildTerminalWindow>,
|
||||
}
|
||||
|
||||
/// Stop the process's terminal input pump before a child takes the tty.
|
||||
@@ -113,7 +186,10 @@ pub(crate) fn pause_terminal_input_for_child() -> io::Result<ChildTerminalInputP
|
||||
.ok()
|
||||
.and_then(|gate| gate.clone())
|
||||
else {
|
||||
return Ok(ChildTerminalInputPause { gate: None });
|
||||
return Ok(ChildTerminalInputPause {
|
||||
gate: None,
|
||||
window: None,
|
||||
});
|
||||
};
|
||||
gate.paused.store(true, Ordering::Release);
|
||||
let deadline = Instant::now() + TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT;
|
||||
@@ -133,11 +209,16 @@ pub(crate) fn pause_terminal_input_for_child() -> io::Result<ChildTerminalInputP
|
||||
// reachable from here and would not change what the thread does.
|
||||
thread::sleep(TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL);
|
||||
}
|
||||
Ok(ChildTerminalInputPause { gate: Some(gate) })
|
||||
let window = child_terminal_window_begin();
|
||||
Ok(ChildTerminalInputPause {
|
||||
gate: Some(gate),
|
||||
window,
|
||||
})
|
||||
}
|
||||
|
||||
impl Drop for ChildTerminalInputPause {
|
||||
fn drop(&mut self) {
|
||||
child_terminal_window_end(self.window.take());
|
||||
if let Some(gate) = self.gate.take() {
|
||||
gate.paused_ack.store(false, Ordering::Release);
|
||||
gate.paused.store(false, Ordering::Release);
|
||||
@@ -147,6 +228,8 @@ impl Drop for ChildTerminalInputPause {
|
||||
|
||||
pub(crate) struct TerminalInputPump {
|
||||
pub(super) rx: std::sync::mpsc::Receiver<TerminalInputMessage>,
|
||||
pub(super) retired_rx: RefCell<RetiredInputReaders>,
|
||||
pub(super) child_windows: RefCell<Vec<ChildTerminalWindow>>,
|
||||
pub(super) stop: Arc<AtomicBool>,
|
||||
pub(super) paused: Arc<AtomicBool>,
|
||||
pub(super) paused_ack: Arc<AtomicBool>,
|
||||
@@ -154,6 +237,12 @@ pub(crate) struct TerminalInputPump {
|
||||
pub(super) last_alive_at: Cell<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct RetiredInputReaders {
|
||||
receivers: Vec<std::sync::mpsc::Receiver<TerminalInputMessage>>,
|
||||
stash: Option<ObservedTerminalEvent>,
|
||||
}
|
||||
|
||||
pub(super) struct TerminalInputPumpParts {
|
||||
pub(super) rx: std::sync::mpsc::Receiver<TerminalInputMessage>,
|
||||
pub(super) stop: Arc<AtomicBool>,
|
||||
@@ -168,6 +257,8 @@ impl TerminalInputPump {
|
||||
publish_child_terminal_gate(&parts.paused, &parts.paused_ack);
|
||||
Ok(Self {
|
||||
rx: parts.rx,
|
||||
retired_rx: RefCell::default(),
|
||||
child_windows: RefCell::default(),
|
||||
stop: parts.stop,
|
||||
paused: parts.paused,
|
||||
paused_ack: parts.paused_ack,
|
||||
@@ -244,29 +335,86 @@ impl TerminalInputPump {
|
||||
})
|
||||
}
|
||||
|
||||
fn has_retired_readers(&self) -> bool {
|
||||
!self.retired_rx.borrow().receivers.is_empty()
|
||||
}
|
||||
|
||||
fn take_retired_event(&self) -> Option<ObservedTerminalEvent> {
|
||||
let mut retired = self.retired_rx.borrow_mut();
|
||||
let mut found = None;
|
||||
retired.receivers.retain(|receiver| {
|
||||
if found.is_some() {
|
||||
return true;
|
||||
}
|
||||
loop {
|
||||
match receiver.try_recv() {
|
||||
Ok(TerminalInputMessage::Event(event)) => {
|
||||
if child_owned_terminal_at(event.observed_at) {
|
||||
continue;
|
||||
}
|
||||
found = Some(event);
|
||||
return true;
|
||||
}
|
||||
Ok(TerminalInputMessage::Heartbeat | TerminalInputMessage::Error(_)) => {}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return true,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return false,
|
||||
}
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
fn take_ready_event(&self) -> Option<ObservedTerminalEvent> {
|
||||
self.take_retired_event()
|
||||
.or_else(|| self.retired_rx.borrow_mut().stash.take())
|
||||
}
|
||||
|
||||
fn deliver_fresh(&self, event: ObservedTerminalEvent) -> ObservedTerminalEvent {
|
||||
match self.take_retired_event() {
|
||||
Some(retired) => {
|
||||
self.retired_rx.borrow_mut().stash = Some(event);
|
||||
retired
|
||||
}
|
||||
None => event,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn recv_timeout(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
) -> io::Result<Option<ObservedTerminalEvent>> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(event) = self.take_ready_event() {
|
||||
self.mark_alive();
|
||||
return Ok(Some(event));
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match self.rx.recv_timeout(remaining) {
|
||||
let wait = if self.has_retired_readers() {
|
||||
remaining.min(RETIRED_INPUT_POLL_SLICE)
|
||||
} else {
|
||||
remaining
|
||||
};
|
||||
match self.rx.recv_timeout(wait) {
|
||||
Ok(TerminalInputMessage::Event(event)) => {
|
||||
self.mark_alive();
|
||||
return Ok(Some(event));
|
||||
return Ok(Some(self.deliver_fresh(event)));
|
||||
}
|
||||
Ok(TerminalInputMessage::Heartbeat) => {
|
||||
self.mark_alive();
|
||||
if remaining.is_zero() {
|
||||
return Ok(None);
|
||||
return Ok(self.take_ready_event());
|
||||
}
|
||||
}
|
||||
Ok(TerminalInputMessage::Error(err)) => {
|
||||
self.mark_alive();
|
||||
return Err(err);
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Ok(None),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(self.take_ready_event());
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
@@ -279,10 +427,14 @@ impl TerminalInputPump {
|
||||
|
||||
pub(super) fn try_recv(&self) -> io::Result<Option<ObservedTerminalEvent>> {
|
||||
loop {
|
||||
if let Some(event) = self.take_ready_event() {
|
||||
self.mark_alive();
|
||||
return Ok(Some(event));
|
||||
}
|
||||
match self.rx.try_recv() {
|
||||
Ok(TerminalInputMessage::Event(event)) => {
|
||||
self.mark_alive();
|
||||
return Ok(Some(event));
|
||||
return Ok(Some(self.deliver_fresh(event)));
|
||||
}
|
||||
Ok(TerminalInputMessage::Heartbeat) => {
|
||||
self.mark_alive();
|
||||
@@ -312,6 +464,7 @@ impl TerminalInputPump {
|
||||
self.paused.store(true, Ordering::Release);
|
||||
if self.handle.is_none() {
|
||||
self.paused_ack.store(true, Ordering::Release);
|
||||
self.claim_child_terminal_window();
|
||||
self.mark_alive();
|
||||
return Ok(());
|
||||
}
|
||||
@@ -328,11 +481,20 @@ impl TerminalInputPump {
|
||||
}
|
||||
tokio::time::sleep(TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL).await;
|
||||
}
|
||||
self.claim_child_terminal_window();
|
||||
self.mark_alive();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn claim_child_terminal_window(&self) {
|
||||
if let Some(window) = child_terminal_window_begin() {
|
||||
self.child_windows.borrow_mut().push(window);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resume_after_child_terminal(&self) {
|
||||
let window = self.child_windows.borrow_mut().pop();
|
||||
child_terminal_window_end(window);
|
||||
self.paused_ack.store(false, Ordering::Release);
|
||||
self.paused.store(false, Ordering::Release);
|
||||
self.mark_alive();
|
||||
@@ -344,12 +506,24 @@ impl TerminalInputPump {
|
||||
/// `event::read` (a stalled Windows console poll, or a Unix tty that
|
||||
/// stopped delivering bytes), so it can never be joined. Instead it is
|
||||
/// detached: `stop` is flagged and the `JoinHandle` dropped, so if the
|
||||
/// thread ever wakes it exits on its own (its send fails once `rx` is
|
||||
/// replaced, and the stop flag covers the poll loop).
|
||||
/// thread ever wakes its loop observes `stop` and exits on its own.
|
||||
///
|
||||
/// Known limitation (U03-10): stop is flagged before the replacement is
|
||||
/// spawned, but a thread already inside `event::read` cannot be stopped.
|
||||
/// If it ever wakes, the one event it read has no receiver and is lost.
|
||||
/// A thread already inside `event::read` cannot be stopped and holds
|
||||
/// crossterm's reader lock, so the one event it returns when the tty wakes
|
||||
/// is the user's next input. The replaced channel is therefore kept as a
|
||||
/// retired receiver. While one exists, waits run in short slices and every
|
||||
/// slice, and every fresh event, checks the retired receivers first, so a
|
||||
/// retired event is normally delivered ahead of the fresh reader's and without
|
||||
/// waiting out the caller's deadline; a fresh event read in the meantime
|
||||
/// is held and delivered next. Retired heartbeats and errors describe a
|
||||
/// reader that no longer owns input and are ignored, and a retired event
|
||||
/// whose read completed inside a child terminal window is discarded. That
|
||||
/// test is read completion, not the moment the key arrived: a composer key
|
||||
/// the reader had already buffered when the handoff began is discarded if
|
||||
/// its read completed inside the window, and a key the child's session
|
||||
/// consumed is still delivered to the composer if the reader only returned
|
||||
/// after the window closed. At most `MAX_RETIRED_INPUT_READERS` are kept,
|
||||
/// oldest dropped first, and a receiver whose thread has exited is pruned.
|
||||
pub(super) fn restart_detached(&mut self) -> io::Result<()> {
|
||||
self.detach_current_thread();
|
||||
let parts = Self::spawn_parts()?;
|
||||
@@ -365,10 +539,16 @@ impl TerminalInputPump {
|
||||
retract_child_terminal_gate(&self.paused);
|
||||
}
|
||||
|
||||
/// Adopt freshly spawned pump parts and reset the liveness clock.
|
||||
/// Adopt freshly spawned pump parts and reset the liveness clock; see
|
||||
/// [`Self::restart_detached`].
|
||||
pub(super) fn install_parts(&mut self, parts: TerminalInputPumpParts) {
|
||||
publish_child_terminal_gate(&parts.paused, &parts.paused_ack);
|
||||
self.rx = parts.rx;
|
||||
let replaced = std::mem::replace(&mut self.rx, parts.rx);
|
||||
let retired = &mut self.retired_rx.get_mut().receivers;
|
||||
retired.push(replaced);
|
||||
if retired.len() > MAX_RETIRED_INPUT_READERS {
|
||||
retired.remove(0);
|
||||
}
|
||||
self.stop = parts.stop;
|
||||
self.paused = parts.paused;
|
||||
self.paused_ack = parts.paused_ack;
|
||||
@@ -386,6 +566,9 @@ impl Drop for TerminalInputPump {
|
||||
// (or its send fails because `rx` was dropped) and exits on its own.
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = self.handle.take();
|
||||
for window in self.child_windows.get_mut().drain(..) {
|
||||
child_terminal_window_end(Some(window));
|
||||
}
|
||||
retract_child_terminal_gate(&self.paused);
|
||||
}
|
||||
}
|
||||
|
||||
+429
-15
@@ -27343,7 +27343,7 @@ fn turn_inspector_switches_isolated_full_turn_pages_with_tagged_reasoning_and_ou
|
||||
|
||||
let latest = pager.body_text();
|
||||
assert!(latest.contains("SECOND-PROMPT"), "{latest}");
|
||||
assert!(latest.contains("[∿ reasoning 1/1 · running]"), "{latest}");
|
||||
assert!(latest.contains("[∿ thinking 1/1 · running]"), "{latest}");
|
||||
assert!(latest.contains("SECOND-THOUGHT"), "{latest}");
|
||||
assert!(latest.contains("[◆ · running]"), "{latest}");
|
||||
assert!(latest.contains("SECOND-ANSWER"), "{latest}");
|
||||
@@ -27355,7 +27355,7 @@ fn turn_inspector_switches_isolated_full_turn_pages_with_tagged_reasoning_and_ou
|
||||
for expected in [
|
||||
"FIRST-PROMPT",
|
||||
"keep this input line",
|
||||
"[∿ reasoning 1/1 · Done]",
|
||||
"[∿ thinking 1/1 · Done]",
|
||||
"FIRST-THOUGHT",
|
||||
"full reasoning tail",
|
||||
"[⚙ using tool]",
|
||||
@@ -31870,6 +31870,8 @@ fn six_worker_progress_storm_keeps_input_render_and_cancel_live() {
|
||||
.expect("send key event");
|
||||
let input = TerminalInputPump {
|
||||
rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
@@ -31928,6 +31930,8 @@ fn queued_terminal_events_keep_receipt_gap_for_late_unbracketed_submit() {
|
||||
|
||||
let input = TerminalInputPump {
|
||||
rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
@@ -31969,6 +31973,9 @@ fn queued_terminal_events_keep_receipt_gap_for_late_unbracketed_submit() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_input_child_pause_drains_codewhale_events_before_editor_handoff() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
tx.send(TerminalInputMessage::Event(ObservedTerminalEvent::new(
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
|
||||
@@ -31985,6 +31992,8 @@ async fn terminal_input_child_pause_drains_codewhale_events_before_editor_handof
|
||||
|
||||
let input = TerminalInputPump {
|
||||
rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
@@ -32021,6 +32030,9 @@ async fn terminal_input_child_pause_drains_codewhale_events_before_editor_handof
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_input_handoff_preserves_pending_cancellation_keys() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
tx.send(TerminalInputMessage::Event(ObservedTerminalEvent::new(
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('\u{3}'), KeyModifiers::NONE)),
|
||||
@@ -32029,6 +32041,8 @@ async fn terminal_input_handoff_preserves_pending_cancellation_keys() {
|
||||
.expect("send raw Ctrl+C");
|
||||
let input = TerminalInputPump {
|
||||
rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
@@ -32067,9 +32081,10 @@ async fn terminal_input_handoff_preserves_pending_cancellation_keys() {
|
||||
input.resume_after_child_terminal();
|
||||
}
|
||||
|
||||
/// `CHILD_TERMINAL_GATE` is process-global — one stdin, one pump. Any test
|
||||
/// that publishes or reads it must hold this, including the restart test,
|
||||
/// which publishes through `install_parts`.
|
||||
/// `CHILD_TERMINAL_GATE` and the child terminal window registry are
|
||||
/// process-global — one stdin, one pump. Any test that publishes or reads the
|
||||
/// gate, or opens, closes or classifies a child terminal window, must hold
|
||||
/// this, including the restart test, which publishes through `install_parts`.
|
||||
static CHILD_TERMINAL_GATE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// #6165: `/hooks edit` handed the terminal to `$EDITOR` with the pump still
|
||||
@@ -32179,6 +32194,8 @@ fn input_pump_restart_detaches_wedged_thread_and_installs_fresh_parts() {
|
||||
let old_stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut pump = TerminalInputPump {
|
||||
rx: old_rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::clone(&old_stop),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
@@ -32236,16 +32253,303 @@ fn input_pump_restart_detaches_wedged_thread_and_installs_fresh_parts() {
|
||||
"restarted pump must deliver events from the replacement channel"
|
||||
);
|
||||
|
||||
// The old channel is orphaned by the swap: a wedged thread that finally
|
||||
// wakes fails its send and exits instead of feeding stale events.
|
||||
old_tx
|
||||
.send(TerminalInputMessage::Heartbeat)
|
||||
.expect("retired channel stays open for a wedged reader");
|
||||
old_tx
|
||||
.send(TerminalInputMessage::Error(std::io::Error::other(
|
||||
"retired reader failed",
|
||||
)))
|
||||
.expect("retired channel stays open for a wedged reader");
|
||||
old_tx
|
||||
.send(TerminalInputMessage::Event(ObservedTerminalEvent::new(
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)),
|
||||
Instant::now(),
|
||||
)))
|
||||
.expect("retired channel stays open for a wedged reader");
|
||||
new_tx
|
||||
.send(TerminalInputMessage::Event(ObservedTerminalEvent::new(
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)),
|
||||
Instant::now(),
|
||||
)))
|
||||
.expect("send event on replacement channel");
|
||||
let mut pending = VecDeque::new();
|
||||
let woken = try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("a retired heartbeat or error is not surfaced")
|
||||
.expect("the woken reader's keystroke is delivered");
|
||||
assert!(
|
||||
old_tx.send(TerminalInputMessage::Heartbeat).is_err(),
|
||||
"old pump channel must be disconnected after restart"
|
||||
matches!(woken.event, Event::Key(key) if key.code == KeyCode::Char('w')),
|
||||
"the key the detached reader consumed must not be lost"
|
||||
);
|
||||
let fresh = next_terminal_event(&pump, &mut pending, Duration::from_millis(50))
|
||||
.expect("replacement channel readable")
|
||||
.expect("then the fresh reader's key");
|
||||
assert!(matches!(fresh.event, Event::Key(key) if key.code == KeyCode::Char('n')));
|
||||
|
||||
let (second_tx, second_rx) = std::sync::mpsc::channel();
|
||||
pump.install_parts(TerminalInputPumpParts {
|
||||
rx: second_rx,
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
handle: std::thread::spawn(|| {}),
|
||||
});
|
||||
old_tx
|
||||
.send(TerminalInputMessage::Event(ObservedTerminalEvent::new(
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
|
||||
Instant::now(),
|
||||
)))
|
||||
.expect("first retired reader is still kept after a second restart");
|
||||
second_tx
|
||||
.send(TerminalInputMessage::Event(ObservedTerminalEvent::new(
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)),
|
||||
Instant::now(),
|
||||
)))
|
||||
.expect("send on second replacement channel");
|
||||
let first = try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("retired channel readable")
|
||||
.expect("the first retired reader's key survives a second restart");
|
||||
assert!(matches!(first.event, Event::Key(key) if key.code == KeyCode::Char('a')));
|
||||
let second = try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("replacement channel readable")
|
||||
.expect("then the current reader's key");
|
||||
assert!(matches!(second.event, Event::Key(key) if key.code == KeyCode::Char('b')));
|
||||
|
||||
let started = Instant::now();
|
||||
drop(pump);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"dropping the pump must not wait on a wedged retired reader"
|
||||
);
|
||||
|
||||
drop(block_tx); // release the wedged stand-in thread
|
||||
}
|
||||
|
||||
fn restarted_pump_with_senders() -> (
|
||||
TerminalInputPump,
|
||||
std::sync::mpsc::Sender<TerminalInputMessage>,
|
||||
std::sync::mpsc::Sender<TerminalInputMessage>,
|
||||
) {
|
||||
let (old_tx, old_rx) = std::sync::mpsc::channel();
|
||||
let mut pump = TerminalInputPump {
|
||||
rx: old_rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
handle: None,
|
||||
last_alive_at: std::cell::Cell::new(Instant::now()),
|
||||
};
|
||||
let (new_tx, new_rx) = std::sync::mpsc::channel();
|
||||
pump.install_parts(TerminalInputPumpParts {
|
||||
rx: new_rx,
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
handle: std::thread::spawn(|| {}),
|
||||
});
|
||||
(pump, old_tx, new_tx)
|
||||
}
|
||||
|
||||
fn key_message(c: char, observed_at: Instant) -> TerminalInputMessage {
|
||||
TerminalInputMessage::Event(ObservedTerminalEvent::new(
|
||||
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)),
|
||||
observed_at,
|
||||
))
|
||||
}
|
||||
|
||||
fn key_char(observed: &ObservedTerminalEvent) -> Option<char> {
|
||||
match observed.event {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
..
|
||||
}) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_wait_delivers_the_retired_key_before_the_fresh_one() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (pump, old_tx, new_tx) = restarted_pump_with_senders();
|
||||
let sender = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
old_tx
|
||||
.send(key_message('h', Instant::now()))
|
||||
.expect("retired send");
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
new_tx
|
||||
.send(key_message('i', Instant::now()))
|
||||
.expect("fresh send");
|
||||
(old_tx, new_tx)
|
||||
});
|
||||
let mut pending = VecDeque::new();
|
||||
let first = next_terminal_event(&pump, &mut pending, Duration::from_millis(400))
|
||||
.expect("readable")
|
||||
.expect("an event");
|
||||
let _keep = sender.join().expect("sender");
|
||||
assert_eq!(key_char(&first), Some('h'));
|
||||
let second = next_terminal_event(&pump, &mut pending, Duration::from_millis(400))
|
||||
.expect("readable")
|
||||
.expect("the held fresh event");
|
||||
assert_eq!(key_char(&second), Some('i'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_wait_returns_a_lone_retired_key_promptly() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (pump, old_tx, new_tx) = restarted_pump_with_senders();
|
||||
let sender = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
old_tx
|
||||
.send(key_message('h', Instant::now()))
|
||||
.expect("retired send");
|
||||
(old_tx, new_tx)
|
||||
});
|
||||
let mut pending = VecDeque::new();
|
||||
let started = Instant::now();
|
||||
let got = next_terminal_event(&pump, &mut pending, Duration::from_secs(5))
|
||||
.expect("readable")
|
||||
.expect("the retired key ends the wait");
|
||||
let elapsed = started.elapsed();
|
||||
let _keep = sender.join().expect("sender");
|
||||
assert_eq!(key_char(&got), Some('h'));
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"returned after {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retired_reader_key_taken_while_a_child_owned_the_terminal_is_discarded() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (pump, old_tx, new_tx) = restarted_pump_with_senders();
|
||||
let before_child = Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
let child_start = Instant::now();
|
||||
let child_window = super::terminal_input::child_terminal_window_begin();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
let during_child = Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
super::terminal_input::child_terminal_window_end(child_window);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
let after_child = Instant::now();
|
||||
assert!(before_child < child_start && child_start < during_child);
|
||||
|
||||
old_tx
|
||||
.send(key_message('v', during_child))
|
||||
.expect("retired send");
|
||||
old_tx
|
||||
.send(key_message('p', before_child))
|
||||
.expect("retired send");
|
||||
old_tx
|
||||
.send(key_message('q', after_child))
|
||||
.expect("retired send");
|
||||
let mut pending = VecDeque::new();
|
||||
let first = try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("readable")
|
||||
.expect("pre-handoff key is kept");
|
||||
let second = try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("readable")
|
||||
.expect("post-handoff key is kept");
|
||||
assert_eq!(key_char(&first), Some('p'));
|
||||
assert_eq!(key_char(&second), Some('q'));
|
||||
assert!(
|
||||
try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("readable")
|
||||
.is_none(),
|
||||
"the key the child's session consumed must not reach the composer"
|
||||
);
|
||||
drop((old_tx, new_tx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_resume_cannot_close_an_outer_child_window() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (pump, old_tx, new_tx) = restarted_pump_with_senders();
|
||||
let outer = super::terminal_input::child_terminal_window_begin();
|
||||
pump.resume_after_child_terminal();
|
||||
pump.resume_after_child_terminal();
|
||||
old_tx
|
||||
.send(key_message('v', Instant::now()))
|
||||
.expect("retired send");
|
||||
let mut pending = VecDeque::new();
|
||||
assert!(
|
||||
try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("readable")
|
||||
.is_none(),
|
||||
"a resume with no pause of its own must not close an outer child's window"
|
||||
);
|
||||
super::terminal_input::child_terminal_window_end(outer);
|
||||
drop((old_tx, new_tx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_child_windows_are_bounded_after_close_without_evicting_open_windows() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (pump, old_tx, new_tx) = restarted_pump_with_senders();
|
||||
let outer = super::terminal_input::child_terminal_window_begin();
|
||||
let observed_at = Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
for _ in 0..8 {
|
||||
let inner = super::terminal_input::child_terminal_window_begin();
|
||||
super::terminal_input::child_terminal_window_end(inner);
|
||||
}
|
||||
old_tx
|
||||
.send(key_message('v', observed_at))
|
||||
.expect("retired send");
|
||||
let mut pending = VecDeque::new();
|
||||
assert!(
|
||||
try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("readable")
|
||||
.is_none()
|
||||
);
|
||||
super::terminal_input::child_terminal_window_end(outer);
|
||||
old_tx
|
||||
.send(key_message('q', observed_at))
|
||||
.expect("retired send");
|
||||
let retained = try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("readable")
|
||||
.expect("oldest completed window was evicted at close");
|
||||
assert_eq!(key_char(&retained), Some('q'));
|
||||
drop((old_tx, new_tx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_one_child_window_twice_leaves_a_newer_one_open() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (pump, old_tx, new_tx) = restarted_pump_with_senders();
|
||||
let outer = super::terminal_input::child_terminal_window_begin();
|
||||
let inner = super::terminal_input::child_terminal_window_begin();
|
||||
super::terminal_input::child_terminal_window_end(outer);
|
||||
super::terminal_input::child_terminal_window_end(outer);
|
||||
old_tx
|
||||
.send(key_message('v', Instant::now()))
|
||||
.expect("retired send");
|
||||
let mut pending = VecDeque::new();
|
||||
assert!(
|
||||
try_next_terminal_event(&pump, &mut pending)
|
||||
.expect("readable")
|
||||
.is_none(),
|
||||
"closing one window twice must leave the still-open one owning the terminal"
|
||||
);
|
||||
super::terminal_input::child_terminal_window_end(inner);
|
||||
drop((old_tx, new_tx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backtrack_cut_index_skips_tool_result_user_messages() {
|
||||
use codewhale_models::{ContentBlock, Message};
|
||||
@@ -33437,6 +33741,8 @@ fn a_scroll_burst_is_folded_into_one_frame() {
|
||||
.expect("send key");
|
||||
let input = TerminalInputPump {
|
||||
rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
@@ -33482,6 +33788,8 @@ fn idle_input_pump() -> TerminalInputPump {
|
||||
let (_tx, rx) = std::sync::mpsc::channel();
|
||||
TerminalInputPump {
|
||||
rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
@@ -35243,6 +35551,8 @@ fn g3_scroll_burst_preserves_the_first_non_scroll_before_later_input() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let input = TerminalInputPump {
|
||||
rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
@@ -35488,23 +35798,41 @@ fn extension_prompt_origin_keeps_denials_and_other_policy_holds() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_profile_fallback_notice_stays_in_the_transcript() {
|
||||
fn account_profile_fallback_notice_is_a_standing_notice_outside_the_transcript() {
|
||||
let _home = SettingsHomeGuard::new();
|
||||
let mut app = App::new(create_test_options(), &Config::default());
|
||||
let notice = app
|
||||
let engine_notice = app
|
||||
.tr(MessageId::ProfileConstitutionUnavailableLocal)
|
||||
.to_string();
|
||||
let before = app.history.len();
|
||||
assert!(super::event_loop::apply_engine_status(
|
||||
let status_before = app.status_message.clone();
|
||||
assert!(!super::event_loop::apply_engine_status(
|
||||
&mut app,
|
||||
notice.clone()
|
||||
engine_notice
|
||||
));
|
||||
assert_eq!(app.history.len(), before);
|
||||
assert_eq!(app.status_message, status_before);
|
||||
assert!(!super::event_loop::apply_engine_status(
|
||||
&mut app,
|
||||
"Executing tools sequentially".into()
|
||||
));
|
||||
assert_eq!(app.history.len(), before + 1);
|
||||
assert!(matches!(&app.history[before], HistoryCell::System { content } if *content == notice));
|
||||
assert_eq!(app.history.len(), before);
|
||||
let notice = app
|
||||
.tr(MessageId::AccountPreferencesNotLoaded)
|
||||
.replace("{command}", "codewhale login");
|
||||
let standing = |app: &App| {
|
||||
app.status_toasts
|
||||
.iter()
|
||||
.find(|toast| toast.text == notice)
|
||||
.cloned()
|
||||
};
|
||||
app.sync_status_message_to_toasts();
|
||||
let shown = standing(&app).expect("a later routine status does not erase the notice");
|
||||
assert_eq!(shown.level, StatusToastLevel::Warning);
|
||||
assert_eq!(shown.ttl_ms, None);
|
||||
assert!(app.dismiss_account_preferences_notice());
|
||||
assert!(standing(&app).is_none());
|
||||
assert!(!app.dismiss_account_preferences_notice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -35844,3 +36172,89 @@ fn issue_7123_reasoning_after_prose_stays_in_the_thinking_cell() {
|
||||
"the assistant cell must not absorb reasoning text"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nested_editor_handoff_closes_every_child_window() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (old_tx, old_rx) = std::sync::mpsc::channel();
|
||||
let mut pump = TerminalInputPump {
|
||||
rx: old_rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
handle: None,
|
||||
last_alive_at: std::cell::Cell::new(Instant::now()),
|
||||
};
|
||||
let (new_tx, new_rx) = std::sync::mpsc::channel();
|
||||
pump.install_parts(TerminalInputPumpParts {
|
||||
rx: new_rx,
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
|
||||
handle: std::thread::spawn(|| {}),
|
||||
});
|
||||
pump.pause_for_child_terminal().await.expect("pump pause");
|
||||
let guard = pause_terminal_input_for_child().expect("gate pause");
|
||||
drop(guard);
|
||||
pump.resume_after_child_terminal();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
old_tx
|
||||
.send(TerminalInputMessage::Event(ObservedTerminalEvent::new(
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)),
|
||||
Instant::now(),
|
||||
)))
|
||||
.expect("retired send");
|
||||
let mut pending = VecDeque::new();
|
||||
let got = try_next_terminal_event(&pump, &mut pending).expect("readable");
|
||||
drop((old_tx, new_tx));
|
||||
assert!(
|
||||
got.is_some(),
|
||||
"a retired key observed after the composer editor closed must reach the composer"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_pump_handoff_closes_its_child_window() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (old_tx, old_rx) = std::sync::mpsc::channel();
|
||||
let mut pump = TerminalInputPump {
|
||||
rx: old_rx,
|
||||
retired_rx: Default::default(),
|
||||
child_windows: Default::default(),
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
handle: None,
|
||||
last_alive_at: std::cell::Cell::new(Instant::now()),
|
||||
};
|
||||
let (new_tx, new_rx) = std::sync::mpsc::channel();
|
||||
pump.install_parts(TerminalInputPumpParts {
|
||||
rx: new_rx,
|
||||
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
paused_ack: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
|
||||
handle: std::thread::spawn(|| {}),
|
||||
});
|
||||
pump.pause_for_child_terminal().await.expect("pump pause");
|
||||
pump.resume_after_child_terminal();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
old_tx
|
||||
.send(TerminalInputMessage::Event(ObservedTerminalEvent::new(
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)),
|
||||
Instant::now(),
|
||||
)))
|
||||
.expect("retired send");
|
||||
let mut pending = VecDeque::new();
|
||||
let got = try_next_terminal_event(&pump, &mut pending).expect("readable");
|
||||
drop((old_tx, new_tx));
|
||||
assert!(
|
||||
got.is_some(),
|
||||
"control: one handoff, key after resume is delivered"
|
||||
);
|
||||
}
|
||||
@@ -855,9 +855,11 @@ impl Renderable for ChatWidget {
|
||||
let regions = codewhale_tui_kit::osc8::link_regions_for_lines(link_area, &self.line_links);
|
||||
codewhale_tui_kit::osc8::set_frame_links(regions);
|
||||
|
||||
if let Some(scrollbar) = self.scrollbar {
|
||||
if let Some(scrollbar) = self.scrollbar
|
||||
&& scrollbar.total > scrollbar.visible
|
||||
{
|
||||
let scrollable_range = scrollbar.total.saturating_sub(scrollbar.visible);
|
||||
let mut state = ScrollbarState::new(scrollable_range)
|
||||
let mut state = ScrollbarState::new(scrollable_range.saturating_add(1))
|
||||
.position(scrollbar.top.min(scrollable_range))
|
||||
.viewport_content_length(scrollbar.visible);
|
||||
Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
|
||||
@@ -32,6 +32,8 @@ mod plugin_e2e_acceptance;
|
||||
mod screen_mode_inline_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod search_text_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod thinking_stream_pty;
|
||||
mod tool_lifecycle_acceptance;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod work_bar_keys_pty;
|
||||
@@ -0,0 +1,585 @@
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::qa_harness::{
|
||||
harness::{Harness, SealedWorkspace, make_sealed_workspace},
|
||||
keys,
|
||||
};
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(20);
|
||||
const MODEL: &str = "deepseek-v4-pro";
|
||||
const PROMPTS: [&str; 2] = ["first slow fixture prompt", "second slow fixture prompt"];
|
||||
const ANSWER: &str = "Fixture answer after thinking.";
|
||||
const REASONING_LINES: usize = 6;
|
||||
const NOTICE: &str = "Account not loaded. Run codewhale login.";
|
||||
const RETIRED_TRANSCRIPT_NOTICE: &str = "could not be loaded";
|
||||
|
||||
fn reasoning_line(index: usize) -> String {
|
||||
format!("thinking fixture line {index} of {REASONING_LINES}")
|
||||
}
|
||||
|
||||
struct PacedProvider {
|
||||
address: SocketAddr,
|
||||
gate: mpsc::Sender<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
turns: Arc<AtomicUsize>,
|
||||
profile_requests: Arc<AtomicUsize>,
|
||||
acceptor: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
struct Served {
|
||||
gates: Mutex<mpsc::Receiver<()>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
turns: Arc<AtomicUsize>,
|
||||
profile_requests: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl PacedProvider {
|
||||
fn start() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("loopback provider");
|
||||
let address = listener.local_addr().expect("loopback address");
|
||||
let (gate, gates) = mpsc::channel();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let turns = Arc::new(AtomicUsize::new(0));
|
||||
let profile_requests = Arc::new(AtomicUsize::new(0));
|
||||
let served = Arc::new(Served {
|
||||
gates: Mutex::new(gates),
|
||||
stop: Arc::clone(&stop),
|
||||
turns: Arc::clone(&turns),
|
||||
profile_requests: Arc::clone(&profile_requests),
|
||||
});
|
||||
let acceptor = std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
if served.stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let Ok(stream) = stream else { continue };
|
||||
let served = Arc::clone(&served);
|
||||
std::thread::spawn(move || {
|
||||
let _ = serve(stream, &served);
|
||||
});
|
||||
}
|
||||
});
|
||||
Self {
|
||||
address,
|
||||
gate,
|
||||
stop,
|
||||
turns,
|
||||
profile_requests,
|
||||
acceptor: Some(acceptor),
|
||||
}
|
||||
}
|
||||
|
||||
fn model_base(&self) -> String {
|
||||
format!("http://{}/v1", self.address)
|
||||
}
|
||||
|
||||
fn account_base(&self) -> String {
|
||||
format!("http://{}", self.address)
|
||||
}
|
||||
|
||||
fn release(&self, gates: usize) {
|
||||
for _ in 0..gates {
|
||||
self.gate.send(()).expect("provider gate");
|
||||
}
|
||||
}
|
||||
|
||||
fn turns(&self) -> usize {
|
||||
self.turns.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PacedProvider {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = TcpStream::connect(self.address);
|
||||
if let Some(acceptor) = self.acceptor.take() {
|
||||
let _ = acceptor.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, served: &Served) -> std::io::Result<()> {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut stream = stream;
|
||||
let mut request_line = String::new();
|
||||
if reader.read_line(&mut request_line)? == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut content_length = 0usize;
|
||||
loop {
|
||||
let mut header = String::new();
|
||||
if reader.read_line(&mut header)? == 0 {
|
||||
break;
|
||||
}
|
||||
let header = header.trim_end();
|
||||
if header.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = header.split_once(':')
|
||||
&& name.eq_ignore_ascii_case("content-length")
|
||||
{
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let mut body = vec![0u8; content_length.min(8 * 1024 * 1024)];
|
||||
reader.read_exact(&mut body)?;
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = parts.next().unwrap_or_default();
|
||||
let path = parts.next().unwrap_or_default();
|
||||
if method == "GET" && path.ends_with("/models") {
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
&serde_json::json!({"object":"list","data":[{"id":MODEL,"object":"model"}]}),
|
||||
);
|
||||
}
|
||||
if path.starts_with("/api/") {
|
||||
served.profile_requests.fetch_add(1, Ordering::AcqRel);
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"401 Unauthorized",
|
||||
&serde_json::json!({"error":"loopback fixture account is signed out"}),
|
||||
);
|
||||
}
|
||||
if method != "POST" {
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"404 Not Found",
|
||||
&serde_json::json!({"error":"not served by the loopback fixture"}),
|
||||
);
|
||||
}
|
||||
let request: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let streaming = request["stream"].as_bool().unwrap_or(false);
|
||||
let with_tools = request["tools"]
|
||||
.as_array()
|
||||
.is_some_and(|tools| !tools.is_empty());
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
let turn = PROMPTS
|
||||
.iter()
|
||||
.rposition(|prompt| text.contains(prompt))
|
||||
.map(|index| index + 1);
|
||||
let paced = streaming
|
||||
&& with_tools
|
||||
&& turn.is_some_and(|turn| {
|
||||
served
|
||||
.turns
|
||||
.compare_exchange(turn - 1, turn, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
});
|
||||
if !streaming {
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
&serde_json::json!({
|
||||
"id":"loopback","object":"chat.completion","model":MODEL,
|
||||
"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}
|
||||
}),
|
||||
);
|
||||
}
|
||||
stream.write_all(
|
||||
b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: close\r\n\r\n",
|
||||
)?;
|
||||
stream.flush()?;
|
||||
if paced {
|
||||
wait_for_gate(served)?;
|
||||
for index in 1..=REASONING_LINES {
|
||||
send_delta(
|
||||
&mut stream,
|
||||
serde_json::json!({"reasoning_content": format!("{}\n", reasoning_line(index))}),
|
||||
)?;
|
||||
}
|
||||
wait_for_gate(served)?;
|
||||
send_delta(&mut stream, serde_json::json!({"content": ANSWER}))?;
|
||||
wait_for_gate(served)?;
|
||||
} else {
|
||||
send_delta(&mut stream, serde_json::json!({"content": "ok"}))?;
|
||||
}
|
||||
send_chunk(
|
||||
&mut stream,
|
||||
&serde_json::json!({
|
||||
"id":"loopback","object":"chat.completion.chunk","model":MODEL,
|
||||
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}
|
||||
}),
|
||||
)?;
|
||||
stream.write_all(b"data: [DONE]\n\n")?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn wait_for_gate(served: &Served) -> std::io::Result<()> {
|
||||
let gates = served.gates.lock().expect("provider gates");
|
||||
loop {
|
||||
if served.stop.load(Ordering::Acquire) {
|
||||
return Err(std::io::ErrorKind::Interrupted.into());
|
||||
}
|
||||
match gates.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(std::io::ErrorKind::BrokenPipe.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_delta(stream: &mut TcpStream, delta: serde_json::Value) -> std::io::Result<()> {
|
||||
send_chunk(
|
||||
stream,
|
||||
&serde_json::json!({
|
||||
"id":"loopback","object":"chat.completion.chunk","model":MODEL,
|
||||
"choices":[{"index":0,"delta":delta,"finish_reason":null}]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn send_chunk(stream: &mut TcpStream, chunk: &serde_json::Value) -> std::io::Result<()> {
|
||||
stream.write_all(format!("data: {chunk}\n\n").as_bytes())?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn respond_json(
|
||||
stream: &mut TcpStream,
|
||||
status: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> std::io::Result<()> {
|
||||
let body = body.to_string();
|
||||
stream.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn prepare(workspace: &SealedWorkspace, provider: &PacedProvider, settings: Option<&str>) {
|
||||
let state = workspace.home().join(".codewhale");
|
||||
std::fs::write(state.join(".onboarded"), "").unwrap();
|
||||
std::fs::write(
|
||||
state.join("config.toml"),
|
||||
format!(
|
||||
"provider = \"deepseek\"\n[providers.deepseek]\nbase_url = {:?}\napi_key = \"loopback-only\"\nmodel = {MODEL:?}\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
|
||||
provider.model_base()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(settings) = settings {
|
||||
std::fs::write(state.join("settings.toml"), settings).unwrap();
|
||||
}
|
||||
let trust = workspace.workspace().join(".deepseek");
|
||||
std::fs::create_dir_all(&trust).unwrap();
|
||||
std::fs::write(trust.join("trusted"), "").unwrap();
|
||||
}
|
||||
|
||||
fn launch(
|
||||
workspace: &SealedWorkspace,
|
||||
provider: &PacedProvider,
|
||||
(rows, cols): (u16, u16),
|
||||
session: &str,
|
||||
animated: bool,
|
||||
) -> Harness {
|
||||
let mut tui = Harness::builder(Harness::codewhale_binary())
|
||||
.cwd(workspace.workspace())
|
||||
.clear_env()
|
||||
.seal_home(workspace.home())
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.env("SHELL", "/bin/sh")
|
||||
.env("CODEWHALE_CLOUD_API_BASE", provider.account_base())
|
||||
.env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
|
||||
.env("CODEWHALE_NO_UPDATE_CHECK", "1")
|
||||
.env("NO_ANIMATIONS", if animated { "0" } else { "1" })
|
||||
.args([
|
||||
"--workspace",
|
||||
workspace.workspace().to_str().unwrap(),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
session,
|
||||
])
|
||||
.size(rows, cols)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
tui.wait_for_text("accept edits", WAIT)
|
||||
.unwrap_or_else(|error| panic!("the dial chip never painted: {error}"));
|
||||
tui
|
||||
}
|
||||
|
||||
fn expect(tui: &mut Harness, what: &str, predicate: impl FnMut(&super::qa_harness::Frame) -> bool) {
|
||||
if let Err(error) = tui.wait_for(predicate, WAIT) {
|
||||
panic!("waiting for {what}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(tui: &mut Harness) {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let mut quiet_since = Instant::now();
|
||||
while Instant::now() < deadline && quiet_since.elapsed() < Duration::from_millis(300) {
|
||||
if tui.pump() {
|
||||
quiet_since = Instant::now();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn report(tui: &mut Harness, label: &str) {
|
||||
let frame = tui.frame();
|
||||
println!(
|
||||
"--- {label} at {}x{} ---\n{}",
|
||||
frame.cols(),
|
||||
frame.rows(),
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
|
||||
fn footer_row(tui: &mut Harness) -> (u16, String) {
|
||||
let frame = tui.frame();
|
||||
(0..frame.rows())
|
||||
.rev()
|
||||
.map(|row| (row, frame.row(row)))
|
||||
.find(|(_, text)| text.contains("accept edits"))
|
||||
.unwrap_or_else(|| panic!("no dial chip on screen:\n{}", frame.debug_dump()))
|
||||
}
|
||||
|
||||
fn assert_status_and_water(tui: &mut Harness, label: &str) {
|
||||
let (footer, status) = footer_row(tui);
|
||||
let frame = tui.frame();
|
||||
for retired in ["operate", "Operate", "Work bar"] {
|
||||
assert!(
|
||||
!frame.contains(retired),
|
||||
"{label}: `{retired}` is on screen:\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
assert!(status.contains("accept edits"), "{label}: {status}");
|
||||
for row in 0..footer.saturating_sub(3) {
|
||||
let text = frame.row(row);
|
||||
let cells: Vec<char> = text.chars().collect();
|
||||
let school = cells.windows(2).any(|pair| {
|
||||
pair.iter()
|
||||
.all(|cell| ('\u{2801}'..='\u{28FF}').contains(cell))
|
||||
});
|
||||
assert!(
|
||||
!school && !text.contains(['˚', '°', '≈', '~']),
|
||||
"{label}: ambient art on transcript row {row} `{text}`:\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn thinking_rows(tui: &mut Harness) -> usize {
|
||||
let frame = tui.frame();
|
||||
(0..frame.rows())
|
||||
.filter(|&row| frame.row(row).contains("Thinking…"))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn stream_first_turn(tui: &mut Harness, provider: &PacedProvider, thinking_shown: bool) {
|
||||
tui.type_line(PROMPTS[0]).unwrap();
|
||||
expect(tui, "the first request and its interrupt hint", |frame| {
|
||||
provider.turns() == 1 && frame.contains("Esc to interrupt")
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(900));
|
||||
report(tui, "loading");
|
||||
assert_status_and_water(tui, "loading");
|
||||
|
||||
provider.release(1);
|
||||
let tail = reasoning_line(REASONING_LINES);
|
||||
if thinking_shown {
|
||||
expect(tui, "the live thinking block", |frame| {
|
||||
frame.contains("Thinking…") && frame.contains(&tail)
|
||||
});
|
||||
} else {
|
||||
expect(tui, "the thinking activity line", |frame| {
|
||||
frame.contains("Thinking…")
|
||||
});
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1300));
|
||||
report(tui, "thinking");
|
||||
assert_status_and_water(tui, "thinking");
|
||||
assert_eq!(thinking_rows(tui), 1, "one Thinking line");
|
||||
let (_, status) = footer_row(tui);
|
||||
assert!(
|
||||
status.contains("thinking ") && !status.contains("reasoning"),
|
||||
"the clock names the phase: {status}"
|
||||
);
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("reasoning..."), "{}", frame.debug_dump());
|
||||
assert_eq!(frame.contains(&tail), thinking_shown);
|
||||
assert!(
|
||||
!frame.contains(&reasoning_line(1)),
|
||||
"a long trace stays inside its window:\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
|
||||
provider.release(1);
|
||||
expect(tui, "the streamed answer", |frame| frame.contains(ANSWER));
|
||||
report(tui, "answer");
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("Thinking…"), "{}", frame.debug_dump());
|
||||
assert_eq!(frame.contains("Thought for "), thinking_shown);
|
||||
assert!(!frame.contains(&tail), "{}", frame.debug_dump());
|
||||
|
||||
provider.release(1);
|
||||
expect(tui, "the settled turn", |frame| {
|
||||
!frame.contains("Esc to interrupt")
|
||||
});
|
||||
settle(tui);
|
||||
report(tui, "settled");
|
||||
assert_status_and_water(tui, "settled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_streams_stops_and_restores_at_the_shipped_widths() {
|
||||
for size in [(24, 80), (32, 100), (32, 120), (40, 140)] {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, None);
|
||||
let mut tui = launch(&workspace, &provider, size, "--fresh", true);
|
||||
stream_first_turn(&mut tui, &provider, true);
|
||||
|
||||
tui.type_line(PROMPTS[1]).unwrap();
|
||||
expect(&mut tui, "the second request", |_| provider.turns() == 2);
|
||||
provider.release(1);
|
||||
let tail = reasoning_line(REASONING_LINES);
|
||||
expect(&mut tui, "thinking before the stop", |frame| {
|
||||
frame.contains("Thinking…") && frame.contains(&tail)
|
||||
});
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
expect(&mut tui, "the stopped turn", |frame| {
|
||||
!frame.contains("Esc to interrupt") && !frame.contains("Thinking…")
|
||||
});
|
||||
settle(&mut tui);
|
||||
report(&mut tui, "stopped");
|
||||
assert_status_and_water(&mut tui, "stopped");
|
||||
provider.release(2);
|
||||
|
||||
tui.send(keys::key::ctrl('u')).unwrap();
|
||||
settle(&mut tui);
|
||||
tui.send(keys::key::ctrl_d()).unwrap();
|
||||
tui.wait_for_exit(Duration::from_secs(10));
|
||||
tui.shutdown();
|
||||
|
||||
let mut tui = launch(&workspace, &provider, size, "--continue", false);
|
||||
expect(&mut tui, "the restored conversation", |frame| {
|
||||
frame.contains(ANSWER)
|
||||
});
|
||||
settle(&mut tui);
|
||||
report(&mut tui, "restored");
|
||||
assert_status_and_water(&mut tui, "restored");
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("Thinking…"), "{}", frame.debug_dump());
|
||||
tui.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_choice_to_hide_thinking_survives_the_new_default() {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, Some("show_thinking = false\n"));
|
||||
let mut tui = launch(&workspace, &provider, (24, 80), "--fresh", false);
|
||||
stream_first_turn(&mut tui, &provider, false);
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("Thought"), "{}", frame.debug_dump());
|
||||
tui.shutdown();
|
||||
let saved: toml::Value = toml::from_str(
|
||||
&std::fs::read_to_string(workspace.home().join(".codewhale/settings.toml")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved["show_thinking"].as_bool(), Some(false));
|
||||
}
|
||||
|
||||
fn sign_in(workspace: &SealedWorkspace, provider: &PacedProvider, session: &str) {
|
||||
let path = workspace.home().join(".codewhale/secrets/secrets.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let secrets =
|
||||
codewhale_secrets::Secrets::new(Arc::new(codewhale_secrets::FileKeyringStore::new(path)));
|
||||
let bundle = serde_json::from_value(serde_json::json!({
|
||||
"tokenType": "Bearer",
|
||||
"accessToken": "loopback-access",
|
||||
"refreshToken": "loopback-refresh",
|
||||
"session": {"id": session},
|
||||
"user": {"id": "loopback-account"}
|
||||
}))
|
||||
.unwrap();
|
||||
codewhale_secrets::account::AccountSessionStore::new(secrets, None, &provider.account_base())
|
||||
.save(bundle)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn run_ungated_turn(tui: &mut Harness, provider: &PacedProvider) {
|
||||
provider.release(3);
|
||||
tui.type_line(PROMPTS[0]).unwrap();
|
||||
expect(tui, "the answer", |frame| frame.contains(ANSWER));
|
||||
expect(tui, "the settled turn", |frame| {
|
||||
!frame.contains("Esc to interrupt")
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_preferences_notice_is_shown_once_per_sign_in_and_esc_dismisses_it() {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, None);
|
||||
sign_in(&workspace, &provider, "first-sign-in");
|
||||
|
||||
for cols in [80, 120] {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, None);
|
||||
sign_in(&workspace, &provider, "first-sign-in");
|
||||
let mut tui = launch(&workspace, &provider, (32, cols), "--fresh", false);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
expect(&mut tui, "the standing notice", |frame| {
|
||||
frame.contains(NOTICE)
|
||||
});
|
||||
report(&mut tui, "account notice");
|
||||
assert!(provider.profile_requests.load(Ordering::Acquire) > 0);
|
||||
let (_, status) = footer_row(&mut tui);
|
||||
assert!(
|
||||
status.contains(NOTICE),
|
||||
"the notice is footer chrome: {status}"
|
||||
);
|
||||
assert!(!tui.frame().contains(RETIRED_TRANSCRIPT_NOTICE));
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
expect(&mut tui, "the dismissed notice", |frame| {
|
||||
!frame.contains(NOTICE)
|
||||
});
|
||||
report(&mut tui, "account notice dismissed");
|
||||
tui.shutdown();
|
||||
}
|
||||
|
||||
let mut tui = launch(&workspace, &provider, (32, 120), "--fresh", false);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
expect(&mut tui, "the first notice", |frame| frame.contains(NOTICE));
|
||||
tui.shutdown();
|
||||
|
||||
let mut tui = launch(&workspace, &provider, (32, 120), "--fresh", false);
|
||||
provider.turns.store(0, Ordering::Release);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
tui.wait_for_idle(Duration::from_secs(6), Duration::from_secs(30))
|
||||
.unwrap();
|
||||
report(&mut tui, "same sign-in, later session");
|
||||
assert!(
|
||||
!tui.frame().contains(NOTICE),
|
||||
"the same sign-in is told once"
|
||||
);
|
||||
assert!(!tui.frame().contains(RETIRED_TRANSCRIPT_NOTICE));
|
||||
tui.shutdown();
|
||||
|
||||
sign_in(&workspace, &provider, "second-sign-in");
|
||||
let mut tui = launch(&workspace, &provider, (32, 120), "--fresh", false);
|
||||
provider.turns.store(0, Ordering::Release);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
expect(&mut tui, "the notice for a later sign-in", |frame| {
|
||||
frame.contains(NOTICE)
|
||||
});
|
||||
report(&mut tui, "later sign-in");
|
||||
tui.shutdown();
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":498,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":166}],"project_tokens":166,"retained_block_tokens":188,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Create denied.txt with touch.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1973 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 1,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"changed":false,"context_updates":0,"description":"","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"changed":false,"context_updates":0,"description":"frozen: 8fcc59a269e1","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it movesLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to thLine truncated
|
||||
{"event":"route_dispatched","route":{"auto_model":false,"base_url":"https://api.deepseek.com/beta","billing":{"billing_mode":"metered","dispatched_at":"<timestamp>"},"billing_product":{"kind":"unproven"},"model":"deepseek-v4-pro","provider":"deepseek","provider_identity":"deepseek"},"turn_id":"<uuid:1>"}
|
||||
{"event":"provider_request_receipt","receipt":{"accounting_error":null,"cache_attribution":"Local prefix changes and wire schema measurements are context for reported usage, not proof that eviction caused a miss or that unreported retries were free.","coverage":"reported","elapsed_ms":"<masked>","outcome":"completed","physical_requests":[],"physical_requests_dropped":0,"prefix_check":{"outcome":"stable"},"primary":true,"request_id":"<masked>","request_index":1,"reused_preparation":false,"route":{"billing_mode":"unknown","billing_surface":"unclassified","dispatched_at":"<timestamp>","endpoint_fingerprint":null,"model":"deepseek-v4-pro","provider":"custom","provider_identity":"conformance","provider_live_pricing":null},"session_id":"conformance-session","source_accounting":true,"source_id":"<masked>","transmission":"outcome_unknown","transport_retries":0,"turn_id":"<uuid:1>","usage":{"input_tokens":150,"output_tokens":15},"usage_reported":true,"wire_observations":[],"wire_observations_dropped":0}}
|
||||
{"duration_ms":"<masked>","event":"turn_usage","first_token_ms":"<masked>","request_ms":"<masked>","usage":{"input_tokens":150,"output_tokens":15}}
|
||||
@@ -15,13 +15,13 @@
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":498,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":166}],"project_tokens":166,"retained_block_tokens":188,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Create denied.txt with touch.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1973 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 1,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"execution_id":"<uuid:2>","id":"call_touch","input":{"command":"touch denied.txt"},"name":"Bash","type":"tool_use"}],"role":"assistant"},{"content":[{"content":"Error: Tool 'Bash' denied by user — the call was not approved. Do not retry the same call; present what you intended and wait for the user's approval or new instructions.","execution_id":"<uuid:2>","is_error":true,"tool_use_id":"call_touch","type":"tool_result"}],"role":"user"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"event":"status","message":"Continuing — tool results"}
|
||||
{"changed":false,"context_updates":0,"description":"","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":1,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it movesLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":1,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to thLine truncated
|
||||
{"event":"message_started","index":0}
|
||||
{"delta":"Understood, I did not create it.","event":"response_delta","index":0}
|
||||
{"event":"provider_request_receipt","receipt":{"accounting_error":null,"cache_attribution":"Local prefix changes and wire schema measurements are context for reported usage, not proof that eviction caused a miss or that unreported retries were free.","coverage":"reported","elapsed_ms":"<masked>","outcome":"completed","physical_requests":[],"physical_requests_dropped":0,"prefix_check":{"outcome":"stable"},"primary":true,"request_id":"<masked>","request_index":2,"reused_preparation":false,"route":{"billing_mode":"unknown","billing_surface":"unclassified","dispatched_at":"<timestamp>","endpoint_fingerprint":null,"model":"deepseek-v4-pro","provider":"custom","provider_identity":"conformance","provider_live_pricing":null},"session_id":"conformance-session","source_accounting":true,"source_id":"<masked>","transmission":"outcome_unknown","transport_retries":0,"turn_id":"<uuid:1>","usage":{"input_tokens":200,"output_tokens":6},"usage_reported":true,"wire_observations":[],"wire_observations_dropped":0}}
|
||||
{"duration_ms":"<masked>","event":"turn_usage","first_token_ms":"<masked>","request_ms":"<masked>","usage":{"input_tokens":200,"output_tokens":6}}
|
||||
{"event":"message_complete","index":0}
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":498,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":166}],"project_tokens":166,"retained_block_tokens":188,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Create denied.txt with touch.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1973 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 1,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"execution_id":"<uuid:2>","id":"call_touch","input":{"command":"touch denied.txt"},"name":"Bash","type":"tool_use"}],"role":"assistant"},{"content":[{"content":"Error: Tool 'Bash' denied by user — the call was not approved. Do not retry the same call; present what you intended and wait for the user's approval or new instructions.","execution_id":"<uuid:2>","is_error":true,"tool_use_id":"call_touch","type":"tool_result"}],"role":"user"},{"content":[{"text":"Understood, I did not create it.","type":"text"}],"role":"assistant"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":1,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":{"truncated":false,"value":"end_turn"},"last_reported_input_tokens":200,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":0,"model_requests_started":2,"model_step_index":1,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"provider_no_tool_call","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"completed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesysteLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":1,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":{"truncated":false,"value":"end_turn"},"last_reported_input_tokens":200,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":0,"model_requests_started":2,"model_step_index":1,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"provider_no_tool_call","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"completed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem readLine truncated
|
||||
{"event":"turn_complete","parent_route_usage":{"input_tokens":350,"output_tokens":21},"routed_usage_dropped_records":0,"status":"completed","tool_catalog":"<pinned by the prompt family>","turn_id":"<uuid:1>","usage":{"input_tokens":350,"output_tokens":21}}
|
||||
{"harness_summary":{"model_requests":2,"non_streaming_requests":0,"workspace_after":{".codewhale/state/.gitignore":"cdbcae15105d6b78",".codewhale/state/subagents.v1.json/legacy.json":"9064f16acf90984c",".codewhale/state/subagents.v3.json":"e874da2951e53d2b",".codewhale/state/subagents.v3.transaction.lock":"e3b0c44298fc1c14","README.md":"41f49bffb38de5ba"}}}
|
||||
@@ -4,7 +4,7 @@
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Tell me a long story.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1971 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"changed":false,"context_updates":0,"description":"","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"changed":false,"context_updates":0,"description":"frozen: 8fcc59a269e1","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it movesLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to thLine truncated
|
||||
{"event":"route_dispatched","route":{"auto_model":false,"base_url":"https://api.deepseek.com/beta","billing":{"billing_mode":"metered","dispatched_at":"<timestamp>"},"billing_product":{"kind":"unproven"},"model":"deepseek-v4-pro","provider":"deepseek","provider_identity":"deepseek"},"turn_id":"<uuid:1>"}
|
||||
{"event":"message_started","index":0}
|
||||
{"delta":"Here is a partial","event":"response_delta","index":0}
|
||||
@@ -12,7 +12,7 @@
|
||||
{"duration_ms":"<masked>","event":"turn_usage","first_token_ms":"<masked>","request_ms":"<masked>","usage":{"input_tokens":120,"output_tokens":0}}
|
||||
{"event":"status","message":"Request cancelled"}
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Tell me a long story.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1971 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"Here is a partial","type":"text"}],"role":"assistant_interrupted"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":0,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":null,"last_reported_input_tokens":120,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":null,"model_requests_started":1,"model_step_index":0,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"interrupted","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"interrupted","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound networkLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":0,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":null,"last_reported_input_tokens":120,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":null,"model_requests_started":1,"model_step_index":0,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"interrupted","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"interrupted","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is onLine truncated
|
||||
{"event":"turn_complete","parent_route_usage":{"input_tokens":120,"output_tokens":0},"routed_usage_dropped_records":0,"status":"interrupted","tool_catalog":"<pinned by the prompt family>","turn_id":"<uuid:1>","usage":{"input_tokens":120,"output_tokens":0}}
|
||||
{"event":"status","message":"Turn interrupted."}
|
||||
{"harness_summary":{"model_requests":1,"non_streaming_requests":0,"workspace_after":{".codewhale/state/.gitignore":"cdbcae15105d6b78",".codewhale/state/subagents.v1.json/legacy.json":"9064f16acf90984c",".codewhale/state/subagents.v3.json":"e874da2951e53d2b",".codewhale/state/subagents.v3.transaction.lock":"e3b0c44298fc1c14","NOTES.md":"f957b19529906961","README.md":"41f49bffb38de5ba"}}}
|
||||
@@ -4,7 +4,7 @@
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"What does README.md say?","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1972 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nActive paths (prioritize these):\n- README.md (file)\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"changed":false,"context_updates":0,"description":"","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"changed":false,"context_updates":0,"description":"frozen: 8fcc59a269e1","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it movesLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to thLine truncated
|
||||
{"event":"route_dispatched","route":{"auto_model":false,"base_url":"https://api.deepseek.com/beta","billing":{"billing_mode":"metered","dispatched_at":"<timestamp>"},"billing_product":{"kind":"unproven"},"model":"deepseek-v4-pro","provider":"deepseek","provider_identity":"deepseek"},"turn_id":"<uuid:1>"}
|
||||
{"event":"provider_request_receipt","receipt":{"accounting_error":null,"cache_attribution":"Local prefix changes and wire schema measurements are context for reported usage, not proof that eviction caused a miss or that unreported retries were free.","coverage":"reported","elapsed_ms":"<masked>","outcome":"completed","physical_requests":[],"physical_requests_dropped":0,"prefix_check":{"outcome":"stable"},"primary":true,"request_id":"<masked>","request_index":1,"reused_preparation":false,"route":{"billing_mode":"unknown","billing_surface":"unclassified","dispatched_at":"<timestamp>","endpoint_fingerprint":null,"model":"deepseek-v4-pro","provider":"custom","provider_identity":"conformance","provider_live_pricing":null},"session_id":"conformance-session","source_accounting":true,"source_id":"<masked>","transmission":"outcome_unknown","transport_retries":0,"turn_id":"<uuid:1>","usage":{"input_tokens":150,"output_tokens":20},"usage_reported":true,"wire_observations":[],"wire_observations_dropped":0}}
|
||||
{"duration_ms":"<masked>","event":"turn_usage","first_token_ms":"<masked>","request_ms":"<masked>","usage":{"input_tokens":150,"output_tokens":20}}
|
||||
@@ -16,13 +16,13 @@
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"What does README.md say?","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1972 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nActive paths (prioritize these):\n- README.md (file)\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"execution_id":"<uuid:2>","id":"call_read_readme","input":{"action":"read","path":"README.md"},"name":"File","type":"tool_use"}],"role":"assistant"},{"content":[{"content":"content_hash=\"sha256:41f49bffb38de5ba88945f09121ffc590f9d7f6ff80fdb9ea8ad10224fecea61\"\nconformance fixture","execution_id":"<uuid:2>","tool_use_id":"call_read_readme","type":"tool_result"}],"role":"user"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"event":"status","message":"Continuing — tool results"}
|
||||
{"changed":false,"context_updates":0,"description":"","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":1,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it movesLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":1,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to thLine truncated
|
||||
{"event":"message_started","index":0}
|
||||
{"delta":"It says: conformance fixture.","event":"response_delta","index":0}
|
||||
{"event":"provider_request_receipt","receipt":{"accounting_error":null,"cache_attribution":"Local prefix changes and wire schema measurements are context for reported usage, not proof that eviction caused a miss or that unreported retries were free.","coverage":"reported","elapsed_ms":"<masked>","outcome":"completed","physical_requests":[],"physical_requests_dropped":0,"prefix_check":{"outcome":"stable"},"primary":true,"request_id":"<masked>","request_index":2,"reused_preparation":false,"route":{"billing_mode":"unknown","billing_surface":"unclassified","dispatched_at":"<timestamp>","endpoint_fingerprint":null,"model":"deepseek-v4-pro","provider":"custom","provider_identity":"conformance","provider_live_pricing":null},"session_id":"conformance-session","source_accounting":true,"source_id":"<masked>","transmission":"outcome_unknown","transport_retries":0,"turn_id":"<uuid:1>","usage":{"input_tokens":200,"output_tokens":6},"usage_reported":true,"wire_observations":[],"wire_observations_dropped":0}}
|
||||
{"duration_ms":"<masked>","event":"turn_usage","first_token_ms":"<masked>","request_ms":"<masked>","usage":{"input_tokens":200,"output_tokens":6}}
|
||||
{"event":"message_complete","index":0}
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"What does README.md say?","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1972 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nActive paths (prioritize these):\n- README.md (file)\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"execution_id":"<uuid:2>","id":"call_read_readme","input":{"action":"read","path":"README.md"},"name":"File","type":"tool_use"}],"role":"assistant"},{"content":[{"content":"content_hash=\"sha256:41f49bffb38de5ba88945f09121ffc590f9d7f6ff80fdb9ea8ad10224fecea61\"\nconformance fixture","execution_id":"<uuid:2>","tool_use_id":"call_read_readme","type":"tool_result"}],"role":"user"},{"content":[{"text":"It says: conformance fixture.","type":"text"}],"role":"assistant"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":1,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":{"truncated":false,"value":"end_turn"},"last_reported_input_tokens":200,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":0,"model_requests_started":2,"model_step_index":1,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"provider_no_tool_call","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"completed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesysteLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":1,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":{"truncated":false,"value":"end_turn"},"last_reported_input_tokens":200,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":0,"model_requests_started":2,"model_step_index":1,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"provider_no_tool_call","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"completed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem readLine truncated
|
||||
{"event":"turn_complete","parent_route_usage":{"input_tokens":350,"output_tokens":26},"routed_usage_dropped_records":0,"status":"completed","tool_catalog":"<pinned by the prompt family>","turn_id":"<uuid:1>","usage":{"input_tokens":350,"output_tokens":26}}
|
||||
{"harness_summary":{"model_requests":2,"non_streaming_requests":0,"workspace_after":{".codewhale/state/.gitignore":"cdbcae15105d6b78",".codewhale/state/subagents.v1.json/legacy.json":"9064f16acf90984c",".codewhale/state/subagents.v3.json":"e874da2951e53d2b",".codewhale/state/subagents.v3.transaction.lock":"e3b0c44298fc1c14","NOTES.md":"f957b19529906961","README.md":"41f49bffb38de5ba"}}}
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Read README.md and NOTES.md.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1973 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nActive paths (prioritize these):\n- NOTES.md (file)\n- README.md (file)\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"changed":false,"context_updates":0,"description":"","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"changed":false,"context_updates":0,"description":"frozen: 8fcc59a269e1","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it movesLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to thLine truncated
|
||||
{"event":"route_dispatched","route":{"auto_model":false,"base_url":"https://api.deepseek.com/beta","billing":{"billing_mode":"metered","dispatched_at":"<timestamp>"},"billing_product":{"kind":"unproven"},"model":"deepseek-v4-pro","provider":"deepseek","provider_identity":"deepseek"},"turn_id":"<uuid:1>"}
|
||||
{"event":"provider_request_receipt","receipt":{"accounting_error":null,"cache_attribution":"Local prefix changes and wire schema measurements are context for reported usage, not proof that eviction caused a miss or that unreported retries were free.","coverage":"reported","elapsed_ms":"<masked>","outcome":"completed","physical_requests":[],"physical_requests_dropped":0,"prefix_check":{"outcome":"stable"},"primary":true,"request_id":"<masked>","request_index":1,"reused_preparation":false,"route":{"billing_mode":"unknown","billing_surface":"unclassified","dispatched_at":"<timestamp>","endpoint_fingerprint":null,"model":"deepseek-v4-pro","provider":"custom","provider_identity":"conformance","provider_live_pricing":null},"session_id":"conformance-session","source_accounting":true,"source_id":"<masked>","transmission":"outcome_unknown","transport_retries":0,"turn_id":"<uuid:1>","usage":{"input_tokens":160,"output_tokens":40},"usage_reported":true,"wire_observations":[],"wire_observations_dropped":0}}
|
||||
{"duration_ms":"<masked>","event":"turn_usage","first_token_ms":"<masked>","request_ms":"<masked>","usage":{"input_tokens":160,"output_tokens":40}}
|
||||
@@ -22,13 +22,13 @@
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Read README.md and NOTES.md.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1973 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nActive paths (prioritize these):\n- NOTES.md (file)\n- README.md (file)\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"execution_id":"<uuid:2>","id":"call_read_a","input":{"action":"read","path":"README.md"},"name":"File","type":"tool_use"},{"execution_id":"<uuid:3>","id":"call_read_b","input":{"action":"read","path":"NOTES.md"},"name":"File","type":"tool_use"}],"role":"assistant"},{"content":[{"content":"content_hash=\"sha256:41f49bffb38de5ba88945f09121ffc590f9d7f6ff80fdb9ea8ad10224fecea61\"\nconformance fixture","execution_id":"<uuid:2>","tool_use_id":"call_read_a","type":"tool_result"}],"role":"user"},{"content":[{"content":"content_hash=\"sha256:f957b19529906961933c5c30f8713c500a9bb5d9d0695c40d48c97a26a3594ec\"\nsecond file","execution_id":"<uuid:3>","tool_use_id":"call_read_b","type":"tool_result"}],"role":"user"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"event":"status","message":"Continuing — tool results"}
|
||||
{"changed":false,"context_updates":0,"description":"","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":1,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it movesLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":1,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to thLine truncated
|
||||
{"event":"message_started","index":0}
|
||||
{"delta":"Both files read.","event":"response_delta","index":0}
|
||||
{"event":"provider_request_receipt","receipt":{"accounting_error":null,"cache_attribution":"Local prefix changes and wire schema measurements are context for reported usage, not proof that eviction caused a miss or that unreported retries were free.","coverage":"reported","elapsed_ms":"<masked>","outcome":"completed","physical_requests":[],"physical_requests_dropped":0,"prefix_check":{"outcome":"stable"},"primary":true,"request_id":"<masked>","request_index":2,"reused_preparation":false,"route":{"billing_mode":"unknown","billing_surface":"unclassified","dispatched_at":"<timestamp>","endpoint_fingerprint":null,"model":"deepseek-v4-pro","provider":"custom","provider_identity":"conformance","provider_live_pricing":null},"session_id":"conformance-session","source_accounting":true,"source_id":"<masked>","transmission":"outcome_unknown","transport_retries":0,"turn_id":"<uuid:1>","usage":{"input_tokens":200,"output_tokens":6},"usage_reported":true,"wire_observations":[],"wire_observations_dropped":0}}
|
||||
{"duration_ms":"<masked>","event":"turn_usage","first_token_ms":"<masked>","request_ms":"<masked>","usage":{"input_tokens":200,"output_tokens":6}}
|
||||
{"event":"message_complete","index":0}
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Read README.md and NOTES.md.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1973 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nActive paths (prioritize these):\n- NOTES.md (file)\n- README.md (file)\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"execution_id":"<uuid:2>","id":"call_read_a","input":{"action":"read","path":"README.md"},"name":"File","type":"tool_use"},{"execution_id":"<uuid:3>","id":"call_read_b","input":{"action":"read","path":"NOTES.md"},"name":"File","type":"tool_use"}],"role":"assistant"},{"content":[{"content":"content_hash=\"sha256:41f49bffb38de5ba88945f09121ffc590f9d7f6ff80fdb9ea8ad10224fecea61\"\nconformance fixture","execution_id":"<uuid:2>","tool_use_id":"call_read_a","type":"tool_result"}],"role":"user"},{"content":[{"content":"content_hash=\"sha256:f957b19529906961933c5c30f8713c500a9bb5d9d0695c40d48c97a26a3594ec\"\nsecond file","execution_id":"<uuid:3>","tool_use_id":"call_read_b","type":"tool_result"}],"role":"user"},{"content":[{"text":"Both files read.","type":"text"}],"role":"assistant"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":1,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":{"truncated":false,"value":"end_turn"},"last_reported_input_tokens":200,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":0,"model_requests_started":2,"model_step_index":1,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"provider_no_tool_call","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"completed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesysteLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":1,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":{"truncated":false,"value":"end_turn"},"last_reported_input_tokens":200,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":0,"model_requests_started":2,"model_step_index":1,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"provider_no_tool_call","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"completed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem readLine truncated
|
||||
{"event":"turn_complete","parent_route_usage":{"input_tokens":360,"output_tokens":46},"routed_usage_dropped_records":0,"status":"completed","tool_catalog":"<pinned by the prompt family>","turn_id":"<uuid:1>","usage":{"input_tokens":360,"output_tokens":46}}
|
||||
{"harness_summary":{"model_requests":2,"non_streaming_requests":0,"workspace_after":{".codewhale/state/.gitignore":"cdbcae15105d6b78",".codewhale/state/subagents.v1.json/legacy.json":"9064f16acf90984c",".codewhale/state/subagents.v3.json":"e874da2951e53d2b",".codewhale/state/subagents.v3.transaction.lock":"e3b0c44298fc1c14","NOTES.md":"f957b19529906961","README.md":"41f49bffb38de5ba"}}}
|
||||
@@ -4,7 +4,7 @@
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Say hello.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1968 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"changed":false,"context_updates":0,"description":"","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"changed":false,"context_updates":0,"description":"frozen: 8fcc59a269e1","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it movesLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to thLine truncated
|
||||
{"event":"route_dispatched","route":{"auto_model":false,"base_url":"https://api.deepseek.com/beta","billing":{"billing_mode":"metered","dispatched_at":"<timestamp>"},"billing_product":{"kind":"unproven"},"model":"deepseek-v4-pro","provider":"deepseek","provider_identity":"deepseek"},"turn_id":"<uuid:1>"}
|
||||
{"event":"message_started","index":0}
|
||||
{"delta":"Hello","event":"response_delta","index":0}
|
||||
@@ -13,6 +13,6 @@
|
||||
{"duration_ms":"<masked>","event":"turn_usage","first_token_ms":"<masked>","request_ms":"<masked>","usage":{"input_tokens":120,"output_tokens":4}}
|
||||
{"event":"message_complete","index":0}
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Say hello.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Ask\nCurrent sandbox posture: workspace-write (writes inside the workspace; network blocked) (local OS sandbox applied)\nEstimated conversation input: 1968 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"Hello there.","type":"text"}],"role":"assistant"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":0,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":{"truncated":false,"value":"end_turn"},"last_reported_input_tokens":120,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":0,"model_requests_started":1,"model_step_index":0,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"provider_no_tool_call","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"completed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesysteLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":0,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":{"truncated":false,"value":"end_turn"},"last_reported_input_tokens":120,"last_response_tool_calls":0,"last_response_tool_calls_suppressed":0,"model_requests_started":1,"model_step_index":0,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"provider_no_tool_call","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"completed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem readLine truncated
|
||||
{"event":"turn_complete","parent_route_usage":{"input_tokens":120,"output_tokens":4},"routed_usage_dropped_records":0,"status":"completed","tool_catalog":"<pinned by the prompt family>","turn_id":"<uuid:1>","usage":{"input_tokens":120,"output_tokens":4}}
|
||||
{"harness_summary":{"model_requests":1,"non_streaming_requests":0,"workspace_after":{".codewhale/state/.gitignore":"cdbcae15105d6b78",".codewhale/state/subagents.v1.json/legacy.json":"9064f16acf90984c",".codewhale/state/subagents.v3.json":"e874da2951e53d2b",".codewhale/state/subagents.v3.transaction.lock":"e3b0c44298fc1c14","NOTES.md":"f957b19529906961","README.md":"41f49bffb38de5ba"}}}
|
||||
+2
-2
@@ -4,13 +4,13 @@
|
||||
{"engine_session_id":"conformance-session","event":"session_updated","instruction_assembly":{"budgets":{"claude_fallback":false,"file_max_bytes":8388608,"global_tokens":100000,"policy":"trim","project_tokens":100000,"static_guard_tokens":667571,"usable_window_tokens":834464},"constitution_source_path":null,"constitution_tokens":0,"global_tokens":0,"notices":[{"action":"Kept","bytes_dropped":0,"bytes_kept":514,"detail":null,"fallback_selected":null,"limit":"None","origin":"Instructions","path":null,"scope":"Project","tokens_dropped":0,"tokens_kept":172}],"project_tokens":172,"retained_block_tokens":193,"source_path":null,"warnings":[]},"messages":[{"content":[{"text":"Write c0205.txt.","type":"text"},{"text":"<turn_meta>\nCurrent local date: <today>\nCurrent workspace: <WORKSPACE>\nCurrent permission posture: Full Access\nCurrent sandbox posture: full access (sandbox disabled)\nEstimated conversation input: 1970 / 1000000 context tokens (0.2%). Automatic compaction is enabled.\n## Repo Working Set\nKey files: README.md\nWhen in doubt, use tools to verify and keep changes focused on the working set.\n</turn_meta>","type":"text"}],"role":"user"},{"content":[{"text":"<codewhale:runtime_event kind=\"project_instructions\" visibility=\"internal\">\n<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md\n\n<project_instructions source=\"project\">\n# Project Context (Auto-generated, ephemeral)\n\n> This context was generated in memory by Codewhale.\n> No .codewhale/instructions.md file was written.\n\n## Bounded Project Overview\n\n```json\n{\n \"project_name\": \"workspace\",\n \"directory_structure\": [\n \"README.md\",\n \"NOTES.md\"\n ],\n \"readme\": {\n \"path\": \"README.md\",\n \"excerpt\": \"conformance fixture\"\n },\n \"config_files\": [],\n \"key_source_files\": [],\n \"counts\": {\n \"config_files\": 0,\n \"directory_entries\": 2,\n \"key_source_files\": 0\n }\n}\n```\n</project_instructions>\n</system-reminder>\n</codewhale:runtime_event>","type":"text"},{"text":"<turn_meta>\nInput provenance: runtime (non-authoritative)\n</turn_meta>","type":"text"}],"role":"user"}],"model":"deepseek-v4-pro","system_prompt":"<pinned by the prompt family>","workspace":"<WORKSPACE>"}
|
||||
{"changed":false,"context_updates":0,"description":"","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"changed":false,"context_updates":0,"description":"frozen: 8fcc59a269e1","event":"prefix_cache_change","last_miss_reason":"","pin_reason":"initial","pinned_combined_hash":"<masked>","stability_pct":100,"system_prompt_changed":false,"tools_changed":false}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it movesLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":0,"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in Full Access when the session sandbox, the network policy and your network grant allow it. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation.\",\"type\":\"boolean\"},\"sandbox_permissions\":{\"description\":\"The wider sandbox mode this exact command needs. Use only as a one-shot retry after a sandbox denial; requires justification and user approval in Ask.\",\"enum\":[\"workspace-write\",\"danger-full-access\"],\"type\":\"string\"},\"timeout\":{\"description\":\"Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to thLine truncated
|
||||
{"event":"route_dispatched","route":{"auto_model":false,"base_url":"https://api.deepseek.com/beta","billing":{"billing_mode":"metered","dispatched_at":"<timestamp>"},"billing_product":{"kind":"unproven"},"model":"deepseek-v4-pro","provider":"deepseek","provider_identity":"deepseek"},"turn_id":"<uuid:1>"}
|
||||
{"category":"invalid_input","code":"invalid_input","event":"error","message":"Model not exist.","recoverable":false,"severity":"error"}
|
||||
{"event":"provider_request_receipt","receipt":{"accounting_error":null,"cache_attribution":"Local prefix changes and wire schema measurements are context for reported usage, not proof that eviction caused a miss or that unreported retries were free.","coverage":"incomplete","elapsed_ms":"<masked>","outcome":"incomplete","physical_requests":[],"physical_requests_dropped":0,"prefix_check":{"outcome":"stable"},"primary":true,"request_id":"<masked>","request_index":1,"reused_preparation":false,"route":{"billing_mode":"unknown","billing_surface":"unclassified","dispatched_at":"<timestamp>","endpoint_fingerprint":null,"model":"deepseek-v4-pro","provider":"custom","provider_identity":"conformance","provider_live_pricing":null},"session_id":"conformance-session","source_accounting":true,"source_id":"<masked>","transmission":"outcome_unknown","transport_retries":0,"turn_id":"<uuid:1>","usage":{"input_tokens":120,"output_tokens":0},"usage_reported":true,"wire_observations":[],"wire_observations_dropped":0}}
|
||||
{"duration_ms":"<masked>","event":"turn_usage","first_token_ms":"<masked>","request_ms":"<masked>","usage":{"input_tokens":120,"output_tokens":0}}
|
||||
{"event":"tool_call_started","input":{"action":"write","content":"side effect\n","path":"c0205.txt"},"tool_call_id":"<uuid:2>","tool_name":"File"}
|
||||
{"event":"tool_call_complete","result":{"content":"Not executed: the provider stream failed before the model response completed (Model not exist.).","metadata":{"error_category":"model_stream_failed","model_output_incomplete":true,"side_effect_status":"not_started"},"outcome":"ok","success":false},"tool_call_id":"<uuid:2>","tool_name":"File"}
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":40,"omitted":8,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"},{"truncated":false,"value":"terminal/cancel"}]}},"registry_tool_count":{"status":"known","value":65},"rendered_tool_count":15,"schema_version":1,"step":0,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":null,"last_reported_input_tokens":120,"last_response_tool_calls":1,"last_response_tool_calls_suppressed":1,"model_requests_started":1,"model_step_index":0,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"failed","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"failed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only iLine truncated
|
||||
{"event":"tool_request_snapshot","snapshot":{"active_tool_catalog_sha256":"9017c53ee026ae4065bed3e4b2e7d773a828556b42d6ebe2c2ebfd8e65377280","capture_source":"prepared model-client request","delivery_status":"unknown (capture does not prove provider delivery)","omitted_tool_count":0,"payload_json_bytes":17394,"payload_measurement_status":"exact (within 1048576-byte measurement bound)","provider":{"model":"deepseek-v4-pro","provider":"Deepseek","status":"available"},"registry_facts_present":true,"registry_only_tools":{"status":"known","value":{"count":41,"omitted":9,"rendered":[{"truncated":false,"value":"Git"},{"truncated":false,"value":"Run"},{"truncated":false,"value":"Web"},{"truncated":false,"value":"apply_patch"},{"truncated":false,"value":"automation"},{"truncated":false,"value":"context_archive_get"},{"truncated":false,"value":"context_archive_search"},{"truncated":false,"value":"diagnostics"},{"truncated":false,"value":"finance"},{"truncated":false,"value":"github"},{"truncated":false,"value":"handle_read"},{"truncated":false,"value":"harness"},{"truncated":false,"value":"image_ocr"},{"truncated":false,"value":"list_dir"},{"truncated":false,"value":"lsp"},{"truncated":false,"value":"note"},{"truncated":false,"value":"notify"},{"truncated":false,"value":"pandoc_convert"},{"truncated":false,"value":"project_map"},{"truncated":false,"value":"registry_sync"},{"truncated":false,"value":"request_plugin_install"},{"truncated":false,"value":"request_user_input"},{"truncated":false,"value":"retrieve_tool_result"},{"truncated":false,"value":"revert_turn"},{"truncated":false,"value":"review"},{"truncated":false,"value":"send_later"},{"truncated":false,"value":"session_get"},{"truncated":false,"value":"session_search"},{"truncated":false,"value":"show_data"},{"truncated":false,"value":"start_mcp_server"},{"truncated":false,"value":"start_registry_mcp_server"},{"truncated":false,"value":"tasks"}]}},"registry_tool_count":{"status":"known","value":66},"rendered_tool_count":15,"schema_version":1,"step":0,"terminal":{"automatic_compaction_attempts":0,"effective_max_steps":null,"emergency_compaction_attempts":0,"empty_stop_retries":0,"final_report_requested":false,"last_prepared_output_limit_tokens":65536,"last_provider_finish_reason":null,"last_reported_input_tokens":120,"last_response_tool_calls":1,"last_response_tool_calls_suppressed":1,"model_requests_started":1,"model_step_index":0,"permission_denial_rounds_without_progress":0,"permission_strategy_switches":0,"reason":"failed","reasoning_only_reprompts":0,"route_context_window_tokens":1000000,"status":"failed","step_budget_source":"max_steps","stream_resumes":0,"transparent_stream_retries":0,"transport_retries":0},"tool_count":15,"tools":[{"allowed_callers":{"status":"known","value":{"count":1,"omitted":0,"rendered":[{"truncated":false,"value":"direct"}]}},"approval":{"status":"known","value":{"truncated":false,"value":"Required"}},"cache_control_type":{"reason":"request field absent","status":"unknown"},"capabilities":{"status":"known","value":{"count":3,"omitted":0,"rendered":[{"truncated":false,"value":"ExecutesCode"},{"truncated":false,"value":"Sandboxable"},{"truncated":false,"value":"RequiresApproval"}]}},"defer_loading":{"status":"known","value":false},"description":{"truncated":true,"value":"Execute a shell command in the workspace and return stdout and stderr (last 2000 lines or 50KB). Optional timeout is foreground seconds (default 30 seconds); a command still running then moves to the background by itself — never killed for time — returning output so far, last-output age, build phase, and a task_id. Call task_shell_wait with that task_id to read more or wait; do not sleep-poll with bash. Before a final answer, collect every still-relevant background task (wait only when genuinely blocked) an"},"input_examples":{"reason":"request field absent","status":"unknown"},"input_schema_json":{"truncated":false,"value":"{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"The command to execute. Actual execution shell: `/bin/bash`. Use Bash syntax: pipelines, redirections, $(command), and && / || are supported. Quote paths and variable expansions, such as \\\"$path\\\"; use single quotes for literal text. For literal multiline input, use a quoted heredoc delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\\\n' 'sample'.\",\"type\":\"string\"},\"description\":{\"description\":\"Optional few-word summary shown to the user beside the command.\",\"type\":\"string\"},\"justification\":{\"description\":\"Required with sandbox_permissions: one sentence explaining why this exact command needs wider access.\",\"type\":\"string\"},\"read_only\":{\"description\":\"Set true to run analysis code (including Python/SQLite) or a network fetch (curl) with mandatory native filesystem read-only isolation. Outbound network is on only in FullLine truncated
|
||||
{"error":"Model not exist.","event":"turn_complete","parent_route_usage":{"input_tokens":120,"output_tokens":0},"routed_usage_dropped_records":0,"status":"failed","tool_catalog":"<pinned by the prompt family>","turn_id":"<uuid:1>","usage":{"input_tokens":120,"output_tokens":0}}
|
||||
{"harness_summary":{"model_requests":1,"non_streaming_requests":0,"workspace_after":{".codewhale/state/.gitignore":"cdbcae15105d6b78",".codewhale/state/subagents.v1.json/legacy.json":"9064f16acf90984c",".codewhale/state/subagents.v3.json":"e874da2951e53d2b",".codewhale/state/subagents.v3.transaction.lock":"e3b0c44298fc1c14","NOTES.md":"f957b19529906961","README.md":"41f49bffb38de5ba"}}}
|
||||
@@ -2230,7 +2230,10 @@ Common settings keys:
|
||||
`@`-mention completions are populated. `fuzzy` searches the workspace and
|
||||
applies mention frecency. `browser` lists only the immediate children of the
|
||||
currently typed directory segment in deterministic alphabetical order.
|
||||
- `show_thinking` (on/off)
|
||||
- `show_thinking` (on/off, default on): shows model thinking in the transcript,
|
||||
live while it streams and collapsed under a `Thought for …` header once the
|
||||
answer starts. Off hides it; answers and tools stay visible. A value already
|
||||
saved in `settings.toml` is kept.
|
||||
- `thinking_default_expanded` (on/off, default off): renders thinking blocks
|
||||
expanded initially when `show_thinking` is enabled. Space still toggles the
|
||||
selected block, and it decides only the blocks you have not touched: a block
|
||||
|
||||
+4
-6
@@ -231,10 +231,8 @@ Re-checked on macOS 26.1, Apple silicon (`macos-arm64`), with a fresh `HOME`:
|
||||
checksums with the system tools, and installed `codewhale` and `codew`
|
||||
(64 MiB each, Mach-O arm64) in 4.3 s. Both report
|
||||
`codewhale 0.10.0 (1be1a703b975)`. They ran without a Gatekeeper prompt.
|
||||
* When Node isn't on `PATH`, it also prints `Computer Use is included and needs
|
||||
Node.js 20 or newer on PATH.` The core TUI works without Node, but Computer Use
|
||||
and the JavaScript execution tool (`js_execution`) stay unavailable until Node
|
||||
is on `PATH`.
|
||||
* The core TUI works without Node; the JavaScript execution tool
|
||||
(`js_execution`) needs Node on `PATH`.
|
||||
* `codewhale doctor` behaves as on Linux (exit 0, `All checks complete!` with no
|
||||
key, file-based secret store under `~/.codewhale/secrets/`), except that it
|
||||
reports `✓ sandbox available: macos-seatbelt`.
|
||||
@@ -602,8 +600,8 @@ Tested with Homebrew 7.0.6 against the v0.10.0 tap formula: the install took
|
||||
release binaries, so there's no compile. It provides **both** `codewhale` and
|
||||
`codew`. The `Hmbown/deepseek-tui` tap formula also depends on `node`, which
|
||||
pulled in 31 bottles (~560 MB) on Linux. That `node` dependency belongs to
|
||||
this tap formula only. The core TUI runs without Node; Computer Use and the JS
|
||||
execution tool use it when it is on PATH.
|
||||
this tap formula only. The core TUI runs without Node; the JS execution tool
|
||||
uses it when it is on PATH.
|
||||
|
||||
* **Upgrade:** `brew upgrade codewhale`. (`codewhale update` refuses, and
|
||||
suggests migrating.)
|
||||
|
||||
+1
-1
@@ -1860,7 +1860,7 @@ ones like `-s` or `--porcelain` — a compound, wrapper, interpreter or
|
||||
unrecognised command, one with any other option, or one whose arguments are
|
||||
what runs or is installed (`go run`, `make`, `git bisect`, package installs)
|
||||
is granted as its full normalized command, and a
|
||||
shell interact or wait call as the exact call — a patch's file set, a `fetch_url` host, an MCP tool, a `web.run` action kind — for
|
||||
shell run given stdin (`stdin`, `input` or `data`), or a shell interact or wait call, as the exact call — a patch's file set, a `fetch_url` host, an MCP tool, a `web.run` action kind — for
|
||||
`open`, the hosts it opened). Computer Use consent and `app_script` calls, and
|
||||
any tool without a class, are granted for the exact call only. A grant never
|
||||
changes the thread's permission posture. Later matching calls on the thread are
|
||||
|
||||
+10
-1
@@ -491,6 +491,15 @@ surfaces = { tui = "preview", api = "partial" }
|
||||
since = "unreleased"
|
||||
owner = "crates/engine/src/tools/present.rs"
|
||||
|
||||
[[feature]]
|
||||
id = "show-data"
|
||||
name = "Charts in the transcript"
|
||||
summary = "Read-only show_data tool: display sparklines, labelled bars or trends, with matching text summaries."
|
||||
status = "experimental"
|
||||
surfaces = { tui = "preview", api = "partial" }
|
||||
since = "unreleased"
|
||||
owner = "crates/engine/src/tools/show_data.rs"
|
||||
|
||||
[[feature]]
|
||||
id = "read-media"
|
||||
name = "read_media"
|
||||
@@ -805,7 +814,7 @@ owner = "crates/tui/src/commands/groups/session/export.rs"
|
||||
[[feature]]
|
||||
id = "live-thinking"
|
||||
name = "Live thinking"
|
||||
summary = "/verbose toggles live model thinking in the transcript."
|
||||
summary = "Model thinking shows live in the transcript, then collapses under a Thought header; /verbose shows it in full."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable" }
|
||||
owner = "crates/tui/src/commands/groups/config/mod.rs"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Fix the failing tests and explain what changed.
|
||||
|
||||
### الإضافات
|
||||
|
||||
صِل [خوادم MCP](../MCP.md)، وأضف [المهارات](../SKILLS.md) و[الإضافات البرمجية](../PLUGINS.md)، وشغّل [الخطافات](../HOOKS.md) عند أحداث الجلسة والأدوات. يتيح وضع الشيفرة (تجريبي، ومفعّل افتراضيًا) للنموذج كتابة برنامج `execute_tools` واحد يستدعي عدة أدوات MCP وأدوات إضافات؛ ويظل كل استدعاء خاضعًا لقواعد الموافقة لديك ويحتفظ بإيصاله. إضافة Computer Use المضمّنة في v0.10.2 (تجريبية، على macOS فقط) تستطيع مراقبة التطبيقات الأخرى وتشغيلها، وتبقى معطّلة حتى تراجع صلاحياتها وتفعّلها ([الدليل](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). قادم في 0.10.3: تصدر الإضافة [بشكل منفصل](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
صِل [خوادم MCP](../MCP.md)، وأضف [المهارات](../SKILLS.md) و[الإضافات البرمجية](../PLUGINS.md)، وشغّل [الخطافات](../HOOKS.md) عند أحداث الجلسة والأدوات. يتيح وضع الشيفرة (تجريبي، ومفعّل افتراضيًا) للنموذج كتابة برنامج `execute_tools` واحد يستدعي عدة أدوات MCP وأدوات إضافات؛ ويظل كل استدعاء خاضعًا لقواعد الموافقة لديك ويحتفظ بإيصاله. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) إضافة منفصلة تصدر بشكل مستقل (تجريبية، على macOS فقط)، وتبقى معطّلة حتى تراجع صلاحياتها وتفعّلها.
|
||||
|
||||
### طرق التشغيل
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Porta més de 40 proveïdors integrats, entre ells Anthropic, OpenAI, DeepSeek,
|
||||
|
||||
### Extensions
|
||||
|
||||
Connecta [servidors MCP](../MCP.md), afegeix [skills](../SKILLS.md) i [connectors](../PLUGINS.md), i executa [hooks](../HOOKS.md) en esdeveniments de sessió i d’eines. El mode codi (experimental, activat per defecte) permet que el model escrigui un programa `execute_tools` que crida diverses eines MCP i de connectors; cada crida continua respectant les teves regles d’aprovació i en conserva el rebut. El connector Computer Use inclòs a v0.10.2 (experimental, només macOS) pot observar i fer servir altres aplicacions, i resta apagat fins que en revises l’accés i l’actives ([guia](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). Arriba amb 0.10.3: el connector es publica [per separat](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Connecta [servidors MCP](../MCP.md), afegeix [skills](../SKILLS.md) i [connectors](../PLUGINS.md), i executa [hooks](../HOOKS.md) en esdeveniments de sessió i d’eines. El mode codi (experimental, activat per defecte) permet que el model escrigui un programa `execute_tools` que crida diverses eines MCP i de connectors; cada crida continua respectant les teves regles d’aprovació i en conserva el rebut. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) és un connector separat publicat pel seu compte (experimental, només macOS), i resta apagat fins que en revises l’accés i l’actives.
|
||||
|
||||
### Maneres d’executar-lo
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Mehr als 40 Anbieter sind eingebaut, darunter Anthropic, OpenAI, DeepSeek, Googl
|
||||
|
||||
### Erweiterungen
|
||||
|
||||
Verbinde [MCP-Server](../MCP.md), füge [Skills](../SKILLS.md) und [Plugins](../PLUGINS.md) hinzu und führe [Hooks](../HOOKS.md) bei Sitzungs- und Werkzeugereignissen aus. Der Code-Modus (experimentell, standardmäßig an) lässt das Modell ein `execute_tools`-Programm schreiben, das mehrere MCP- und Plugin-Werkzeuge aufruft; jeder Aufruf folgt weiter deinen Freigaberegeln und behält seinen Beleg. Das in v0.10.2 enthaltene Computer-Use-Plugin (experimentell, nur macOS) kann andere Apps beobachten und bedienen; es bleibt aus, bis du seinen Zugriff geprüft und es aktiviert hast ([Anleitung](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). Kommt in 0.10.3: Das Plugin erscheint [separat](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Verbinde [MCP-Server](../MCP.md), füge [Skills](../SKILLS.md) und [Plugins](../PLUGINS.md) hinzu und führe [Hooks](../HOOKS.md) bei Sitzungs- und Werkzeugereignissen aus. Der Code-Modus (experimentell, standardmäßig an) lässt das Modell ein `execute_tools`-Programm schreiben, das mehrere MCP- und Plugin-Werkzeuge aufruft; jeder Aufruf folgt weiter deinen Freigaberegeln und behält seinen Beleg. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) ist ein separates, eigenständig veröffentlichtes Plugin (experimentell, nur macOS) und bleibt aus, bis du seinen Zugriff geprüft und es aktiviert hast.
|
||||
|
||||
### Wege, es zu starten
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Trae más de 40 proveedores integrados, entre ellos Anthropic, OpenAI, DeepSeek,
|
||||
|
||||
### Extensiones
|
||||
|
||||
Conecta [servidores MCP](../MCP.md), agrega [skills](../SKILLS.md) y [plugins](../PLUGINS.md), y ejecuta [hooks](../HOOKS.md) en eventos de sesión y de herramientas. El modo código (experimental, activado por defecto) permite que el modelo escriba un programa `execute_tools` que llama a varias herramientas MCP y de plugins; cada llamada sigue respetando tus reglas de aprobación y conserva su recibo. El plugin Computer Use incluido en v0.10.2 (experimental, solo macOS) puede observar y operar otras apps, y permanece apagado hasta que revises su acceso y lo actives ([guía](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). Próximamente en 0.10.3: el plugin se publica [por separado](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Conecta [servidores MCP](../MCP.md), agrega [skills](../SKILLS.md) y [plugins](../PLUGINS.md), y ejecuta [hooks](../HOOKS.md) en eventos de sesión y de herramientas. El modo código (experimental, activado por defecto) permite que el modelo escriba un programa `execute_tools` que llama a varias herramientas MCP y de plugins; cada llamada sigue respetando tus reglas de aprobación y conserva su recibo. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) es un plugin separado publicado por su cuenta (experimental, solo macOS) y permanece apagado hasta que revises su acceso y lo actives.
|
||||
|
||||
### Formas de ejecutarlo
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Plus de 40 fournisseurs sont intégrés, dont Anthropic, OpenAI, DeepSeek, Googl
|
||||
|
||||
### Extensions
|
||||
|
||||
Connectez des [serveurs MCP](../MCP.md), ajoutez des [skills](../SKILLS.md) et des [plugins](../PLUGINS.md), et lancez des [hooks](../HOOKS.md) sur les événements de session et d’outil. Le mode code (expérimental, activé par défaut) permet au modèle d’écrire un programme `execute_tools` qui appelle plusieurs outils MCP et de plugins ; chaque appel respecte toujours vos règles d’approbation et garde son reçu. Le plugin Computer Use fourni avec v0.10.2 (expérimental, macOS uniquement) peut observer et piloter d’autres applications ; il reste désactivé tant que vous n’avez pas vérifié ses accès et ne l’avez pas activé ([guide](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). À venir dans 0.10.3 : le plugin est publié [séparément](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Connectez des [serveurs MCP](../MCP.md), ajoutez des [skills](../SKILLS.md) et des [plugins](../PLUGINS.md), et lancez des [hooks](../HOOKS.md) sur les événements de session et d’outil. Le mode code (expérimental, activé par défaut) permet au modèle d’écrire un programme `execute_tools` qui appelle plusieurs outils MCP et de plugins ; chaque appel respecte toujours vos règles d’approbation et garde son reçu. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) est un plugin séparé publié indépendamment (expérimental, macOS uniquement) ; il reste désactivé tant que vous n’avez pas vérifié ses accès et ne l’avez pas activé.
|
||||
|
||||
### Façons de le lancer
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Codewhale को कोई काम दें, और यह काम पू
|
||||
|
||||
### एक्सटेंशन
|
||||
|
||||
[MCP सर्वर](../MCP.md) जोड़ें, [स्किल](../SKILLS.md) और [प्लगइन](../PLUGINS.md) जोड़ें, और सत्र और टूल इवेंट पर [हुक](../HOOKS.md) चलाएँ। कोड मोड (प्रायोगिक, डिफ़ॉल्ट रूप से चालू) मॉडल को एक `execute_tools` प्रोग्राम लिखने देता है जो कई MCP और प्लगइन टूल बुलाता है; हर कॉल फिर भी आपके मंज़ूरी नियमों का पालन करता है और अपनी रसीद रखता है। v0.10.2 के साथ आने वाला Computer Use प्लगइन (प्रायोगिक, केवल macOS) दूसरे ऐप देख और चला सकता है, और जब तक आप इसकी पहुँच की समीक्षा करके इसे चालू नहीं करते, यह बंद रहता है ([गाइड](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md))। 0.10.3 में आ रहा है: यह प्लगइन [अलग से रिलीज़](https://github.com/codewhale-hq/codewhale-cu-plugin) होगा।
|
||||
[MCP सर्वर](../MCP.md) जोड़ें, [स्किल](../SKILLS.md) और [प्लगइन](../PLUGINS.md) जोड़ें, और सत्र और टूल इवेंट पर [हुक](../HOOKS.md) चलाएँ। कोड मोड (प्रायोगिक, डिफ़ॉल्ट रूप से चालू) मॉडल को एक `execute_tools` प्रोग्राम लिखने देता है जो कई MCP और प्लगइन टूल बुलाता है; हर कॉल फिर भी आपके मंज़ूरी नियमों का पालन करता है और अपनी रसीद रखता है। [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) एक अलग प्लगइन है जो अपने आप रिलीज़ होता है (प्रायोगिक, केवल macOS), और जब तक आप इसकी पहुँच की समीक्षा करके इसे चालू नहीं करते, यह बंद रहता है।
|
||||
|
||||
### चलाने के तरीके
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Lebih dari 40 penyedia sudah terpasang, termasuk Anthropic, OpenAI, DeepSeek, Go
|
||||
|
||||
### Ekstensi
|
||||
|
||||
Hubungkan [server MCP](../MCP.md), tambahkan [skill](../SKILLS.md) dan [plugin](../PLUGINS.md), dan jalankan [hook](../HOOKS.md) pada peristiwa sesi dan alat. Mode kode (eksperimental, aktif secara bawaan) memungkinkan model menulis satu program `execute_tools` yang memanggil beberapa alat MCP dan plugin; setiap panggilan tetap mengikuti aturan persetujuan Anda dan menyimpan tanda terimanya. Plugin Computer Use yang dibundel dengan v0.10.2 (eksperimental, hanya macOS) dapat mengamati dan mengoperasikan aplikasi lain, dan tetap mati sampai Anda meninjau aksesnya dan mengaktifkannya ([panduan](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). Hadir di 0.10.3: plugin ini [dirilis terpisah](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Hubungkan [server MCP](../MCP.md), tambahkan [skill](../SKILLS.md) dan [plugin](../PLUGINS.md), dan jalankan [hook](../HOOKS.md) pada peristiwa sesi dan alat. Mode kode (eksperimental, aktif secara bawaan) memungkinkan model menulis satu program `execute_tools` yang memanggil beberapa alat MCP dan plugin; setiap panggilan tetap mengikuti aturan persetujuan Anda dan menyimpan tanda terimanya. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) adalah plugin terpisah yang dirilis sendiri (eksperimental, hanya macOS), dan tetap mati sampai Anda meninjau aksesnya dan mengaktifkannya.
|
||||
|
||||
### Cara menjalankannya
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Più di 40 provider sono integrati, tra cui Anthropic, OpenAI, DeepSeek, Google,
|
||||
|
||||
### Estensioni
|
||||
|
||||
Collega [server MCP](../MCP.md), aggiungi [skill](../SKILLS.md) e [plugin](../PLUGINS.md), ed esegui [hook](../HOOKS.md) sugli eventi di sessione e degli strumenti. La modalità codice (sperimentale, attiva per impostazione predefinita) permette al modello di scrivere un programma `execute_tools` che chiama più strumenti MCP e di plugin; ogni chiamata rispetta comunque le tue regole di approvazione e conserva la sua ricevuta. Il plugin Computer Use incluso in v0.10.2 (sperimentale, solo macOS) può osservare e usare altre app, e resta spento finché non ne controlli l’accesso e lo attivi ([guida](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). In arrivo con 0.10.3: il plugin viene rilasciato [a parte](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Collega [server MCP](../MCP.md), aggiungi [skill](../SKILLS.md) e [plugin](../PLUGINS.md), ed esegui [hook](../HOOKS.md) sugli eventi di sessione e degli strumenti. La modalità codice (sperimentale, attiva per impostazione predefinita) permette al modello di scrivere un programma `execute_tools` che chiama più strumenti MCP e di plugin; ogni chiamata rispetta comunque le tue regole di approvazione e conserva la sua ricevuta. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) è un plugin separato rilasciato autonomamente (sperimentale, solo macOS) e resta spento finché non ne controlli l’accesso e lo attivi.
|
||||
|
||||
### Modi per avviarlo
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Anthropic、OpenAI、DeepSeek、Google、Mistral、Moonshot、OpenRouter、xAI
|
||||
|
||||
### 拡張
|
||||
|
||||
[MCP サーバー](../MCP.md)を接続し、[スキル](../SKILLS.md)と[プラグイン](../PLUGINS.md)を追加し、セッションやツールのイベントで[フック](../HOOKS.md)を実行できます。コードモード(実験的、既定で有効)では、モデルが複数の MCP ツールやプラグインツールを呼び出す `execute_tools` プログラムを 1 つ書けます。各呼び出しはあなたの承認ルールに従い、レシートも残ります。v0.10.2 に同梱の Computer Use プラグイン(実験的、macOS のみ)は他のアプリを観察・操作でき、あなたがアクセス権を確認して有効にするまではオフのままです([ガイド](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md))。0.10.3 で予定:このプラグインは[別途リリース](https://github.com/codewhale-hq/codewhale-cu-plugin)されます。
|
||||
[MCP サーバー](../MCP.md)を接続し、[スキル](../SKILLS.md)と[プラグイン](../PLUGINS.md)を追加し、セッションやツールのイベントで[フック](../HOOKS.md)を実行できます。コードモード(実験的、既定で有効)では、モデルが複数の MCP ツールやプラグインツールを呼び出す `execute_tools` プログラムを 1 つ書けます。各呼び出しはあなたの承認ルールに従い、レシートも残ります。[Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) は独立してリリースされる別のプラグイン(実験的、macOS のみ)で、アクセス権を確認して有効にするまでオフのままです。
|
||||
|
||||
### 実行方法
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Anthropic, OpenAI, DeepSeek, Google, Mistral, Moonshot, OpenRouter, xAI를 비
|
||||
|
||||
### 확장
|
||||
|
||||
[MCP 서버](../MCP.md)를 연결하고, [스킬](../SKILLS.md)과 [플러그인](../PLUGINS.md)을 추가하고, 세션과 도구 이벤트에서 [훅](../HOOKS.md)을 실행하세요. 코드 모드(실험적, 기본 사용)는 모델이 여러 MCP 도구와 플러그인 도구를 호출하는 `execute_tools` 프로그램 하나를 작성하게 합니다. 각 호출은 여전히 승인 규칙을 따르고 영수증을 남깁니다. v0.10.2에 포함된 Computer Use 플러그인(실험적, macOS 전용)은 다른 앱을 관찰하고 조작할 수 있으며, 접근 권한을 검토하고 켜기 전까지는 꺼져 있습니다([가이드](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). 0.10.3 예정: 이 플러그인은 [별도로 릴리스](https://github.com/codewhale-hq/codewhale-cu-plugin)됩니다.
|
||||
[MCP 서버](../MCP.md)를 연결하고, [스킬](../SKILLS.md)과 [플러그인](../PLUGINS.md)을 추가하고, 세션과 도구 이벤트에서 [훅](../HOOKS.md)을 실행하세요. 코드 모드(실험적, 기본 사용)는 모델이 여러 MCP 도구와 플러그인 도구를 호출하는 `execute_tools` 프로그램 하나를 작성하게 합니다. 각 호출은 여전히 승인 규칙을 따르고 영수증을 남깁니다. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin)는 별도로 릴리스되는 독립 플러그인(실험적, macOS 전용)이며, 접근 권한을 검토하고 켜기 전까지는 꺼져 있습니다.
|
||||
|
||||
### 실행 방법
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Wbudowano ponad 40 dostawców, w tym Anthropic, OpenAI, DeepSeek, Google, Mistra
|
||||
|
||||
### Rozszerzenia
|
||||
|
||||
Podłączaj [serwery MCP](../MCP.md), dodawaj [umiejętności](../SKILLS.md) i [wtyczki](../PLUGINS.md) oraz uruchamiaj [hooki](../HOOKS.md) przy zdarzeniach sesji i narzędzi. Tryb kodu (eksperymentalny, domyślnie włączony) pozwala modelowi napisać jeden program `execute_tools`, który wywołuje kilka narzędzi MCP i wtyczek; każde wywołanie nadal podlega twoim regułom zatwierdzania i zachowuje potwierdzenie. Wtyczka Computer Use dołączona do v0.10.2 (eksperymentalna, tylko macOS) może obserwować inne aplikacje i nimi sterować; pozostaje wyłączona, dopóki nie sprawdzisz jej dostępu i jej nie włączysz ([przewodnik](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). Nadchodzi w 0.10.3: wtyczka będzie wydawana [osobno](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Podłączaj [serwery MCP](../MCP.md), dodawaj [umiejętności](../SKILLS.md) i [wtyczki](../PLUGINS.md) oraz uruchamiaj [hooki](../HOOKS.md) przy zdarzeniach sesji i narzędzi. Tryb kodu (eksperymentalny, domyślnie włączony) pozwala modelowi napisać jeden program `execute_tools`, który wywołuje kilka narzędzi MCP i wtyczek; każde wywołanie nadal podlega twoim regułom zatwierdzania i zachowuje potwierdzenie. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) to osobna wtyczka wydawana niezależnie (eksperymentalna, tylko macOS) i pozostaje wyłączona, dopóki nie sprawdzisz jej dostępu i jej nie włączysz.
|
||||
|
||||
### Sposoby uruchamiania
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Mais de 40 provedores vêm integrados, entre eles Anthropic, OpenAI, DeepSeek, G
|
||||
|
||||
### Extensões
|
||||
|
||||
Conecte [servidores MCP](../MCP.md), adicione [skills](../SKILLS.md) e [plugins](../PLUGINS.md), e rode [hooks](../HOOKS.md) em eventos de sessão e de ferramentas. O modo código (experimental, ativado por padrão) permite que o modelo escreva um programa `execute_tools` que chama várias ferramentas MCP e de plugins; cada chamada continua seguindo suas regras de aprovação e mantém seu recibo. O plugin Computer Use incluído na v0.10.2 (experimental, só macOS) pode observar e operar outros apps, e fica desligado até você revisar o acesso dele e ativá-lo ([guia](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). Chegando na 0.10.3: o plugin passa a ser lançado [separadamente](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Conecte [servidores MCP](../MCP.md), adicione [skills](../SKILLS.md) e [plugins](../PLUGINS.md), e rode [hooks](../HOOKS.md) em eventos de sessão e de ferramentas. O modo código (experimental, ativado por padrão) permite que o modelo escreva um programa `execute_tools` que chama várias ferramentas MCP e de plugins; cada chamada continua seguindo suas regras de aprovação e mantém seu recibo. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) é um plugin separado lançado por conta própria (experimental, só macOS) e fica desligado até você revisar o acesso dele e ativá-lo.
|
||||
|
||||
### Formas de rodar
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Fix the failing tests and explain what changed.
|
||||
|
||||
### Расширения
|
||||
|
||||
Подключайте [MCP-серверы](../MCP.md), добавляйте [навыки](../SKILLS.md) и [плагины](../PLUGINS.md) и запускайте [хуки](../HOOKS.md) на события сеанса и инструментов. Режим кода (экспериментальный, включён по умолчанию) позволяет модели написать одну программу `execute_tools`, которая вызывает несколько инструментов MCP и плагинов; каждый вызов по-прежнему подчиняется вашим правилам подтверждения и сохраняет квитанцию. Плагин Computer Use, входящий в v0.10.2 (экспериментальный, только macOS), может наблюдать за другими приложениями и управлять ими; он выключен, пока вы не проверите его доступ и не включите его ([руководство](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). Появится в 0.10.3: плагин будет выпускаться [отдельно](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Подключайте [MCP-серверы](../MCP.md), добавляйте [навыки](../SKILLS.md) и [плагины](../PLUGINS.md) и запускайте [хуки](../HOOKS.md) на события сеанса и инструментов. Режим кода (экспериментальный, включён по умолчанию) позволяет модели написать одну программу `execute_tools`, которая вызывает несколько инструментов MCP и плагинов; каждый вызов по-прежнему подчиняется вашим правилам подтверждения и сохраняет квитанцию. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) — отдельный плагин, выпускаемый самостоятельно (экспериментальный, только macOS); он выключен, пока вы не проверите его доступ и не включите его.
|
||||
|
||||
### Способы запуска
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Anthropic, OpenAI, DeepSeek, Google, Mistral, Moonshot, OpenRouter ve xAI dahil
|
||||
|
||||
### Uzantılar
|
||||
|
||||
[MCP sunucularını](../MCP.md) bağlayın, [beceriler](../SKILLS.md) ve [eklentiler](../PLUGINS.md) ekleyin, oturum ve araç olaylarında [kancalar](../HOOKS.md) çalıştırın. Kod modu (deneysel, varsayılan olarak açık), modelin birkaç MCP ve eklenti aracını çağıran tek bir `execute_tools` programı yazmasına izin verir; her çağrı yine onay kurallarınıza uyar ve makbuzunu korur. v0.10.2 ile gelen Computer Use eklentisi (deneysel, yalnızca macOS) diğer uygulamaları izleyip kullanabilir ve siz erişimini inceleyip etkinleştirene kadar kapalı kalır ([kılavuz](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). 0.10.3 ile gelecek: eklenti [ayrı olarak](https://github.com/codewhale-hq/codewhale-cu-plugin) yayımlanır.
|
||||
[MCP sunucularını](../MCP.md) bağlayın, [beceriler](../SKILLS.md) ve [eklentiler](../PLUGINS.md) ekleyin, oturum ve araç olaylarında [kancalar](../HOOKS.md) çalıştırın. Kod modu (deneysel, varsayılan olarak açık), modelin birkaç MCP ve eklenti aracını çağıran tek bir `execute_tools` programı yazmasına izin verir; her çağrı yine onay kurallarınıza uyar ve makbuzunu korur. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) bağımsız olarak yayımlanan ayrı bir eklentidir (deneysel, yalnızca macOS) ve siz erişimini inceleyip etkinleştirene kadar kapalı kalır.
|
||||
|
||||
### Çalıştırma yolları
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Fix the failing tests and explain what changed.
|
||||
|
||||
### Розширення
|
||||
|
||||
Підключайте [MCP-сервери](../MCP.md), додавайте [навички](../SKILLS.md) і [плагіни](../PLUGINS.md) та запускайте [хуки](../HOOKS.md) на події сеансу й інструментів. Режим коду (експериментальний, увімкнений за замовчуванням) дає змогу моделі написати одну програму `execute_tools`, яка викликає кілька інструментів MCP і плагінів; кожен виклик і далі підпорядковується вашим правилам підтвердження та зберігає квитанцію. Плагін Computer Use, що входить до v0.10.2 (експериментальний, лише macOS), може спостерігати за іншими застосунками й керувати ними; він вимкнений, доки ви не перевірите його доступ і не ввімкнете його ([посібник](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). З’явиться в 0.10.3: плагін випускатиметься [окремо](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Підключайте [MCP-сервери](../MCP.md), додавайте [навички](../SKILLS.md) і [плагіни](../PLUGINS.md) та запускайте [хуки](../HOOKS.md) на події сеансу й інструментів. Режим коду (експериментальний, увімкнений за замовчуванням) дає змогу моделі написати одну програму `execute_tools`, яка викликає кілька інструментів MCP і плагінів; кожен виклик і далі підпорядковується вашим правилам підтвердження та зберігає квитанцію. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) — окремий плагін, що випускається самостійно (експериментальний, лише macOS); він вимкнений, доки ви не перевірите його доступ і не ввімкнете його.
|
||||
|
||||
### Способи запуску
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Hơn 40 nhà cung cấp được tích hợp sẵn, gồm Anthropic, OpenAI, Dee
|
||||
|
||||
### Tiện ích mở rộng
|
||||
|
||||
Kết nối [máy chủ MCP](../MCP.md), thêm [kỹ năng](../SKILLS.md) và [plugin](../PLUGINS.md), và chạy [hook](../HOOKS.md) trên các sự kiện phiên và công cụ. Chế độ mã (thử nghiệm, bật mặc định) cho phép mô hình viết một chương trình `execute_tools` gọi nhiều công cụ MCP và plugin; mỗi lời gọi vẫn tuân theo quy tắc phê duyệt của bạn và giữ biên nhận. Plugin Computer Use đi kèm v0.10.2 (thử nghiệm, chỉ macOS) có thể quan sát và điều khiển các ứng dụng khác, và nó luôn tắt cho đến khi bạn xem lại quyền truy cập và bật nó ([hướng dẫn](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)). Sắp có trong 0.10.3: plugin này được [phát hành riêng](https://github.com/codewhale-hq/codewhale-cu-plugin).
|
||||
Kết nối [máy chủ MCP](../MCP.md), thêm [kỹ năng](../SKILLS.md) và [plugin](../PLUGINS.md), và chạy [hook](../HOOKS.md) trên các sự kiện phiên và công cụ. Chế độ mã (thử nghiệm, bật mặc định) cho phép mô hình viết một chương trình `execute_tools` gọi nhiều công cụ MCP và plugin; mỗi lời gọi vẫn tuân theo quy tắc phê duyệt của bạn và giữ biên nhận. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) là một plugin riêng được phát hành độc lập (thử nghiệm, chỉ macOS) và luôn tắt cho đến khi bạn xem lại quyền truy cập và bật nó.
|
||||
|
||||
### Các cách chạy
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Fix the failing tests and explain what changed.
|
||||
|
||||
### 扩展
|
||||
|
||||
连接 [MCP 服务器](../MCP.md),添加[技能](../SKILLS.md)和[插件](../PLUGINS.md),并在会话和工具事件上运行[钩子](../HOOKS.md)。代码模式(实验性,默认开启)让模型编写一个调用多个 MCP 和插件工具的 `execute_tools` 程序;每次调用仍需遵守你的批准规则,并保留回执。v0.10.2 内置的 Computer Use 插件(实验性,仅限 macOS)可以观察并操作其他应用,在你审查其权限并启用之前一直处于关闭状态([指南](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md))。即将在 0.10.3 推出:该插件将[单独发布](https://github.com/codewhale-hq/codewhale-cu-plugin)。
|
||||
连接 [MCP 服务器](../MCP.md),添加[技能](../SKILLS.md)和[插件](../PLUGINS.md),并在会话和工具事件上运行[钩子](../HOOKS.md)。代码模式(实验性,默认开启)让模型编写一个调用多个 MCP 和插件工具的 `execute_tools` 程序;每次调用仍需遵守你的批准规则,并保留回执。[Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) 是单独发布的独立插件(实验性,仅限 macOS),在你审查其权限并开启之前一直处于关闭状态。
|
||||
|
||||
### 运行方式
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:fd76d718e3a2 -->
|
||||
<!-- source: README.md sha256:c66c04179a4e -->
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
@@ -79,7 +79,7 @@ Fix the failing tests and explain what changed.
|
||||
|
||||
### 擴充
|
||||
|
||||
連接 [MCP 伺服器](../MCP.md),加入[技能](../SKILLS.md)和[外掛](../PLUGINS.md),並在工作階段和工具事件上執行[掛鉤](../HOOKS.md)。程式碼模式(實驗性,預設開啟)讓模型撰寫一個呼叫多個 MCP 和外掛工具的 `execute_tools` 程式;每次呼叫仍須遵守你的核准規則,並保留收據。v0.10.2 內建的 Computer Use 外掛(實驗性,僅限 macOS)可以觀察並操作其他應用程式,在你檢查其權限並啟用之前一直保持關閉([指南](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md))。即將在 0.10.3 推出:此外掛將[另外發行](https://github.com/codewhale-hq/codewhale-cu-plugin)。
|
||||
連接 [MCP 伺服器](../MCP.md),加入[技能](../SKILLS.md)和[外掛](../PLUGINS.md),並在工作階段和工具事件上執行[掛鉤](../HOOKS.md)。程式碼模式(實驗性,預設開啟)讓模型撰寫一個呼叫多個 MCP 和外掛工具的 `execute_tools` 程式;每次呼叫仍須遵守你的核准規則,並保留收據。[Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin) 是另外發行的獨立外掛(實驗性,僅限 macOS),在你檢查其權限並開啟之前一直保持關閉。
|
||||
|
||||
### 執行方式
|
||||
|
||||
|
||||
@@ -1152,7 +1152,7 @@ codewhale 还把用户偏好存储在:
|
||||
- `mention_menu_limit`(整数,默认 `128`):输入区渲染可见窗口之前保留的 `@`-提及弹出候选最大数。可见行仍取决于终端高度。
|
||||
- `mention_walk_depth`(整数,默认 `10`):`@`-提及补全遍历的最大工作区深度。在深度嵌套的工作区设为 `0` 表示无限深度;在非常大的仓库保持默认,除非需要。
|
||||
- `mention_menu_behavior`(`fuzzy`、`browser`;默认 `fuzzy`):控制 `@`-提及补全如何填充。`fuzzy` 搜索工作区并应用提及 frecency。`browser` 只按确定性字母顺序列出当前输入目录段的直接子项。
|
||||
- `show_thinking`(on/off)
|
||||
- `show_thinking`(on/off,默认 on):在对话记录中显示模型思考,流式输出时实时显示,回答开始后折叠到“思考用时 …”标题下。设为 off 则隐藏思考;回答和工具仍然可见。`settings.toml` 中已保存的值保持不变。
|
||||
- `thinking_default_expanded`(on/off,默认 off):启用 `show_thinking` 时,让思考块初始以展开状态渲染。Space 仍可切换所选块,并且它只决定你尚未动过的块:你自己展开或折叠过的块,即使之后更改这个设置,也会保持该状态。这在 Space 绑定可能被拦截的 SSH/tmux 环境中很有用。
|
||||
- `thinking_preview_lines`(整数,默认 `2`):**折叠**的已完成思考仍显示多少正文行。`0` 仅头部;`10` 是较旧的完整显示。实时流式预览不变。用 Space 展开块,或设置 `thinking_default_expanded` 打开每个块。
|
||||
- `help_expand_groups`(on/off,默认 off):让帮助/快捷键从每个组展开开始。默认折叠长尾(Grok 风格);输入过滤仍展开匹配。
|
||||
|
||||
@@ -174,7 +174,7 @@ PATH 代码块始终是安装器打印的最后内容。如果你的 PATH 已经
|
||||
在 macOS 26.1、Apple silicon(`macos-arm64`)上,用全新的 `HOME` 重新检查过:
|
||||
|
||||
* 安装器打印了 `Installing Codewhale for macos-arm64`,用系统自带工具校验了校验和,并在 4.3 秒内装好了 `codewhale` 和 `codew`(各 64 MiB,Mach-O arm64)。两者都报告 `codewhale 0.10.0 (1be1a703b975)`。运行时没有弹出 Gatekeeper 提示。
|
||||
* 当 `PATH` 上没有 Node 时,它还会打印 `Computer Use is included and needs Node.js 20 or newer on PATH.`。核心 TUI 没有 Node 也能用,但在 `PATH` 上有 Node 之前,Computer Use 和 JavaScript 执行工具(`js_execution`)不可用。
|
||||
* 核心 TUI 没有 Node 也能用;JavaScript 执行工具(`js_execution`)需要 `PATH` 上有 Node。
|
||||
* `codewhale doctor` 的行为与 Linux 相同(退出码 0;没有密钥时输出 `All checks complete!`;文件型密钥存储位于 `~/.codewhale/secrets/`),只是它会报告 `✓ sandbox available: macos-seatbelt`。
|
||||
|
||||
### 把它加入 PATH
|
||||
@@ -440,7 +440,7 @@ Run `brew trust --formula hmbown/deepseek-tui/codewhale` or `brew trust hmbown/d
|
||||
|
||||
先运行 `brew trust hmbown/deepseek-tui`,或者使用上面的完整名称。
|
||||
|
||||
用 Homebrew 7.0.6 对照 v0.10.0 的 tap formula 测试过:安装用了 73 秒。tap formula 跟踪最新发布并下载官方发布二进制,所以不需要编译。它**同时**提供 `codewhale` 和 `codew`。`Hmbown/deepseek-tui` 这个 tap 的 formula 还依赖 `node`,在 Linux 上拉了 31 个 bottle(约 560 MB)。这个 `node` 依赖只属于该 tap formula。核心 TUI 没有 Node 也能运行;Computer Use 和 JS 执行工具在 PATH 上有 Node 时会使用它。
|
||||
用 Homebrew 7.0.6 对照 v0.10.0 的 tap formula 测试过:安装用了 73 秒。tap formula 跟踪最新发布并下载官方发布二进制,所以不需要编译。它**同时**提供 `codewhale` 和 `codew`。`Hmbown/deepseek-tui` 这个 tap 的 formula 还依赖 `node`,在 Linux 上拉了 31 个 bottle(约 560 MB)。这个 `node` 依赖只属于该 tap formula。核心 TUI 没有 Node 也能运行;JS 执行工具在 PATH 上有 Node 时会使用它。
|
||||
|
||||
* **升级:** `brew upgrade codewhale`。(`codewhale update` 会拒绝,并建议迁移。)
|
||||
* **卸载:** `brew uninstall codewhale && brew untap Hmbown/deepseek-tui`。这也会自动移除该 tap 的 node 依赖以及它拉进来的其他东西。Homebrew 的下载缓存(`~/.cache/Homebrew`,约 330 MB)会保留,直到运行 `brew cleanup --prune=all`。
|
||||
|
||||
@@ -5,6 +5,8 @@ import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const DEFAULT_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const THREAD_NOT_OWNED_MESSAGE = "This chat can only use threads it started. Send /new to start a new thread.";
|
||||
|
||||
function normalizeCursorValue(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
if (Number.isFinite(number) && number >= 0) return Math.floor(number);
|
||||
@@ -78,7 +80,9 @@ export class ThreadStore {
|
||||
actions: options.actions === true,
|
||||
actionLimit: options.actionLimit || 200,
|
||||
actionTtlMs: options.actionTtlMs || DEFAULT_ACTION_TTL_MS,
|
||||
privateMode: options.privateMode === true
|
||||
privateMode: options.privateMode === true,
|
||||
ownerLimit: options.ownerLimit || options.actionLimit || 200,
|
||||
legacyOwnerScope: typeof options.legacyOwnerScope === "function" ? options.legacyOwnerScope : () => ""
|
||||
};
|
||||
this.data = { chats: {} };
|
||||
this.saveDirty = false;
|
||||
@@ -90,6 +94,7 @@ export class ThreadStore {
|
||||
ensureShape() {
|
||||
if (!this.data || typeof this.data !== "object") this.data = {};
|
||||
if (!this.data.chats || typeof this.data.chats !== "object") this.data.chats = {};
|
||||
if (!Array.isArray(this.data.threadOwners)) this.data.threadOwners = [];
|
||||
if (this.options.messageLimit > 0 && !Array.isArray(this.data.messages)) {
|
||||
this.data.messages = [];
|
||||
}
|
||||
@@ -107,8 +112,11 @@ export class ThreadStore {
|
||||
async load() {
|
||||
try {
|
||||
const raw = await readFile(this.filePath, "utf8");
|
||||
this.data = JSON.parse(raw);
|
||||
const parsed = JSON.parse(raw);
|
||||
const unmigrated = !parsed || typeof parsed !== "object" || !Array.isArray(parsed.threadOwners);
|
||||
this.data = parsed;
|
||||
this.ensureShape();
|
||||
if (unmigrated) this.data.threadOwners = this.legacyThreadOwners();
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
@@ -215,6 +223,102 @@ export class ThreadStore {
|
||||
return this.data.chats[chatId];
|
||||
}
|
||||
|
||||
legacyThreadOwners() {
|
||||
const chatsByThread = new Map();
|
||||
const reference = (threadId, chatId) => {
|
||||
if (!chatsByThread.has(threadId)) chatsByThread.set(threadId, new Set());
|
||||
chatsByThread.get(threadId).add(chatId);
|
||||
};
|
||||
const candidates = [];
|
||||
for (const [chatId, state] of Object.entries(this.data.chats || {})) {
|
||||
if (!state || typeof state !== "object") continue;
|
||||
const own = [];
|
||||
for (const entry of Array.isArray(state.threadBindings) ? state.threadBindings : []) {
|
||||
if (!entry?.threadId || !entry.actorId) continue;
|
||||
reference(entry.threadId, chatId);
|
||||
own.push({ threadId: entry.threadId, chatId, scope: String(entry.actorId) });
|
||||
}
|
||||
if (state.threadId) {
|
||||
reference(state.threadId, chatId);
|
||||
const scope = this.options.legacyOwnerScope(chatId, state);
|
||||
if (typeof scope === "string") own.push({ threadId: state.threadId, chatId, scope, legacy: true });
|
||||
}
|
||||
candidates.push(...own.slice(-this.options.ownerLimit));
|
||||
}
|
||||
const seen = new Set();
|
||||
return candidates.filter((entry) => {
|
||||
const key = JSON.stringify([entry.threadId, entry.chatId, entry.scope]);
|
||||
if (seen.has(key) || chatsByThread.get(entry.threadId).size !== 1) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
ownsThread(chatId, threadId, scope = "") {
|
||||
if (!chatId || !threadId) return false;
|
||||
return this.data.threadOwners.some((entry) => entry.legacy !== true && entry.threadId === threadId && entry.chatId === chatId && entry.scope === scope);
|
||||
}
|
||||
|
||||
ownershipPending(chatId, threadId, scope = "") {
|
||||
if (!chatId || !threadId || this.ownsThread(chatId, threadId, scope)) return false;
|
||||
return this.data.threadOwners.some((entry) => entry.legacy === true && entry.threadId === threadId && entry.chatId === chatId && entry.scope === scope);
|
||||
}
|
||||
|
||||
legacyOwnerCount() {
|
||||
return this.data.threadOwners.filter((entry) => entry.legacy === true).length;
|
||||
}
|
||||
|
||||
async verifyLegacyOwners(runtimeJson, accept, { concurrency = 8 } = {}) {
|
||||
const ids = [...new Set(this.data.threadOwners.filter((entry) => entry.legacy === true).map((entry) => entry.threadId))];
|
||||
const verdicts = new Map();
|
||||
for (let index = 0; index < ids.length; index += concurrency) {
|
||||
await Promise.all(ids.slice(index, index + concurrency).map(async (threadId) => {
|
||||
try {
|
||||
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
|
||||
verdicts.set(threadId, accept(detail?.thread ?? detail) === true);
|
||||
} catch (error) {
|
||||
if (error?.status === 404) verdicts.set(threadId, false);
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (verdicts.size) {
|
||||
this.data.threadOwners = this.data.threadOwners.flatMap((entry) => {
|
||||
if (entry.legacy !== true || !verdicts.has(entry.threadId)) return [entry];
|
||||
if (!verdicts.get(entry.threadId)) return [];
|
||||
const { legacy, ...confirmed } = entry;
|
||||
return [confirmed];
|
||||
});
|
||||
await this.save();
|
||||
}
|
||||
return this.legacyOwnerCount();
|
||||
}
|
||||
|
||||
requireOwnedThread(chatId, threadId, scope = "") {
|
||||
if (!this.ownsThread(chatId, threadId, scope)) throw new ThreadOwnershipError(THREAD_NOT_OWNED_MESSAGE);
|
||||
}
|
||||
|
||||
ownedThreadIds(chatId, scope = "") {
|
||||
if (!chatId) return [];
|
||||
return this.data.threadOwners
|
||||
.filter((entry) => entry.legacy !== true && entry.chatId === chatId && entry.scope === scope)
|
||||
.map((entry) => entry.threadId)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
async recordThreadOwner(chatId, threadId, scope = "") {
|
||||
if (!chatId || !threadId) throw new ThreadOwnershipError(THREAD_NOT_OWNED_MESSAGE);
|
||||
const held = this.data.threadOwners.find((entry) => entry.threadId === threadId);
|
||||
if (held) {
|
||||
if (held.chatId === chatId && held.scope === scope) return;
|
||||
throw new ThreadOwnershipError(THREAD_NOT_OWNED_MESSAGE);
|
||||
}
|
||||
const owners = [...this.data.threadOwners, { threadId, chatId, scope }];
|
||||
const mine = owners.filter((entry) => entry.chatId === chatId);
|
||||
const evicted = new Set(mine.slice(0, Math.max(0, mine.length - this.options.ownerLimit)));
|
||||
this.data.threadOwners = owners.filter((entry) => !evicted.has(entry));
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async finishObservedTurn(chatId, threadId, turnId) {
|
||||
const current = this.data.chats[chatId];
|
||||
if (turnId && current?.threadId === threadId && current.activeTurnId === turnId) {
|
||||
@@ -364,16 +468,59 @@ export class ApprovalOwnershipError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ThreadOwnershipError extends Error {
|
||||
constructor(message = THREAD_NOT_OWNED_MESSAGE) {
|
||||
super(message);
|
||||
this.name = "ThreadOwnershipError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function settleLegacyOwners(store, runtimeJson, accept, { retryMs = 30_000, timeoutMs = 15_000, onSettled = null } = {}) {
|
||||
const bounded = (route) => runtimeJson(route, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
const run = async (retried) => {
|
||||
const pendingBefore = new Set(
|
||||
(store.data?.threadOwners || []).filter((entry) => entry.legacy === true).map((entry) => entry.threadId)
|
||||
);
|
||||
const remaining = await store.verifyLegacyOwners(bounded, accept).catch(() => store.legacyOwnerCount());
|
||||
if (retried && typeof onSettled === "function" && pendingBefore.size) {
|
||||
const stillPending = new Set(
|
||||
(store.data?.threadOwners || []).filter((entry) => entry.legacy === true).map((entry) => entry.threadId)
|
||||
);
|
||||
const settled = new Set([...pendingBefore].filter((id) => !stillPending.has(id)));
|
||||
if (settled.size) {
|
||||
await Promise.resolve(onSettled(settled)).catch((error) => {
|
||||
console.error("failed after settling legacy thread owners", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (remaining > 0) setTimeout(() => void run(true), retryMs).unref();
|
||||
};
|
||||
await run(false);
|
||||
}
|
||||
|
||||
export function createdByBridge(thread, { systemPrompt, workspace }) {
|
||||
return thread?.system_prompt === systemPrompt && String(thread?.workspace ?? "") === workspace;
|
||||
}
|
||||
|
||||
export async function listOwnedThreads(store, runtimeJson, chatId, scope = "", limit = 8) {
|
||||
const ids = store.ownedThreadIds(chatId, scope);
|
||||
if (!ids.length) return [];
|
||||
const query = new URLSearchParams({ limit: String(limit), include_archived: "true", thread_ids: ids.join(",") });
|
||||
const rows = await runtimeJson(`/v1/threads/summary?${query}`);
|
||||
return (Array.isArray(rows) ? rows : []).filter((thread) => store.ownsThread(chatId, thread?.id, scope)).slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime owns pending approvals and their turn ids. The existing chat store
|
||||
* owns the immutable human who initiated that accepted turn. Admission to a
|
||||
* group, a newer sender or a legacy unbound button does not grant this authority.
|
||||
*/
|
||||
export async function decideApproval(runtimeJson, { store, chatId, actorId, approvalId, decision, remember = false, turnId = null }) {
|
||||
export async function decideApproval(runtimeJson, { store, chatId, actorId, approvalId, decision, remember = false, turnId = null, ownsThread = null }) {
|
||||
if (decision !== "allow" && decision !== "deny") throw new ApprovalOwnershipError("decision must be allow or deny");
|
||||
const state = await store.getChat(chatId);
|
||||
const threadId = state?.threadId;
|
||||
if (!threadId) throw new ApprovalOwnershipError("this chat has no Runtime thread yet");
|
||||
if (typeof ownsThread === "function" && !ownsThread(threadId)) throw new ThreadOwnershipError(THREAD_NOT_OWNED_MESSAGE);
|
||||
if (!actorId) throw new ApprovalOwnershipError("missing initiating human");
|
||||
if (!approvalId) throw new ApprovalOwnershipError("missing approval id");
|
||||
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import {
|
||||
ApprovalOwnershipError, ThreadStore, decideApproval, settleStoredApproval,
|
||||
ApprovalOwnershipError, THREAD_NOT_OWNED_MESSAGE, ThreadOwnershipError, ThreadStore, decideApproval, settleStoredApproval,
|
||||
admitTurn, createRuntimeClient, activeTurnBlock, latestRunningTurn, readThreadEvents, readJsonSafe, compactRuntimeError
|
||||
} from "../src/lib.mjs";
|
||||
import { isAllowed, isGroupChat } from "../../telegram-bridge/src/lib.mjs";
|
||||
@@ -27,10 +27,11 @@ const actors = {
|
||||
"telegram-bridge": "telegram-user:alice", "feishu-bridge": "feishu-openId:alice",
|
||||
"wecom-bridge": "wecom-user:alice", "weixin-bridge": "weixin-account:bot-a:user:chat-a"
|
||||
};
|
||||
async function withStore(fn, options = {}) {
|
||||
async function withStore(fn, options = {}, ownerScope = "") {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "bridge-approvals-"));
|
||||
try {
|
||||
const store = await ThreadStore.open(path.join(dir, "state.json"), { actions: true, messageLimit: 500, ...options });
|
||||
await store.recordThreadOwner(chatId, threadId, ownerScope);
|
||||
await store.setChat(chatId, { threadId });
|
||||
await fn(store);
|
||||
} finally { await rm(dir, { recursive: true, force: true }); }
|
||||
@@ -46,9 +47,11 @@ function extract(source, name) {
|
||||
async function shipping(bridge, names, values) {
|
||||
const source = await readFile(path.join(root, bridge, "src/index.mjs"), "utf8");
|
||||
const functions = [...new Set([
|
||||
...(bridge === "weixin-bridge" && names.includes("turnActor") ? ["accountIdentity"] : []),
|
||||
...(bridge === "weixin-bridge" && names.includes("turnActor") ? ["accountIdentity", "weixinActorId", "ownsThread"] : []),
|
||||
...(bridge === "telegram-bridge" && (names.includes("runPrompt") || names.includes("startPromptTurn"))
|
||||
? ["recoveryAllowed", "supportsAdmissionRecovery"] : []), ...names
|
||||
? ["recoveryAllowed", "supportsAdmissionRecovery"] : []),
|
||||
...(bridge === "telegram-bridge" && ["runPrompt", "startPromptTurn", "handleCommand"].some((name) => names.includes(name))
|
||||
? ["releaseUnownedRecovery", "clearActiveTurn"] : []), ...names
|
||||
])];
|
||||
return new Function(...Object.keys(values), functions.map((name) => extract(source, name)).join("\n") +
|
||||
`\nreturn { ${names.join(", ")} };`)(...Object.values(values));
|
||||
@@ -101,8 +104,9 @@ async function withRuntime(fn) {
|
||||
}
|
||||
function environment(store, runtime, extra = {}) {
|
||||
return {
|
||||
threadStore: store, runtimeJson: runtime.runtimeJson, decideRuntimeApproval: decideApproval,
|
||||
threadStore: store, THREAD_NOT_OWNED_MESSAGE, ThreadOwnershipError, runtimeJson: runtime.runtimeJson, decideRuntimeApproval: decideApproval,
|
||||
settleStoredApproval, ApprovalOwnershipError, incomingIdentity: wecomIdentity,
|
||||
WECOM_SYSTEM_PROMPT: "fixture prompt", FEISHU_SYSTEM_PROMPT: "fixture prompt", TELEGRAM_SYSTEM_PROMPT: "fixture prompt",
|
||||
sendText: async () => {}, replyText: async () => {}, sendTurnText: async () => {},
|
||||
parseApprovalDecisionArgs: () => { throw new Error("fixture uses actual parsed approval id"); },
|
||||
ensureThread: async () => store.getChat(chatId), activeTurnBlock, latestRunningTurn,
|
||||
@@ -228,7 +232,7 @@ for (const bridge of Object.keys(identities)) {
|
||||
assert.equal(runtime.state.posts.length, 0);
|
||||
await consumer(chatId, action, identities[bridge]("alice"));
|
||||
assert.deepEqual(runtime.state.posts, [{ route: "/v1/approvals/approval-a", body: { decision: "allow", remember: bridge !== "wecom-bridge" } }]);
|
||||
}));
|
||||
}, {}, bridge === "weixin-bridge" ? actors[bridge] : ""));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -249,7 +253,7 @@ test("Weixin shipping decision rejects replacement or missing account identity w
|
||||
botAccount.accountId = "bot-a";
|
||||
await consumer(chatId, action);
|
||||
assert.deepEqual(runtime.state.posts, [{ route: "/v1/approvals/approval-a", body: { decision: "allow", remember: true } }]);
|
||||
}));
|
||||
}, {}, actors["weixin-bridge"]));
|
||||
});
|
||||
|
||||
test("Weixin shipping decision refuses a legacy origin without bot account provenance after reload", async () => {
|
||||
@@ -261,7 +265,7 @@ test("Weixin shipping decision refuses a legacy origin without bot account prove
|
||||
await consumer(chatId, { decision: "allow", approvalId: "approval-a", remember: true });
|
||||
assert.equal(runtime.state.posts.length, 0);
|
||||
assert.equal(reopened.turnOrigin(chatId, threadId, turnId).actorId, "weixin-user:chat-a");
|
||||
}));
|
||||
}, {}, actors["weixin-bridge"]));
|
||||
});
|
||||
|
||||
for (const bridge of ["telegram-bridge", "feishu-bridge", "wecom-bridge"]) {
|
||||
@@ -356,7 +360,7 @@ test("Telegram and Feishu actual dispatch passes the admitted human to prompt an
|
||||
const lib = await import(`../../${bridge}/src/lib.mjs`);
|
||||
const calls = [];
|
||||
const identity = identities[bridge]("alice");
|
||||
const values = { ...lib, sendText: async () => {},
|
||||
const values = { ...lib, sendText: async () => {}, threadStore: { getChat: async () => null },
|
||||
decideApproval: async (...args) => calls.push(args),
|
||||
startPromptTurn: (...args) => calls.push(args),
|
||||
runPrompt: async (...args) => calls.push(args) };
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { ThreadStore, THREAD_NOT_OWNED_MESSAGE, decideApproval } from "../src/lib.mjs";
|
||||
import { startStub } from "./support/stub-server.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const integrations = path.join(here, "..", "..");
|
||||
const NOT_OWNED = new RegExp(THREAD_NOT_OWNED_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
const NOTE = "ALICE_PRIVATE_NOTE";
|
||||
const PROMPT_TAIL = "Keep status updates concise. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval.";
|
||||
const BRIDGE_WORKSPACE = "/fixture/bridge-workspace";
|
||||
|
||||
async function until(check, label, timeoutMs = 30000) {
|
||||
const started = Date.now();
|
||||
for (;;) {
|
||||
const value = check();
|
||||
if (value) return value;
|
||||
if (Date.now() - started > timeoutMs) assert.fail(`timed out waiting for ${label}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function withBridge(name, env, run, { seedMap = null, seedThreads = [], prepare = null } = {}) {
|
||||
const stub = await startStub();
|
||||
for (const thread of seedThreads) stub.seedThread(thread);
|
||||
if (prepare) prepare(stub);
|
||||
const scratch = await mkdtemp(path.join(tmpdir(), "cw-bridge-ownership-"));
|
||||
const mapPath = path.join(scratch, "thread-map.json");
|
||||
const seeded = typeof seedMap === "function" ? seedMap(stub) : seedMap;
|
||||
if (seeded) await writeFile(mapPath, JSON.stringify(seeded));
|
||||
const logs = [];
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["--import", pathToFileURL(path.join(here, "support/register.mjs")).href, path.join(integrations, name, "src/index.mjs")],
|
||||
{
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
FAKE_CHAT_URL: stub.url,
|
||||
CODEWHALE_RUNTIME_URL: stub.url,
|
||||
CODEWHALE_RUNTIME_TOKEN: "fixture-runtime-token",
|
||||
CODEWHALE_WORKSPACE: BRIDGE_WORKSPACE,
|
||||
...env(stub, mapPath)
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
}
|
||||
);
|
||||
child.stdout.on("data", (chunk) => logs.push(String(chunk)));
|
||||
child.stderr.on("data", (chunk) => logs.push(String(chunk)));
|
||||
const repliesTo = (who) => stub.state.replies.filter((reply) => reply.to === who).map((reply) => reply.text);
|
||||
const say = async (push, who, text, expect) => {
|
||||
const before = repliesTo(who).length;
|
||||
push(who, text);
|
||||
return until(() => {
|
||||
const fresh = repliesTo(who).slice(before);
|
||||
return fresh.some((reply) => expect.test(reply)) ? fresh : null;
|
||||
}, `${name}: reply to ${who} for ${JSON.stringify(text)} matching ${expect}`).catch((error) => {
|
||||
console.log(`replies=${JSON.stringify(stub.state.replies)} logs=${logs.join("")}`);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
try {
|
||||
await run({ stub, say, mapPath });
|
||||
} finally {
|
||||
child.kill("SIGKILL");
|
||||
await stub.close();
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const bridges = {
|
||||
wecom: {
|
||||
name: "wecom-bridge",
|
||||
systemPrompt: `You are being controlled from a WeCom (企业微信) phone chat. ${PROMPT_TAIL}`,
|
||||
alice: "alice", bob: "bob",
|
||||
chatKey: (who) => `single:${who}`,
|
||||
env: (stub, mapPath) => ({
|
||||
WECOM_BOT_ID: "fixture-bot", WECOM_BOT_SECRET: "fixture-not-a-secret",
|
||||
WECOM_CHAT_ALLOWLIST: "alice,bob", WECOM_THREAD_MAP_PATH: mapPath
|
||||
}),
|
||||
push: (stub) => (who, text) => stub.pushWecom(who, text)
|
||||
},
|
||||
feishu: {
|
||||
name: "feishu-bridge",
|
||||
systemPrompt: `You are being controlled from a Feishu/Lark phone chat. ${PROMPT_TAIL}`,
|
||||
alice: "oc_alice", bob: "oc_bob",
|
||||
chatKey: (who) => who,
|
||||
env: (stub, mapPath) => ({
|
||||
FEISHU_APP_ID: "fixture-app", FEISHU_APP_SECRET: "fixture-not-a-secret",
|
||||
CODEWHALE_CHAT_ALLOWLIST: "oc_alice,oc_bob", FEISHU_THREAD_MAP_PATH: mapPath
|
||||
}),
|
||||
push: (stub) => (who, text) => stub.pushFeishu(`ou_${who}`, who, text)
|
||||
},
|
||||
telegram: {
|
||||
name: "telegram-bridge",
|
||||
systemPrompt: `You are being controlled from a Telegram phone chat. ${PROMPT_TAIL}`,
|
||||
alice: "111", bob: "222",
|
||||
chatKey: (who) => who,
|
||||
env: (stub, mapPath) => ({
|
||||
TELEGRAM_BOT_TOKEN: "1000:fixture-not-a-token", TELEGRAM_API_BASE: `${stub.url}/tg`,
|
||||
TELEGRAM_CHAT_ALLOWLIST: "111,222", TELEGRAM_THREAD_MAP_PATH: mapPath, TELEGRAM_POLL_TIMEOUT_SECONDS: "1"
|
||||
}),
|
||||
push: (stub) => (who, text) => stub.pushTelegram(Number(who), text)
|
||||
}
|
||||
};
|
||||
|
||||
const turnPosts = (stub, threadId) => stub.state.runtimeRequests.filter((request) => request.route === `POST /v1/threads/${threadId}/turns`);
|
||||
|
||||
for (const [label, bridge] of Object.entries(bridges)) {
|
||||
test(`${label}: a chat lists and resumes only the threads it started`, async () => {
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say, mapPath }) => {
|
||||
const push = bridge.push(stub);
|
||||
const { alice, bob } = bridge;
|
||||
|
||||
await say(push, alice, "/new", /Created thread/);
|
||||
const aliceThread = stub.state.threads[0].id;
|
||||
await say(push, alice, `remember ${NOTE}`, /STUB-MODEL/);
|
||||
|
||||
const bobListing = (await say(push, bob, "/threads", /No threads started|thr-/)).join("\n");
|
||||
assert.ok(!bobListing.includes(aliceThread) && !bobListing.includes(NOTE), "another chat's thread is not listed");
|
||||
|
||||
const refused = (await say(push, bob, `/resume ${aliceThread}`, /Resumed|can only use threads/)).join("\n");
|
||||
assert.match(refused, NOT_OWNED);
|
||||
const unknown = (await say(push, bob, "/resume thr-does-not-exist", /Resumed|can only use threads|failed/i)).join("\n");
|
||||
assert.match(unknown, NOT_OWNED, "an unknown id gets the same reply as a foreign one");
|
||||
assert.ok(!stub.state.runtimeRequests.some((request) => request.route === "GET /v1/threads/thr-does-not-exist"),
|
||||
"an unowned id never reaches the Runtime");
|
||||
|
||||
const map = JSON.parse(await readFile(mapPath, "utf8"));
|
||||
assert.notEqual(map.chats[bridge.chatKey(bob)]?.threadId, aliceThread, "the refusal leaves the chat unbound");
|
||||
|
||||
await say(push, bob, "hello from bob", /STUB-MODEL/);
|
||||
assert.equal(turnPosts(stub, aliceThread).filter((request) => request.body?.prompt === "hello from bob").length, 0);
|
||||
|
||||
const aliceListing = (await say(push, alice, "/threads", /thr-/)).join("\n");
|
||||
assert.ok(aliceListing.includes(aliceThread) && aliceListing.includes(NOTE));
|
||||
const created = (await say(push, alice, "/new", /Created thread/)).join("\n");
|
||||
const secondThread = created.match(/Created thread (\S+)/)[1];
|
||||
assert.notEqual(secondThread, aliceThread, "/new starts a thread even when the chat already has one");
|
||||
assert.ok(stub.state.threads.some((thread) => thread.id === secondThread), "the new thread exists in the Runtime");
|
||||
await say(push, alice, `/resume ${aliceThread}`, /Resumed thread/);
|
||||
await say(push, alice, "still mine", /STUB-MODEL/);
|
||||
assert.equal(turnPosts(stub, aliceThread).filter((request) => request.body?.prompt === "still mine").length, 1);
|
||||
assert.notEqual(secondThread, aliceThread);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${label}: a thread no chat of this bridge started is neither listed nor resumable`, async () => {
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say }) => {
|
||||
const push = bridge.push(stub);
|
||||
const { bob } = bridge;
|
||||
const listing = (await say(push, bob, "/threads", /No threads started|thr-/)).join("\n");
|
||||
assert.ok(!listing.includes("thr-desktop"));
|
||||
const refused = (await say(push, bob, "/resume thr-desktop", /Resumed|can only use threads/)).join("\n");
|
||||
assert.match(refused, NOT_OWNED);
|
||||
assert.ok(!stub.state.runtimeRequests.some((request) => request.route.includes("/v1/threads/thr-desktop")));
|
||||
}, { seedThreads: [{ id: "thr-desktop", workspace: "/fixture/other-project", summary: `desktop work ${NOTE}`, runningTurnId: "turn-desktop" }] });
|
||||
});
|
||||
|
||||
test(`${label}: a thread shared by two chats in an existing thread map fails closed`, async () => {
|
||||
const seedMap = { chats: {
|
||||
[bridge.chatKey(bridge.alice)]: { threadId: "thr-shared", lastSeq: 0, activeTurnId: null },
|
||||
[bridge.chatKey(bridge.bob)]: { threadId: "thr-shared", lastSeq: 0, activeTurnId: null }
|
||||
} };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say }) => {
|
||||
const push = bridge.push(stub);
|
||||
const reply = (await say(push, bridge.bob, "continue please", /can only use threads|STUB-MODEL/)).join("\n");
|
||||
assert.match(reply, NOT_OWNED);
|
||||
assert.equal(turnPosts(stub, "thr-shared").length, 0, "no turn reaches the shared thread");
|
||||
const listing = (await say(push, bridge.alice, "/threads", /No threads started|thr-/)).join("\n");
|
||||
assert.ok(!listing.includes("thr-shared"));
|
||||
const created = (await say(push, bridge.bob, "/new", /Created thread|can only use threads/)).join("\n");
|
||||
assert.doesNotMatch(created, NOT_OWNED, "/new gives the chat a thread of its own");
|
||||
const fresh = created.match(/Created thread (\S+)/)[1];
|
||||
assert.notEqual(fresh, "thr-shared");
|
||||
await say(push, bridge.bob, "hello after new", /STUB-MODEL/);
|
||||
assert.equal(turnPosts(stub, fresh).length, 1);
|
||||
assert.equal(turnPosts(stub, "thr-shared").length, 0);
|
||||
}, { seedMap, seedThreads: [{ id: "thr-shared", workspace: "/fixture/bridge-workspace", summary: "shared" }] });
|
||||
});
|
||||
|
||||
test(`${label}: a thread only one chat points at in an existing thread map stays with that chat`, async () => {
|
||||
const seedMap = { chats: { [bridge.chatKey(bridge.alice)]: { threadId: "thr-legacy", lastSeq: 0, activeTurnId: null } } };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say }) => {
|
||||
const push = bridge.push(stub);
|
||||
await say(push, bridge.alice, "carry on", /STUB-MODEL/);
|
||||
assert.equal(turnPosts(stub, "thr-legacy").length, 1);
|
||||
const refused = (await say(push, bridge.bob, "/resume thr-legacy", /Resumed|can only use threads/)).join("\n");
|
||||
assert.match(refused, NOT_OWNED);
|
||||
}, { seedMap, seedThreads: [{ id: "thr-legacy", workspace: BRIDGE_WORKSPACE, systemPrompt: bridge.systemPrompt, summary: "legacy" }] });
|
||||
});
|
||||
|
||||
test(`${label}: a chat bound to a thread started outside the bridge at upgrade does not keep it`, async () => {
|
||||
const seedMap = { chats: { [bridge.chatKey(bridge.bob)]: { threadId: "thr-desktop", lastSeq: 0, activeTurnId: null } } };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say, mapPath }) => {
|
||||
const push = bridge.push(stub);
|
||||
const listing = (await say(push, bridge.bob, "/threads", /No threads started|thr-/)).join("\n");
|
||||
assert.ok(!listing.includes("thr-desktop") && !listing.includes(NOTE), "the thread is not listed");
|
||||
const reply = (await say(push, bridge.bob, "what was I doing?", /can only use threads|STUB-MODEL/)).join("\n");
|
||||
assert.match(reply, NOT_OWNED);
|
||||
assert.equal(turnPosts(stub, "thr-desktop").length, 0, "no turn reaches the thread");
|
||||
const refused = (await say(push, bridge.bob, "/resume thr-desktop", /Resumed|can only use threads/)).join("\n");
|
||||
assert.match(refused, NOT_OWNED);
|
||||
const map = JSON.parse(await readFile(mapPath, "utf8"));
|
||||
assert.ok(!map.threadOwners.some((entry) => entry.threadId === "thr-desktop"), "no ownership is recorded for it");
|
||||
}, { seedMap, seedThreads: [{ id: "thr-desktop", workspace: "/fixture/other-project", summary: `desktop work ${NOTE}` }] });
|
||||
});
|
||||
|
||||
test(`${label}: a bridge thread kept at upgrade is confirmed once and stays owned after a restart`, async () => {
|
||||
const seedMap = { chats: { [bridge.chatKey(bridge.alice)]: { threadId: "thr-legacy", lastSeq: 0, activeTurnId: null } } };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say, mapPath }) => {
|
||||
const push = bridge.push(stub);
|
||||
await say(push, bridge.alice, "carry on", /STUB-MODEL/);
|
||||
const map = JSON.parse(await readFile(mapPath, "utf8"));
|
||||
const entry = map.threadOwners.find((owner) => owner.threadId === "thr-legacy");
|
||||
assert.ok(entry && entry.legacy === undefined, "the grant is confirmed and no longer marked");
|
||||
}, { seedMap, seedThreads: [{ id: "thr-legacy", workspace: BRIDGE_WORKSPACE, systemPrompt: bridge.systemPrompt, summary: "legacy" }] });
|
||||
});
|
||||
|
||||
test(`${label}: a chat on a disowned shared thread cannot decide an approval on it`, async () => {
|
||||
const bobKey = bridge.chatKey(bridge.bob);
|
||||
const actor = { wecom: "wecom-user:bob", feishu: "feishu-openId:ou_oc_bob", telegram: "telegram-user:222" }[label];
|
||||
const seedMap = { chats: {
|
||||
[bridge.chatKey(bridge.alice)]: { threadId: "thr-shared", lastSeq: 0, activeTurnId: null },
|
||||
[bobKey]: { threadId: "thr-shared", lastSeq: 0, activeTurnId: null,
|
||||
turnOrigins: [{ threadId: "thr-shared", turnId: "turn-bob", actorId: actor }],
|
||||
authorizedIdentity: { chatId: bobKey, chatType: label === "feishu" ? "p2p" : "private", userId: bridge.bob, openId: "ou_oc_bob", isBot: false } }
|
||||
} };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say }) => {
|
||||
const push = bridge.push(stub);
|
||||
const reply = (await say(push, bridge.bob, "/allow appr-1", /Approval|can only use threads|not waiting/)).join("\n");
|
||||
assert.match(reply, NOT_OWNED);
|
||||
assert.equal(stub.state.approvalDecisions.length, 0, "the decision never reaches the Runtime");
|
||||
}, { seedMap, seedThreads: [{ id: "thr-shared", workspace: BRIDGE_WORKSPACE, systemPrompt: bridge.systemPrompt, summary: "shared", runningTurnId: "turn-bob",
|
||||
pendingApprovals: [{ id: "appr-1", turn_id: "turn-bob" }] }] });
|
||||
});
|
||||
|
||||
test(`${label}: a chat on its own thread can still decide an approval`, async () => {
|
||||
const aliceKey = bridge.chatKey(bridge.alice);
|
||||
const actor = { wecom: "wecom-user:alice", feishu: "feishu-openId:ou_oc_alice", telegram: "telegram-user:111" }[label];
|
||||
const seedMap = { chats: { [aliceKey]: { threadId: "thr-own", lastSeq: 0, activeTurnId: null,
|
||||
turnOrigins: [{ threadId: "thr-own", turnId: "turn-alice", actorId: actor }],
|
||||
authorizedIdentity: { chatId: aliceKey, chatType: label === "feishu" ? "p2p" : "private", userId: bridge.alice, openId: "ou_oc_alice", isBot: false } } } };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say }) => {
|
||||
const push = bridge.push(stub);
|
||||
await say(push, bridge.alice, "/allow appr-1", /Approval appr-1: allow/);
|
||||
assert.deepEqual(stub.state.approvalDecisions.map((entry) => entry.id), ["appr-1"]);
|
||||
}, { seedMap, seedThreads: [{ id: "thr-own", workspace: BRIDGE_WORKSPACE, systemPrompt: bridge.systemPrompt, summary: "own", runningTurnId: "turn-alice",
|
||||
pendingApprovals: [{ id: "appr-1", turn_id: "turn-alice" }] }] });
|
||||
});
|
||||
}
|
||||
|
||||
test("wecom: a chat bound to a thread started outside the bridge cannot steer that thread's running turn", async () => {
|
||||
const bridge = bridges.wecom;
|
||||
const seedMap = { chats: { "single:bob": { threadId: "thr-desktop", lastSeq: 0, activeTurnId: null } } };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say }) => {
|
||||
const push = bridge.push(stub);
|
||||
const reply = (await say(push, "bob", "rm the tests and push", /can only use threads|forwarded|active turn/)).join("\n");
|
||||
assert.match(reply, NOT_OWNED);
|
||||
assert.ok(!stub.state.runtimeRequests.some((request) => /\/steer$/.test(request.route)), "no steer reaches the thread");
|
||||
}, { seedMap, seedThreads: [{ id: "thr-desktop", workspace: "/fixture/other-project", summary: `desktop work ${NOTE}`, runningTurnId: "turn-desktop" }] });
|
||||
});
|
||||
|
||||
test("feishu: a chat bound to a thread started outside the bridge does not receive its running turn on restart", async () => {
|
||||
const bridge = bridges.feishu;
|
||||
const seedMap = { chats: { oc_bob: { threadId: "thr-desktop", lastSeq: 0, activeTurnId: "turn-old",
|
||||
authorizedIdentity: { chatId: "oc_bob", chatType: "p2p", openId: "ou_oc_bob" } } } };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say, mapPath }) => {
|
||||
const push = bridge.push(stub);
|
||||
await say(push, "oc_bob", "/help", /./);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const toBob = stub.state.replies.filter((reply) => reply.to === "oc_bob").map((reply) => reply.text).join("\n");
|
||||
assert.ok(!toBob.includes("OTHER_CLIENT_OUTPUT") && !/Reattaching/.test(toBob), "no output from the thread is relayed");
|
||||
assert.ok(!stub.state.runtimeRequests.some((request) => request.route.endsWith("/events")), "the thread's events are never read");
|
||||
const map = JSON.parse(await readFile(mapPath, "utf8"));
|
||||
assert.equal(map.chats.oc_bob.activeTurnId, null, "the stored turn id is cleared");
|
||||
}, { seedMap, prepare: (stub) => {
|
||||
const entry = stub.seedThread({ id: "thr-desktop", workspace: "/fixture/other-project", summary: "desktop work", runningTurnId: "turn-desktop" });
|
||||
entry.events.push({ seq: 1, thread_id: "thr-desktop", turn_id: "turn-desktop", event: "item.delta", payload: { kind: "agent_message", delta: "OTHER_CLIENT_OUTPUT" } });
|
||||
entry.events.push({ seq: 2, thread_id: "thr-desktop", turn_id: "turn-desktop", event: "turn.completed", payload: { turn: { status: "completed" } } });
|
||||
} });
|
||||
});
|
||||
|
||||
const otherChatEvents = (entry) => {
|
||||
entry.events.push({ seq: 1, thread_id: "thr-shared", turn_id: "turn-other", event: "item.delta", payload: { kind: "agent_message", delta: "OTHER_CHAT_OUTPUT" } });
|
||||
entry.events.push({ seq: 2, thread_id: "thr-shared", turn_id: "turn-other", event: "turn.completed", payload: { turn: { status: "completed" } } });
|
||||
};
|
||||
|
||||
test("feishu: a chat the migration disowned does not reattach to another chat's running turn on restart", async () => {
|
||||
const bridge = bridges.feishu;
|
||||
const seedMap = { chats: {
|
||||
oc_alice: { threadId: "thr-shared", lastSeq: 0, activeTurnId: null },
|
||||
oc_bob: { threadId: "thr-shared", lastSeq: 0, activeTurnId: "turn-stale",
|
||||
authorizedIdentity: { chatId: "oc_bob", chatType: "p2p", openId: "ou_oc_bob" } }
|
||||
} };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say, mapPath }) => {
|
||||
const push = bridge.push(stub);
|
||||
const listing = (await say(push, "oc_bob", "/threads", /No threads started|thr-/)).join("\n");
|
||||
assert.ok(!listing.includes("thr-shared"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const toBob = stub.state.replies.filter((reply) => reply.to === "oc_bob").map((reply) => reply.text).join("\n");
|
||||
assert.ok(!toBob.includes("OTHER_CHAT_OUTPUT") && !/Reattaching/.test(toBob), "no other chat's turn output is relayed");
|
||||
assert.ok(!stub.state.runtimeRequests.some((request) => request.route.includes("/v1/threads/thr-shared")), "the shared thread is never read");
|
||||
const map = JSON.parse(await readFile(mapPath, "utf8"));
|
||||
assert.equal(map.chats.oc_bob.activeTurnId, null, "the stale turn id is cleared");
|
||||
}, { seedMap, prepare: (stub) => otherChatEvents(stub.seedThread({ id: "thr-shared", workspace: "/fixture/bridge-workspace", summary: "shared", runningTurnId: "turn-other" })) });
|
||||
});
|
||||
|
||||
test("telegram: a chat the migration disowned does not stream another chat's turn and can start over", async () => {
|
||||
const bridge = bridges.telegram;
|
||||
const seedMap = { chats: {
|
||||
"111": { threadId: "thr-shared", lastSeq: 0, activeTurnId: null },
|
||||
"222": { threadId: "thr-shared", lastSeq: 0, activeTurnId: "turn-other", activeMessageKey: "stale-key",
|
||||
authorizedIdentity: { chatId: "222", chatType: "private", userId: "222", isBot: false } }
|
||||
} };
|
||||
await withBridge(bridge.name, bridge.env, async ({ stub, say, mapPath }) => {
|
||||
const push = bridge.push(stub);
|
||||
const created = (await say(push, "222", "/new", /Created thread|pending turn|can only use threads/)).join("\n");
|
||||
assert.match(created, /Created thread/, "the stale turn does not block /new");
|
||||
const toBob = stub.state.replies.filter((reply) => reply.to === "222").map((reply) => reply.text).join("\n");
|
||||
assert.ok(!toBob.includes("OTHER_CHAT_OUTPUT"), "no other chat's turn output is relayed");
|
||||
assert.ok(!stub.state.runtimeRequests.some((request) => request.route === "GET /v1/threads/thr-shared/events"), "the shared thread's events are never read");
|
||||
const map = JSON.parse(await readFile(mapPath, "utf8"));
|
||||
assert.equal(map.chats["222"].activeTurnId, null);
|
||||
}, { seedMap, prepare: (stub) => otherChatEvents(stub.seedThread({ id: "thr-shared", workspace: "/fixture/bridge-workspace", summary: "shared", runningTurnId: "turn-other" })) });
|
||||
});
|
||||
|
||||
async function withStore(fn, options = {}, seed = null) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cw-thread-owners-"));
|
||||
const file = path.join(dir, "map.json");
|
||||
try {
|
||||
if (seed) await writeFile(file, JSON.stringify(seed));
|
||||
await fn(await ThreadStore.open(file, options), file);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("thread owners are durable, survive a chat state replacement and never transfer", async () => {
|
||||
await withStore(async (store, file) => {
|
||||
await store.recordThreadOwner("a", "t1");
|
||||
await store.setChat("a", { threadId: "t1" });
|
||||
await store.setChat("a", { threadId: "t2" });
|
||||
assert.ok(store.ownsThread("a", "t1"));
|
||||
assert.ok(!store.ownsThread("b", "t1"));
|
||||
assert.ok(!store.ownsThread("a", "t1", "other-scope"));
|
||||
await assert.rejects(store.recordThreadOwner("b", "t1"), /can only use threads/);
|
||||
await store.recordThreadOwner("a", "t1");
|
||||
const reopened = await ThreadStore.open(file);
|
||||
assert.ok(reopened.ownsThread("a", "t1"));
|
||||
assert.deepEqual(reopened.ownedThreadIds("a"), ["t1"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("thread owners are bounded per chat and drop the oldest first", async () => {
|
||||
await withStore(async (store) => {
|
||||
for (const id of ["t1", "t2", "t3"]) await store.recordThreadOwner("a", id);
|
||||
await store.recordThreadOwner("b", "u1");
|
||||
assert.deepEqual(store.ownedThreadIds("a"), ["t3", "t2"]);
|
||||
assert.ok(store.ownsThread("b", "u1"));
|
||||
}, { ownerLimit: 2 });
|
||||
});
|
||||
|
||||
function runtimeOf(threads, { failing = new Set() } = {}) {
|
||||
const calls = [];
|
||||
const runtimeJson = async (route) => {
|
||||
calls.push(route);
|
||||
const id = decodeURIComponent(route.split("/").at(-1));
|
||||
if (failing.has(id)) throw Object.assign(new Error("connection refused"), { status: undefined });
|
||||
if (!(id in threads)) throw Object.assign(new Error("not found"), { status: 404 });
|
||||
return { thread: threads[id] };
|
||||
};
|
||||
return { runtimeJson, calls };
|
||||
}
|
||||
|
||||
test("an existing store migrates once: unique threads wait for confirmation, shared ones are owned by no chat", async () => {
|
||||
const seed = { chats: {
|
||||
a: { threadId: "only-a" }, b: { threadId: "shared" }, c: { threadId: "shared" },
|
||||
w: { threadId: "w-now", bindingAccountId: "acct", threadBindings: [{ threadId: "w-old", accountId: "acct", actorId: "weixin-account:acct:user:w" }] }
|
||||
} };
|
||||
const legacyOwnerScope = (chatId, state) => (state.bindingAccountId ? `weixin-account:${state.bindingAccountId}:user:${chatId}` : "");
|
||||
const confirmed = { system_prompt: "bridge", workspace: "/w" };
|
||||
const accept = (thread) => thread.system_prompt === "bridge";
|
||||
await withStore(async (store, file) => {
|
||||
assert.ok(!store.ownsThread("a", "only-a"), "a migrated grant is not usable before it is confirmed");
|
||||
assert.ok(store.ownershipPending("a", "only-a"), "an unconfirmed grant is pending");
|
||||
assert.deepEqual(store.ownedThreadIds("a"), []);
|
||||
const scope = "weixin-account:acct:user:w";
|
||||
assert.ok(store.ownsThread("w", "w-old", scope), "an old per-account binding was recorded by the bridge itself and needs no confirmation");
|
||||
assert.ok(!store.ownershipPending("w", "w-old", scope));
|
||||
const { runtimeJson, calls } = runtimeOf({ "only-a": confirmed, "w-now": confirmed, shared: confirmed });
|
||||
assert.equal(await store.verifyLegacyOwners(runtimeJson, accept), 0);
|
||||
assert.deepEqual(calls.sort(), ["/v1/threads/only-a", "/v1/threads/w-now"], "only unshared migrated grants are looked up, once each");
|
||||
assert.ok(store.ownsThread("a", "only-a"));
|
||||
assert.ok(!store.ownershipPending("a", "only-a"));
|
||||
assert.ok(!store.ownsThread("b", "shared") && !store.ownsThread("c", "shared"));
|
||||
assert.ok(store.ownsThread("w", "w-now", scope) && !store.ownsThread("w", "w-now"));
|
||||
await store.recordThreadOwner("a", "fresh");
|
||||
await store.setChat("c", { threadId: "only-a" });
|
||||
const reopened = await ThreadStore.open(file, { legacyOwnerScope });
|
||||
assert.ok(!reopened.ownsThread("c", "only-a"), "the migration does not run again once owners are recorded");
|
||||
assert.ok(reopened.ownsThread("a", "only-a"), "a confirmed grant survives a restart");
|
||||
assert.equal(reopened.legacyOwnerCount(), 0);
|
||||
}, { legacyOwnerScope }, seed);
|
||||
});
|
||||
|
||||
test("a migrated grant is dropped when the thread was not started by the bridge or is gone, and kept unconfirmed when the Runtime cannot be asked", async () => {
|
||||
const seed = { chats: { a: { threadId: "t-ok" }, b: { threadId: "t-foreign" }, c: { threadId: "t-gone" }, d: { threadId: "t-down" } } };
|
||||
const accept = (thread) => thread.system_prompt === "bridge" && thread.workspace === "/w";
|
||||
await withStore(async (store, file) => {
|
||||
const threads = {
|
||||
"t-ok": { system_prompt: "bridge", workspace: "/w" },
|
||||
"t-foreign": { workspace: "/w" },
|
||||
"t-down": { system_prompt: "bridge", workspace: "/w" }
|
||||
};
|
||||
assert.equal(await store.verifyLegacyOwners(runtimeOf(threads, { failing: new Set(["t-down"]) }).runtimeJson, accept), 1);
|
||||
assert.ok(store.ownsThread("a", "t-ok"));
|
||||
assert.ok(!store.ownershipPending("a", "t-ok"));
|
||||
for (const [chat, id] of [["b", "t-foreign"], ["c", "t-gone"], ["d", "t-down"]]) assert.ok(!store.ownsThread(chat, id), id);
|
||||
assert.ok(!store.ownershipPending("b", "t-foreign") && !store.ownershipPending("c", "t-gone"));
|
||||
assert.ok(store.ownershipPending("d", "t-down"));
|
||||
const reopened = await ThreadStore.open(file);
|
||||
assert.equal(reopened.legacyOwnerCount(), 1, "the unconfirmed grant is still pending after a restart");
|
||||
assert.ok(reopened.ownershipPending("d", "t-down"));
|
||||
assert.ok(!reopened.data.threadOwners.some((entry) => entry.threadId === "t-foreign" || entry.threadId === "t-gone"));
|
||||
assert.equal(await reopened.verifyLegacyOwners(runtimeOf(threads).runtimeJson, accept), 0);
|
||||
assert.ok(reopened.ownsThread("d", "t-down"), "a later check confirms it");
|
||||
assert.ok(!reopened.ownershipPending("d", "t-down"));
|
||||
}, {}, seed);
|
||||
});
|
||||
|
||||
test("pending-ownership recovery preserves stored turns and retained admissions when Runtime thread reads return 503 at startup", async () => {
|
||||
const tg = bridges.telegram;
|
||||
const fsBridge = bridges.feishu;
|
||||
await Promise.all([
|
||||
withBridge(tg.name, tg.env, async ({ stub }) => {
|
||||
await until(
|
||||
() => turnPosts(stub, "thr-own").some((request) => request.body?.prompt === "RETAINED_PROMPT"),
|
||||
"retained admission should post after ownership confirms",
|
||||
45000
|
||||
);
|
||||
}, {
|
||||
seedThreads: [{ id: "thr-own", workspace: BRIDGE_WORKSPACE, systemPrompt: tg.systemPrompt, summary: "own" }],
|
||||
prepare: (stub) => { stub.state.failDetailUntil = Date.now() + 4000; },
|
||||
seedMap: (stub) => ({ chats: { "222": {
|
||||
threadId: "thr-own", lastSeq: 0, activeTurnId: null,
|
||||
authorizedIdentity: { chatId: "222", chatType: "private", userId: "222", isBot: false },
|
||||
pendingAdmission: {
|
||||
botAccountId: "1000", runtimeUrl: stub.url, actorId: "telegram-user:222",
|
||||
identity: { chatId: "222", chatType: "private", userId: "222", username: "", isBot: false },
|
||||
messageKey: "telegram:222:77", request: { prompt: "RETAINED_PROMPT", input_summary: "RETAINED_PROMPT" },
|
||||
operationKey: null, threadId: "thr-own", submitted: false, sinceSeq: null,
|
||||
threadRequest: { workspace: BRIDGE_WORKSPACE, system_prompt: tg.systemPrompt }
|
||||
}
|
||||
} } })
|
||||
}),
|
||||
withBridge(tg.name, tg.env, async ({ stub }) => {
|
||||
await until(
|
||||
() => stub.state.replies.some((reply) => reply.to === "111" && reply.text.includes("OWN_TURN_OUTPUT")),
|
||||
"telegram stored turn should relay after ownership confirms",
|
||||
45000
|
||||
);
|
||||
}, {
|
||||
seedMap: { chats: { "111": {
|
||||
threadId: "thr-own", lastSeq: 0, activeTurnId: "turn-own", activeMessageKey: "k1",
|
||||
authorizedIdentity: { chatId: "111", chatType: "private", userId: "111", isBot: false }
|
||||
} } },
|
||||
prepare: (stub) => {
|
||||
stub.state.failDetailUntil = Date.now() + 4000;
|
||||
const entry = stub.seedThread({ id: "thr-own", workspace: BRIDGE_WORKSPACE, systemPrompt: tg.systemPrompt, summary: "own", runningTurnId: "turn-own" });
|
||||
entry.events.push({ seq: 1, thread_id: "thr-own", turn_id: "turn-own", event: "item.delta", payload: { kind: "agent_message", delta: "OWN_TURN_OUTPUT" } });
|
||||
entry.events.push({ seq: 2, thread_id: "thr-own", turn_id: "turn-own", event: "turn.completed", payload: { turn: { status: "completed" } } });
|
||||
}
|
||||
}),
|
||||
withBridge(fsBridge.name, fsBridge.env, async ({ stub }) => {
|
||||
await until(
|
||||
() => stub.state.replies.some((reply) => reply.to === "oc_alice" && reply.text.includes("OWN_FEISHU_OUTPUT")),
|
||||
"feishu stored turn should reattach after ownership confirms",
|
||||
45000
|
||||
);
|
||||
}, {
|
||||
seedMap: { chats: { oc_alice: {
|
||||
threadId: "thr-own", lastSeq: 0, activeTurnId: "turn-own",
|
||||
authorizedIdentity: { chatId: "oc_alice", chatType: "p2p", openId: "ou_oc_alice" }
|
||||
} } },
|
||||
prepare: (stub) => {
|
||||
stub.state.failDetailUntil = Date.now() + 4000;
|
||||
const entry = stub.seedThread({ id: "thr-own", workspace: BRIDGE_WORKSPACE, systemPrompt: fsBridge.systemPrompt, summary: "own", runningTurnId: "turn-own" });
|
||||
entry.events.push({ seq: 1, thread_id: "thr-own", turn_id: "turn-own", event: "item.delta", payload: { kind: "agent_message", delta: "OWN_FEISHU_OUTPUT" } });
|
||||
entry.events.push({ seq: 2, thread_id: "thr-own", turn_id: "turn-own", event: "turn.completed", payload: { turn: { status: "completed" } } });
|
||||
}
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("core approval decisions check ownership only when the caller supplies it", async () => {
|
||||
await withStore(async (store) => {
|
||||
await store.setChat("a", { threadId: "t1" });
|
||||
await store.recordTurnOrigin("a", "t1", "turn-1", "human-1");
|
||||
const posts = [];
|
||||
const runtimeJson = async (route, options = {}) => {
|
||||
if (options.method === "POST") { posts.push(route); return {}; }
|
||||
return { thread: {}, pending_approvals: [{ id: "appr-1", turn_id: "turn-1" }] };
|
||||
};
|
||||
const args = { store, chatId: "a", actorId: "human-1", approvalId: "appr-1", decision: "allow" };
|
||||
await assert.rejects(decideApproval(runtimeJson, { ...args, ownsThread: (id) => store.ownsThread("a", id) }), /can only use threads/);
|
||||
assert.deepEqual(posts, []);
|
||||
await decideApproval(runtimeJson, args);
|
||||
assert.deepEqual(posts, ["/v1/approvals/appr-1"], "a store that records no owners keeps its behaviour");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user