feat(extension-host): DSH userQuestions.ask through the Engine question owner (CW-7054)

Lands the DSH user-questions candidate stack on current main. A plugin tool
injecting `userQuestions` can call ctx.userQuestions.ask({agent: exec.agent,
questions}) during its own invocation; the host sends user-questions/ask with
the invocation's question ticket and the existing Engine request_user_input
owner presents it (terminal, Runtime API pending_user_inputs) with
require_typed_answers. Answers come back typed (choice / custom / skipped),
0..1000 options with optional descriptions, plan-review intent, exact
UserInputClosed cleanup, pinned UserQuestionError / HarnessError identities
(ASK_ABORTED, CALLER_NOT_LIVE, DELEGATED_CALLER, EMPTY_QUESTIONS, BAD_INTENT).
Compatibility table advertises `userQuestions` (ask only, no wait).

Applied, in order (CW/artifacts/dsh-user-questions-20261026): packet-a
f0ac4100, legacy-custom b0119f6a, packet-b ae2e614c, host-v2 236221a4,
packet-c-runtime 62b7ad2a, packet-c-mechanical 193d7118 (two rustfmt-only
hunks dropped: main already matched), tui 12fdba8d, tui-mechanics 96ab84fd,
longscroll 23685f64, typed-menu edb62d35, preserve-input 49a3e68e,
host-tests b861dafd, error-contract-v2 f24754338, advertisement a83ef8dd.
protocol.generated.ts regenerated from Rust (CODEWHALE_CONFORMANCE_UPDATE=1),
dist/ rebuilt, THIRD_PARTY_NOTICES.md lists the new dsh-user-questions
excerpt. event_loop.rs and supervisor.rs staged without peers' uncommitted
hunks (idle-cpu poll change; a windows cfg_attr).

Paths: CHANGELOG.md THIRD_PARTY_NOTICES.md crates/protocol/src/event_msg.rs crates/protocol/src/lib.rs crates/protocol/tests/parity_protocol.rs crates/tui/CHANGELOG.md crates/tui/extension-host/dist/LICENSES.txt crates/tui/extension-host/dist/agent-presets.mjs crates/tui/extension-host/dist/codewhale-extension-host.mjs crates/tui/extension-host/dist/dsh-compatibility.json crates/tui/extension-host/dist/dsh-composition-review.mjs crates/tui/extension-host/dist/protocol.mjs crates/tui/extension-host/dist/rpc.mjs crates/tui/extension-host/src/dsh/compatibility.json crates/tui/extension-host/src/dsh/dsh-message-compat.js crates/tui/extension-host/src/main.ts crates/tui/extension-host/src/protocol.generated.ts crates/tui/extension-host/src/root.ts crates/tui/extension-host/test/harness.mjs crates/tui/extension-host/test/host.test.mjs crates/tui/src/core/engine.rs crates/tui/src/core/engine/approval.rs crates/tui/src/core/engine/tests/runtime_state.rs crates/tui/src/core/engine/tests/sse_turn_recovery.rs crates/tui/src/core/engine/tests/test_cases_12.rs crates/tui/src/core/engine/turn_loop.rs crates/tui/src/core/events.rs crates/tui/src/core/protocol_parity.rs crates/tui/src/exec_agent.rs crates/tui/src/extension_host/core_call.rs crates/tui/src/extension_host/core_call_tests.rs crates/tui/src/extension_host/mod.rs crates/tui/src/extension_host/protocol.rs crates/tui/src/extension_host/protocol/tests.rs crates/tui/src/extension_host/supervisor.rs crates/tui/src/extension_host/tests.rs crates/tui/src/extension_host/tool.rs crates/tui/src/runtime_api.rs crates/tui/src/runtime_api/tests.rs crates/tui/src/runtime_threads.rs crates/tui/src/runtime_threads/tests.rs crates/tui/src/runtime_threads/tests/task_ownership.rs crates/tui/src/tools/codemode.rs crates/tui/src/tools/user_input.rs crates/tui/src/tui/pager.rs crates/tui/src/tui/pet_watch/mod.rs crates/tui/src/tui/phase_strip/tideline_tests.rs crates/tui/src/tui/plan_handoff.rs crates/tui/src/tui/ui/apply.rs crates/tui/src/tui/ui/event_loop.rs crates/tui/src/tui/ui/frame.rs crates/tui/src/tui/ui/observer_hooks.rs crates/tui/src/tui/ui/tests.rs crates/tui/src/tui/user_input.rs

Verified (macOS; tree also held peers' uncommitted edits at test time):
- extension-host typecheck; Node 420: 413 pass, 7 skip, 0 fail; Bun 420:
  414 pass, 6 skip, 0 fail.
- CODEWHALE_EXT_HOST_TESTS=1 + compiled Bun host, scripts/dev-test.sh tui
  extension_host:: tools::codemode:: tools::user_input:: tui::user_input
  tui::pager tui::plan_handoff tui::pet_watch tui::phase_strip
  tui::persistence_actor:: runtime_threads:: runtime_api:: plugins::install::
  exec_agent:: core::engine::tests:: core::protocol_parity tui::ui::tests::
  -> 2656 run, 2655 passed, 1 failed (licence notice missing; fixed in this
  commit, rerun 1/1 passed). scripts/dev-test.sh protocol: 78/78. CLI build.
- Live Engine HTTP (artifacts/dsh-e2e-20261027/logs/q1, loopback provider):
  DSH bundle tool flushed (true), asked one question; Runtime listed it in
  pending_user_inputs with require_typed_answers; answering Blue via
  POST /v1/user-input returned delivered:true and the plugin received
  [{"id":"colour","selected":["Blue"]}].
Not verified: TUI and desktop App rendering live, Linux/Windows, hosted CI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 12:53:47 -07:00
1 parent 96498ce2a7
commit fff5a476f9
54 files changed
+3703 -622

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Remote control works again:** `codewhale rc` (`/rc`) can attach to the hosted control plane for web Chat again. The control plane had only accepted the retired 0.9.13 runtime identity, so every 0.10.x Engine was refused with `runner_runtime_identity_unavailable`; it now accepts the exact released runtime identity (commit, image and digest) and fails closed when they are not configured.
- **Modern MCP client:** MCP servers now connect through the official MCP SDK (2.4) behind Codewhale's Rust broker by default (`mcp_backend = "host"`; `"rust"` stays available for one release). Servers that speak the 2026-07-28 revision are discovered with `server/discover`; other stdio and HTTP servers fall back to the legacy `initialize` handshake, and configured SSE servers keep theirs. Rust keeps process, network, credential, catalog, approval and cancellation authority. (CW-6713)
- **MCP compatibility fixes:** older servers that answer the discovery probe with an error or an unrelated result now connect through the legacy handshake instead of being refused after the probe. A server that rejects sign-in reports its real 401 and the `/mcp login` hint rather than the probe's "method not found". A stale OAuth token is refreshed once when the probe itself is rejected. A server whose command cannot start now says why (for example, command not found) instead of a generic broker refusal.
- **DeepSeek Harness plugins:** reviewed DSH plugins (definition API pinned to DSH 0.2.1-rc.2 in a shared compatibility table) run in the Native extension host. Tool output is validated against its declared schema, unsupported options are refused instead of ignored, and plugin commands can submit a prompt with visible plugin attribution. Terminal command receipts are kept in thread snapshots without entering model history. Changing the compatibility table invalidates earlier trust. A newly enabled plugin's tools are offered on the first turn: the turn waits up to 10 seconds for the plugin host to finish loading instead of adding them only from the next turn. Plugin UI views stay disabled, and the import receipt says why: WebKit's network restrictions do not stop WebRTC, so plugin UI waits for an isolated renderer. A plugin tool can call `ctx.sessions.flush(exec.agent.session)` to have the Engine save the conversation so far before it continues (Runtime, terminal and `exec` sessions); it reports failure instead of claiming a save. Installing a plugin whose bytes changed after review now returns a 409 conflict instead of a server error. (CW-7054, CW-7549)
- **DeepSeek Harness plugins:** reviewed DSH plugins (definition API pinned to DSH 0.2.1-rc.2 in a shared compatibility table) run in the Native extension host. Tool output is validated against its declared schema, unsupported options are refused instead of ignored, and plugin commands can submit a prompt with visible plugin attribution. Terminal command receipts are kept in thread snapshots without entering model history. Changing the compatibility table invalidates earlier trust. A newly enabled plugin's tools are offered on the first turn: the turn waits up to 10 seconds for the plugin host to finish loading instead of adding them only from the next turn. Plugin UI views stay disabled, and the import receipt says why: WebKit's network restrictions do not stop WebRTC, so plugin UI waits for an isolated renderer. A plugin tool can call `ctx.sessions.flush(exec.agent.session)` to have the Engine save the conversation so far before it continues (Runtime, terminal and `exec` sessions); it reports failure instead of claiming a save. Installing a plugin whose bytes changed after review now returns a 409 conflict instead of a server error. A plugin tool can ask you a question with `ctx.userQuestions.ask` and gets back the options you picked, your own text, or a skip; the question shows like any other Codewhale question in the terminal and through the Runtime API. (CW-7054, CW-7549)
- **MCP catalogs stay fresh:** a server's tools, resources, prompts and skills are rediscovered on their own connection when an advertised `ttlMs` expires or the server sends a list-changed notice. A tool resolved against the old catalog is refused with "retry the call", a removed tool cannot run, and a failed refresh reconnects instead of serving stale entries. Catalogs are never shared across servers, credentials or reconnects. (CW-7547)
- **MCP Skills extension:** servers that advertise the published Skills extension have their skills listed in `list_mcp_resources`. Listing never activates a skill, and reading a skill file is refused unless its size and sha256 digest match the listing. MCP skills do not yet join `/skills`. (CW-7545)
- **Computer Use is installed, not bundled:** the Computer Use plugin is no longer vendored into the binary. Install it from the `codewhale` marketplace, which points at its independent release; its consent tools load on first use, and decisions still require a human approval card. (CW-6732)
+2 -1
View File
@@ -110,7 +110,8 @@ excerpts of `@deepseek-ai/dsh-tools` 0.2.1-rc.2 (`lib/index.js`), under the
same licence text. `crates/tui/extension-host/src/dsh/dsh-message-compat.js`
contains verbatim excerpts of `@deepseek-ai/dsh-brand` 0.2.1-rc.2,
`@deepseek-ai/dsh-llm` 0.2.1-rc.2, `@deepseek-ai/dsh-session` 0.2.1-rc.2,
`@deepseek-ai/dsh-system-prompt` 0.2.1-rc.2 and
`@deepseek-ai/dsh-system-prompt` 0.2.1-rc.2,
`@deepseek-ai/dsh-user-questions` 0.2.1-rc.2 and
`@deepseek-ai/dsh-util-crypto` 0.2.1-rc.2, under the same licence text.
Upstream: <https://github.com/deepseek-ai/deepseek-harness>
+33
View File
@@ -264,9 +264,20 @@ pub struct McpManagerSnapshot {
/// Structured clarification request (`request_user_input`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserInputRequest {
#[serde(default)]
pub require_typed_answers: bool,
pub questions: Vec<UserInputQuestionEvent>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UserInputClosedOutcome {
Answered,
Cancelled,
TimedOut,
Unavailable,
}
/// Which permission gate produced a `ToolGateDecision`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -743,6 +754,12 @@ pub enum EventMsg {
id: String,
request: UserInputRequest,
},
UserInputClosed {
thread_id: ThreadId,
session_id: SessionId,
id: String,
outcome: UserInputClosedOutcome,
},
/// Authoritative API conversation state (`Message`s / `SystemPrompt`
/// serialized).
SessionUpdated {
@@ -873,6 +890,7 @@ pub const EVENT_KINDS: &[&str] = &[
"approval_required",
"approval_withdrawn",
"user_input_required",
"user_input_closed",
"session_updated",
"elevation_required",
"lsp_repair_update",
@@ -934,6 +952,7 @@ impl EventMsg {
Self::ApprovalRequired { .. } => "approval_required",
Self::ApprovalWithdrawn { .. } => "approval_withdrawn",
Self::UserInputRequired { .. } => "user_input_required",
Self::UserInputClosed { .. } => "user_input_closed",
Self::SessionUpdated { .. } => "session_updated",
Self::ElevationRequired { .. } => "elevation_required",
Self::LspRepairUpdate { .. } => "lsp_repair_update",
@@ -995,6 +1014,7 @@ impl EventMsg {
| Self::ApprovalRequired { thread_id, .. }
| Self::ApprovalWithdrawn { thread_id, .. }
| Self::UserInputRequired { thread_id, .. }
| Self::UserInputClosed { thread_id, .. }
| Self::SessionUpdated { thread_id, .. }
| Self::ElevationRequired { thread_id, .. }
| Self::LspRepairUpdate { thread_id, .. }
@@ -1056,6 +1076,7 @@ impl EventMsg {
| Self::ApprovalRequired { session_id, .. }
| Self::ApprovalWithdrawn { session_id, .. }
| Self::UserInputRequired { session_id, .. }
| Self::UserInputClosed { session_id, .. }
| Self::SessionUpdated { session_id, .. }
| Self::ElevationRequired { session_id, .. }
| Self::LspRepairUpdate { session_id, .. }
@@ -1482,16 +1503,28 @@ mod tests {
session_id: s.clone(),
id: "c2".into(),
request: UserInputRequest {
require_typed_answers: true,
questions: vec![UserInputQuestionEvent {
header: "h".into(),
id: "q1".into(),
question: "?".into(),
detail: Some("Exact detail".into()),
intent: Some(crate::UserInputIntentEvent::PlanReview {
approve: "Proceed".into(),
call_id: Some("plan-1".into()),
}),
options: vec![],
allow_free_text: true,
multi_select: false,
}],
},
},
EventMsg::UserInputClosed {
thread_id: t.clone(),
session_id: s.clone(),
id: "c2".into(),
outcome: UserInputClosedOutcome::TimedOut,
},
EventMsg::SessionUpdated {
thread_id: t.clone(),
session_id: s.clone(),
+30 -3
View File
@@ -940,19 +940,43 @@ pub struct UserInputOptionEvent {
/// Short label for the option (also the value submitted when picked).
pub label: String,
/// Longer description shown alongside the label.
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum UserInputIntentEvent {
PlanReview {
approve: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
call_id: Option<String>,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum UserInputAnswerKindEvent {
Choice,
Custom,
Skipped,
}
/// A single clarification question posed to the user.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserInputQuestionEvent {
/// Compact header shown as the question title.
#[serde(default)]
pub header: String,
/// Stable identifier used to correlate answers back to this question.
pub id: String,
/// The question body.
pub question: String,
/// 2-4 suggested answers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<UserInputIntentEvent>,
#[serde(default)]
pub options: Vec<UserInputOptionEvent>,
/// When `true`, the client should also offer a free-text response.
#[serde(default)]
@@ -975,16 +999,19 @@ pub struct UserInputRequestEvent {
pub turn_id: String,
/// Unique identifier for this user-input request (clients reply with it).
pub request_id: String,
/// 1-3 questions to present.
#[serde(default)]
pub require_typed_answers: bool,
pub questions: Vec<UserInputQuestionEvent>,
}
/// One answer to a clarification question.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserInputAnswerEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<UserInputAnswerKindEvent>,
/// The `id` of the question this answer corresponds to.
pub id: String,
/// The selected option's label, or `"Other"` for a free-text response.
/// The selected option's label, or empty for typed custom/skip responses.
pub label: String,
/// The resolved value (option label, or the typed free-text).
pub value: String,
+27
View File
@@ -331,10 +331,16 @@ fn user_input_request_event_frame_round_trip() {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
request_id: "ui-1".to_string(),
require_typed_answers: true,
questions: vec![UserInputQuestionEvent {
header: "Scope".to_string(),
id: "scope".to_string(),
question: "Which surfaces?".to_string(),
detail: Some("Detailed plan".to_string()),
intent: Some(codewhale_protocol::UserInputIntentEvent::PlanReview {
approve: "All".into(),
call_id: Some("plan-call".into()),
}),
options: vec![
UserInputOptionEvent {
label: "TUI".to_string(),
@@ -354,6 +360,19 @@ fn user_input_request_event_frame_round_trip() {
let encoded = serde_json::to_value(&frame).expect("serialize user input frame");
assert_eq!(encoded["event"], "user_input_request");
assert_eq!(encoded["request"]["call_id"], "call-1");
assert_eq!(encoded["request"]["require_typed_answers"], true);
assert_eq!(
encoded["request"]["questions"][0]["detail"],
"Detailed plan"
);
assert_eq!(
encoded["request"]["questions"][0]["intent"]["kind"],
"plan-review"
);
assert_eq!(
encoded["request"]["questions"][0]["intent"]["call_id"],
"plan-call"
);
assert_eq!(encoded["request"]["request_id"], "ui-1");
assert_eq!(encoded["request"]["questions"][0]["header"], "Scope");
assert_eq!(encoded["request"]["questions"][0]["allow_free_text"], true);
@@ -400,6 +419,9 @@ fn user_input_request_event_defaults_flags_when_omitted() {
let EventFrame::UserInputRequest { request } = decoded else {
panic!("expected user_input_request frame");
};
assert!(!request.require_typed_answers);
assert_eq!(request.questions[0].detail, None);
assert_eq!(request.questions[0].intent, None);
assert!(!request.questions[0].allow_free_text);
assert!(!request.questions[0].multi_select);
}
@@ -411,6 +433,7 @@ fn submit_user_input_app_request_round_trip() {
let req = AppRequest::SubmitUserInput {
request_id: "ui-1".to_string(),
answers: vec![UserInputAnswerEvent {
kind: Some(codewhale_protocol::UserInputAnswerKindEvent::Choice),
id: "scope".to_string(),
label: "All".to_string(),
value: "All".to_string(),
@@ -431,6 +454,10 @@ fn submit_user_input_app_request_round_trip() {
assert_eq!(request_id, "ui-1");
assert_eq!(answers.len(), 1);
assert_eq!(answers[0].label, "All");
assert_eq!(
answers[0].kind,
Some(codewhale_protocol::UserInputAnswerKindEvent::Choice)
);
}
#[test]
+1 -1
View File
@@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Remote control works again:** `codewhale rc` (`/rc`) can attach to the hosted control plane for web Chat again. The control plane had only accepted the retired 0.9.13 runtime identity, so every 0.10.x Engine was refused with `runner_runtime_identity_unavailable`; it now accepts the exact released runtime identity (commit, image and digest) and fails closed when they are not configured.
- **Modern MCP client:** MCP servers now connect through the official MCP SDK (2.4) behind Codewhale's Rust broker by default (`mcp_backend = "host"`; `"rust"` stays available for one release). Servers that speak the 2026-07-28 revision are discovered with `server/discover`; other stdio and HTTP servers fall back to the legacy `initialize` handshake, and configured SSE servers keep theirs. Rust keeps process, network, credential, catalog, approval and cancellation authority. (CW-6713)
- **MCP compatibility fixes:** older servers that answer the discovery probe with an error or an unrelated result now connect through the legacy handshake instead of being refused after the probe. A server that rejects sign-in reports its real 401 and the `/mcp login` hint rather than the probe's "method not found". A stale OAuth token is refreshed once when the probe itself is rejected. A server whose command cannot start now says why (for example, command not found) instead of a generic broker refusal.
- **DeepSeek Harness plugins:** reviewed DSH plugins (definition API pinned to DSH 0.2.1-rc.2 in a shared compatibility table) run in the Native extension host. Tool output is validated against its declared schema, unsupported options are refused instead of ignored, and plugin commands can submit a prompt with visible plugin attribution. Terminal command receipts are kept in thread snapshots without entering model history. Changing the compatibility table invalidates earlier trust. A newly enabled plugin's tools are offered on the first turn: the turn waits up to 10 seconds for the plugin host to finish loading instead of adding them only from the next turn. Plugin UI views stay disabled, and the import receipt says why: WebKit's network restrictions do not stop WebRTC, so plugin UI waits for an isolated renderer. A plugin tool can call `ctx.sessions.flush(exec.agent.session)` to have the Engine save the conversation so far before it continues (Runtime, terminal and `exec` sessions); it reports failure instead of claiming a save. Installing a plugin whose bytes changed after review now returns a 409 conflict instead of a server error. (CW-7054, CW-7549)
- **DeepSeek Harness plugins:** reviewed DSH plugins (definition API pinned to DSH 0.2.1-rc.2 in a shared compatibility table) run in the Native extension host. Tool output is validated against its declared schema, unsupported options are refused instead of ignored, and plugin commands can submit a prompt with visible plugin attribution. Terminal command receipts are kept in thread snapshots without entering model history. Changing the compatibility table invalidates earlier trust. A newly enabled plugin's tools are offered on the first turn: the turn waits up to 10 seconds for the plugin host to finish loading instead of adding them only from the next turn. Plugin UI views stay disabled, and the import receipt says why: WebKit's network restrictions do not stop WebRTC, so plugin UI waits for an isolated renderer. A plugin tool can call `ctx.sessions.flush(exec.agent.session)` to have the Engine save the conversation so far before it continues (Runtime, terminal and `exec` sessions); it reports failure instead of claiming a save. Installing a plugin whose bytes changed after review now returns a 409 conflict instead of a server error. A plugin tool can ask you a question with `ctx.userQuestions.ask` and gets back the options you picked, your own text, or a skip; the question shows like any other Codewhale question in the terminal and through the Runtime API. (CW-7054, CW-7549)
- **MCP catalogs stay fresh:** a server's tools, resources, prompts and skills are rediscovered on their own connection when an advertised `ttlMs` expires or the server sends a list-changed notice. A tool resolved against the old catalog is refused with "retry the call", a removed tool cannot run, and a failed refresh reconnects instead of serving stale entries. Catalogs are never shared across servers, credentials or reconnects. (CW-7547)
- **MCP Skills extension:** servers that advertise the published Skills extension have their skills listed in `list_mcp_resources`. Listing never activates a skill, and reading a skill file is refused unless its size and sha256 digest match the listing. MCP skills do not yet join `/skills`. (CW-7545)
- **Computer Use is installed, not bundled:** the Computer Use plugin is no longer vendored into the binary. Install it from the `codewhale` marketplace, which points at its independent release; its consent tools load on first use, and decisions still require a human approval card. (CW-6732)
+29
View File
@@ -18,6 +18,7 @@ Packages:
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@@ -475,6 +476,34 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Verbatim excerpts of this package are in src/dsh/dsh-message-compat.js, under the same licence text as @deepseek-ai/dsh-util-values above.
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
+85 -6
View File
@@ -12080,7 +12080,8 @@ var init_compatibility = __esm({
"events",
"reflect",
"registry",
"sessions"
"sessions",
"userQuestions"
],
refused_services: [
"shellHooks",
@@ -12103,7 +12104,8 @@ var init_compatibility = __esm({
"skills",
"avatars",
"mcp",
"logger"
"logger",
"userQuestions"
],
row_bridges: {
"@deepseek-ai/dsh-mcp-client": "mcp",
@@ -12126,7 +12128,8 @@ var init_compatibility = __esm({
"@deepseek-ai/dsh-commands/brand": "dsh-commands-brand",
"@deepseek-ai/dsh-llm": "dsh-llm-values",
"@deepseek-ai/dsh-session": "dsh-session-values",
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values"
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values",
"@deepseek-ai/dsh-user-questions": "dsh-user-questions-values"
},
definition_api: {
package: "@deepseek-ai/dsh-tools",
@@ -12151,16 +12154,20 @@ var init_compatibility = __esm({
command_followup: "one-text-message-submit-on-success",
pure_api: {
file: "src/dsh/dsh-message-compat.js",
source_sha256: "efbafde829c7ecde345ccbc84d94b676466330b7073da05163f93c03b1defc32",
source_sha256: "aac208e72eac3601b02c5a4ce95bfeb82f1bb3ba0d1e906d786d1e25609e9857",
exports: {
"@deepseek-ai/dsh-llm": [
"createUserMessage"
"createUserMessage",
"HarnessError"
],
"@deepseek-ai/dsh-session": [
"SessionId"
],
"@deepseek-ai/dsh-system-prompt": [
"PERSONA_PREFIX_SECTION"
],
"@deepseek-ai/dsh-user-questions": [
"UserQuestionError"
]
},
packages: [
@@ -12235,6 +12242,22 @@ var init_compatibility = __esm({
64
],
sha256: "2c4929c2c18e511aa0fefe216eb50e703191057bb15ba1412ddcfdcc8bfc020f"
},
{
name: "HarnessError declaration",
lines: [
126,
126
],
sha256: "d260a23e0a20850d3b623cbeed54626b47d43fa74e000c5d529dc0a341ab63ee"
},
{
name: "HarnessError body",
lines: [
128,
134
],
sha256: "30ee24ef29e412b1ab2f0ca89ab600bb0260022d1479d43ca2ad21173b774ccf"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
@@ -12309,6 +12332,29 @@ var init_compatibility = __esm({
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: false
},
{
package: "@deepseek-ai/dsh-user-questions",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-user-questions/-/dsh-user-questions-0.2.1-rc.2.tgz",
integrity: "sha512-XVsvMCjxHuDX4YxhwWqRi1IExp7evWVyQ1Gis+90ZBmRNRH/MujjboE+QVc79sdg+p79tzJmqPUDxPj1JOyjzQ==",
tarball_sha256: "f5194abbe1b3412ad56487b5e6f1aae455cce8bbf29899f824218b2ddd02ab69",
source_file: "lib/index.js",
source_sha256: "e1c51afac2e31be8593c6426976e5f31dbe2a709b85ae1ea43ed406146f54265",
excerpts: [
{
name: "UserQuestionError",
lines: [
429,
434
],
sha256: "5d49a4ecdf40039b26824f3575a79820b48ed011f90641870f44fe5a41bf5ced"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
}
],
reuse: {
@@ -12321,11 +12367,18 @@ var init_compatibility = __esm({
230
],
sha256: "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
},
constructor_names: [
"HarnessError",
"UserQuestionError"
]
},
tool_caller_projection: "readonly-id-session-header-cwd",
session_methods: {
flush: "invocation-bound-acknowledged-checkpoint-with-host-redaction-policy"
},
user_question_methods: {
ask: "invocation-bound-root-agent-typed-answers-no-wait"
}
};
}
@@ -12784,6 +12837,31 @@ var init_lib3 = __esm({
}
});
// src/dsh/dsh-message-compat.js
var HarnessError, UserQuestionError;
var init_dsh_message_compat = __esm({
"src/dsh/dsh-message-compat.js"() {
"use strict";
init_lib3();
HarnessError = class extends Error {
code;
constructor(message, code, options) {
super(message, options);
this.code = code;
this.name = new.target.name;
}
};
UserQuestionError = class extends HarnessError {
constructor(message, code, options) {
super(message, code, options);
this.name = "UserQuestionError";
}
};
Object.defineProperty(HarnessError, "name", { value: "HarnessError" });
Object.defineProperty(UserQuestionError, "name", { value: "UserQuestionError" });
}
});
// src/dsh/dsh-tools-compat.js
var init_dsh_tools_compat = __esm({
"src/dsh/dsh-tools-compat.js"() {
@@ -12862,6 +12940,7 @@ var init_root = __esm({
init_composition();
init_protocol();
init_rpc();
init_dsh_message_compat();
init_resolve_hooks();
init_compatibility();
init_dsh_tools_compat();
+261 -40
View File
@@ -3269,6 +3269,58 @@ var init_dsh_tools_compat = __esm({
}
});
// src/dsh/dsh-message-compat.js
function brandString(value) {
return value;
}
function randomUUID() {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
const hex = Array.from(bytes, (byte, index) => {
return (index === 6 ? byte & 15 | 64 : index === 8 ? byte & 63 | 128 : byte).toString(16).padStart(2, "0");
}).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function createMessage(input) {
return deepFreeze(structuredClone({
...input,
id: brandString(randomUUID())
}));
}
function createUserMessage(input) {
return createMessage({
...input,
role: "user"
});
}
function SessionId(id) {
return brandString(id);
}
var PERSONA_PREFIX_SECTION, HarnessError2, UserQuestionError;
var init_dsh_message_compat = __esm({
"src/dsh/dsh-message-compat.js"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_lib3();
PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
HarnessError2 = class extends Error {
code;
constructor(message, code, options) {
super(message, options);
this.code = code;
this.name = new.target.name;
}
};
UserQuestionError = class extends HarnessError2 {
constructor(message, code, options) {
super(message, code, options);
this.name = "UserQuestionError";
}
};
Object.defineProperty(HarnessError2, "name", { value: "HarnessError" });
Object.defineProperty(UserQuestionError, "name", { value: "UserQuestionError" });
}
});
// src/dsh/fiber-state.ts
var FiberState;
var init_fiber_state = __esm({
@@ -13802,7 +13854,8 @@ var init_compatibility = __esm({
"events",
"reflect",
"registry",
"sessions"
"sessions",
"userQuestions"
],
refused_services: [
"shellHooks",
@@ -13825,7 +13878,8 @@ var init_compatibility = __esm({
"skills",
"avatars",
"mcp",
"logger"
"logger",
"userQuestions"
],
row_bridges: {
"@deepseek-ai/dsh-mcp-client": "mcp",
@@ -13848,7 +13902,8 @@ var init_compatibility = __esm({
"@deepseek-ai/dsh-commands/brand": "dsh-commands-brand",
"@deepseek-ai/dsh-llm": "dsh-llm-values",
"@deepseek-ai/dsh-session": "dsh-session-values",
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values"
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values",
"@deepseek-ai/dsh-user-questions": "dsh-user-questions-values"
},
definition_api: {
package: "@deepseek-ai/dsh-tools",
@@ -13873,16 +13928,20 @@ var init_compatibility = __esm({
command_followup: "one-text-message-submit-on-success",
pure_api: {
file: "src/dsh/dsh-message-compat.js",
source_sha256: "efbafde829c7ecde345ccbc84d94b676466330b7073da05163f93c03b1defc32",
source_sha256: "aac208e72eac3601b02c5a4ce95bfeb82f1bb3ba0d1e906d786d1e25609e9857",
exports: {
"@deepseek-ai/dsh-llm": [
"createUserMessage"
"createUserMessage",
"HarnessError"
],
"@deepseek-ai/dsh-session": [
"SessionId"
],
"@deepseek-ai/dsh-system-prompt": [
"PERSONA_PREFIX_SECTION"
],
"@deepseek-ai/dsh-user-questions": [
"UserQuestionError"
]
},
packages: [
@@ -13957,6 +14016,22 @@ var init_compatibility = __esm({
64
],
sha256: "2c4929c2c18e511aa0fefe216eb50e703191057bb15ba1412ddcfdcc8bfc020f"
},
{
name: "HarnessError declaration",
lines: [
126,
126
],
sha256: "d260a23e0a20850d3b623cbeed54626b47d43fa74e000c5d529dc0a341ab63ee"
},
{
name: "HarnessError body",
lines: [
128,
134
],
sha256: "30ee24ef29e412b1ab2f0ca89ab600bb0260022d1479d43ca2ad21173b774ccf"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
@@ -14031,6 +14106,29 @@ var init_compatibility = __esm({
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: false
},
{
package: "@deepseek-ai/dsh-user-questions",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-user-questions/-/dsh-user-questions-0.2.1-rc.2.tgz",
integrity: "sha512-XVsvMCjxHuDX4YxhwWqRi1IExp7evWVyQ1Gis+90ZBmRNRH/MujjboE+QVc79sdg+p79tzJmqPUDxPj1JOyjzQ==",
tarball_sha256: "f5194abbe1b3412ad56487b5e6f1aae455cce8bbf29899f824218b2ddd02ab69",
source_file: "lib/index.js",
source_sha256: "e1c51afac2e31be8593c6426976e5f31dbe2a709b85ae1ea43ed406146f54265",
excerpts: [
{
name: "UserQuestionError",
lines: [
429,
434
],
sha256: "5d49a4ecdf40039b26824f3575a79820b48ed011f90641870f44fe5a41bf5ced"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
}
],
reuse: {
@@ -14043,11 +14141,18 @@ var init_compatibility = __esm({
230
],
sha256: "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
},
constructor_names: [
"HarnessError",
"UserQuestionError"
]
},
tool_caller_projection: "readonly-id-session-header-cwd",
session_methods: {
flush: "invocation-bound-acknowledged-checkpoint-with-host-redaction-policy"
},
user_question_methods: {
ask: "invocation-bound-root-agent-typed-answers-no-wait"
}
};
}
@@ -14446,6 +14551,7 @@ var init_protocol_generated = __esm({
{ name: "registry/unregister", direction: "host_to_core", request: true, params: "UnregisterParams", tiers: ["plugin", "builtin"] },
{ name: "core/call", direction: "host_to_core", request: true, params: "CoreCallParams", tiers: ["plugin", "builtin"] },
{ name: "session/checkpoint", direction: "host_to_core", request: true, params: "SessionCheckpointParams", tiers: ["plugin", "builtin"] },
{ name: "user-questions/ask", direction: "host_to_core", request: true, params: "UserQuestionParams", tiers: ["plugin", "builtin"] },
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
@@ -14639,12 +14745,32 @@ var init_protocol_generated = __esm({
ToolCallParams: {
strict: false,
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", session_id: "string", agent_id: "string", origin_turn_id: "string" }
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", session_id: "string", agent_id: "string", origin_turn_id: "string" }
},
UnregisterParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, handle: "uint" },
optional: {}
},
UserInputIntent: {
strict: false,
required: { approve: "string", kind: { enum: ["plan-review"] } },
optional: { call_id: "string" }
},
UserInputOption: {
strict: false,
required: { label: "string" },
optional: { description: "string" }
},
UserInputQuestion: {
strict: false,
required: { id: "string", question: "string" },
optional: { header: "string", detail: "string", intent: { ref: "UserInputIntent" }, options: { items: { ref: "UserInputOption" } }, allow_free_text: "boolean", multi_select: "boolean" }
},
UserQuestionParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, ticket: "string", questions: { items: { ref: "UserInputQuestion" } } },
optional: {}
}
};
}
@@ -15683,6 +15809,7 @@ var init_root = __esm({
init_lib2();
init_protocol();
init_rpc();
init_dsh_message_compat();
init_resolve_hooks();
init_compatibility();
init_dsh_tools_compat();
@@ -15859,6 +15986,125 @@ var init_root = __esm({
return result;
}
}
class UserQuestionsShim extends Service {
constructor(ctx) {
super(ctx, "userQuestions");
}
async ask(request) {
const invocation = host2.toolInvocations.getStore();
const owner = this.ctx[OWNER];
const data2 = (value, allowed) => {
if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, "expected a plain question object");
const result2 = /* @__PURE__ */ Object.create(null);
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (typeof key !== "string" || !allowed.includes(key) || !descriptor.enumerable || !("value" in descriptor)) throw new RpcError(ErrorCode.InvalidParams, "unsupported question property");
if (descriptor.value !== void 0) result2[key] = descriptor.value;
}
return result2;
};
const list2 = (value, maximum) => {
if (!Array.isArray(value) || value.length > maximum || Reflect.ownKeys(value).length !== value.length + 1) throw new RpcError(ErrorCode.InvalidParams, "invalid question array");
return Array.from({ length: value.length }, (_, index) => {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) throw new RpcError(ErrorCode.InvalidParams, "invalid question array item");
return descriptor.value;
});
};
const input = data2(request, ["agent", "questions", "signal", "wait"]);
if (input.wait !== void 0) throw new RpcError(ErrorCode.NotAvailable, "timed or continued questions are not yet supported");
request = input;
if (request.signal !== void 0 && !(request.signal instanceof AbortSignal)) {
throw new RpcError(ErrorCode.InvalidParams, "question signal must be an AbortSignal");
}
if (request.signal?.aborted) throw new UserQuestionError("ask_user_question was aborted before the user answered", "ASK_ABORTED");
const items = list2(request.questions, 10);
if (items.length === 0) throw new UserQuestionError("ask_user_question requires at least one question", "EMPTY_QUESTIONS");
if (request.agent === void 0) throw new RpcError(ErrorCode.NotAvailable, "userQuestions.ask requires this live tool invocation’s agent");
if (!invocation || !invocation.active || invocation.signal.aborted || invocation.agent === void 0 || request.agent !== invocation.agent) {
throw new UserQuestionError("human interaction requires the exact live calling agent when an agent is supplied", "CALLER_NOT_LIVE");
}
if (invocation.owner !== owner || owner?.state !== "active" || owner.disposing) throw new RpcError(ErrorCode.NotAvailable, "userQuestions.ask service owner is no longer current");
if (invocation.delegated) throw new UserQuestionError("human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result", "DELEGATED_CALLER");
const live = () => invocation.active && !invocation.signal.aborted && invocation.owner === owner && owner?.state === "active" && !owner.disposing && invocation.questionTicket !== void 0 && request.agent === invocation.agent;
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "userQuestions.ask requires this live tool invocation’s question ticket");
const signal = request.signal === void 0 ? invocation.signal : AbortSignal.any([invocation.signal, request.signal]);
const questions = items.map((value) => {
const question = data2(value, ["id", "question", "header", "detail", "options", "multiSelect", "intent"]);
const options = list2(question.options === void 0 ? [] : question.options, 1e3);
const intent = question.intent === void 0 ? void 0 : data2(question.intent, ["kind", "approve", "callId"]);
if (intent !== void 0 && intent.kind !== "plan-review") throw new RpcError(ErrorCode.InvalidParams, "unsupported question intent");
return {
id: question.id,
question: question.question,
header: question.header === void 0 ? "" : question.header,
...question.detail === void 0 ? {} : { detail: question.detail },
...intent === void 0 ? {} : { intent: {
kind: "plan-review",
approve: intent.approve,
...intent.callId === void 0 ? {} : { call_id: intent.callId }
} },
options: options.map((option) => {
const item = data2(option, ["label", "description"]);
return { label: item.label, description: item.description === void 0 ? "" : item.description };
}),
allow_free_text: true,
multi_select: question.multiSelect === void 0 ? false : question.multiSelect
};
});
if (!isJson(questions)) throw new RpcError(ErrorCode.InvalidParams, "question data must be lossless JSON");
for (const question of questions) {
if (question.intent === void 0) continue;
if (!question.options.some((option) => option.label === question.intent.approve)) throw new UserQuestionError("question " + question.id + " declares an intent whose approve label names none of its options", "BAD_INTENT");
if (question.detail === void 0) throw new UserQuestionError("question " + question.id + " declares an intent without the detail it reviews", "BAD_INTENT");
}
if (Buffer.byteLength(JSON.stringify(questions)) > 1024 * 1024) throw new RpcError(ErrorCode.InvalidParams, "question payload exceeds 1 MiB");
const call = invocation;
const run = async () => {
if (!live() || signal.aborted) throw new RpcError(ErrorCode.Cancelled, "question invocation ended");
const result2 = await host2.rpc.request("user-questions/ask", { owner: call.owner.ref, ticket: call.questionTicket, questions }, signal).catch((error) => {
if (error instanceof RpcError && isJson(error.data) && error.data !== null && typeof error.data === "object" && !Array.isArray(error.data)) {
const code = error.data.user_question_error;
if (error.code === ErrorCode.NotAvailable && code === "NO_PROVIDER" || error.code === ErrorCode.Cancelled && code === "ASK_CANCELLED") {
throw new UserQuestionError(error.message, code, { cause: error });
}
}
if (signal.aborted) throw new UserQuestionError("ask_user_question was aborted before the user answered", "ASK_ABORTED", { cause: error });
throw error;
});
if (!live() || signal.aborted) throw new RpcError(ErrorCode.Cancelled, "question invocation ended");
if (!isJson(result2) || result2 === null || typeof result2 !== "object" || Array.isArray(result2) || !Array.isArray(result2.answers)) {
throw new RpcError(ErrorCode.Internal, "invalid question answer receipt");
}
const answers = questions.map((question) => ({ id: question.id, selected: [], custom: void 0, skipped: false, rows: 0 }));
for (const row of result2.answers) {
if (row === null || typeof row !== "object" || Array.isArray(row) || typeof row.label !== "string" || typeof row.value !== "string") throw new RpcError(ErrorCode.Internal, "invalid question answer row");
const answer = answers.find((answer2) => answer2.id === row.id);
const question = questions.find((question2) => question2.id === row.id);
if (!answer || !question || answer.skipped) throw new RpcError(ErrorCode.Internal, "question answer identity mismatch");
if (row.kind === "choice" && row.label === row.value && question.options.some((option) => option.label === row.value) && !answer.selected.includes(row.value)) answer.selected.push(row.value);
else if (row.kind === "custom" && row.label === "" && row.value.trim() !== "" && answer.custom === void 0) answer.custom = row.value;
else if (row.kind === "skipped" && row.label === "" && row.value === "" && answer.rows === 0) answer.skipped = true;
else throw new RpcError(ErrorCode.Internal, "ambiguous question answer receipt");
answer.rows += 1;
if (!question.multi_select && answer.selected.length + (answer.custom === void 0 ? 0 : 1) > 1) throw new RpcError(ErrorCode.Internal, "invalid single-select answer");
}
if (answers.some((answer) => answer.rows === 0)) throw new RpcError(ErrorCode.Internal, "missing question answer");
return { answers: answers.map(({ id, selected, custom }) => ({ id, selected, ...custom === void 0 ? {} : { custom } })) };
};
const result = call.pending.then(run);
call.pending = result.then(() => void 0, () => void 0);
let cancel = () => {
};
const cancelled = new Promise((_, reject) => {
cancel = () => reject(new UserQuestionError("ask_user_question was aborted before the user answered", "ASK_ABORTED"));
signal.addEventListener("abort", cancel, { once: true });
if (signal.aborted) cancel();
});
return Promise.race([result, cancelled]).finally(() => signal.removeEventListener("abort", cancel));
}
}
Object.freeze(UserQuestionsShim.prototype);
Object.freeze(SessionsShim.prototype);
Object.freeze(StorageShim.prototype);
shimClasses.set("tools", ToolsShim);
@@ -15866,6 +16112,7 @@ var init_root = __esm({
shimClasses.set("prompt", PromptShim);
shimClasses.set("storage", StorageShim);
shimClasses.set("sessions", SessionsShim);
shimClasses.set("userQuestions", UserQuestionsShim);
shimClasses.set("skills", SkillsShim);
shimClasses.set("avatars", AvatarsShim);
shimClasses.set("mcp", McpShim);
@@ -15875,6 +16122,7 @@ var init_root = __esm({
root.plugin(PromptShim);
root.plugin(StorageShim);
root.plugin(SessionsShim);
root.plugin(UserQuestionsShim);
root.plugin(SkillsShim);
root.plugin(AvatarsShim);
root.plugin(McpShim);
@@ -16145,6 +16393,9 @@ var init_root = __esm({
const invocation = {
owner: local.owner,
session: agent?.session,
agent,
questionTicket: identity?.question_ticket,
delegated: identity?.agent_id !== void 0,
ticket: identity?.checkpoint_ticket,
signal: AbortSignal.any([signal, closed.signal]),
active: true,
@@ -17406,38 +17657,7 @@ defineMethod("transform", [
init_lib();
init_lib3();
init_dsh_tools_compat();
// src/dsh/dsh-message-compat.js
init_define_BUILTIN_MODULE_DIGESTS();
init_lib3();
function brandString(value) {
return value;
}
function randomUUID() {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
const hex = Array.from(bytes, (byte, index) => {
return (index === 6 ? byte & 15 | 64 : index === 8 ? byte & 63 | 128 : byte).toString(16).padStart(2, "0");
}).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function createMessage(input) {
return deepFreeze(structuredClone({
...input,
id: brandString(randomUUID())
}));
}
function createUserMessage(input) {
return createMessage({
...input,
role: "user"
});
}
function SessionId(id) {
return brandString(id);
}
var PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
// src/main.ts
init_dsh_message_compat();
init_composition();
init_src();
init_src2();
@@ -17620,7 +17840,8 @@ installResolveHooks({
cosmokit: lib_exports,
"dsh-util-values": lib_exports4,
"dsh-tools": dsh_tools_compat_exports,
"dsh-llm-values": { createUserMessage },
"dsh-llm-values": { createUserMessage, HarnessError: HarnessError2 },
"dsh-user-questions-values": { UserQuestionError },
"dsh-session-values": { SessionId },
"dsh-system-prompt-values": { PERSONA_PREFIX_SECTION },
"dsh-composition": composition_exports,
+59 -6
View File
@@ -14,7 +14,8 @@
"events",
"reflect",
"registry",
"sessions"
"sessions",
"userQuestions"
],
"refused_services": [
"shellHooks",
@@ -37,7 +38,8 @@
"skills",
"avatars",
"mcp",
"logger"
"logger",
"userQuestions"
],
"row_bridges": {
"@deepseek-ai/dsh-mcp-client": "mcp",
@@ -60,7 +62,8 @@
"@deepseek-ai/dsh-commands/brand": "dsh-commands-brand",
"@deepseek-ai/dsh-llm": "dsh-llm-values",
"@deepseek-ai/dsh-session": "dsh-session-values",
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values"
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values",
"@deepseek-ai/dsh-user-questions": "dsh-user-questions-values"
},
"definition_api": {
"package": "@deepseek-ai/dsh-tools",
@@ -85,16 +88,20 @@
"command_followup": "one-text-message-submit-on-success",
"pure_api": {
"file": "src/dsh/dsh-message-compat.js",
"source_sha256": "efbafde829c7ecde345ccbc84d94b676466330b7073da05163f93c03b1defc32",
"source_sha256": "aac208e72eac3601b02c5a4ce95bfeb82f1bb3ba0d1e906d786d1e25609e9857",
"exports": {
"@deepseek-ai/dsh-llm": [
"createUserMessage"
"createUserMessage",
"HarnessError"
],
"@deepseek-ai/dsh-session": [
"SessionId"
],
"@deepseek-ai/dsh-system-prompt": [
"PERSONA_PREFIX_SECTION"
],
"@deepseek-ai/dsh-user-questions": [
"UserQuestionError"
]
},
"packages": [
@@ -169,6 +176,22 @@
64
],
"sha256": "2c4929c2c18e511aa0fefe216eb50e703191057bb15ba1412ddcfdcc8bfc020f"
},
{
"name": "HarnessError declaration",
"lines": [
126,
126
],
"sha256": "d260a23e0a20850d3b623cbeed54626b47d43fa74e000c5d529dc0a341ab63ee"
},
{
"name": "HarnessError body",
"lines": [
128,
134
],
"sha256": "30ee24ef29e412b1ab2f0ca89ab600bb0260022d1479d43ca2ad21173b774ccf"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
@@ -243,6 +266,29 @@
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": false
},
{
"package": "@deepseek-ai/dsh-user-questions",
"version": "0.2.1-rc.2",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@deepseek-ai/dsh-user-questions/-/dsh-user-questions-0.2.1-rc.2.tgz",
"integrity": "sha512-XVsvMCjxHuDX4YxhwWqRi1IExp7evWVyQ1Gis+90ZBmRNRH/MujjboE+QVc79sdg+p79tzJmqPUDxPj1JOyjzQ==",
"tarball_sha256": "f5194abbe1b3412ad56487b5e6f1aae455cce8bbf29899f824218b2ddd02ab69",
"source_file": "lib/index.js",
"source_sha256": "e1c51afac2e31be8593c6426976e5f31dbe2a709b85ae1ea43ed406146f54265",
"excerpts": [
{
"name": "UserQuestionError",
"lines": [
429,
434
],
"sha256": "5d49a4ecdf40039b26824f3575a79820b48ed011f90641870f44fe5a41bf5ced"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": true
}
],
"reuse": {
@@ -255,10 +301,17 @@
230
],
"sha256": "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
},
"constructor_names": [
"HarnessError",
"UserQuestionError"
]
},
"tool_caller_projection": "readonly-id-session-header-cwd",
"session_methods": {
"flush": "invocation-bound-acknowledged-checkpoint-with-host-redaction-policy"
},
"user_question_methods": {
"ask": "invocation-bound-root-agent-typed-answers-no-wait"
}
}
+85 -6
View File
@@ -12031,7 +12031,8 @@ var init_compatibility = __esm({
"events",
"reflect",
"registry",
"sessions"
"sessions",
"userQuestions"
],
refused_services: [
"shellHooks",
@@ -12054,7 +12055,8 @@ var init_compatibility = __esm({
"skills",
"avatars",
"mcp",
"logger"
"logger",
"userQuestions"
],
row_bridges: {
"@deepseek-ai/dsh-mcp-client": "mcp",
@@ -12077,7 +12079,8 @@ var init_compatibility = __esm({
"@deepseek-ai/dsh-commands/brand": "dsh-commands-brand",
"@deepseek-ai/dsh-llm": "dsh-llm-values",
"@deepseek-ai/dsh-session": "dsh-session-values",
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values"
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values",
"@deepseek-ai/dsh-user-questions": "dsh-user-questions-values"
},
definition_api: {
package: "@deepseek-ai/dsh-tools",
@@ -12102,16 +12105,20 @@ var init_compatibility = __esm({
command_followup: "one-text-message-submit-on-success",
pure_api: {
file: "src/dsh/dsh-message-compat.js",
source_sha256: "efbafde829c7ecde345ccbc84d94b676466330b7073da05163f93c03b1defc32",
source_sha256: "aac208e72eac3601b02c5a4ce95bfeb82f1bb3ba0d1e906d786d1e25609e9857",
exports: {
"@deepseek-ai/dsh-llm": [
"createUserMessage"
"createUserMessage",
"HarnessError"
],
"@deepseek-ai/dsh-session": [
"SessionId"
],
"@deepseek-ai/dsh-system-prompt": [
"PERSONA_PREFIX_SECTION"
],
"@deepseek-ai/dsh-user-questions": [
"UserQuestionError"
]
},
packages: [
@@ -12186,6 +12193,22 @@ var init_compatibility = __esm({
64
],
sha256: "2c4929c2c18e511aa0fefe216eb50e703191057bb15ba1412ddcfdcc8bfc020f"
},
{
name: "HarnessError declaration",
lines: [
126,
126
],
sha256: "d260a23e0a20850d3b623cbeed54626b47d43fa74e000c5d529dc0a341ab63ee"
},
{
name: "HarnessError body",
lines: [
128,
134
],
sha256: "30ee24ef29e412b1ab2f0ca89ab600bb0260022d1479d43ca2ad21173b774ccf"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
@@ -12260,6 +12283,29 @@ var init_compatibility = __esm({
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: false
},
{
package: "@deepseek-ai/dsh-user-questions",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-user-questions/-/dsh-user-questions-0.2.1-rc.2.tgz",
integrity: "sha512-XVsvMCjxHuDX4YxhwWqRi1IExp7evWVyQ1Gis+90ZBmRNRH/MujjboE+QVc79sdg+p79tzJmqPUDxPj1JOyjzQ==",
tarball_sha256: "f5194abbe1b3412ad56487b5e6f1aae455cce8bbf29899f824218b2ddd02ab69",
source_file: "lib/index.js",
source_sha256: "e1c51afac2e31be8593c6426976e5f31dbe2a709b85ae1ea43ed406146f54265",
excerpts: [
{
name: "UserQuestionError",
lines: [
429,
434
],
sha256: "5d49a4ecdf40039b26824f3575a79820b48ed011f90641870f44fe5a41bf5ced"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
}
],
reuse: {
@@ -12272,11 +12318,18 @@ var init_compatibility = __esm({
230
],
sha256: "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
},
constructor_names: [
"HarnessError",
"UserQuestionError"
]
},
tool_caller_projection: "readonly-id-session-header-cwd",
session_methods: {
flush: "invocation-bound-acknowledged-checkpoint-with-host-redaction-policy"
},
user_question_methods: {
ask: "invocation-bound-root-agent-typed-answers-no-wait"
}
};
}
@@ -12433,6 +12486,31 @@ var init_lib3 = __esm({
}
});
// src/dsh/dsh-message-compat.js
var HarnessError, UserQuestionError;
var init_dsh_message_compat = __esm({
"src/dsh/dsh-message-compat.js"() {
"use strict";
init_lib3();
HarnessError = class extends Error {
code;
constructor(message, code, options) {
super(message, options);
this.code = code;
this.name = new.target.name;
}
};
UserQuestionError = class extends HarnessError {
constructor(message, code, options) {
super(message, code, options);
this.name = "UserQuestionError";
}
};
Object.defineProperty(HarnessError, "name", { value: "HarnessError" });
Object.defineProperty(UserQuestionError, "name", { value: "UserQuestionError" });
}
});
// src/dsh/dsh-tools-compat.js
var init_dsh_tools_compat = __esm({
"src/dsh/dsh-tools-compat.js"() {
@@ -12511,6 +12589,7 @@ var init_root = __esm({
init_composition();
init_protocol();
init_rpc();
init_dsh_message_compat();
init_resolve_hooks();
init_compatibility();
init_dsh_tools_compat();
+22 -1
View File
@@ -38,6 +38,7 @@ var METHODS = [
{ name: "registry/unregister", direction: "host_to_core", request: true, params: "UnregisterParams", tiers: ["plugin", "builtin"] },
{ name: "core/call", direction: "host_to_core", request: true, params: "CoreCallParams", tiers: ["plugin", "builtin"] },
{ name: "session/checkpoint", direction: "host_to_core", request: true, params: "SessionCheckpointParams", tiers: ["plugin", "builtin"] },
{ name: "user-questions/ask", direction: "host_to_core", request: true, params: "UserQuestionParams", tiers: ["plugin", "builtin"] },
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
@@ -231,12 +232,32 @@ var SHAPES = {
ToolCallParams: {
strict: false,
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", session_id: "string", agent_id: "string", origin_turn_id: "string" }
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", session_id: "string", agent_id: "string", origin_turn_id: "string" }
},
UnregisterParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, handle: "uint" },
optional: {}
},
UserInputIntent: {
strict: false,
required: { approve: "string", kind: { enum: ["plan-review"] } },
optional: { call_id: "string" }
},
UserInputOption: {
strict: false,
required: { label: "string" },
optional: { description: "string" }
},
UserInputQuestion: {
strict: false,
required: { id: "string", question: "string" },
optional: { header: "string", detail: "string", intent: { ref: "UserInputIntent" }, options: { items: { ref: "UserInputOption" } }, allow_free_text: "boolean", multi_select: "boolean" }
},
UserQuestionParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, ticket: "string", questions: { items: { ref: "UserInputQuestion" } } },
optional: {}
}
};
+22 -1
View File
@@ -35,6 +35,7 @@ var METHODS = [
{ name: "registry/unregister", direction: "host_to_core", request: true, params: "UnregisterParams", tiers: ["plugin", "builtin"] },
{ name: "core/call", direction: "host_to_core", request: true, params: "CoreCallParams", tiers: ["plugin", "builtin"] },
{ name: "session/checkpoint", direction: "host_to_core", request: true, params: "SessionCheckpointParams", tiers: ["plugin", "builtin"] },
{ name: "user-questions/ask", direction: "host_to_core", request: true, params: "UserQuestionParams", tiers: ["plugin", "builtin"] },
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
@@ -228,12 +229,32 @@ var SHAPES = {
ToolCallParams: {
strict: false,
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", session_id: "string", agent_id: "string", origin_turn_id: "string" }
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", session_id: "string", agent_id: "string", origin_turn_id: "string" }
},
UnregisterParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, handle: "uint" },
optional: {}
},
UserInputIntent: {
strict: false,
required: { approve: "string", kind: { enum: ["plan-review"] } },
optional: { call_id: "string" }
},
UserInputOption: {
strict: false,
required: { label: "string" },
optional: { description: "string" }
},
UserInputQuestion: {
strict: false,
required: { id: "string", question: "string" },
optional: { header: "string", detail: "string", intent: { ref: "UserInputIntent" }, options: { items: { ref: "UserInputOption" } }, allow_free_text: "boolean", multi_select: "boolean" }
},
UserQuestionParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, ticket: "string", questions: { items: { ref: "UserInputQuestion" } } },
optional: {}
}
};
@@ -14,7 +14,8 @@
"events",
"reflect",
"registry",
"sessions"
"sessions",
"userQuestions"
],
"refused_services": [
"shellHooks",
@@ -37,7 +38,8 @@
"skills",
"avatars",
"mcp",
"logger"
"logger",
"userQuestions"
],
"row_bridges": {
"@deepseek-ai/dsh-mcp-client": "mcp",
@@ -60,7 +62,8 @@
"@deepseek-ai/dsh-commands/brand": "dsh-commands-brand",
"@deepseek-ai/dsh-llm": "dsh-llm-values",
"@deepseek-ai/dsh-session": "dsh-session-values",
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values"
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values",
"@deepseek-ai/dsh-user-questions": "dsh-user-questions-values"
},
"definition_api": {
"package": "@deepseek-ai/dsh-tools",
@@ -85,16 +88,20 @@
"command_followup": "one-text-message-submit-on-success",
"pure_api": {
"file": "src/dsh/dsh-message-compat.js",
"source_sha256": "efbafde829c7ecde345ccbc84d94b676466330b7073da05163f93c03b1defc32",
"source_sha256": "aac208e72eac3601b02c5a4ce95bfeb82f1bb3ba0d1e906d786d1e25609e9857",
"exports": {
"@deepseek-ai/dsh-llm": [
"createUserMessage"
"createUserMessage",
"HarnessError"
],
"@deepseek-ai/dsh-session": [
"SessionId"
],
"@deepseek-ai/dsh-system-prompt": [
"PERSONA_PREFIX_SECTION"
],
"@deepseek-ai/dsh-user-questions": [
"UserQuestionError"
]
},
"packages": [
@@ -169,6 +176,22 @@
64
],
"sha256": "2c4929c2c18e511aa0fefe216eb50e703191057bb15ba1412ddcfdcc8bfc020f"
},
{
"name": "HarnessError declaration",
"lines": [
126,
126
],
"sha256": "d260a23e0a20850d3b623cbeed54626b47d43fa74e000c5d529dc0a341ab63ee"
},
{
"name": "HarnessError body",
"lines": [
128,
134
],
"sha256": "30ee24ef29e412b1ab2f0ca89ab600bb0260022d1479d43ca2ad21173b774ccf"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
@@ -243,6 +266,29 @@
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": false
},
{
"package": "@deepseek-ai/dsh-user-questions",
"version": "0.2.1-rc.2",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@deepseek-ai/dsh-user-questions/-/dsh-user-questions-0.2.1-rc.2.tgz",
"integrity": "sha512-XVsvMCjxHuDX4YxhwWqRi1IExp7evWVyQ1Gis+90ZBmRNRH/MujjboE+QVc79sdg+p79tzJmqPUDxPj1JOyjzQ==",
"tarball_sha256": "f5194abbe1b3412ad56487b5e6f1aae455cce8bbf29899f824218b2ddd02ab69",
"source_file": "lib/index.js",
"source_sha256": "e1c51afac2e31be8593c6426976e5f31dbe2a709b85ae1ea43ed406146f54265",
"excerpts": [
{
"name": "UserQuestionError",
"lines": [
429,
434
],
"sha256": "5d49a4ecdf40039b26824f3575a79820b48ed011f90641870f44fe5a41bf5ced"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": true
}
],
"reuse": {
@@ -255,10 +301,17 @@
230
],
"sha256": "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
},
"constructor_names": [
"HarnessError",
"UserQuestionError"
]
},
"tool_caller_projection": "readonly-id-session-header-cwd",
"session_methods": {
"flush": "invocation-bound-acknowledged-checkpoint-with-host-redaction-policy"
},
"user_question_methods": {
"ask": "invocation-bound-root-agent-typed-answers-no-wait"
}
}
@@ -32,4 +32,23 @@ function SessionId(id) {
const PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
export { createUserMessage, SessionId, PERSONA_PREFIX_SECTION }
var HarnessError = class extends Error {
code;
constructor(message, code, options) {
super(message, options);
this.code = code;
this.name = new.target.name;
}
};
var UserQuestionError = class extends HarnessError {
constructor(message, code, options) {
super(message, code, options);
this.name = "UserQuestionError";
}
};
Object.defineProperty(HarnessError, "name", { value: "HarnessError" });
Object.defineProperty(UserQuestionError, "name", { value: "UserQuestionError" });
export { createUserMessage, SessionId, PERSONA_PREFIX_SECTION, HarnessError, UserQuestionError }
+3 -2
View File
@@ -13,7 +13,7 @@ import * as schemastery from '@deepseek-ai/schemastery'
import * as cosmokit from '@deepseek-ai/cosmokit'
import * as dshUtilValues from '@deepseek-ai/dsh-util-values'
import * as dshToolsCompat from './dsh/dsh-tools-compat.js'
import { createUserMessage, SessionId, PERSONA_PREFIX_SECTION } from './dsh/dsh-message-compat.js'
import { createUserMessage, SessionId, PERSONA_PREFIX_SECTION, HarnessError, UserQuestionError } from './dsh/dsh-message-compat.js'
import * as dshComposition from './dsh/composition.ts'
import * as dshLoader from './dsh/upstream/loader/src/index.ts'
import * as dshInclude from './dsh/upstream/include/src/index.ts'
@@ -83,7 +83,8 @@ installResolveHooks({
cosmokit: cosmokit as unknown as Record<string, unknown>,
'dsh-util-values': dshUtilValues as unknown as Record<string, unknown>,
'dsh-tools': dshToolsCompat as unknown as Record<string, unknown>,
'dsh-llm-values': { createUserMessage },
'dsh-llm-values': { createUserMessage, HarnessError },
'dsh-user-questions-values': { UserQuestionError },
'dsh-session-values': { SessionId },
'dsh-system-prompt-values': { PERSONA_PREFIX_SECTION },
'dsh-composition': dshComposition as unknown as Record<string, unknown>,
@@ -46,6 +46,7 @@ export const METHODS = [
{ name: 'registry/unregister', direction: 'host_to_core', request: true, params: 'UnregisterParams', tiers: ['plugin', 'builtin'] },
{ name: 'core/call', direction: 'host_to_core', request: true, params: 'CoreCallParams', tiers: ['plugin', 'builtin'] },
{ name: 'session/checkpoint', direction: 'host_to_core', request: true, params: 'SessionCheckpointParams', tiers: ['plugin', 'builtin'] },
{ name: 'user-questions/ask', direction: 'host_to_core', request: true, params: 'UserQuestionParams', tiers: ['plugin', 'builtin'] },
{ name: 'proc/launch', direction: 'host_to_core', request: true, params: 'ProcLaunchParams', tiers: ['builtin'] },
{ name: 'proc/read', direction: 'host_to_core', request: true, params: 'ProcSessionParams', tiers: ['builtin'] },
{ name: 'proc/write', direction: 'host_to_core', request: true, params: 'ProcWriteParams', tiers: ['builtin'] },
@@ -260,13 +261,33 @@ export const SHAPES: { readonly [name: string]: Shape } = {
ToolCallParams: {
strict: false,
required: { handle: 'uint', call_id: 'string', input: 'json', deadline_ms: 'uint' },
optional: { workspace: 'string', ticket: 'string', checkpoint_ticket: 'string', session_id: 'string', agent_id: 'string', origin_turn_id: 'string' },
optional: { workspace: 'string', ticket: 'string', checkpoint_ticket: 'string', question_ticket: 'string', session_id: 'string', agent_id: 'string', origin_turn_id: 'string' },
},
UnregisterParams: {
strict: true,
required: { owner: { ref: 'OwnerRef' }, handle: 'uint' },
optional: {},
},
UserInputIntent: {
strict: false,
required: { approve: 'string', kind: { enum: ['plan-review'] } },
optional: { call_id: 'string' },
},
UserInputOption: {
strict: false,
required: { label: 'string' },
optional: { description: 'string' },
},
UserInputQuestion: {
strict: false,
required: { id: 'string', question: 'string' },
optional: { header: 'string', detail: 'string', intent: { ref: 'UserInputIntent' }, options: { items: { ref: 'UserInputOption' } }, allow_free_text: 'boolean', multi_select: 'boolean' },
},
UserQuestionParams: {
strict: true,
required: { owner: { ref: 'OwnerRef' }, ticket: 'string', questions: { items: { ref: 'UserInputQuestion' } } },
optional: {},
},
}
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
@@ -299,7 +320,10 @@ export interface CommandRunParams {
origin_turn_id?: string
}
export type ContentBlockWire = { type: 'text'; text: string }
export interface ContentBlockWire {
text: string
type: 'text'
}
export interface CoreCallParams {
owner: OwnerRef
@@ -536,6 +560,7 @@ export interface ToolCallParams {
workspace?: string
ticket?: string
checkpoint_ticket?: string
question_ticket?: string
session_id?: string
agent_id?: string
origin_turn_id?: string
@@ -551,3 +576,44 @@ export interface UnregisterParams {
owner: OwnerRef
handle: number
}
export interface UserInputAnswer {
kind?: UserInputAnswerKind
id: string
label: string
value: string
}
export type UserInputAnswerKind = 'choice' | 'custom' | 'skipped'
export interface UserInputIntent {
approve: string
call_id?: string
kind: 'plan-review'
}
export interface UserInputOption {
label: string
description?: string
}
export interface UserInputQuestion {
header?: string
id: string
question: string
detail?: string
intent?: UserInputIntent
options?: UserInputOption[]
allow_free_text?: boolean
multi_select?: boolean
}
export interface UserInputResponse {
answers: UserInputAnswer[]
}
export interface UserQuestionParams {
owner: OwnerRef
ticket: string
questions: UserInputQuestion[]
}
+125
View File
@@ -31,6 +31,7 @@ import {
type ToolResultWire,
} from './protocol.ts'
import { RpcError, type RpcPeer } from './rpc.ts'
import { UserQuestionError } from './dsh/dsh-message-compat.js'
import { explainImportError } from './dsh/resolve-hooks.ts'
import compatibility from './dsh/compatibility.json' with { type: 'json' }
import { assertSupportedJsonSchema, validateJsonSchemaValue, ToolOutputError } from './dsh/dsh-tools-compat.js'
@@ -135,6 +136,9 @@ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promis
interface ToolInvocation {
owner: OwnerRecord
session: object | undefined
agent: object | undefined
questionTicket: string | undefined
delegated: boolean
ticket: string | undefined
signal: AbortSignal
active: boolean
@@ -306,6 +310,121 @@ export class HostRoot {
return result
}
}
class UserQuestionsShim extends Service {
constructor(ctx: any) { super(ctx, 'userQuestions') }
async ask(request: { agent?: unknown; questions: unknown; signal?: AbortSignal; wait?: unknown }) {
const invocation = host.toolInvocations.getStore()
const owner: OwnerRecord | undefined = (this.ctx as any)[OWNER]
const data = (value: unknown, allowed: string[]): Record<string, unknown> => {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, 'expected a plain question object')
const result: Record<string, unknown> = Object.create(null)
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key)!
if (typeof key !== 'string' || !allowed.includes(key) || !descriptor.enumerable || !('value' in descriptor)) throw new RpcError(ErrorCode.InvalidParams, 'unsupported question property')
if (descriptor.value !== undefined) result[key] = descriptor.value
}
return result
}
const list = (value: unknown, maximum: number): unknown[] => {
if (!Array.isArray(value) || value.length > maximum || Reflect.ownKeys(value).length !== value.length + 1) throw new RpcError(ErrorCode.InvalidParams, 'invalid question array')
return Array.from({ length: value.length }, (_, index) => {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) throw new RpcError(ErrorCode.InvalidParams, 'invalid question array item')
return descriptor.value
})
}
const input = data(request, ['agent', 'questions', 'signal', 'wait'])
if (input.wait !== undefined) throw new RpcError(ErrorCode.NotAvailable, 'timed or continued questions are not yet supported')
request = input as typeof request
if (request.signal !== undefined && !(request.signal instanceof AbortSignal)) {
throw new RpcError(ErrorCode.InvalidParams, 'question signal must be an AbortSignal')
}
if (request.signal?.aborted) throw new UserQuestionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
const items = list(request.questions, 10)
if (items.length === 0) throw new UserQuestionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
if (request.agent === undefined) throw new RpcError(ErrorCode.NotAvailable, 'userQuestions.ask requires this live tool invocation’s agent')
if (!invocation || !invocation.active || invocation.signal.aborted || invocation.agent === undefined || request.agent !== invocation.agent) {
throw new UserQuestionError('human interaction requires the exact live calling agent when an agent is supplied', 'CALLER_NOT_LIVE')
}
if (invocation.owner !== owner || owner?.state !== 'active' || owner.disposing) throw new RpcError(ErrorCode.NotAvailable, 'userQuestions.ask service owner is no longer current')
if (invocation.delegated) throw new UserQuestionError("human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result", 'DELEGATED_CALLER')
const live = () => invocation.active && !invocation.signal.aborted
&& invocation.owner === owner && owner?.state === 'active' && !owner.disposing
&& invocation.questionTicket !== undefined && request.agent === invocation.agent
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'userQuestions.ask requires this live tool invocation’s question ticket')
const signal = request.signal === undefined ? invocation.signal : AbortSignal.any([invocation.signal, request.signal])
const questions = items.map(value => {
const question = data(value, ['id', 'question', 'header', 'detail', 'options', 'multiSelect', 'intent'])
const options = list(question.options === undefined ? [] : question.options, 1000)
const intent = question.intent === undefined ? undefined : data(question.intent, ['kind', 'approve', 'callId'])
if (intent !== undefined && intent.kind !== 'plan-review') throw new RpcError(ErrorCode.InvalidParams, 'unsupported question intent')
return {
id: question.id, question: question.question, header: question.header === undefined ? '' : question.header,
...(question.detail === undefined ? {} : { detail: question.detail }),
...(intent === undefined ? {} : { intent: { kind: 'plan-review', approve: intent.approve,
...(intent.callId === undefined ? {} : { call_id: intent.callId }) } }),
options: options.map(option => {
const item = data(option, ['label', 'description'])
return { label: item.label, description: item.description === undefined ? '' : item.description }
}),
allow_free_text: true, multi_select: question.multiSelect === undefined ? false : question.multiSelect,
}
})
if (!isJson(questions)) throw new RpcError(ErrorCode.InvalidParams, 'question data must be lossless JSON')
for (const question of questions) {
if (question.intent === undefined) continue
if (!question.options.some(option => option.label === question.intent!.approve)) throw new UserQuestionError('question ' + question.id + ' declares an intent whose approve label names none of its options', 'BAD_INTENT')
if (question.detail === undefined) throw new UserQuestionError('question ' + question.id + ' declares an intent without the detail it reviews', 'BAD_INTENT')
}
if (Buffer.byteLength(JSON.stringify(questions)) > 1024 * 1024) throw new RpcError(ErrorCode.InvalidParams, 'question payload exceeds 1 MiB')
const call = invocation!
const run = async () => {
if (!live() || signal.aborted) throw new RpcError(ErrorCode.Cancelled, 'question invocation ended')
const result = await host.rpc.request<unknown>('user-questions/ask', { owner: call.owner.ref, ticket: call.questionTicket, questions }, signal).catch(error => {
if (error instanceof RpcError && isJson(error.data)
&& error.data !== null && typeof error.data === 'object' && !Array.isArray(error.data)) {
const code = error.data.user_question_error
if ((error.code === ErrorCode.NotAvailable && code === 'NO_PROVIDER')
|| (error.code === ErrorCode.Cancelled && code === 'ASK_CANCELLED')) {
throw new UserQuestionError(error.message, code, { cause: error })
}
}
if (signal.aborted) throw new UserQuestionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED', { cause: error })
throw error
})
if (!live() || signal.aborted) throw new RpcError(ErrorCode.Cancelled, 'question invocation ended')
if (!isJson(result) || result === null || typeof result !== 'object' || Array.isArray(result) || !Array.isArray(result.answers)) {
throw new RpcError(ErrorCode.Internal, 'invalid question answer receipt')
}
const answers = questions.map(question => ({ id: question.id, selected: [] as string[], custom: undefined as string | undefined, skipped: false, rows: 0 }))
for (const row of result.answers) {
if (row === null || typeof row !== 'object' || Array.isArray(row) || typeof row.label !== 'string' || typeof row.value !== 'string') throw new RpcError(ErrorCode.Internal, 'invalid question answer row')
const answer = answers.find(answer => answer.id === row.id)
const question = questions.find(question => question.id === row.id)
if (!answer || !question || answer.skipped) throw new RpcError(ErrorCode.Internal, 'question answer identity mismatch')
if (row.kind === 'choice' && row.label === row.value && question.options.some(option => option.label === row.value) && !answer.selected.includes(row.value)) answer.selected.push(row.value)
else if (row.kind === 'custom' && row.label === '' && row.value.trim() !== '' && answer.custom === undefined) answer.custom = row.value
else if (row.kind === 'skipped' && row.label === '' && row.value === '' && answer.rows === 0) answer.skipped = true
else throw new RpcError(ErrorCode.Internal, 'ambiguous question answer receipt')
answer.rows += 1
if (!question.multi_select && answer.selected.length + (answer.custom === undefined ? 0 : 1) > 1) throw new RpcError(ErrorCode.Internal, 'invalid single-select answer')
}
if (answers.some(answer => answer.rows === 0)) throw new RpcError(ErrorCode.Internal, 'missing question answer')
return { answers: answers.map(({ id, selected, custom }) => ({ id, selected, ...(custom === undefined ? {} : { custom }) })) }
}
const result = call.pending.then(run)
call.pending = result.then(() => undefined, () => undefined)
let cancel: () => void = () => {}
const cancelled = new Promise<never>((_, reject) => {
cancel = () => reject(new UserQuestionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
signal.addEventListener('abort', cancel, { once: true })
if (signal.aborted) cancel()
})
return Promise.race([result, cancelled]).finally(() => signal.removeEventListener('abort', cancel))
}
}
Object.freeze(UserQuestionsShim.prototype)
Object.freeze(SessionsShim.prototype)
Object.freeze(StorageShim.prototype)
shimClasses.set('tools', ToolsShim)
@@ -313,6 +432,7 @@ export class HostRoot {
shimClasses.set('prompt', PromptShim)
shimClasses.set('storage', StorageShim)
shimClasses.set('sessions', SessionsShim)
shimClasses.set('userQuestions', UserQuestionsShim)
shimClasses.set('skills', SkillsShim)
shimClasses.set('avatars', AvatarsShim)
shimClasses.set('mcp',McpShim)
@@ -322,6 +442,7 @@ export class HostRoot {
root.plugin(PromptShim)
root.plugin(StorageShim)
root.plugin(SessionsShim)
root.plugin(UserQuestionsShim)
root.plugin(SkillsShim)
root.plugin(AvatarsShim)
root.plugin(McpShim)
@@ -592,6 +713,9 @@ export class HostRoot {
const invocation: ToolInvocation = {
owner: local.owner,
session: agent?.session,
agent,
questionTicket: identity?.question_ticket,
delegated: identity?.agent_id !== undefined,
ticket: identity?.checkpoint_ticket,
signal: AbortSignal.any([signal, closed.signal]),
active: true,
@@ -652,6 +776,7 @@ export class HostRoot {
/** The read-only context a call carries beyond its input; a field the core did not send is absent. */
interface InvocationIdentityWire {
question_ticket?: string
checkpoint_ticket?: string
session_id?: string
agent_id?: string
+5 -3
View File
@@ -51,7 +51,7 @@ export const LIMITS = { max_frame: 32 * 1024 * 1024, max_inflight: 256, dispose_
* cancelled is not answered. Requests are recorded in `coreCalls`, the host's
* `$/cancel` ids in `cancels`.
*/
export async function startHost({ admit, env, ownGroup = false, tier = 'plugin', coreCall, checkpoint, compiledHost, cwd } = {}) {
export async function startHost({ admit, env, ownGroup = false, tier = 'plugin', coreCall, checkpoint, userQuestions, compiledHost, cwd } = {}) {
const started = performance.now()
// `ownGroup` spawns the host as a process-group leader and tells it so, as
// the Rust core does on Unix.
@@ -72,6 +72,7 @@ export async function startHost({ admit, env, ownGroup = false, tier = 'plugin',
faulted: [],
coreCalls: [],
checkpoints: [],
questions: [],
cancels: [],
registry: [],
hello: null,
@@ -144,9 +145,10 @@ export async function startHost({ admit, env, ownGroup = false, tier = 'plugin',
host.cancels.push(message.params.id)
break
case 'session/checkpoint':
case 'user-questions/ask':
case 'core/call': {
const handler = message.method === 'session/checkpoint' ? checkpoint : coreCall
const records = message.method === 'session/checkpoint' ? host.checkpoints : host.coreCalls
const handler = message.method === 'session/checkpoint' ? checkpoint : message.method === 'user-questions/ask' ? userQuestions : coreCall
const records = message.method === 'session/checkpoint' ? host.checkpoints : message.method === 'user-questions/ask' ? host.questions : host.coreCalls
records.push({ id: message.id, ...message.params })
const answer = handler ? handler(message.params, message.id) : { error: { code: -32002, message: 'no handler for ' + message.method } }
const id = message.id
+212 -9
View File
@@ -174,11 +174,11 @@ test('injecting a service the host does not provide fails with its name', async
const { result } = await activate(host, 'needs-secrets', plugin.entry)
assert.equal(result.status, 'failed')
assert.match(result.diagnostic, /requires `secrets`/)
const provided = tempPlugin("export const inject = ['shellHooks', 'sessions']; export function apply(ctx) { if (!ctx.shellHooks || typeof ctx.sessions.flush !== 'function') throw new Error('missing provided service') }\n")
const provided = tempPlugin("export const inject = ['shellHooks', 'sessions', 'userQuestions']; export function apply(ctx) { if (!ctx.shellHooks || typeof ctx.sessions.flush !== 'function' || typeof ctx.userQuestions.ask !== 'function') throw new Error('missing provided service') }\n")
t.after(() => provided.cleanup())
const available = await activate(host, 'needs-shell-hooks', provided.entry)
assert.equal(available.result.status, 'ok', available.result.diagnostic)
for (const service of ['llm', 'systemPrompt', 'agents', 'subagents', 'userQuestions']) {
for (const service of ['llm', 'systemPrompt', 'agents', 'subagents']) {
const pure = tempPlugin(`import { createUserMessage } from '@deepseek-ai/dsh-llm'
export const inject = [${JSON.stringify(service)}]
export function apply() { createUserMessage({ content: [], source: { kind: 'fixture' } }) }
@@ -197,7 +197,7 @@ test('an unsupported DSH peer fails the import loudly', async (t) => {
const { result } = await activate(host, 'needs-agent', plugin.entry)
assert.equal(result.status, 'failed')
assert.match(result.diagnostic, /requires `@deepseek-ai\/dsh-agent`/)
for (const specifier of ['@deepseek-ai/dsh-llm/typert', '@deepseek-ai/dsh-llm/message', '@deepseek-ai/dsh-session/types', '@deepseek-ai/dsh-system-prompt/src/index.js']) {
for (const specifier of ['@deepseek-ai/dsh-llm/typert', '@deepseek-ai/dsh-llm/message', '@deepseek-ai/dsh-session/types', '@deepseek-ai/dsh-system-prompt/src/index.js', '@deepseek-ai/dsh-user-questions/typert', '@deepseek-ai/dsh-user-questions/remote']) {
const subpath = tempPlugin(`import '${specifier}'\nexport function apply() {}\n`)
t.after(subpath.cleanup)
const packageName = specifier.split('/').slice(0, 2).join('/')
@@ -771,10 +771,24 @@ import assert from 'node:assert/strict'
import * as llm from '@deepseek-ai/dsh-llm'
import * as session from '@deepseek-ai/dsh-session'
import * as prompt from '@deepseek-ai/dsh-system-prompt'
import * as questions from '@deepseek-ai/dsh-user-questions'
export const name = 'dsh-style'
export const inject = ['commands']
export function apply(ctx) {
assert.deepEqual(Object.keys(llm), ['createUserMessage'])
assert.deepEqual(Object.keys(llm), ['HarnessError', 'createUserMessage'])
assert.deepEqual(Object.keys(questions), ['UserQuestionError'])
const cause = new Error('original cause')
const failure = new questions.UserQuestionError('human cancelled', 'ASK_CANCELLED', { cause })
assert.ok(failure instanceof questions.UserQuestionError)
assert.ok(failure instanceof llm.HarnessError)
assert.ok(failure instanceof Error)
assert.equal(failure.name, 'UserQuestionError')
assert.equal(failure.message, 'human cancelled')
assert.equal(failure.code, 'ASK_CANCELLED')
assert.equal(failure.cause, cause)
const baseFailure = new llm.HarnessError('base', 'FIXTURE', { cause })
assert.equal(baseFailure.name, 'HarnessError')
assert.equal(baseFailure.cause, cause)
assert.deepEqual(Object.keys(session), ['SessionId'])
assert.deepEqual(Object.keys(prompt), ['PERSONA_PREFIX_SECTION'])
assert.equal(prompt.PERSONA_PREFIX_SECTION, 'deployment:persona-prefix')
@@ -821,7 +835,7 @@ export function apply(ctx) {
}
`)
t.after(plugin.cleanup)
for (const name of ['dsh-llm', 'dsh-session', 'dsh-system-prompt']) {
for (const name of ['dsh-llm', 'dsh-session', 'dsh-system-prompt', 'dsh-user-questions']) {
const root = join(plugin.dir, 'node_modules', '@deepseek-ai', name)
mkdirSync(root, { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: `@deepseek-ai/${name}`, type: 'module', main: 'index.js' }))
@@ -1155,15 +1169,23 @@ test('several core calls can be in flight at once, each answered to its own requ
test('storage survives owner reload, refuses disposed owner access, and call identity is per invocation', async (t) => {
let checkpointResult = true
const host = await startHost({ checkpoint: () => typeof checkpointResult === 'function' ? checkpointResult() : checkpointResult })
let questionResult = { answers: [{ id: 'review', kind: 'choice', label: 'Other', value: 'Other' }] }
const host = await startHost({
checkpoint: () => typeof checkpointResult === 'function' ? checkpointResult() : checkpointResult,
userQuestions: (params, id) => typeof questionResult === 'function' ? questionResult(params, id) : questionResult,
})
t.after(() => host.stop())
const plugin = tempPlugin(`
export const inject = ['tools', 'commands', 'storage', 'sessions']
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
export const inject = ['tools', 'commands', 'storage', 'sessions', 'userQuestions']
let savedStorage
let savedAgent
let savedSessions
let savedQuestions
let queuedQuestionOutcome
export async function apply(ctx, config) {
if (config.capture) { savedStorage = ctx.storage; savedSessions = ctx.sessions }
if (config.capture) { savedStorage = ctx.storage; savedSessions = ctx.sessions; savedQuestions = ctx.userQuestions }
const identity = (exec) => ({
frozen: Object.isFrozen(exec),
sessionId: exec.sessionId ?? null,
@@ -1179,6 +1201,44 @@ export async function apply(ctx, config) {
})
ctx.tools.register({ name: config.tool, description: 'Read owner-local state.', parameters: { type: 'object' },
async execute(input, exec) {
if (input.questionProbe) return queuedQuestionOutcome ?? null
if (input.ask) {
try {
const service = input.ask === 'foreign-owner' ? savedQuestions : ctx.userQuestions
const agent = input.ask === 'forged' ? { id: exec.agent?.id, session: exec.agent?.session } : input.ask === 'retained' ? savedAgent : exec.agent
const request = { agent, questions: input.questions ?? [{ id: 'review', question: 'Review?', options: [{ label: 'Other' }] }] }
if (input.optionalUndefined) {
request.signal = undefined
request.wait = undefined
request.questions = request.questions.map(question => ({
...question, header: question.header, detail: question.detail, multiSelect: question.multiSelect,
intent: question.intent === undefined ? undefined : { ...question.intent, callId: undefined },
options: question.options?.map(option => ({ ...option, description: undefined })),
}))
}
if (input.shape === 'accessor') Object.defineProperty(request.questions[0], 'detail', { enumerable: true, get() { throw new Error('question getter executed') } })
if (input.shape === 'symbol') request.questions[0][Symbol('hidden')] = 'value'
if (input.shape === 'nonplain') Object.setPrototypeOf(request.questions[0], { hidden: true })
if (input.shape === 'sparse') request.questions = new Array(1)
if (input.shape === 'extra-array-key') request.questions.extra = true
if (input.shape === 'wait') request.wait = 1
if (input.shape === 'signal') request.signal = {}
if (input.shape === 'aborted') request.signal = AbortSignal.abort()
if (input.ask === 'queued-abort') {
const checkpoint = ctx.sessions.flush(exec.agent.session)
const controller = new AbortController()
const queued = service.ask({ ...request, signal: controller.signal })
controller.abort()
queuedQuestionOutcome = await queued.then(value => ({ answered: value }), error => ({ questionError: error.code }))
await checkpoint
return queuedQuestionOutcome
}
return { asked: input.many ? await Promise.all([service.ask(request), service.ask(request)]) : await service.ask(request) }
} catch (error) {
if (typeof error.code === 'string' && (!(error instanceof UserQuestionError) || !(error instanceof HarnessError) || error.name !== 'UserQuestionError')) throw new Error('lost question error singleton')
return { questionError: error.code ?? 'untyped' }
}
}
if (input.flush) {
try {
const target = input.flush === 'forged' ? { header: { cwd: exec.workspace } } : input.flush === 'retained' ? savedAgent?.session : exec.agent?.session
@@ -1260,12 +1320,155 @@ export async function apply(ctx, config) {
releaseCheckpoint(true)
checkpointResult = true
assert.deepEqual(await host.call('host/ping', {}), {})
const questionIdentity = { ...rootIdentity, question_ticket: 'exact-question-ticket' }
for (const [input, extra, code] of [
[{ ask: 'current' }, rootIdentity, ErrorCode.NotAvailable],
[{ ask: 'current' }, checkpointIdentity, ErrorCode.NotAvailable],
[{ ask: 'current' }, identity, 'DELEGATED_CALLER'],
[{ ask: 'current' }, { question_ticket: 'missing-context-ticket' }, ErrorCode.NotAvailable],
[{ ask: 'forged' }, questionIdentity, 'CALLER_NOT_LIVE'],
[{ ask: 'retained' }, questionIdentity, 'CALLER_NOT_LIVE'],
]) assert.deepEqual((await tool('first_state', input, extra)).structured, { questionError: code })
assert.equal(host.questions.length, 0)
for (const shape of ['accessor', 'symbol', 'nonplain', 'sparse', 'extra-array-key', 'signal']) {
assert.deepEqual((await tool('first_state', { ask: 'current', shape }, questionIdentity)).structured, { questionError: ErrorCode.InvalidParams }, shape)
}
assert.deepEqual((await tool('first_state', { ask: 'current', questions: [] }, questionIdentity)).structured, { questionError: 'EMPTY_QUESTIONS' })
assert.deepEqual((await tool('first_state', { ask: 'current', shape: 'wait' }, questionIdentity)).structured, { questionError: ErrorCode.NotAvailable })
assert.deepEqual((await tool('first_state', { ask: 'current', shape: 'aborted' }, questionIdentity)).structured, { questionError: 'ASK_ABORTED' })
assert.equal(host.questions.length, 0)
for (const question of [
{ id: 'plan', question: 'Proceed?', detail: 'Plan', options: [{ label: 'yes' }], intent: { kind: 'plan-review', approve: 'missing' } },
{ id: 'plan', question: 'Proceed?', options: [{ label: 'yes' }], intent: { kind: 'plan-review', approve: 'yes' } },
]) assert.deepEqual((await tool('first_state', { ask: 'current', questions: [question] }, questionIdentity)).structured, { questionError: 'BAD_INTENT' })
assert.equal(host.questions.length, 0)
const selectedOther = { answers: [{ id: 'review', selected: ['Other'] }] }
assert.deepEqual((await tool('first_state', { ask: 'current', optionalUndefined: true }, questionIdentity)).structured, { asked: selectedOther })
assert.deepEqual(host.questions[0], {
id: host.questions[0].id, owner: first.ref, ticket: 'exact-question-ticket',
questions: [{ id: 'review', question: 'Review?', header: '', options: [{ label: 'Other', description: '' }], allow_free_text: true, multi_select: false }],
})
questionResult = { answers: [{ id: 'free', kind: 'custom', label: '', value: 'Other, literally typed' }] }
for (const options of [undefined, []]) {
assert.deepEqual((await tool('first_state', { ask: 'current', optionalUndefined: true, questions: [{ id: 'free', question: 'Explain', ...(options === undefined ? {} : { options }) }] }, questionIdentity)).structured,
{ asked: { answers: [{ id: 'free', selected: [], custom: 'Other, literally typed' }] } })
assert.deepEqual(host.questions.at(-1).questions[0].options, [])
}
const labels = Array.from({ length: 100 }, (_, index) => index === 0 ? 'Other' : index === 99 ? 'one,two' : 'choice-' + index)
questionResult = { answers: [{ id: 'many', kind: 'choice', label: 'one,two', value: 'one,two' }] }
assert.deepEqual((await tool('first_state', { ask: 'current', optionalUndefined: true, questions: [{ id: 'many', question: 'Choose', options: labels.map(label => ({ label })) }] }, questionIdentity)).structured,
{ asked: { answers: [{ id: 'many', selected: ['one,two'] }] } })
assert.deepEqual(host.questions.at(-1).questions[0].options, labels.map(label => ({ label, description: '' })))
questionResult = { answers: [{ id: 'plan', kind: 'choice', label: 'Approve', value: 'Approve' }] }
assert.deepEqual((await tool('first_state', { ask: 'current', optionalUndefined: true, questions: [{ id: 'plan', question: 'Proceed?', detail: 'Reviewed plan', options: [{ label: 'Approve' }], intent: { kind: 'plan-review', approve: 'Approve' } }] }, questionIdentity)).structured,
{ asked: { answers: [{ id: 'plan', selected: ['Approve'] }] } })
assert.deepEqual(host.questions.at(-1).questions[0].intent, { kind: 'plan-review', approve: 'Approve' })
const mixedQuestions = [
{ id: 'multi', question: 'Choose several', multiSelect: true, detail: 'Exact labels', options: [{ label: 'Other' }, { label: 'a,b' }] },
{ id: 'skip', question: 'Optional', options: [] },
]
questionResult = { answers: [
{ id: 'skip', kind: 'skipped', label: '', value: '' },
{ id: 'multi', kind: 'choice', label: 'Other', value: 'Other' },
{ id: 'multi', kind: 'choice', label: 'a,b', value: 'a,b' },
{ id: 'multi', kind: 'custom', label: '', value: 'my, own text' },
] }
assert.deepEqual((await tool('first_state', { ask: 'current', questions: mixedQuestions }, questionIdentity)).structured,
{ asked: { answers: [{ id: 'multi', selected: ['Other', 'a,b'], custom: 'my, own text' }, { id: 'skip', selected: [] }] } })
assert.equal(host.questions.at(-1).questions[0].detail, 'Exact labels')
assert.equal(host.questions.at(-1).questions[0].multi_select, true)
questionResult = { answers: [{ id: 'multi', kind: 'choice', label: 'Other', value: 'Other' }] }
assert.deepEqual((await tool('first_state', { ask: 'current', questions: mixedQuestions }, questionIdentity)).structured, { questionError: ErrorCode.Internal })
const choice = { id: 'review', kind: 'choice', label: 'Other', value: 'Other' }
for (const receipt of [
null, {}, { answers: null }, { answers: [] }, { answers: [null] },
{ answers: [{ ...choice, id: 'foreign' }] }, { answers: [{ ...choice, kind: 'unknown' }] },
{ answers: [{ id: 'review', label: 'Other', value: 'Other' }] },
{ answers: [{ ...choice, value: 'Other, something' }] }, { answers: [choice, choice] },
{ answers: [{ ...choice, label: 'not offered', value: 'not offered' }] },
{ answers: [{ id: 'review', kind: 'custom', label: 'Other', value: 'custom' }] },
{ answers: [{ id: 'review', kind: 'custom', label: '', value: ' ' }] },
{ answers: [choice, { id: 'review', kind: 'custom', label: '', value: 'custom' }] },
{ answers: [{ id: 'review', kind: 'skipped', label: '', value: '' }, choice] },
{ answers: [{ id: 'review', kind: 'skipped', label: '', value: 'not empty' }] },
]) {
questionResult = receipt
assert.deepEqual((await tool('first_state', { ask: 'current' }, questionIdentity)).structured, { questionError: ErrorCode.Internal }, JSON.stringify(receipt))
}
questionResult = { error: { code: ErrorCode.Refused, message: 'questions refused' } }
assert.deepEqual((await tool('first_state', { ask: 'current' }, questionIdentity)).structured, { questionError: ErrorCode.Refused })
for (const [code, data, expected] of [
[ErrorCode.NotAvailable, { user_question_error: 'NO_PROVIDER' }, 'NO_PROVIDER'],
[ErrorCode.NotAvailable, undefined, ErrorCode.NotAvailable],
[ErrorCode.NotAvailable, { user_question_error: 'unknown' }, ErrorCode.NotAvailable],
[ErrorCode.Refused, { user_question_error: 'NO_PROVIDER' }, ErrorCode.Refused],
[ErrorCode.Cancelled, undefined, ErrorCode.Cancelled],
[ErrorCode.Cancelled, { user_question_error: 'ASK_CANCELLED' }, 'ASK_CANCELLED'],
[ErrorCode.NotAvailable, { user_question_error: 'ASK_CANCELLED' }, ErrorCode.NotAvailable],
[ErrorCode.Cancelled, { user_question_error: 'NO_PROVIDER' }, ErrorCode.Cancelled],
]) {
questionResult = { error: { code, message: 'answerer result', ...(data === undefined ? {} : { data }) } }
assert.deepEqual((await tool('first_state', { ask: 'current' }, questionIdentity)).structured, { questionError: expected })
}
inFlight = 0
peak = 0
questionResult = async () => {
inFlight++
peak = Math.max(peak, inFlight)
await new Promise(resolve => setTimeout(resolve, 10))
inFlight--
return { answers: [choice] }
}
assert.deepEqual((await tool('first_state', { ask: 'current', many: true }, questionIdentity)).structured, { asked: [selectedOther, selectedOther] })
assert.equal(peak, 1, 'one invocation serializes its questions')
const beforeQueued = host.questions.length
checkpointResult = new Promise(resolve => { releaseCheckpoint = resolve })
const queuedQuestion = host.request('tool/call', {
handle: handle('first_state'), call_id: 'queued-question', input: { ask: 'queued-abort' }, deadline_ms: 5000,
...questionIdentity, checkpoint_ticket: 'question-queue-checkpoint-ticket',
})
await host.waitFor(message => message.method === 'session/checkpoint' && message.params.ticket === 'question-queue-checkpoint-ticket')
assert.deepEqual((await tool('first_state', { questionProbe: true })).structured, { questionError: 'ASK_ABORTED' })
assert.equal(host.questions.length, beforeQueued, 'a cancelled queued question never reaches core')
releaseCheckpoint(true)
assert.deepEqual((await queuedQuestion.promise).structured, { questionError: 'ASK_ABORTED' })
checkpointResult = true
let releaseQuestion
questionResult = new Promise(resolve => { releaseQuestion = resolve })
const pendingQuestion = host.request('tool/call', {
handle: handle('first_state'), call_id: 'cancel-question', input: { ask: 'current' }, deadline_ms: 5000,
...rootIdentity, question_ticket: 'cancel-question-ticket',
})
await host.waitFor(message => message.method === 'user-questions/ask' && message.params.ticket === 'cancel-question-ticket')
const questionRequest = host.questions.find(request => request.ticket === 'cancel-question-ticket')
const cancelledQuestion = host.waitFor(message => message.method === '$/cancel' && message.params.id === questionRequest.id)
host.cancel(pendingQuestion.id)
await assert.rejects(pendingQuestion.promise, error => error.code === ErrorCode.Cancelled)
await cancelledQuestion
host.send({ jsonrpc: '2.0', id: questionRequest.id, result: { answers: [choice] } })
releaseQuestion({ answers: [choice] })
questionResult = { answers: [choice] }
assert.deepEqual(await host.call('host/ping', {}), {})
assert.deepEqual((await tool('first_state', { ask: 'current' }, questionIdentity)).structured, { asked: selectedOther })
assert.equal(host.coreCalls.length, 0, 'read-only projections do not grant core calls')
const command = await host.call('command/run', { handle: handle('first-state'), command_id: 'state-command', raw_input: '', deadline_ms: 5000, ...identity })
assert.deepEqual(JSON.parse(command.text), { frozen: true, sessionId: 'session-one', agentId: 'agent-one', originTurnId: 'turn-one' })
await host.call('ext/deactivate', { owner: first.ref })
const other = await activate(host, 'other-state-owner', plugin.entry, { config: { tool: 'other_state', command: 'other-state' }, data_dir: otherDir })
assert.equal(other.result.status, 'ok')
const beforeForeign = host.questions.length
assert.deepEqual((await tool('other_state', { ask: 'foreign-owner' }, questionIdentity)).structured, { questionError: ErrorCode.NotAvailable })
assert.equal(host.questions.length, beforeForeign)
questionResult = new Promise(resolve => { releaseQuestion = resolve })
const disposedQuestion = host.request('tool/call', {
handle: handle('first_state'), call_id: 'disposed-question', input: { ask: 'current' }, deadline_ms: 5000,
...rootIdentity, question_ticket: 'disposed-question-ticket',
})
await host.waitFor(message => message.method === 'user-questions/ask' && message.params.ticket === 'disposed-question-ticket')
await host.call('ext/deactivate', { owner: first.ref })
releaseQuestion({ answers: [choice] })
assert.deepEqual((await disposedQuestion.promise).structured, { questionError: ErrorCode.Cancelled })
questionResult = { answers: [choice] }
assert.deepEqual((await tool('other_state', { ask: 'foreign-owner' }, questionIdentity)).structured, { questionError: ErrorCode.NotAvailable })
assert.deepEqual((await tool('other_state', { flush: 'foreign-owner' }, checkpointIdentity)).structured, { flushError: ErrorCode.NotAvailable })
assert.deepEqual((await tool('other_state', { saved: true })).structured, { error: 'not_available' })
assert.equal((await tool('other_state')).structured.value, null, 'another owner directory has separate state')
+2
View File
@@ -539,6 +539,7 @@ pub struct EngineConfig {
/// or `Some(Duration::ZERO)` waits until the person answers or cancels.
/// A positive duration is one absolute deadline for that wait.
pub user_input_timeout: Option<Duration>,
pub user_input_responder: bool,
/// Per-turn step allowance while a goal is active (#5994). Hosts opt in
/// with their resolved `[goal] max_steps`; `None` keeps the ordinary
/// `max_steps` ceiling for goal turns too — which is what exec/worker
@@ -671,6 +672,7 @@ impl Default for EngineConfig {
tools_always_load: HashSet::new(),
user_input_limits: crate::tools::user_input::UserInputLimits::default(),
user_input_timeout: None,
user_input_responder: false,
goal_max_steps: None,
// Mirrors `Config::prefers_bwrap`: on unless explicitly opted out.
prefer_bwrap: true,
+173 -106
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use tokio_util::sync::CancellationToken;
use crate::approval_log::{ApprovalDecider, ApprovalOutcome, ApprovalReceipt};
use crate::core::events::Event;
use crate::core::events::{Event, UserInputClosedOutcome};
use crate::tools::spec::ToolError;
use crate::tools::user_input::{UserInputRequest, UserInputResponse};
@@ -442,128 +442,193 @@ impl Engine {
tool_id: &str,
request: UserInputRequest,
) -> Result<UserInputResponse, ToolError> {
// C02-19: a question that never reached a host has nobody to answer
// it. Fail now instead of waiting out the timeout — which by default
// is no timeout at all.
if self
.send_event(Event::UserInputRequired {
id: tool_id.to_string(),
request,
})
self.await_user_input_withdrawal(tool_id, request, CancellationToken::new(), None)
.await
.is_err()
{
return Err(ToolError::execution_failed(
"User input request could not reach its host, so nobody was asked. \
Continue without the answer or ask in your reply instead."
.to_string(),
));
}
// R1, as for tool approval: the per-turn wall-clock budget bounds the
// agent's own time, not how long a person takes to answer.
self.turn_wall_clock.begin_human_wait();
let response = self.await_user_input_decision(tool_id).await;
// The waiter has ended. Every queued reply is now stale, including a
// second answer racing the accepted one or the configured deadline.
while let Ok(decision) = self.rx_user_input.try_recv() {
decision.reject();
}
self.turn_wall_clock.end_human_wait();
response
}
async fn await_user_input_decision(
pub(super) async fn await_user_input_withdrawal(
&mut self,
tool_id: &str,
request: UserInputRequest,
withdrawal: CancellationToken,
invocation_clock: Option<&std::sync::Mutex<crate::tools::codemode::PauseClock>>,
) -> Result<UserInputResponse, ToolError> {
// #6003: `[tools] user_input_timeout_seconds`. Absent, or an explicit
// 0, waits until the person answers or cancels. A positive value is
// one absolute deadline for the whole wait: `select!` drops the
// losing branches whenever the heartbeat wins, so a relative
// `timeout(wait, ..)` rebuilt per iteration never fired.
if !self.config.user_input_responder
|| self.host_profile != super::EngineHostProfile::Normal
|| self.is_acp_turn()
{
return Err(ToolError::execution_failed(
"User input is unavailable for this host",
));
}
request.validate_transport()?;
let wait = self
.config
.user_input_timeout
.filter(|wait| !wait.is_zero());
let started = std::time::Instant::now();
let deadline = wait.map(|wait| tokio::time::Instant::now() + wait);
let tx_event = self.tx_event.clone();
if tx_event.max_capacity() < 2 {
return Err(ToolError::execution_failed(
"User input host cannot reserve its request and closure",
));
}
let mut permits = tokio::select! {
biased;
_ = self.cancel_token.cancelled() => return Err(ToolError::cancelled("Request cancelled while awaiting user input")),
_ = withdrawal.cancelled() => return Err(ToolError::cancelled("User input request withdrawn")),
_ = async { match deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, None => std::future::pending().await } } => return Err(ToolError::Timeout { seconds: wait.map(|wait| wait.as_secs()).unwrap_or(0) }),
permits = tx_event.reserve_many(2) => permits.map_err(|_| ToolError::execution_failed("User input request could not reach its host, so nobody was asked"))?,
};
if self.cancel_token.is_cancelled() || withdrawal.is_cancelled() {
return Err(ToolError::cancelled(
"User input request withdrawn before display",
));
}
if deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
return Err(ToolError::Timeout {
seconds: wait.map(|wait| wait.as_secs()).unwrap_or(0),
});
}
let required = permits.next().expect("reserved request capacity");
let closed = permits.next().expect("reserved closure capacity");
let cancel = self.cancel_token.clone();
let cancel_suffix = self.cancel_reason_suffix();
self.turn_wall_clock.begin_human_wait();
let mut guard = UserInputWait {
_invocation_pause: invocation_clock.map(crate::tools::codemode::PauseGuard::new),
id: tool_id.to_string(),
outcome: UserInputClosedOutcome::Cancelled,
closed: Some(closed),
clock: &mut self.turn_wall_clock,
decisions: &mut self.rx_user_input,
};
required.send(Event::UserInputRequired {
id: tool_id.to_string(),
request: request.clone(),
});
let response = guard
.await_decision(
&request,
&tx_event,
&cancel,
&withdrawal,
&cancel_suffix,
wait,
deadline,
)
.await;
guard.outcome = match &response {
Ok(_) => UserInputClosedOutcome::Answered,
Err(ToolError::Cancelled { .. }) => UserInputClosedOutcome::Cancelled,
Err(error)
if error
.metadata()
.and_then(|value| value.get("user_question_error"))
.and_then(serde_json::Value::as_str)
== Some("ASK_CANCELLED") =>
{
UserInputClosedOutcome::Cancelled
}
Err(ToolError::Timeout { .. }) => UserInputClosedOutcome::TimedOut,
Err(_) => UserInputClosedOutcome::Unavailable,
};
response
}
}
struct UserInputWait<'a> {
_invocation_pause: Option<crate::tools::codemode::PauseGuard<'a>>,
id: String,
outcome: UserInputClosedOutcome,
closed: Option<tokio::sync::mpsc::Permit<'a, Event>>,
clock: &'a mut codewhale_runtime::turn_budget::TurnWallClock,
decisions: &'a mut tokio::sync::mpsc::Receiver<UserInputDecision>,
}
impl Drop for UserInputWait<'_> {
fn drop(&mut self) {
while let Ok(decision) = self.decisions.try_recv() {
decision.reject();
}
self.clock.end_human_wait();
if let Some(closed) = self.closed.take() {
closed.send(Event::UserInputClosed {
id: self.id.clone(),
outcome: self.outcome,
});
}
}
}
impl UserInputWait<'_> {
async fn await_decision(
&mut self,
request: &UserInputRequest,
tx_event: &tokio::sync::mpsc::Sender<Event>,
cancel: &CancellationToken,
withdrawal: &CancellationToken,
cancel_suffix: &str,
wait: Option<Duration>,
deadline: Option<tokio::time::Instant>,
) -> Result<UserInputResponse, ToolError> {
let started = std::time::Instant::now();
let mut heartbeat = tokio::time::interval(WAIT_HEARTBEAT);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
heartbeat.tick().await;
let mut announced = false;
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => return Err(ToolError::cancelled(format!("Request cancelled while awaiting user input{cancel_suffix}"))),
_ = withdrawal.cancelled() => return Err(ToolError::cancelled("User input request withdrawn")),
_ = tx_event.closed() => return Err(ToolError::execution_failed("User input host disconnected")),
_ = async { match deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, None => std::future::pending().await } } => {
let seconds = wait.map(|wait| wait.as_secs()).unwrap_or(0);
let _ = tx_event.try_send(Event::Status { message: format!("User input timed out after {seconds}s") });
return Err(ToolError::Timeout { seconds });
}
decision = self.decisions.recv() => {
let Some(decision) = decision else {
return Err(ToolError::execution_failed("User input channel closed"));
};
if cancel.is_cancelled() || withdrawal.is_cancelled() {
decision.reject();
return Err(ToolError::cancelled("User input request withdrawn"));
}
if deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
decision.reject();
return Err(ToolError::Timeout { seconds: wait.map(|wait| wait.as_secs()).unwrap_or(0) });
}
match decision {
UserInputDecision::Submitted { id, response, accepted } if id == self.id => {
if request.validate_response(&response).is_err() {
let _ = accepted.send(false);
} else if accepted.send(true).is_ok() {
return Ok(response);
}
}
UserInputDecision::Cancelled { id, accepted } if id == self.id => {
if accepted.send(true).is_ok() {
return Err(if request.require_typed_answers {
ToolError::execution_failed_with_metadata(
"the user cancelled ask_user_question",
serde_json::json!({"user_question_error": "ASK_CANCELLED"}),
)
} else {
ToolError::cancelled("User input cancelled")
});
}
}
other => other.reject(),
}
}
_ = heartbeat.tick() => {
// An indefinite wait (`user_input_timeout_seconds = 0`) is
// the case that needs this most: nothing else bounds it.
let waited = started.elapsed();
let message = wait_announcement("user input", tool_id, waited);
tracing::warn!(tool_id, waited_secs = waited.as_secs(), "{message}");
let message = wait_announcement("user input", &self.id, waited);
tracing::warn!(tool_id = self.id, waited_secs = waited.as_secs(), "{message}");
if !announced {
announced = true;
let _ = self.send_event(Event::Status { message }).await;
}
}
_ = self.cancel_token.cancelled() => {
let suffix = self.cancel_reason_suffix();
return Err(ToolError::cancelled(
format!("Request cancelled while awaiting user input{suffix}"),
));
}
result = async {
match deadline {
None => Ok(self.rx_user_input.recv().await),
Some(deadline) => {
tokio::time::timeout_at(deadline, self.rx_user_input.recv()).await
}
}
} => {
match result {
Ok(Some(decision)) => {
// A ready mailbox can win `select!` when the
// cancellation/deadline is also ready. Verify the
// wait still belongs to this request before ack.
if self.cancel_token.is_cancelled() {
decision.reject();
return Err(ToolError::cancelled(
format!("Request cancelled while awaiting user input{}", self.cancel_reason_suffix()),
));
}
if deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
decision.reject();
return Err(ToolError::Timeout { seconds: wait.map(|wait| wait.as_secs()).unwrap_or(0) });
}
match decision {
UserInputDecision::Submitted { id, response, accepted } if id == tool_id => {
// An abandoned/timed-out sender must not
// commit an answer after it saw failure.
if accepted.send(true).is_ok() {
return Ok(response);
}
}
UserInputDecision::Cancelled { id, accepted } if id == tool_id => {
if accepted.send(true).is_ok() {
return Err(ToolError::cancelled(
"User input cancelled".to_string(),
));
}
}
other => other.reject(),
}
}
Ok(None) => {
return Err(ToolError::execution_failed(
"User input channel closed".to_string(),
));
}
Err(_) => {
let seconds = wait.map(|wait| wait.as_secs()).unwrap_or(0);
let _ = self.send_event(Event::Status {
message: format!("User input timed out after {seconds}s"),
})
.await;
return Err(ToolError::Timeout { seconds });
}
announced = tx_event.try_send(Event::Status { message }).is_ok();
}
}
}
@@ -815,14 +880,16 @@ mod tests {
let (mut engine, _handle) = Engine::new(
EngineConfig {
user_input_timeout: Some(Duration::from_millis(200)),
user_input_responder: true,
terminal_chrome_enabled: false,
..EngineConfig::default()
},
&Config::default(),
);
let request = UserInputRequest {
questions: Vec::new(),
};
let request: UserInputRequest = serde_json::from_value(serde_json::json!({
"questions": [{"id":"answer", "question":"Answer?", "allow_free_text":true}]
}))
.unwrap();
let outcome = tokio::time::timeout(
Duration::from_secs(3),
engine.await_user_input("user-input-deadline", request),
+245 -38
View File
@@ -132,10 +132,11 @@ async fn a_session_boundary_abandons_the_previous_mcp_boot() {
assert!(joined.unwrap_err().is_cancelled(), "the pass was aborted");
}
fn empty_user_input_request() -> crate::tools::user_input::UserInputRequest {
crate::tools::user_input::UserInputRequest {
questions: Vec::new(),
}
fn user_input_request() -> crate::tools::user_input::UserInputRequest {
serde_json::from_value(serde_json::json!({
"questions":[{"id":"choice","question":"Answer?","allow_free_text":true}]
}))
.unwrap()
}
#[tokio::test]
@@ -195,11 +196,102 @@ async fn an_undeliverable_user_input_request_fails_fast() {
// C02-19: the question cannot reach a host (its event channel is closed,
// while the answer channel stays open), so nobody can ever answer it.
let workspace = tempdir().unwrap();
let (mut engine, handle) = quiet_engine(deterministic_engine_config(workspace.path()));
let (mut engine, handle) = quiet_engine(EngineConfig {
user_input_responder: true,
..deterministic_engine_config(workspace.path())
});
engine.config.user_input_responder = false;
assert!(matches!(
engine
.await_user_input("unavailable", user_input_request())
.await,
Err(ToolError::ExecutionFailed { .. })
));
assert!(handle.rx_event.write().await.try_recv().is_err());
engine.config.user_input_responder = true;
engine.host_profile = EngineHostProfile::Child;
assert!(matches!(
engine.await_user_input("child", user_input_request()).await,
Err(ToolError::ExecutionFailed { .. })
));
engine.host_profile = EngineHostProfile::Normal;
let (tiny, _tiny_rx) = mpsc::channel(1);
let regular = std::mem::replace(&mut engine.tx_event, tiny);
assert!(matches!(
engine.await_user_input("tiny", user_input_request()).await,
Err(ToolError::ExecutionFailed { .. })
));
engine.tx_event = regular;
for timed_out in [false, true] {
let (full, mut full_rx) = mpsc::channel(2);
full.send(Event::Status {
message: "first".into(),
})
.await
.unwrap();
full.send(Event::Status {
message: "second".into(),
})
.await
.unwrap();
let regular = std::mem::replace(&mut engine.tx_event, full);
engine.config.user_input_timeout = timed_out.then_some(Duration::from_millis(10));
let withdrawal = CancellationToken::new();
let invocation_clock = std::sync::Mutex::new(crate::tools::codemode::PauseClock::new());
{
let wait = engine.await_user_input_withdrawal(
"undisplayed",
user_input_request(),
withdrawal.clone(),
Some(&invocation_clock),
);
tokio::pin!(wait);
assert!(
tokio::time::timeout(Duration::from_millis(2), &mut wait)
.await
.is_err()
);
assert!(
invocation_clock
.lock()
.unwrap()
.remaining(Duration::from_millis(1))
.is_none()
);
if timed_out {
assert!(matches!(wait.await, Err(ToolError::Timeout { .. })));
} else {
withdrawal.cancel();
assert!(matches!(wait.await, Err(ToolError::Cancelled { .. })));
}
}
assert!(matches!(full_rx.try_recv(), Ok(Event::Status { .. })));
assert!(matches!(full_rx.try_recv(), Ok(Event::Status { .. })));
assert!(full_rx.try_recv().is_err());
engine.tx_event = regular;
}
engine.config.user_input_timeout = None;
{
let wait = engine.await_user_input("disconnected", user_input_request());
tokio::pin!(wait);
assert!(
tokio::time::timeout(Duration::from_millis(2), &mut wait)
.await
.is_err()
);
assert!(
matches!(handle.rx_event.write().await.recv().await,Some(Event::UserInputRequired {id,..}) if id == "disconnected")
);
handle.rx_event.write().await.close();
assert!(matches!(wait.await, Err(ToolError::ExecutionFailed { .. })));
}
assert!(
matches!(handle.rx_event.write().await.recv().await,Some(Event::UserInputClosed {id,outcome:crate::core::events::UserInputClosedOutcome::Unavailable}) if id == "disconnected")
);
handle.rx_event.write().await.close();
let result = tokio::time::timeout(
Duration::from_secs(5),
engine.await_user_input("ask-undeliverable", empty_user_input_request()),
engine.await_user_input("ask-undeliverable", user_input_request()),
)
.await
.expect("an undeliverable question must not wait for an answer");
@@ -216,12 +308,13 @@ async fn an_open_user_input_question_is_not_charged_to_the_turn() {
let workspace = tempdir().unwrap();
let wait = Duration::from_millis(400);
let (mut engine, _handle) = quiet_engine(EngineConfig {
user_input_responder: true,
user_input_timeout: Some(wait),
..deterministic_engine_config(workspace.path())
});
let before = engine.turn_wall_clock.spent();
let result = engine
.await_user_input("ask-unanswered", empty_user_input_request())
.await_user_input("ask-unanswered", user_input_request())
.await;
assert!(
matches!(result, Err(ToolError::Timeout { .. })),
@@ -232,44 +325,153 @@ async fn an_open_user_input_question_is_not_charged_to_the_turn() {
charged < wait / 2,
"the human wait was charged to the turn budget: {charged:?}"
);
for withdraw in [false, true] {
let (mut engine, handle) = quiet_engine(EngineConfig {
user_input_responder: true,
..deterministic_engine_config(workspace.path())
});
let withdrawal = CancellationToken::new();
let invocation_clock = std::sync::Mutex::new(crate::tools::codemode::PauseClock::new());
let (accepted, rejected) = tokio::sync::oneshot::channel();
{
let wait = engine.await_user_input_withdrawal(
"withdrawn",
user_input_request(),
withdrawal.clone(),
Some(&invocation_clock),
);
tokio::pin!(wait);
assert!(
tokio::time::timeout(Duration::from_millis(5), &mut wait)
.await
.is_err()
);
assert!(
invocation_clock
.lock()
.unwrap()
.remaining(Duration::from_millis(1))
.is_some()
);
handle
.tx_user_input
.send(UserInputDecision::Submitted {
id: "withdrawn".into(),
response: UserInputResponse {
answers: Vec::new(),
},
accepted,
})
.await
.unwrap();
if withdraw {
withdrawal.cancel();
assert!(matches!(wait.await, Err(ToolError::Cancelled { .. })));
}
}
assert!(!rejected.await.unwrap());
assert!(
matches!(handle.rx_event.write().await.recv().await, Some(Event::UserInputRequired {id,..}) if id == "withdrawn")
);
assert!(
matches!(handle.rx_event.write().await.recv().await, Some(Event::UserInputClosed {id,outcome:crate::core::events::UserInputClosedOutcome::Cancelled}) if id == "withdrawn")
);
let resumed = engine.turn_wall_clock.spent();
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(
invocation_clock
.lock()
.unwrap()
.remaining(Duration::from_millis(1))
.is_none()
);
assert!(
engine.turn_wall_clock.spent().saturating_sub(resumed) >= Duration::from_millis(15)
);
}
}
#[tokio::test]
async fn user_input_acknowledges_only_live_answer_or_cancellation() {
for timeout in [None, Some(Duration::ZERO)] {
for cancel in [false, true] {
let workspace = tempdir().unwrap();
let (mut engine, handle) = quiet_engine(EngineConfig {
user_input_timeout: timeout,
..deterministic_engine_config(workspace.path())
});
let waiter = tokio::spawn(async move {
engine
.await_user_input("live-question", empty_user_input_request())
.await
});
assert!(matches!(handle.rx_event.write().await.recv().await,
Some(Event::UserInputRequired { id, .. }) if id == "live-question"));
if cancel {
handle
.cancel_user_input("live-question")
.await
.expect("live cancel accepted");
assert!(matches!(
waiter.await.unwrap(),
Err(ToolError::Cancelled { .. })
));
} else {
handle
.submit_user_input(
"live-question",
for typed in [false, true] {
let workspace = tempdir().unwrap();
let (mut engine, handle) = quiet_engine(EngineConfig {
user_input_responder: true,
user_input_timeout: timeout,
..deterministic_engine_config(workspace.path())
});
let mut request = user_input_request();
request.require_typed_answers = typed;
let waiter =
tokio::spawn(
async move { engine.await_user_input("live-question", request).await },
);
assert!(matches!(handle.rx_event.write().await.recv().await,
Some(Event::UserInputRequired {id, ..}) if id == "live-question"));
if cancel {
handle
.cancel_user_input("live-question")
.await
.expect("live cancel accepted");
let error = waiter.await.unwrap().unwrap_err();
if typed {
assert_eq!(
error.metadata(),
Some(&serde_json::json!({"user_question_error": "ASK_CANCELLED"}))
);
} else {
assert!(matches!(&error, ToolError::Cancelled { .. }));
assert!(error.metadata().is_none());
}
} else {
if typed {
assert!(
handle
.submit_user_input(
"live-question",
UserInputResponse {
answers: Vec::new()
}
)
.await
.is_err()
);
}
let response = if typed {
serde_json::from_value(serde_json::json!({"answers":[{"id":"choice","kind":"custom","label":"","value":"answer"}]})).unwrap()
} else {
UserInputResponse {
answers: Vec::new(),
},
}
};
handle
.submit_user_input("live-question", response)
.await
.expect("live answer accepted");
assert!(waiter.await.unwrap().is_ok());
}
loop {
if let Some(Event::UserInputClosed { id, outcome }) = tokio::time::timeout(
Duration::from_secs(1),
handle.rx_event.write().await.recv(),
)
.await
.expect("live answer accepted");
assert!(waiter.await.unwrap().is_ok());
.expect("close receipt must arrive")
{
assert_eq!(id, "live-question");
assert_eq!(
outcome,
if cancel {
crate::core::events::UserInputClosedOutcome::Cancelled
} else {
crate::core::events::UserInputClosedOutcome::Answered
}
);
break;
}
}
}
}
}
@@ -280,11 +482,12 @@ async fn user_input_expired_deadline_or_cancellation_rejects_ready_reply() {
for canceled in [false, true] {
let workspace = tempdir().unwrap();
let (mut engine, handle) = quiet_engine(EngineConfig {
user_input_responder: true,
user_input_timeout: (!canceled).then_some(Duration::from_millis(200)),
..deterministic_engine_config(workspace.path())
});
let cancel = engine.cancel_token.clone();
let wait = engine.await_user_input("expired-question", empty_user_input_request());
let wait = engine.await_user_input("expired-question", user_input_request());
tokio::pin!(wait);
assert!(
tokio::time::timeout(Duration::from_millis(5), &mut wait)
@@ -328,10 +531,13 @@ async fn user_input_expired_deadline_or_cancellation_rejects_ready_reply() {
#[tokio::test]
async fn user_input_mismatched_and_abandoned_replies_do_not_end_live_wait() {
let workspace = tempdir().unwrap();
let (mut engine, handle) = quiet_engine(deterministic_engine_config(workspace.path()));
let (mut engine, handle) = quiet_engine(EngineConfig {
user_input_responder: true,
..deterministic_engine_config(workspace.path())
});
let waiter = tokio::spawn(async move {
engine
.await_user_input("current-question", empty_user_input_request())
.await_user_input("current-question", user_input_request())
.await
});
assert!(matches!(
@@ -374,6 +580,7 @@ async fn user_input_mismatched_and_abandoned_replies_do_not_end_live_wait() {
"current-question",
UserInputResponse {
answers: vec![crate::tools::user_input::UserInputAnswer {
kind: None,
id: "choice".into(),
label: "Live".into(),
value: "accepted-live-reply".into(),
@@ -349,6 +349,7 @@ async fn verify_next_user_turn_after_loss(failure: Failure) {
Event::ApprovalRequired { .. }
| Event::ElevationRequired { .. }
| Event::UserInputRequired { .. }
| Event::UserInputClosed { .. }
),
"this regression has no pending approval or user-input gate: {event:?}"
);
@@ -72,10 +72,11 @@ async fn auto_review_asks_the_user_and_returns_the_answer() {
.mount(&server)
.await;
let api_config = Config::default()
.with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let api_config =
Config::default().with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let (engine, handle) = Engine::new(
EngineConfig {
user_input_responder: true,
model: codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string(),
workspace: workspace.path().to_path_buf(),
snapshots_enabled: false,
@@ -93,7 +94,10 @@ async fn auto_review_asks_the_user_and_returns_the_answer() {
content: "continue autonomously".to_string(),
images: Vec::new(),
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL),
route: resolved_route_for_test(
&api_config,
codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL,
),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
@@ -134,6 +138,7 @@ async fn auto_review_asks_the_user_and_returns_the_answer() {
id,
crate::tools::user_input::UserInputResponse {
answers: vec![crate::tools::user_input::UserInputAnswer {
kind: None,
id: "choice".to_string(),
label: "B".to_string(),
value: "take-path-b".to_string(),
@@ -244,8 +249,8 @@ async fn full_access_permission_allow_runs_background_destructive_shell_without_
.mount(&server)
.await;
let api_config = Config::default()
.with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let api_config =
Config::default().with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let engine_config = EngineConfig {
model: codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string(),
workspace: workspace.path().to_path_buf(),
@@ -277,7 +282,10 @@ async fn full_access_permission_allow_runs_background_destructive_shell_without_
content: "please run a background shell".to_string(),
images: Vec::new(),
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL),
route: resolved_route_for_test(
&api_config,
codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL,
),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
@@ -406,8 +414,8 @@ async fn yolo_mode_does_not_prompt_for_background_shell() {
.mount(&server)
.await;
let api_config = Config::default()
.with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let api_config =
Config::default().with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let (engine, handle) = Engine::new(
EngineConfig {
model: codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string(),
@@ -427,7 +435,10 @@ async fn yolo_mode_does_not_prompt_for_background_shell() {
content: "please run a background shell".to_string(),
images: Vec::new(),
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL),
route: resolved_route_for_test(
&api_config,
codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL,
),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
@@ -544,8 +555,8 @@ async fn yolo_mode_executes_publish_like_shell_without_prompt() {
.mount(&server)
.await;
let api_config = Config::default()
.with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let api_config =
Config::default().with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let (engine, handle) = Engine::new(
EngineConfig {
model: codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string(),
@@ -565,7 +576,10 @@ async fn yolo_mode_executes_publish_like_shell_without_prompt() {
content: "please publish this crate".to_string(),
images: Vec::new(),
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL),
route: resolved_route_for_test(
&api_config,
codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL,
),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
@@ -686,8 +700,8 @@ async fn yolo_mode_does_not_prompt_for_mcp_action() {
.mount(&server)
.await;
let api_config = Config::default()
.with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let api_config =
Config::default().with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
let (engine, handle) = Engine::new(
EngineConfig {
model: codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string(),
@@ -707,7 +721,10 @@ async fn yolo_mode_does_not_prompt_for_mcp_action() {
content: "please open the PR".to_string(),
images: Vec::new(),
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL),
route: resolved_route_for_test(
&api_config,
codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL,
),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
@@ -1039,8 +1056,7 @@ fn plan_mode_registry_can_expose_agent_launcher_without_shell_tools() {
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
let context = engine.build_tool_context(AppMode::Plan, false);
let client = CodewhaleClient::new(
&Config::default()
.with_legacy_root(Some("test-key".to_string()), None),
&Config::default().with_legacy_root(Some("test-key".to_string()), None),
)
.expect("stub client");
let manager = crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
@@ -1212,8 +1228,7 @@ fn mode_invariant_matrix_covers_context_catalog_subagents_and_prompt_metadata()
}
let client = CodewhaleClient::new(
&Config::default()
.with_legacy_root(Some("test-key".to_string()), None),
&Config::default().with_legacy_root(Some("test-key".to_string()), None),
)
.expect("stub client");
let manager =
+42
View File
@@ -3773,6 +3773,48 @@ impl Engine {
Some(request) = requests.recv() => {
let request = match request {
crate::tools::codemode::NestedCallRequest::Tool(request) => request,
crate::tools::codemode::NestedCallRequest::UserQuestion { caller, mut request, withdraw, clock, reply } => {
if reply.is_closed() || withdraw.is_cancelled() {
continue;
}
if extension.is_none() || self.child_host.is_some() || caller.agent_id.is_some()
|| caller.session_id != self.session.id || caller.workspace != self.session.workspace
|| Some(caller.turn_id.as_str()) != checkpoint_turn_id.as_deref()
{
let _ = reply.send(Err(ToolError::not_available("user question does not belong to a serving root turn")));
continue;
}
if !self.config.user_input_responder {
let _ = reply.send(Err(ToolError::execution_failed_with_metadata(
"no user-questions answerer accepted the request",
serde_json::json!({"user_question_error": "NO_PROVIDER"}),
)));
continue;
}
request.require_typed_answers = true;
for question in &mut request.questions {
question.allow_free_text = true;
}
if let Err(error) = request.validate_transport() {
let _ = reply.send(Err(error));
continue;
}
let request_id = format!("question_{}", uuid::Uuid::new_v4().simple());
let outcome = {
let wait = self.await_user_input_withdrawal(&request_id, request, withdraw, Some(clock.as_ref()));
tokio::pin!(wait);
tokio::select! {
biased;
() = cancel.cancelled() => {
return (Ok(RichToolResult::plain(interrupted_active_tool_result())), true);
}
result = &mut run => return (result, false),
result = &mut wait => result,
}
};
let _ = reply.send(outcome);
continue;
}
crate::tools::codemode::NestedCallRequest::Checkpoint { caller, withdraw, reply } => {
if reply.is_closed() || withdraw.is_cancelled() {
continue;
+14
View File
@@ -22,6 +22,15 @@ use crate::tools::user_input::UserInputRequest;
use codewhale_config::ProviderKind;
use codewhale_models::{Message, SystemPrompt, Tool, Usage};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UserInputClosedOutcome {
Answered,
Cancelled,
TimedOut,
Unavailable,
}
/// Status receipt carried by the one request a reasoning-only / empty-stop
/// nudge rides (C02-04). The nudge itself never joins the session.
pub(crate) const REQUEST_NUDGE_RECEIPT_PREFIX: &str =
@@ -872,6 +881,11 @@ pub enum Event {
request: UserInputRequest,
},
UserInputClosed {
id: String,
outcome: UserInputClosedOutcome,
},
/// Authoritative API conversation state from the engine session.
///
/// The UI receives granular display events, but those are not always a
+29
View File
@@ -345,6 +345,7 @@ fn roster_row_to_wire(row: &AgentRosterRow) -> wire::AgentRosterRow {
fn user_input_to_wire(request: &UserInputRequest) -> wire::UserInputRequest {
wire::UserInputRequest {
require_typed_answers: request.require_typed_answers,
questions: request
.questions
.iter()
@@ -352,6 +353,15 @@ fn user_input_to_wire(request: &UserInputRequest) -> wire::UserInputRequest {
header: question.header.clone(),
id: question.id.clone(),
question: question.question.clone(),
detail: question.detail.clone(),
intent: question.intent.as_ref().map(|intent| match intent {
crate::tools::user_input::UserInputIntent::PlanReview { approve, call_id } => {
codewhale_protocol::UserInputIntentEvent::PlanReview {
approve: approve.clone(),
call_id: call_id.clone(),
}
}
}),
options: question
.options
.iter()
@@ -963,6 +973,25 @@ pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg {
id: id.clone(),
request: user_input_to_wire(request),
},
Event::UserInputClosed { id, outcome } => wire::EventMsg::UserInputClosed {
thread_id,
session_id,
id: id.clone(),
outcome: match outcome {
crate::core::events::UserInputClosedOutcome::Answered => {
wire::UserInputClosedOutcome::Answered
}
crate::core::events::UserInputClosedOutcome::Cancelled => {
wire::UserInputClosedOutcome::Cancelled
}
crate::core::events::UserInputClosedOutcome::TimedOut => {
wire::UserInputClosedOutcome::TimedOut
}
crate::core::events::UserInputClosedOutcome::Unavailable => {
wire::UserInputClosedOutcome::Unavailable
}
},
},
Event::SessionUpdated {
checkpoint: _,
session_id: engine_session_id,
+1
View File
@@ -836,6 +836,7 @@ pub(crate) async fn run_exec_agent(
&execution_config,
),
user_input_timeout: execution_config.user_input_timeout(),
user_input_responder: false,
goal_max_steps: None,
tools: if fleet_authority_active {
None
+154 -21
View File
@@ -77,7 +77,7 @@ use tokio_util::sync::CancellationToken;
use super::ManagerShared;
use super::protocol::{
ContentBlockWire, CoreCallParams, RpcErrorWire, SessionCheckpointParams, ToolResultWire,
error_code,
UserQuestionParams, error_code,
};
use super::supervisor::HostRequestContext;
use super::ticket::{Grant, Presented, Ticket, TicketKind};
@@ -93,6 +93,7 @@ use codewhale_config::OwnerRef;
/// The protocol method an invocation ticket is good for.
pub(crate) const METHOD: &str = "core/call";
const CHECKPOINT_METHOD: &str = "session/checkpoint";
const QUESTION_METHOD: &str = "user-questions/ask";
/// `core/call`s one invocation may make in all.
pub(crate) const MAX_CALLS_PER_INVOCATION: u32 = 50;
/// A backstop only: an invocation ends (and revokes its ticket) long before
@@ -260,8 +261,9 @@ pub(crate) struct Invocation {
content_hash: String,
calls: CodemodeInvoker,
gate: NestedCallGate,
checkpoint_caller: Option<SessionCheckpointCaller>,
checkpoint_lock: tokio::sync::Mutex<()>,
root_caller: Option<SessionCheckpointCaller>,
tickets: Vec<Ticket>,
session_call_lock: tokio::sync::Mutex<()>,
/// Fires when the invocation ends or is revoked: withdraws every
/// `core/call` still waiting or running for it.
cancel: CancellationToken,
@@ -336,7 +338,7 @@ impl CoreCalls {
ttl: TICKET_TTL,
uses: MAX_CALLS_PER_INVOCATION,
});
let checkpoint_caller = context
let root_caller = context
.execution
.session_objects
.as_ref()
@@ -356,7 +358,7 @@ impl CoreCalls {
call_id: call_id.to_string(),
workspace: context.workspace.clone(),
});
let checkpoint_ticket = checkpoint_caller.as_ref().map(|_| {
let checkpoint_ticket = root_caller.as_ref().map(|_| {
self.tickets.mint(Grant {
kind: TicketKind::Invocation,
tier,
@@ -368,6 +370,18 @@ impl CoreCalls {
uses: MAX_CALLS_PER_INVOCATION,
})
});
let question_ticket = root_caller.as_ref().map(|_| {
self.tickets.mint(Grant {
kind: TicketKind::Invocation,
tier,
host_generation,
owner: owner.clone(),
method: QUESTION_METHOD,
target: json!({ "call_id": call_id }),
ttl: TICKET_TTL,
uses: MAX_CALLS_PER_INVOCATION,
})
});
let invocation = Arc::new(Invocation {
tier,
host_generation,
@@ -377,8 +391,12 @@ impl CoreCalls {
content_hash,
calls,
gate: gate.clone(),
checkpoint_caller,
checkpoint_lock: tokio::sync::Mutex::new(()),
root_caller,
tickets: std::iter::once(ticket.clone())
.chain(checkpoint_ticket.iter().cloned())
.chain(question_ticket.iter().cloned())
.collect(),
session_call_lock: tokio::sync::Mutex::new(()),
cancel: CancellationToken::new(),
});
self.invocations
@@ -391,7 +409,14 @@ impl CoreCalls {
Arc::clone(&invocation),
);
}
if let Some(question_ticket) = question_ticket.as_ref() {
self.invocations.lock().expect("invocations lock").insert(
question_ticket.expose().to_string(),
Arc::clone(&invocation),
);
}
Some(InvocationGuard {
question_ticket,
checkpoint_ticket,
core_calls: Arc::clone(self),
ticket,
@@ -422,6 +447,9 @@ impl CoreCalls {
.collect()
};
for invocation in gone {
for ticket in &invocation.tickets {
self.tickets.revoke(ticket);
}
invocation.cancel.cancel();
}
}
@@ -464,14 +492,10 @@ impl CoreCalls {
}
fn end(&self, ticket: &Ticket) {
self.tickets.revoke(ticket);
if let Some(invocation) = self
.invocations
.lock()
.expect("invocations lock")
.remove(ticket.expose())
{
invocation.cancel.cancel();
if let Some(invocation) = self.invocation(ticket.expose()) {
self.revoke_where(|candidate| std::ptr::eq(candidate, invocation.as_ref()));
} else {
self.tickets.revoke(ticket);
}
}
@@ -568,7 +592,7 @@ impl CoreCalls {
CHECKPOINT_METHOD,
&cx,
)?;
let caller = invocation.checkpoint_caller.clone().ok_or_else(|| {
let caller = invocation.root_caller.clone().ok_or_else(|| {
refuse(
error_code::NOT_AVAILABLE,
"session checkpoint requires a live root invocation".into(),
@@ -581,7 +605,7 @@ impl CoreCalls {
() = cx.cancel.cancelled() => Err(crate::tools::spec::ToolError::cancelled("session checkpoint was cancelled by its host")),
() = withdraw.cancelled() => Err(crate::tools::spec::ToolError::cancelled("session checkpoint invocation ended")),
result = async {
let _checkpoint = invocation.checkpoint_lock.lock().await;
let _session_call = invocation.session_call_lock.lock().await;
invocation.gate.checkpoint(caller, withdraw.clone()).await
} => result,
};
@@ -625,6 +649,113 @@ impl CoreCalls {
})
}
pub(crate) async fn serve_question(
&self,
shared: &ManagerShared,
tier: HostTier,
host_generation: u64,
params: UserQuestionParams,
cx: HostRequestContext,
) -> Result<Value, RpcErrorWire> {
let refuse = |code, message| RpcErrorWire {
code,
message,
data: None,
};
let invocation = self.redeem_invocation(
shared,
tier,
host_generation,
&params.owner,
&params.ticket,
QUESTION_METHOD,
&cx,
)?;
let caller = invocation.root_caller.clone().ok_or_else(|| {
refuse(
error_code::NOT_AVAILABLE,
"user question requires a live root invocation".into(),
)
})?;
let request = crate::tools::user_input::UserInputRequest {
require_typed_answers: true,
questions: params
.questions
.into_iter()
.map(|mut question| {
question.allow_free_text = true;
question
})
.collect(),
};
request
.validate_transport()
.map_err(|error| refuse(error_code::INVALID_PARAMS, error.to_string()))?;
let withdraw = invocation.cancel.child_token();
let _withdraw_guard = withdraw.clone().drop_guard();
let outcome = tokio::select! {
biased;
() = cx.cancel.cancelled() => Err(crate::tools::spec::ToolError::cancelled("user question was cancelled by its host")),
() = withdraw.cancelled() => Err(crate::tools::spec::ToolError::cancelled("user question invocation ended")),
result = async {
let _session_call = invocation.session_call_lock.lock().await;
invocation.gate.user_question(caller, request, withdraw.clone(), invocation.calls.clock()).await
} => result,
};
if cx.cancel.is_cancelled() || invocation.cancel.is_cancelled() {
return Err(refuse(
error_code::CANCELLED,
"user question invocation was withdrawn".into(),
));
}
if shared
.registry
.lock()
.expect("registry lock")
.tier_of(&params.owner)
!= Some(tier)
{
return Err(refuse(
error_code::REFUSED,
"user question owner is no longer current".into(),
));
}
shared
.check_selection(
invocation
.plugins
.as_ref()
.and_then(|plugins| plugins.caller_selection()),
invocation.plugins.as_deref(),
&params.owner.plugin_id,
&invocation.content_hash,
invocation.scope.as_ref(),
)
.map_err(|message| refuse(error_code::REFUSED, message))?;
outcome
.map(|response| serde_json::to_value(response).expect("user input response serializes"))
.map_err(|error| {
let question_code = error
.metadata()
.and_then(|metadata| metadata.get("user_question_error"))
.and_then(Value::as_str)
.filter(|code| matches!(*code, "NO_PROVIDER" | "ASK_CANCELLED"));
let code = if matches!(&error, crate::tools::spec::ToolError::Cancelled { .. })
|| question_code == Some("ASK_CANCELLED")
{
error_code::CANCELLED
} else {
error_code::NOT_AVAILABLE
};
let data = question_code.map(|code| json!({"user_question_error": code}));
RpcErrorWire {
code,
message: error.to_string(),
data,
}
})
}
/// Redeem `params` presented by `tier`'s host process `host_generation`
/// and run the call. An invalid presentation is a refusal, and a burst of
/// them a violation `cx` reports.
@@ -746,11 +877,12 @@ pub(crate) fn wire_from_response(response: ToolCallResponse) -> ToolResultWire {
}
/// Holds an invocation open. Dropping it (the `tool/call` ended or its future
/// was dropped) revokes the ticket and withdraws whatever is still pending.
/// was dropped) revokes its tickets and withdraws whatever is still pending.
pub(crate) struct InvocationGuard {
core_calls: Arc<CoreCalls>,
ticket: Ticket,
checkpoint_ticket: Option<Ticket>,
question_ticket: Option<Ticket>,
invocation: Arc<Invocation>,
}
@@ -764,6 +896,10 @@ impl InvocationGuard {
self.checkpoint_ticket.as_ref().map(Ticket::expose)
}
pub(crate) fn question_ticket(&self) -> Option<&str> {
self.question_ticket.as_ref().map(Ticket::expose)
}
/// The clock the `tool/call` deadline runs on: paused while one of this
/// invocation's core calls waits on the gate.
pub(crate) fn clock(&self) -> Arc<Mutex<PauseClock>> {
@@ -781,8 +917,5 @@ impl InvocationGuard {
impl Drop for InvocationGuard {
fn drop(&mut self) {
self.core_calls.end(&self.ticket);
if let Some(ticket) = self.checkpoint_ticket.as_ref() {
self.core_calls.end(ticket);
}
}
}
@@ -57,6 +57,12 @@ fn serve(mut requests: mpsc::Receiver<NestedCallRequest>, server: Arc<Server>) {
while let Some(request) = requests.recv().await {
let request = match request {
NestedCallRequest::Tool(request) => request,
NestedCallRequest::UserQuestion { reply, .. } => {
let _ = reply.send(Err(ToolError::not_available(
"fixture has no user question backend",
)));
continue;
}
NestedCallRequest::Checkpoint { reply, .. } => {
let _ = reply.send(Err(ToolError::not_available(
"fixture has no session checkpoint backend",
@@ -798,6 +804,7 @@ async fn a_ticket_for_a_revoked_owner_is_refused_even_if_it_was_never_revoked()
let mut f = fixture(true);
let incomplete = f.begin(7, &f.owner);
assert!(incomplete.checkpoint_ticket().is_none());
assert!(incomplete.question_ticket().is_none());
drop(incomplete);
f.context = f
.context
@@ -812,6 +819,115 @@ async fn a_ticket_for_a_revoked_owner_is_refused_even_if_it_was_never_revoked()
.with_origin_turn_id("checkpoint-turn");
let guard = f.begin(7, &f.owner);
let checkpoint_ticket = guard.checkpoint_ticket().unwrap().to_string();
let question_ticket = guard.question_ticket().unwrap().to_string();
let questions = vec![crate::tools::user_input::UserInputQuestion {
id: "question".into(),
question: "Choose or write".into(),
header: String::new(),
detail: None,
intent: None,
options: Vec::new(),
allow_free_text: false,
multi_select: false,
}];
let (cx, _violations, _cancel) = HostRequestContext::for_test(20);
let unavailable = f
.manager
.shared
.core_calls
.serve_question(
&f.manager.shared,
HostTier::Plugin,
7,
super::protocol::UserQuestionParams {
owner: f.owner.clone(),
ticket: question_ticket.clone(),
questions: questions.clone(),
},
cx,
)
.await
.unwrap_err();
assert_eq!(unavailable.code, error_code::NOT_AVAILABLE);
assert!(
unavailable
.message
.contains("fixture has no user question backend")
);
let (wrong_method, _) = f
.serve(HostTier::Plugin, 7, &f.owner, &question_ticket)
.await;
assert!(refused(&wrong_method));
for (tier, generation, owner, ticket) in [
(
HostTier::Plugin,
7,
f.owner.clone(),
guard.ticket().to_string(),
),
(
HostTier::Plugin,
7,
f.owner.clone(),
checkpoint_ticket.clone(),
),
(
HostTier::Plugin,
7,
f.other.clone(),
question_ticket.clone(),
),
(
HostTier::Builtin,
7,
f.owner.clone(),
question_ticket.clone(),
),
(
HostTier::Plugin,
8,
f.owner.clone(),
question_ticket.clone(),
),
] {
let (cx, _violations, _cancel) = HostRequestContext::for_test(21);
let result = f
.manager
.shared
.core_calls
.serve_question(
&f.manager.shared,
tier,
generation,
super::protocol::UserQuestionParams {
owner,
ticket,
questions: questions.clone(),
},
cx,
)
.await;
assert!(refused(&result), "{result:?}");
}
let (cx, _violations, _cancel) = HostRequestContext::for_test(22);
let malformed = f
.manager
.shared
.core_calls
.serve_question(
&f.manager.shared,
HostTier::Plugin,
7,
super::protocol::UserQuestionParams {
owner: f.owner.clone(),
ticket: question_ticket.clone(),
questions: Vec::new(),
},
cx,
)
.await
.unwrap_err();
assert_eq!(malformed.code, error_code::INVALID_PARAMS);
let (cx, _violations, _cancel) = HostRequestContext::for_test(2);
let unavailable = f
.manager
@@ -846,6 +962,12 @@ async fn a_ticket_for_a_revoked_owner_is_refused_even_if_it_was_never_revoked()
f.owner.clone(),
guard.ticket().to_string(),
),
(
HostTier::Plugin,
7,
f.owner.clone(),
question_ticket.clone(),
),
(
HostTier::Plugin,
7,
@@ -918,6 +1040,187 @@ async fn a_ticket_for_a_revoked_owner_is_refused_even_if_it_was_never_revoked()
assert!(withdraw.is_cancelled());
assert!(reply.is_closed());
drop(pending);
{
let pending = f.begin(7, &f.owner);
let (cx, _violations, _cancel) = HostRequestContext::for_test(23);
let question = f.manager.shared.core_calls.serve_question(
&f.manager.shared,
HostTier::Plugin,
7,
super::protocol::UserQuestionParams {
owner: f.owner.clone(),
ticket: pending.question_ticket().unwrap().to_string(),
questions: questions.clone(),
},
cx,
);
tokio::pin!(question);
let request = tokio::select! {
result = &mut question => panic!("question unexpectedly settled: {result:?}"),
request = requests.recv() => request.unwrap(),
};
let NestedCallRequest::UserQuestion {
caller,
request,
withdraw,
reply,
..
} = request
else {
panic!("expected typed question")
};
assert_eq!(caller.session_id, "checkpoint-session");
assert_eq!(caller.turn_id, "checkpoint-turn");
assert_eq!(caller.workspace, f.context.workspace);
assert!(caller.agent_id.is_none());
assert!(request.require_typed_answers);
assert!(request.questions[0].allow_free_text);
assert!(!withdraw.is_cancelled());
let (cx, _violations, _cancel) = HostRequestContext::for_test(24);
let checkpoint = f.manager.shared.core_calls.serve_checkpoint(
&f.manager.shared,
HostTier::Plugin,
7,
super::protocol::SessionCheckpointParams {
owner: f.owner.clone(),
ticket: pending.checkpoint_ticket().unwrap().to_string(),
},
cx,
);
tokio::pin!(checkpoint);
assert!(tokio::time::timeout(Duration::from_millis(2), async {
tokio::select! {
result = &mut checkpoint => panic!("checkpoint passed an unanswered question: {result:?}"),
request = requests.recv() => panic!("parallel session request: {}", request.is_some()),
}
}).await.is_err());
let response = crate::tools::user_input::UserInputResponse {
answers: vec![crate::tools::user_input::UserInputAnswer {
kind: Some(crate::tools::user_input::UserInputAnswerKind::Custom),
id: "question".into(),
label: String::new(),
value: "exact, custom Other".into(),
}],
};
request.validate_response(&response).unwrap();
let expected = serde_json::to_value(&response).unwrap();
reply.send(Ok(response)).unwrap();
assert_eq!(question.await.unwrap(), expected);
let request = tokio::select! {
result = &mut checkpoint => panic!("checkpoint unexpectedly settled: {result:?}"),
request = requests.recv() => request.unwrap(),
};
let NestedCallRequest::Checkpoint { reply, .. } = request else {
panic!("expected serialized checkpoint")
};
reply.send(Ok(true)).unwrap();
assert_eq!(checkpoint.await.unwrap(), Value::Bool(true));
}
for (error, expected_code, expected_data) in [
(
ToolError::not_available("backend closed"),
error_code::NOT_AVAILABLE,
None,
),
(
ToolError::execution_failed_with_metadata(
"no responder",
json!({"user_question_error": "NO_PROVIDER", "internal": "not forwarded"}),
),
error_code::NOT_AVAILABLE,
Some(json!({"user_question_error": "NO_PROVIDER"})),
),
(
ToolError::execution_failed_with_metadata(
"other failure",
json!({"user_question_error": "OTHER"}),
),
error_code::NOT_AVAILABLE,
None,
),
(
ToolError::execution_failed_with_metadata(
"human cancelled",
json!({"user_question_error": "ASK_CANCELLED", "internal": "not forwarded"}),
),
error_code::CANCELLED,
Some(json!({"user_question_error": "ASK_CANCELLED"})),
),
(
ToolError::cancelled("human cancelled"),
error_code::CANCELLED,
None,
),
] {
let pending = f.begin(7, &f.owner);
let (cx, _violations, _cancel) = HostRequestContext::for_test(26);
let question = f.manager.shared.core_calls.serve_question(
&f.manager.shared,
HostTier::Plugin,
7,
super::protocol::UserQuestionParams {
owner: f.owner.clone(),
ticket: pending.question_ticket().unwrap().to_string(),
questions: questions.clone(),
},
cx,
);
tokio::pin!(question);
let request = tokio::select! {
result = &mut question => panic!("question unexpectedly settled: {result:?}"),
request = requests.recv() => request.unwrap(),
};
let NestedCallRequest::UserQuestion { reply, .. } = request else {
panic!("expected typed question")
};
reply.send(Err(error)).unwrap();
let result = question.await.unwrap_err();
assert_eq!(result.code, expected_code);
assert_eq!(result.data, expected_data);
}
for cancellation in ["host", "invocation", "revocation"] {
let mut pending = Some(f.begin(7, &f.owner));
let ticket = pending
.as_ref()
.unwrap()
.question_ticket()
.unwrap()
.to_string();
let (cx, _violations, cancel) = HostRequestContext::for_test(25);
let question = f.manager.shared.core_calls.serve_question(
&f.manager.shared,
HostTier::Plugin,
7,
super::protocol::UserQuestionParams {
owner: f.owner.clone(),
ticket,
questions: questions.clone(),
},
cx,
);
tokio::pin!(question);
let request = tokio::select! {
result = &mut question => panic!("question unexpectedly settled: {result:?}"),
request = requests.recv() => request.unwrap(),
};
match cancellation {
"host" => cancel.cancel(),
"invocation" => drop(pending.take()),
"revocation" => f.manager.shared.core_calls.revoke_host(HostTier::Plugin, 7),
_ => unreachable!(),
}
assert_eq!(question.await.unwrap_err().code, error_code::CANCELLED);
let NestedCallRequest::UserQuestion {
withdraw, reply, ..
} = request
else {
panic!("expected typed question")
};
assert!(withdraw.is_cancelled());
assert!(reply.is_closed());
drop(pending);
}
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
let mut child_context = f.context.clone();
child_context.execution.owner_agent_id = Some("child".into());
let child = f
@@ -935,7 +1238,11 @@ async fn a_ticket_for_a_revoked_owner_is_refused_even_if_it_was_never_revoked()
)
.unwrap();
assert!(child.checkpoint_ticket().is_none());
assert!(child.question_ticket().is_none());
drop(child);
drop(guard);
let guard = f.begin(7, &f.owner);
let checkpoint_ticket = guard.checkpoint_ticket().unwrap().to_string();
// The owner is replaced by a new activation: its token changes, so the
// old one the host still holds no longer names the current owner.
let ticket = guard.ticket().to_string();
+18
View File
@@ -944,6 +944,24 @@ impl HostEvents for Events {
.serve(&shared, self.generation, params, cx)
.await
}
protocol::HostRequest::UserQuestion(params) => {
let shared = self
.shared
.upgrade()
.ok_or_else(|| refuse("extension host manager is gone"))?;
if shared
.tier_runtime(self.tier)
.host_generation
.load(Ordering::SeqCst)
!= self.generation
{
return Err(refuse("stale host generation"));
}
shared
.core_calls
.serve_question(&shared, self.tier, self.generation, params, cx)
.await
}
protocol::HostRequest::SessionCheckpoint(params) => {
let shared = self
.shared
+21
View File
@@ -172,6 +172,7 @@ pub const METHODS: &[MethodSpec] = &[
row(Direction::HostToCore, "registry/unregister", true),
row(Direction::HostToCore, "core/call", true),
row(Direction::HostToCore, "session/checkpoint", true),
row(Direction::HostToCore, "user-questions/ask", true),
MethodSpec {
tiers: &[HostTier::Builtin],
..row(Direction::HostToCore, "proc/launch", true)
@@ -534,6 +535,15 @@ pub struct SessionCheckpointParams {
pub ticket: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct UserQuestionParams {
pub owner: OwnerRef,
pub ticket: String,
pub questions: Vec<crate::tools::user_input::UserInputQuestion>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
@@ -721,6 +731,7 @@ pub enum HostRequest {
Unregister(UnregisterParams),
CoreCall(CoreCallParams),
SessionCheckpoint(SessionCheckpointParams),
UserQuestion(UserQuestionParams),
ExecutionRedeem(ExecutionRedeemParams),
ProcClose(ProcSessionParams),
NetStart(ProcLaunchParams),
@@ -742,6 +753,7 @@ impl HostRequest {
Self::Unregister(params) => &params.owner.plugin_id,
Self::CoreCall(params) => &params.owner.plugin_id,
Self::SessionCheckpoint(params) => &params.owner.plugin_id,
Self::UserQuestion(params) => &params.owner.plugin_id,
Self::ExecutionRedeem(params) => &params.owner.plugin_id,
Self::ProcClose(params) => &params.owner.plugin_id,
Self::NetClose(params) => &params.owner.plugin_id,
@@ -859,6 +871,10 @@ pub fn parse_host_message(value: Value, tier: HostTier) -> Result<HostMessage, P
id,
request: HostRequest::Unregister(params(&method, p)?),
},
("user-questions/ask", Some(id)) => HostMessage::Request {
id,
request: HostRequest::UserQuestion(params(&method, p)?),
},
("session/checkpoint", Some(id)) => HostMessage::Request {
id,
request: HostRequest::SessionCheckpoint(params(&method, p)?),
@@ -965,6 +981,9 @@ impl HostMessage {
}
HostRequest::ExecutionRedeem(p) => request_value(*id, "exec/redeem", to_value(p)),
HostRequest::CoreCall(p) => request_value(*id, "core/call", to_value(p)),
HostRequest::UserQuestion(p) => {
request_value(*id, "user-questions/ask", to_value(p))
}
HostRequest::SessionCheckpoint(p) => {
request_value(*id, "session/checkpoint", to_value(p))
}
@@ -1068,6 +1087,8 @@ pub struct ToolCallParams {
pub ticket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint_ticket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub question_ticket: Option<String>,
/// Per-invocation identity; never cached at activation or by the host root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
@@ -8,7 +8,7 @@
//! under CI, like every conformance golden), then rebuild `dist/`.
//!
//! Known limit: a wire shape outside that vocabulary (floats, inline
//! objects, unions in params) panics here rather than being approximated.
//! objects, multi-variant unions in params) panics here rather than being approximated.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
@@ -50,6 +50,7 @@ fn params_schema(method: &str, generator: &mut SchemaGenerator) -> Schema {
"registry/unregister" => generator.subschema_for::<UnregisterParams>(),
"core/call" => generator.subschema_for::<CoreCallParams>(),
"session/checkpoint" => generator.subschema_for::<SessionCheckpointParams>(),
"user-questions/ask" => generator.subschema_for::<UserQuestionParams>(),
"ext/faulted" => generator.subschema_for::<FaultedParams>(),
"log" => generator.subschema_for::<LogParams>(),
other => panic!("method `{other}` has no params type in the TypeScript generator"),
@@ -206,6 +207,10 @@ fn parse_def(name: &str, schema: &Value) -> Def {
.collect();
return match consts {
Some(values) => Def::Enum(values),
None if members.len() == 1 => Def::Object {
strict: members[0].get("additionalProperties") == Some(&Value::Bool(false)),
fields: object_fields(name, &members[0]),
},
None => Def::Union(
members
.iter()
@@ -263,7 +268,7 @@ fn kind(ty: &Ty, defs: &BTreeMap<String, Def>) -> String {
),
Def::Union(_) => panic!("{name}: the host validator does not check unions"),
},
Ty::Const(value) => panic!("`{value}`: a tag outside a union"),
Ty::Const(value) => format!("{{ enum: [{}] }}", quote(value)),
Ty::Array(item) => format!("{{ items: {} }}", kind(item, defs)),
}
}
@@ -358,6 +363,7 @@ fn render() -> String {
let _ = generator.subschema_for::<ToolResultWire>();
let _ = generator.subschema_for::<CommandResultWire>();
let _ = generator.subschema_for::<HookVerdictWire>();
let _ = generator.subschema_for::<crate::tools::user_input::UserInputResponse>();
let defs: BTreeMap<String, Def> = generator
.definitions()
.iter()
@@ -497,6 +503,11 @@ const CORE_ONLY: &[&str] = &[
/// Every method, with why it gives the host no core authority. Adding a
/// method means adding its row here, in review, with that reason.
const REVIEWED: &[(&str, &str, &str)] = &[
(
"host_to_core",
"user-questions/ask",
"opaque live root-invocation ticket and bounded question data; the existing Engine owns request identity, human wait, typed answer validation and cancellation; no host-selected session, reply, approval or new turn",
),
(
"host_to_core",
"session/checkpoint",
@@ -695,7 +706,7 @@ fn host_protocol_never_gains_core_authority() {
);
// Nothing is decoded or sent outside the table: every method-shaped
// literal in this module is a table row.
let literal = regex::Regex::new(r#""([A-Za-z$][\w$]*/[A-Za-z_]+)""#).expect("regex");
let literal = regex::Regex::new(r#""([A-Za-z$][\w$-]*/[A-Za-z_]+)""#).expect("regex");
let source = include_str!("../protocol.rs");
let mut seen = 0;
for capture in literal.captures_iter(source) {
@@ -779,6 +790,40 @@ fn a_method_reserved_for_the_builtin_tier_is_refused_in_both_directions() {
/// plugin tier by accident.
#[test]
fn production_methods_follow_their_tier_rows_and_reserved_families_stay_builtin_only() {
for count in [0, 1, 100] {
let mut questions = json!([{
"id": "q", "question": "Choose", "header": "",
"options": (0..count).map(|index| json!({"label": format!("choice-{index}"), "description": ""})).collect::<Vec<_>>(),
"allow_free_text": true, "multi_select": true,
}]);
if count > 0 {
questions[0]["detail"] = json!("Review this exact plan");
questions[0]["intent"] =
json!({"kind":"plan-review", "approve":"choice-0", "call_id":"plan-call"});
}
let frame = json!({
"jsonrpc": "2.0", "id": 87, "method": "user-questions/ask",
"params": { "owner": {"plugin_id":"question-owner", "owner_token":"opaque", "generation":1}, "ticket":"question-ticket", "questions":questions },
});
for tier in HostTier::ALL {
let parsed = parse_host_message(frame.clone(), tier).unwrap();
assert_eq!(parsed.to_value(), frame);
let HostMessage::Request {
request: HostRequest::UserQuestion(params),
..
} = parsed
else {
panic!("wrong question variant")
};
assert_eq!(params.questions[0].options.len(), count);
assert!(parse_core_message(frame.clone(), tier).is_err());
for field in ["session_id", "request_id", "require_typed_answers"] {
let mut forged = frame.clone();
forged["params"][field] = json!("foreign-selector");
assert!(parse_host_message(forged, tier).is_err());
}
}
}
for spec in METHODS {
for tier in HostTier::ALL {
assert_eq!(
+7 -2
View File
@@ -91,8 +91,8 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use crate::dependencies::{HostRuntime, HostRuntimeKind};
use crate::tools::codemode::PauseClock;
use codewhale_dependencies::{HostRuntime, HostRuntimeKind};
use super::protocol::{
self, CoreRequest, Direction, HelloParams, HostLimits, HostMessage, HostNotification,
@@ -291,7 +291,7 @@ pub const HOST_MEMORY_CAP: u64 = 1 << 30;
/// `extension-host/test/harness.mjs` (`HOST_ARGS`).
///
/// - Node: a 256 MB old-space cap, `__proto__` throws, no native addons,
/// and the [`codewhale_dependencies::NODE_NATIVE_CODE_FLAGS`] this Node
/// and the [`crate::dependencies::NODE_NATIVE_CODE_FLAGS`] this Node
/// accepts (`node:sqlite`, and `node:ffi` where it exists). On Windows it
/// also keeps symlinked paths as given, so module resolution never lstats
/// the drive root the LPAC cannot read.
@@ -1369,6 +1369,11 @@ pub(crate) fn registry_host_request<E: HostEvents + ?Sized>(
events.unregister(&params);
Ok(json!({}))
}
HostRequest::UserQuestion(_) => Err(RpcErrorWire {
code: error_code::NOT_AVAILABLE,
message: "user-questions/ask is not served here".to_string(),
data: None,
}),
HostRequest::SessionCheckpoint(_) => Err(RpcErrorWire {
code: error_code::REFUSED,
message: "session/checkpoint is not served here".to_string(),
+3
View File
@@ -2697,6 +2697,7 @@ async fn ordinary_exit_rejects_requests_from_a_drained_calls_waker() {
workspace: None,
ticket: None,
checkpoint_ticket: None,
question_ticket: None,
session_id: None,
agent_id: None,
origin_turn_id: None,
@@ -2758,6 +2759,7 @@ async fn idle_retirement_seals_admission_and_does_not_wait_for_heartbeat() {
workspace: None,
ticket: None,
checkpoint_ticket: None,
question_ticket: None,
session_id: None,
agent_id: None,
origin_turn_id: None,
@@ -2855,6 +2857,7 @@ async fn two_dirty_teardowns_wait_for_a_live_call_then_replay_without_spending_c
workspace: None,
ticket: None,
checkpoint_ticket: None,
question_ticket: None,
session_id: None,
agent_id: None,
origin_turn_id: None,
+3
View File
@@ -376,6 +376,9 @@ impl ToolSpec for HostToolSpec {
checkpoint_ticket: invocation
.as_ref()
.and_then(|i| i.checkpoint_ticket().map(str::to_string)),
question_ticket: invocation
.as_ref()
.and_then(|i| i.question_ticket().map(str::to_string)),
session_id: context
.execution
.session_objects
+27 -20
View File
@@ -1274,14 +1274,7 @@ struct DecideApprovalResponse {
#[derive(Debug, Deserialize)]
struct SubmitUserInputBody {
answers: Vec<UserInputAnswerBody>,
}
#[derive(Debug, Deserialize)]
struct UserInputAnswerBody {
id: String,
label: String,
value: String,
answers: Vec<crate::tools::user_input::UserInputAnswer>,
}
#[derive(Debug, Serialize)]
@@ -2872,7 +2865,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
)
.route(
"/v1/user-input/{thread_id}/{input_id}",
post(submit_user_input),
post(submit_user_input).delete(cancel_user_input_request),
)
.route("/v1/tasks", get(list_tasks).post(create_task))
.route("/v1/tasks/{id}", get(get_task))
@@ -6299,17 +6292,9 @@ async fn submit_user_input(
Path((thread_id, input_id)): Path<(String, String)>,
Json(req): Json<SubmitUserInputBody>,
) -> Result<Json<SubmitUserInputResponse>, ApiError> {
use crate::tools::user_input::{UserInputAnswer, UserInputResponse};
let answers: Vec<UserInputAnswer> = req
.answers
.into_iter()
.map(|a| UserInputAnswer {
id: a.id,
label: a.label,
value: a.value,
})
.collect();
let response = UserInputResponse { answers };
let response = crate::tools::user_input::UserInputResponse {
answers: req.answers,
};
let delivered = state
.runtime_threads
.submit_user_input(&thread_id, &input_id, response)
@@ -6327,6 +6312,27 @@ async fn submit_user_input(
}))
}
async fn cancel_user_input_request(
State(state): State<RuntimeApiState>,
Path((thread_id, input_id)): Path<(String, String)>,
) -> Result<Json<SubmitUserInputResponse>, ApiError> {
let delivered = state
.runtime_threads
.cancel_user_input(&thread_id, &input_id)
.await
.map_err(map_thread_err)?;
if !delivered {
return Err(ApiError::not_found(format!(
"no pending user-input request with id '{input_id}'"
)));
}
Ok(Json(SubmitUserInputResponse {
ok: true,
input_id,
delivered,
}))
}
async fn runtime_info(
State(state): State<RuntimeApiState>,
request: Request,
@@ -10635,6 +10641,7 @@ fn map_compat_stream_event(event: &crate::runtime_threads::RuntimeEventRecord) -
"thread_id": event.thread_id,
"turn_id": event.turn_id,
"status": status,
"outcome": payload.get("outcome"),
"terminal": payload.get("terminal").and_then(Value::as_bool).unwrap_or(false),
}),
))
+106 -2
View File
@@ -3690,7 +3690,13 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech
})
.await?;
let request = crate::tools::user_input::UserInputRequest {
require_typed_answers: true,
questions: vec![crate::tools::user_input::UserInputQuestion {
detail: Some("Inspect the full plan".into()),
intent: Some(crate::tools::user_input::UserInputIntent::PlanReview {
approve: "Continue".into(),
call_id: Some("plan-compat".into()),
}),
header: "Continue".to_string(),
id: "choice".to_string(),
question: "Continue the compatibility turn?".to_string(),
@@ -3704,7 +3710,7 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech
description: "Cancel the turn".to_string(),
},
],
allow_free_text: false,
allow_free_text: true,
multi_select: false,
}],
};
@@ -3852,6 +3858,44 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech
assert_eq!(required_payload["request"]["questions"][0]["id"], "choice");
assert!(required_payload.get("answers").is_none());
assert_eq!(required_payload["request"]["require_typed_answers"], true);
assert_eq!(
required_payload["request"]["questions"][0]["detail"],
"Inspect the full plan"
);
assert_eq!(
required_payload["request"]["questions"][0]["intent"]["kind"],
"plan-review"
);
for answer in [
json!({"id":"choice","label":"Continue","value":"Continue"}),
json!({"kind":"choice","id":"choice","label":"Continue","value":"forged"}),
json!({"kind":"custom","id":"choice","label":"Other","value":"text"}),
json!({"kind":"skipped","id":"choice","label":"","value":"not empty"}),
] {
let rejected = client
.post(format!(
"http://{addr}/v1/user-input/{thread_id}/input_compat"
))
.json(&json!({"answers":[answer]}))
.send()
.await?;
assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
assert_eq!(
runtime_threads
.get_thread_detail(&thread_id)
.await?
.pending_user_inputs
.len(),
1
);
assert!(
!runtime_threads
.events_since(&thread_id, None)?
.iter()
.any(|event| event.event == "user_input.answered")
);
}
const SECRET_ANSWER: &str = "compat-answer-must-not-be-echoed";
let submitted: serde_json::Value = client
.post(format!(
@@ -3860,7 +3904,8 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech
.json(&json!({
"answers": [{
"id": "choice",
"label": "Continue",
"kind": "custom",
"label": "",
"value": SECRET_ANSWER,
}],
}))
@@ -3878,6 +3923,10 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech
.context("compatibility user input was canceled instead of submitted")?;
assert_eq!(submitted_id, "input_compat");
assert_eq!(submitted_response.answers[0].value, SECRET_ANSWER);
assert_eq!(
submitted_response.answers[0].kind,
Some(crate::tools::user_input::UserInputAnswerKind::Custom)
);
release_completion
.send(())
.map_err(|_| anyhow::anyhow!("mock interaction engine dropped completion release"))?;
@@ -11591,6 +11640,61 @@ async fn submit_user_input_404s_without_entering_engine_mailbox_for_unknown_id()
"unknown user input reached the engine mailbox"
);
let client = codewhale_release::tls::reqwest_client();
let missing = client
.delete(format!(
"http://{addr}/v1/user-input/{}/input-missing",
thread.id
))
.send()
.await?;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
runtime_threads.register_pending_user_input_for_thread_for_test(&thread.id, "cancel-known");
let cancellation = client
.delete(format!(
"http://{addr}/v1/user-input/{}/cancel-known",
thread.id
))
.send();
let (response, delivered) = tokio::join!(cancellation, harness.recv_user_input_cancellation());
let response: Value = response?.error_for_status()?.json().await?;
assert_eq!(
response,
json!({"ok":true,"input_id":"cancel-known","delivered":true})
);
assert_eq!(delivered.as_deref(), Some("cancel-known"));
assert!(
runtime_threads
.get_thread_detail(&thread.id)
.await?
.pending_user_inputs
.is_empty()
);
let replay = client
.delete(format!(
"http://{addr}/v1/user-input/{}/cancel-known",
thread.id
))
.send()
.await?;
assert_eq!(replay.status(), StatusCode::NOT_FOUND);
let late = client
.post(format!(
"http://{addr}/v1/user-input/{}/cancel-known",
thread.id
))
.json(&json!({"answers":[]}))
.send()
.await?;
assert_eq!(late.status(), StatusCode::NOT_FOUND);
assert_eq!(
runtime_threads
.events_since(&thread.id, None)?
.iter()
.filter(|event| event.event == "user_input.canceled")
.count(),
1
);
handle.abort();
Ok(())
}
+368 -212
View File
@@ -8255,6 +8255,267 @@ enum PendingUserInputClaim {
enum UserInputTerminalOutcome {
Answered(crate::tools::user_input::UserInputResponse),
Canceled { terminal: bool },
Closed(crate::core::events::UserInputClosedOutcome),
}
#[derive(Clone)]
struct RuntimeUserInputSettlement {
thread_id: String,
pending_user_inputs: Arc<parking_lot::Mutex<HashMap<(String, String), PendingUserInputEntry>>>,
store: RuntimeThreadStore,
projection: Arc<Mutex<()>>,
event_emit: Arc<Mutex<()>>,
event_tx: broadcast::Sender<RuntimeEventRecord>,
owner: std::sync::Weak<RuntimeProcessOwnerLock>,
}
impl RuntimeUserInputSettlement {
fn claim_pending_user_input(
&self,
thread_id: &str,
input_id: &str,
turn_id: Option<&str>,
response: Option<&crate::tools::user_input::UserInputResponse>,
) -> Result<PendingUserInputClaim> {
let mut pending = self.pending_user_inputs.lock();
let Some(entry) = pending.get_mut(&(thread_id.to_string(), input_id.to_string())) else {
return Ok(PendingUserInputClaim::Missing);
};
if turn_id.is_some_and(|turn_id| entry.request.turn_id != turn_id) {
return Ok(PendingUserInputClaim::Missing);
}
if entry.indeterminate {
return Ok(PendingUserInputClaim::Indeterminate);
}
if entry.settling {
return Ok(PendingUserInputClaim::Settling);
}
if let Some(response) = response {
entry.request.request.validate_response(response)?;
}
entry.settling = true;
Ok(PendingUserInputClaim::Claimed(entry.request.clone()))
}
fn restore_pending_user_input_claim(&self, thread_id: &str, request: &PendingUserInputRequest) {
let settlement_tx = if let Some(entry) = self
.pending_user_inputs
.lock()
.get_mut(&(thread_id.to_string(), request.id.clone()))
&& entry.request.turn_id == request.turn_id
{
entry.settling = false;
entry.indeterminate = false;
Some(entry.settlement_tx.clone())
} else {
None
};
if let Some(settlement_tx) = settlement_tx {
settlement_tx.send_modify(|epoch| *epoch = epoch.saturating_add(1));
}
}
fn mark_pending_user_input_indeterminate(
&self,
thread_id: &str,
request: &PendingUserInputRequest,
) {
let settlement_tx = if let Some(entry) = self
.pending_user_inputs
.lock()
.get_mut(&(thread_id.to_string(), request.id.clone()))
&& entry.request.turn_id == request.turn_id
{
entry.settling = true;
entry.indeterminate = true;
Some(entry.settlement_tx.clone())
} else {
None
};
if let Some(settlement_tx) = settlement_tx {
settlement_tx.send_modify(|epoch| *epoch = epoch.saturating_add(1));
}
}
fn finish_pending_user_input_settlement(
&self,
thread_id: &str,
request: &PendingUserInputRequest,
) -> Option<watch::Sender<u64>> {
let mut pending = self.pending_user_inputs.lock();
let key = (thread_id.to_string(), request.id.clone());
let settlement_tx = if pending.get(&key).is_some_and(|entry| {
entry.request.turn_id == request.turn_id && entry.settling && !entry.indeterminate
}) {
pending.remove(&key).map(|entry| entry.settlement_tx)
} else {
None
};
drop(pending);
settlement_tx
}
async fn settle_claimed_user_input(
&self,
thread_id: &str,
engine: Option<EngineHandle>,
request: PendingUserInputRequest,
outcome: UserInputTerminalOutcome,
) -> Result<bool> {
let _owner = self
.owner
.upgrade()
.context("Runtime user-input owner was released")?;
anyhow::ensure!(
thread_id == self.thread_id,
"Runtime user-input thread changed"
);
let projection_lock = self.projection.clone();
let _projection = projection_lock.lock().await;
let (event, payload) = match &outcome {
UserInputTerminalOutcome::Answered(_) => (
"user_input.answered",
json!({ "id": &request.id, "input_id": &request.id }),
),
UserInputTerminalOutcome::Closed(outcome) => (
if *outcome == crate::core::events::UserInputClosedOutcome::Answered {
"user_input.answered"
} else {
"user_input.canceled"
},
json!({"id": &request.id, "input_id": &request.id, "outcome": outcome}),
),
UserInputTerminalOutcome::Canceled { terminal } => (
"user_input.canceled",
json!({
"id": &request.id,
"input_id": &request.id,
"terminal": terminal,
}),
),
};
if let Err(error) = self
.emit_event(thread_id, Some(&request.turn_id), None, event, payload)
.await
{
if event_append_is_indeterminate(&error) {
self.mark_pending_user_input_indeterminate(thread_id, &request);
} else {
self.restore_pending_user_input_claim(thread_id, &request);
}
return Err(error);
}
drop(_projection);
let terminal = matches!(
&outcome,
UserInputTerminalOutcome::Canceled { terminal: true }
| UserInputTerminalOutcome::Closed(_)
);
let delivery_result = match (engine, outcome) {
(Some(engine), UserInputTerminalOutcome::Answered(response)) => {
engine.submit_user_input(&request.id, response).await
}
(Some(engine), UserInputTerminalOutcome::Canceled { .. }) => {
engine.cancel_user_input(&request.id).await
}
(_, UserInputTerminalOutcome::Closed(_)) | (None, _) => Ok(()),
};
if let Err(error) = delivery_result {
if !terminal {
// Keep the same claim until Engine's verdict. A simultaneous
// ToolCallComplete waits for this owner, then closes it; a
// failed delivery alone must not erase an indefinite waiter.
let _projection = projection_lock.lock().await;
if let Err(receipt_error) = self.emit_event(
thread_id,
Some(&request.turn_id),
None,
"user_input.required",
json!({
"id": &request.id,
"request": &request.request,
"delivery_error": "Engine did not accept the decision. Retry if the question is still pending.",
}),
).await {
if event_append_is_indeterminate(&receipt_error) {
self.mark_pending_user_input_indeterminate(thread_id, &request);
} else {
self.restore_pending_user_input_claim(thread_id, &request);
}
return Err(receipt_error);
}
self.restore_pending_user_input_claim(thread_id, &request);
return Err(error);
}
// A terminal turn already ended the waiter. Its durable cleanup
// remains authoritative even when Engine rejects a late cancel.
tracing::debug!(thread_id, input_id = %request.id,
"Terminal user-input cancellation was durable after waiter ended: {error}");
}
let settlement_tx = self.finish_pending_user_input_settlement(thread_id, &request);
if let Some(settlement_tx) = settlement_tx {
settlement_tx.send_modify(|epoch| *epoch = epoch.saturating_add(1));
}
Ok(true)
}
async fn close(
&self,
input_id: &str,
turn_id: Option<&str>,
outcome: crate::core::events::UserInputClosedOutcome,
) -> Result<()> {
let request = loop {
match self.claim_pending_user_input(&self.thread_id, input_id, turn_id, None)? {
PendingUserInputClaim::Claimed(request) => break request,
PendingUserInputClaim::Missing => return Ok(()),
PendingUserInputClaim::Settling => {
let progress = self
.pending_user_inputs
.lock()
.get(&(self.thread_id.clone(), input_id.to_string()))
.filter(|entry| {
entry.settling
&& !entry.indeterminate
&& turn_id.is_none_or(|turn_id| entry.request.turn_id == turn_id)
})
.map(|entry| entry.settlement_tx.subscribe());
if let Some(mut progress) = progress {
let _ = progress.changed().await;
}
}
PendingUserInputClaim::Indeterminate => bail!(
"User-input request '{input_id}' has an indeterminate terminal receipt; inspect Runtime storage before closing"
),
}
};
self.settle_claimed_user_input(
&self.thread_id,
None,
request,
UserInputTerminalOutcome::Closed(outcome),
)
.await?;
Ok(())
}
async fn emit_event(
&self,
thread_id: &str,
turn_id: Option<&str>,
item_id: Option<&str>,
event: impl Into<String>,
payload: Value,
) -> Result<RuntimeEventRecord> {
let _emit = self.event_emit.lock().await;
let record = self
.store
.append_event(thread_id, turn_id, item_id, event, payload)
.await?;
let _ = self.event_tx.send(record.clone());
Ok(record)
}
}
struct PendingDynamicToolEntry {
@@ -9090,6 +9351,18 @@ impl RuntimeThreadManager {
self.pending_approvals.lock().remove(approval_id);
}
fn user_input_settlement(&self, thread_id: &str) -> RuntimeUserInputSettlement {
RuntimeUserInputSettlement {
thread_id: thread_id.to_string(),
pending_user_inputs: self.pending_user_inputs.clone(),
store: self.store.clone(),
projection: self.projection_lock(thread_id),
event_emit: self.event_emit.clone(),
event_tx: self.event_tx.clone(),
owner: Arc::downgrade(&self._process_owner_lock),
}
}
fn register_pending_user_input(&self, thread_id: &str, request: PendingUserInputRequest) {
let (settlement_tx, _settlement_rx) = watch::channel(0);
self.pending_user_inputs.lock().insert(
@@ -9103,29 +9376,6 @@ impl RuntimeThreadManager {
);
}
fn claim_pending_user_input(
&self,
thread_id: &str,
input_id: &str,
turn_id: Option<&str>,
) -> PendingUserInputClaim {
let mut pending = self.pending_user_inputs.lock();
let Some(entry) = pending.get_mut(&(thread_id.to_string(), input_id.to_string())) else {
return PendingUserInputClaim::Missing;
};
if turn_id.is_some_and(|turn_id| entry.request.turn_id != turn_id) {
return PendingUserInputClaim::Missing;
}
if entry.indeterminate {
return PendingUserInputClaim::Indeterminate;
}
if entry.settling {
return PendingUserInputClaim::Settling;
}
entry.settling = true;
PendingUserInputClaim::Claimed(entry.request.clone())
}
fn discard_pending_user_input_registration(&self, thread_id: &str, input_id: &str) {
let key = (thread_id.to_string(), input_id.to_string());
let mut pending = self.pending_user_inputs.lock();
@@ -9166,64 +9416,6 @@ impl RuntimeThreadManager {
Ok((claims, settling))
}
fn restore_pending_user_input_claim(&self, thread_id: &str, request: &PendingUserInputRequest) {
let settlement_tx = if let Some(entry) = self
.pending_user_inputs
.lock()
.get_mut(&(thread_id.to_string(), request.id.clone()))
&& entry.request.turn_id == request.turn_id
{
entry.settling = false;
entry.indeterminate = false;
Some(entry.settlement_tx.clone())
} else {
None
};
if let Some(settlement_tx) = settlement_tx {
settlement_tx.send_modify(|epoch| *epoch = epoch.saturating_add(1));
}
}
fn mark_pending_user_input_indeterminate(
&self,
thread_id: &str,
request: &PendingUserInputRequest,
) {
let settlement_tx = if let Some(entry) = self
.pending_user_inputs
.lock()
.get_mut(&(thread_id.to_string(), request.id.clone()))
&& entry.request.turn_id == request.turn_id
{
entry.settling = true;
entry.indeterminate = true;
Some(entry.settlement_tx.clone())
} else {
None
};
if let Some(settlement_tx) = settlement_tx {
settlement_tx.send_modify(|epoch| *epoch = epoch.saturating_add(1));
}
}
fn finish_pending_user_input_settlement(
&self,
thread_id: &str,
request: &PendingUserInputRequest,
) -> Option<watch::Sender<u64>> {
let mut pending = self.pending_user_inputs.lock();
let key = (thread_id.to_string(), request.id.clone());
let settlement_tx = if pending.get(&key).is_some_and(|entry| {
entry.request.turn_id == request.turn_id && entry.settling && !entry.indeterminate
}) {
pending.remove(&key).map(|entry| entry.settlement_tx)
} else {
None
};
drop(pending);
settlement_tx
}
fn pending_requests_for_thread(
&self,
thread_id: &str,
@@ -9474,7 +9666,10 @@ impl RuntimeThreadManager {
};
state.engine.clone()
};
let request = match self.claim_pending_user_input(thread_id, input_id, None) {
let request = match self
.user_input_settlement(thread_id)
.claim_pending_user_input(thread_id, input_id, None, Some(&response))?
{
PendingUserInputClaim::Claimed(request) => request,
PendingUserInputClaim::Missing | PendingUserInputClaim::Settling => {
return Ok(false);
@@ -9494,6 +9689,7 @@ impl RuntimeThreadManager {
let (ack_tx, ack_rx) = oneshot::channel();
let worker = tokio::spawn(async move {
let result = manager
.user_input_settlement(&thread_id)
.settle_claimed_user_input(
&thread_id,
Some(engine),
@@ -9508,7 +9704,6 @@ impl RuntimeThreadManager {
ack_rx.await.context("User-input settlement task failed")?
}
#[cfg_attr(not(test), expect(dead_code))]
pub async fn cancel_user_input(&self, thread_id: &str, input_id: &str) -> Result<bool> {
let admission = self.config_admission.read().await;
self.ensure_accepting_execution()?;
@@ -9519,7 +9714,10 @@ impl RuntimeThreadManager {
};
state.engine.clone()
};
let request = match self.claim_pending_user_input(thread_id, input_id, None) {
let request = match self
.user_input_settlement(thread_id)
.claim_pending_user_input(thread_id, input_id, None, None)?
{
PendingUserInputClaim::Claimed(request) => request,
PendingUserInputClaim::Missing | PendingUserInputClaim::Settling => {
return Ok(false);
@@ -9535,6 +9733,7 @@ impl RuntimeThreadManager {
let (ack_tx, ack_rx) = oneshot::channel();
let worker = tokio::spawn(async move {
let result = manager
.user_input_settlement(&thread_id)
.settle_claimed_user_input(
&thread_id,
Some(engine),
@@ -9551,92 +9750,44 @@ impl RuntimeThreadManager {
.context("User-input cancellation task failed")?
}
async fn settle_claimed_user_input(
async fn publish_user_input_request(
&self,
thread_id: &str,
engine: Option<EngineHandle>,
request: PendingUserInputRequest,
outcome: UserInputTerminalOutcome,
) -> Result<bool> {
turn_id: &str,
engine: &EngineHandle,
id: String,
request: crate::tools::user_input::UserInputRequest,
) -> Result<()> {
let projection_lock = self.projection_lock(thread_id);
let _projection = projection_lock.lock().await;
let (event, payload) = match &outcome {
UserInputTerminalOutcome::Answered(_) => (
"user_input.answered",
json!({ "id": &request.id, "input_id": &request.id }),
),
UserInputTerminalOutcome::Canceled { terminal } => (
"user_input.canceled",
let projection = projection_lock.lock().await;
self.register_pending_user_input(
thread_id,
PendingUserInputRequest {
id: id.clone(),
turn_id: turn_id.to_string(),
request: request.clone(),
},
);
if let Err(err) = self
.emit_event(
thread_id,
Some(turn_id),
None,
"user_input.required",
json!({
"id": &request.id,
"input_id": &request.id,
"terminal": terminal,
"id": id,
"request": request,
}),
),
};
if let Err(error) = self
.emit_event(thread_id, Some(&request.turn_id), None, event, payload)
)
.await
{
if event_append_is_indeterminate(&error) {
self.mark_pending_user_input_indeterminate(thread_id, &request);
} else {
self.restore_pending_user_input_claim(thread_id, &request);
}
return Err(error);
self.discard_pending_user_input_registration(thread_id, &id);
drop(projection);
let _ = engine.cancel_user_input(&id).await;
return Err(err);
}
drop(_projection);
let terminal = matches!(
&outcome,
UserInputTerminalOutcome::Canceled { terminal: true }
);
let delivery_result = match (engine, outcome) {
(Some(engine), UserInputTerminalOutcome::Answered(response)) => {
engine.submit_user_input(&request.id, response).await
}
(Some(engine), UserInputTerminalOutcome::Canceled { .. }) => {
engine.cancel_user_input(&request.id).await
}
(None, _) => Ok(()),
};
if let Err(error) = delivery_result {
if !terminal {
// Keep the same claim until Engine's verdict. A simultaneous
// ToolCallComplete waits for this owner, then closes it; a
// failed delivery alone must not erase an indefinite waiter.
let _projection = projection_lock.lock().await;
if let Err(receipt_error) = self.emit_event(
thread_id,
Some(&request.turn_id),
None,
"user_input.required",
json!({
"id": &request.id,
"request": &request.request,
"delivery_error": "Engine did not accept the decision. Retry if the question is still pending.",
}),
).await {
if event_append_is_indeterminate(&receipt_error) {
self.mark_pending_user_input_indeterminate(thread_id, &request);
} else {
self.restore_pending_user_input_claim(thread_id, &request);
}
return Err(receipt_error);
}
self.restore_pending_user_input_claim(thread_id, &request);
return Err(error);
}
// A terminal turn already ended the waiter. Its durable cleanup
// remains authoritative even when Engine rejects a late cancel.
tracing::debug!(thread_id, input_id = %request.id,
"Terminal user-input cancellation was durable after waiter ended: {error}");
}
let settlement_tx = self.finish_pending_user_input_settlement(thread_id, &request);
if let Some(settlement_tx) = settlement_tx {
settlement_tx.send_modify(|epoch| *epoch = epoch.saturating_add(1));
}
Ok(true)
drop(projection);
Ok(())
}
async fn settle_user_input_for_completed_tool(
@@ -9646,7 +9797,10 @@ impl RuntimeThreadManager {
input_id: &str,
) -> Result<()> {
let request = loop {
match self.claim_pending_user_input(thread_id, input_id, Some(turn_id)) {
match self
.user_input_settlement(thread_id)
.claim_pending_user_input(thread_id, input_id, Some(turn_id), None)?
{
PendingUserInputClaim::Claimed(request) => break request,
PendingUserInputClaim::Missing => return Ok(()),
PendingUserInputClaim::Settling => {
@@ -9677,13 +9831,14 @@ impl RuntimeThreadManager {
// Engine already ended this waiter (timeout, cancellation or failure).
// Reuse durable request settlement without sending a stale decision
// back to it. The tool result carries the actual reason it ended.
self.settle_claimed_user_input(
thread_id,
None,
request,
UserInputTerminalOutcome::Canceled { terminal: false },
)
.await?;
self.user_input_settlement(thread_id)
.settle_claimed_user_input(
thread_id,
None,
request,
UserInputTerminalOutcome::Canceled { terminal: false },
)
.await?;
Ok(())
}
@@ -9697,13 +9852,14 @@ impl RuntimeThreadManager {
let (requests, settling) =
self.claim_pending_user_inputs_for_turn(thread_id, turn_id)?;
for request in requests {
self.settle_claimed_user_input(
thread_id,
engine.clone(),
request,
UserInputTerminalOutcome::Canceled { terminal: true },
)
.await?;
self.user_input_settlement(thread_id)
.settle_claimed_user_input(
thread_id,
engine.clone(),
request,
UserInputTerminalOutcome::Canceled { terminal: true },
)
.await?;
}
if settling.is_empty() {
return Ok(());
@@ -9771,6 +9927,7 @@ impl RuntimeThreadManager {
id: input_id.to_string(),
turn_id: "test-turn".to_string(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
},
@@ -17748,6 +17905,7 @@ impl RuntimeThreadManager {
features.disable(codewhale_runtime::features::Feature::Mcp);
}
let engine_cfg = EngineConfig {
user_input_responder: true,
session_checkpoint_backend: Some(
crate::core::events::SessionCheckpointProjection::RuntimeReceiptRedacted,
),
@@ -18067,6 +18225,7 @@ impl RuntimeThreadManager {
notices: self.notices.clone(),
owner: Arc::downgrade(&self._process_owner_lock),
};
let input_settlement = self.user_input_settlement(&thread.id);
let handle = engine.clone();
let thread_id = thread.id.clone();
let pump = tokio::spawn(async move {
@@ -18136,14 +18295,18 @@ impl RuntimeThreadManager {
}
break 'pump;
};
let owns_turn = {
let (owns_receiver, owns_turn) = {
let active = active.lock().await;
active.engines.get(&thread_id).is_some_and(|state| {
state.active_turn.is_some()
&& identity.upgrade().is_some_and(|receiver| {
active
.engines
.get(&thread_id)
.map(|state| {
let owns_receiver = identity.upgrade().is_some_and(|receiver| {
Arc::ptr_eq(&receiver, &state.engine.rx_event)
})
})
});
(owns_receiver, owns_receiver && state.active_turn.is_some())
})
.unwrap_or((false, false))
};
if !owns_turn {
match &event {
@@ -18165,6 +18328,13 @@ impl RuntimeThreadManager {
EngineEvent::UserInputRequired { id, .. } => {
let _ = handle.cancel_user_input(id).await;
}
EngineEvent::UserInputClosed { id, outcome } if owns_receiver => {
if let Err(error) = input_settlement.close(id, None, *outcome).await
{
tracing::error!(thread_id = %thread_id, input_id = %id, "Runtime user-input closure failed: {error:#}");
break 'pump;
}
}
_ => {}
}
break;
@@ -19285,6 +19455,13 @@ impl RuntimeThreadManager {
bail!("engine event channel closed before turn {turn_id} completed");
};
if let EngineEvent::UserInputClosed { id, outcome } = &event {
self.user_input_settlement(&thread_id)
.close(id, Some(&turn_id), *outcome)
.await?;
continue;
}
if let EngineEvent::SessionUpdated {
checkpoint: Some(checkpoint),
..
@@ -20460,6 +20637,12 @@ impl RuntimeThreadManager {
if !event_channel_closed =>
{
match event {
Some(EngineEvent::UserInputRequired { id, request }) => {
self.publish_user_input_request(&thread_id, &turn_id, &engine, id, request).await?;
}
Some(EngineEvent::UserInputClosed { id, outcome }) => {
self.user_input_settlement(&thread_id).close(&id, Some(&turn_id), outcome).await?;
}
Some(EngineEvent::SessionUpdated { checkpoint: Some(checkpoint), .. }) => {
checkpoint.fail("Runtime checkpoint cannot wait behind an approval");
}
@@ -20709,35 +20892,8 @@ impl RuntimeThreadManager {
}
}
EngineEvent::UserInputRequired { id, request } => {
let projection_lock = self.projection_lock(&thread_id);
let projection = projection_lock.lock().await;
self.register_pending_user_input(
&thread_id,
PendingUserInputRequest {
id: id.clone(),
turn_id: turn_id.clone(),
request: request.clone(),
},
);
if let Err(err) = self
.emit_event(
&thread_id,
Some(&turn_id),
None,
"user_input.required",
json!({
"id": id,
"request": request,
}),
)
.await
{
self.discard_pending_user_input_registration(&thread_id, &id);
drop(projection);
let _ = engine.cancel_user_input(&id).await;
return Err(err);
}
drop(projection);
self.publish_user_input_request(&thread_id, &turn_id, &engine, id, request)
.await?;
}
EngineEvent::Status { message } => {
// A heartbeat queued behind another event while its
+260 -25
View File
@@ -9089,6 +9089,7 @@ async fn terminal_settlement_preserves_late_sink_receipts_during_pending_request
id: "input_late_settlement".to_string(),
turn_id: turn.id.clone(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
},
@@ -13256,7 +13257,13 @@ async fn user_input_snapshot_survives_reload_and_clears_after_submission() -> Re
.send(EngineEvent::UserInputRequired {
id: "input_reload".to_string(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: true,
questions: vec![crate::tools::user_input::UserInputQuestion {
detail: Some("Review this plan".into()),
intent: Some(crate::tools::user_input::UserInputIntent::PlanReview {
approve: "Yes".into(),
call_id: Some("plan-call".into()),
}),
header: "Continue".to_string(),
id: "continue".to_string(),
question: "Continue with the check?".to_string(),
@@ -13297,12 +13304,54 @@ async fn user_input_snapshot_survives_reload_and_clears_after_submission() -> Re
"Continue with the check?"
);
assert!(detail.pending_user_inputs[0].request.require_typed_answers);
assert_eq!(
detail.pending_user_inputs[0].request.questions[0]
.detail
.as_deref(),
Some("Review this plan")
);
let events_before = manager.events_since(&thread.id, None)?.len();
for answer in [
json!({"id":"continue","label":"Yes","value":"Yes"}),
json!({"kind":"choice","id":"continue","label":"Yes","value":"forged"}),
json!({"kind":"custom","id":"continue","label":"","value":"forbidden custom"}),
json!({"kind":"skipped","id":"continue","label":"Yes","value":""}),
json!({"kind":"choice","id":"other-question","label":"Yes","value":"Yes"}),
] {
let response = serde_json::from_value(json!({"answers":[answer]}))?;
assert!(
manager
.submit_user_input(&thread.id, "input_reload", response)
.await
.is_err()
);
assert_eq!(manager.events_since(&thread.id, None)?.len(), events_before);
assert!(
!manager
.pending_user_inputs
.lock()
.get(&(thread.id.clone(), "input_reload".into()))
.unwrap()
.settling
);
}
assert!(
tokio::time::timeout(
Duration::from_millis(25),
harness.recv_user_input_submission()
)
.await
.is_err()
);
let (submitted, delivered) = tokio::join!(
manager.submit_user_input(
&thread.id,
"input_reload",
crate::tools::user_input::UserInputResponse {
answers: vec![crate::tools::user_input::UserInputAnswer {
kind: Some(crate::tools::user_input::UserInputAnswerKind::Choice),
id: "continue".to_string(),
label: "Yes".to_string(),
value: "Yes".to_string(),
@@ -13331,6 +13380,13 @@ async fn user_input_snapshot_survives_reload_and_clears_after_submission() -> Re
&& event.payload.get("input_id").and_then(Value::as_str) == Some("input_reload")
}));
harness
.tx_event
.send(EngineEvent::UserInputClosed {
id: "input_reload".into(),
outcome: crate::core::events::UserInputClosedOutcome::Answered,
})
.await?;
harness
.tx_event
.send(EngineEvent::TurnComplete {
@@ -13346,6 +13402,15 @@ async fn user_input_snapshot_survives_reload_and_clears_after_submission() -> Re
base_url: None,
})
.await?;
wait_for_terminal_turn(&manager, &turn.id).await?;
assert_eq!(
manager
.events_since(&thread.id, None)?
.iter()
.filter(|event| event.event == "user_input.answered")
.count(),
1
);
Ok(())
}
@@ -13362,6 +13427,7 @@ async fn unknown_user_input_id_is_not_delivered_to_engine() -> Result<()> {
"input_missing",
crate::tools::user_input::UserInputResponse {
answers: vec![crate::tools::user_input::UserInputAnswer {
kind: None,
id: "choice".to_string(),
label: "Missing".to_string(),
value: "must-not-enter-engine-mailbox".to_string(),
@@ -13385,15 +13451,68 @@ async fn unknown_user_input_id_is_not_delivered_to_engine() -> Result<()> {
"user_input.answered" | "user_input.canceled"
)
}));
manager.register_pending_user_input_for_thread_for_test(&thread.id, "idle-question");
harness
.tx_event
.send(EngineEvent::UserInputClosed {
id: "idle-question".into(),
outcome: crate::core::events::UserInputClosedOutcome::Unavailable,
})
.await?;
tokio::time::timeout(Duration::from_secs(2), async {
while !manager.pending_requests_for_thread(&thread.id).1.is_empty() {
sleep(Duration::from_millis(10)).await;
}
})
.await
.context("idle event pump did not close the question")?;
assert!(
!manager
.submit_user_input(
&thread.id,
"idle-question",
crate::tools::user_input::UserInputResponse {
answers: Vec::new()
}
)
.await?
);
assert!(
manager
.events_since(&thread.id, None)?
.iter()
.any(|event| event.event == "user_input.canceled"
&& event.payload["outcome"] == "unavailable")
);
Ok(())
}
#[tokio::test]
async fn user_input_tool_timeout_and_cancel_clear_snapshot_before_turn_end() -> Result<()> {
for error in [
crate::tools::spec::ToolError::Timeout { seconds: 1 },
crate::tools::spec::ToolError::cancelled("turn canceled"),
for (error, closed) in [
(crate::tools::spec::ToolError::Timeout { seconds: 1 }, None),
(
crate::tools::spec::ToolError::cancelled("turn canceled"),
None,
),
(
crate::tools::spec::ToolError::Timeout { seconds: 1 },
Some(crate::core::events::UserInputClosedOutcome::TimedOut),
),
(
crate::tools::spec::ToolError::cancelled("turn canceled"),
Some(crate::core::events::UserInputClosedOutcome::Cancelled),
),
(
crate::tools::spec::ToolError::cancelled("unavailable"),
Some(crate::core::events::UserInputClosedOutcome::Unavailable),
),
] {
let input_id = if closed.is_some() {
"nested-question"
} else {
"input-timeout"
};
let manager = test_manager(test_runtime_dir())?;
let thread = manager
.create_thread(CreateThreadRequest::default())
@@ -13424,8 +13543,9 @@ async fn user_input_tool_timeout_and_cancel_clear_snapshot_before_turn_end() ->
harness
.tx_event
.send(EngineEvent::UserInputRequired {
id: "input-timeout".into(),
id: input_id.into(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
})
@@ -13447,15 +13567,25 @@ async fn user_input_tool_timeout_and_cancel_clear_snapshot_before_turn_end() ->
.await
.context("question was not registered")??;
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
id: "input-timeout".into(),
model_call: None,
name: "request_user_input".into(),
result: Err(error),
})
.await?;
if let Some(outcome) = closed {
harness
.tx_event
.send(EngineEvent::UserInputClosed {
id: input_id.into(),
outcome,
})
.await?;
} else {
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
id: "input-timeout".into(),
model_call: None,
name: "request_user_input".into(),
result: Err(error),
})
.await?;
}
tokio::time::timeout(TURN_SETTLEMENT_DEADLOCK_TIMEOUT, async {
loop {
let detail = manager.get_thread_detail(&thread.id).await?;
@@ -13465,8 +13595,14 @@ async fn user_input_tool_timeout_and_cancel_clear_snapshot_before_turn_end() ->
event.event == "user_input.canceled"
&& event.turn_id.as_deref() == Some(turn.id.as_str())
&& event.payload.get("input_id").and_then(Value::as_str)
== Some("input-timeout")
&& event.payload.get("terminal").and_then(Value::as_bool) == Some(false)
== Some(input_id)
&& if let Some(outcome) = closed {
event.payload.get("outcome")
== Some(&serde_json::to_value(outcome).unwrap())
} else {
event.payload.get("terminal").and_then(Value::as_bool)
== Some(false)
}
})
{
assert!(!events.iter().any(|event| event.event == "turn.completed"));
@@ -13485,7 +13621,7 @@ async fn user_input_tool_timeout_and_cancel_clear_snapshot_before_turn_end() ->
!manager
.submit_user_input(
&thread.id,
"input-timeout",
input_id,
crate::tools::user_input::UserInputResponse {
answers: Vec::new()
},
@@ -13539,6 +13675,7 @@ async fn user_input_tool_settlement_preserves_other_request_ids_turns_and_retrya
id: id.into(),
turn_id: turn_id.into(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
},
@@ -13550,7 +13687,12 @@ async fn user_input_tool_settlement_preserves_other_request_ids_turns_and_retrya
.settle_user_input_for_completed_tool(&thread.id, "turn-question", "outer-call")
.await?;
manager
.settle_user_input_for_completed_tool(&thread.id, "turn-old", "inner-question")
.user_input_settlement(&thread.id)
.close(
"inner-question",
Some("turn-old"),
crate::core::events::UserInputClosedOutcome::Cancelled,
)
.await?;
assert_eq!(manager.pending_requests_for_thread(&thread.id).1.len(), 2);
assert!(
@@ -13562,21 +13704,36 @@ async fn user_input_tool_settlement_preserves_other_request_ids_turns_and_retrya
let fault = EventAppendFaultGuard::arm(&thread.id, EventAppendTestFault::AfterSync);
let error = manager
.settle_user_input_for_completed_tool(&thread.id, "turn-question", "inner-question")
.user_input_settlement(&thread.id)
.close(
"inner-question",
Some("turn-question"),
crate::core::events::UserInputClosedOutcome::Cancelled,
)
.await
.expect_err("injected append failure must retain the request");
drop(fault);
assert!(format!("{error:#}").contains("rolled back"));
assert_eq!(manager.pending_requests_for_thread(&thread.id).1.len(), 2);
manager
.settle_user_input_for_completed_tool(&thread.id, "turn-question", "inner-question")
.user_input_settlement(&thread.id)
.close(
"inner-question",
Some("turn-question"),
crate::core::events::UserInputClosedOutcome::Cancelled,
)
.await?;
let pending = manager.pending_requests_for_thread(&thread.id).1;
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, "new-question");
// Duplicate completion is idempotent and never manufactures a receipt.
manager
.settle_user_input_for_completed_tool(&thread.id, "turn-question", "inner-question")
.user_input_settlement(&thread.id)
.close(
"inner-question",
Some("turn-question"),
crate::core::events::UserInputClosedOutcome::Cancelled,
)
.await?;
assert_eq!(
manager
@@ -13603,12 +13760,14 @@ async fn user_input_tool_settlement_waits_for_answer_receipt_and_recovers_failed
id: "input-race".into(),
turn_id: "turn-race".into(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
},
);
let PendingUserInputClaim::Claimed(request) =
manager.claim_pending_user_input(&thread.id, "input-race", None)
let PendingUserInputClaim::Claimed(request) = manager
.user_input_settlement(&thread.id)
.claim_pending_user_input(&thread.id, "input-race", None, None)?
else {
bail!("answer did not own the request");
};
@@ -13623,6 +13782,7 @@ async fn user_input_tool_settlement_waits_for_answer_receipt_and_recovers_failed
);
if answered {
manager
.user_input_settlement(&thread.id)
.settle_claimed_user_input(
&thread.id,
None,
@@ -13637,7 +13797,9 @@ async fn user_input_tool_settlement_waits_for_answer_receipt_and_recovers_failed
} else {
// The API worker restores its claim after a retryable append
// error. The already-completed waiter still needs retirement.
manager.restore_pending_user_input_claim(&thread.id, &request);
manager
.user_input_settlement(&thread.id)
.restore_pending_user_input_claim(&thread.id, &request);
}
tokio::time::timeout(Duration::from_secs(2), &mut settlement)
.await
@@ -13676,6 +13838,7 @@ async fn user_input_rejected_answer_or_cancel_restores_exact_question_for_retry(
id: "input-rejected".into(),
turn_id: "turn-rejected".into(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
},
@@ -13740,6 +13903,7 @@ async fn user_input_terminal_tool_result_does_not_resurrect_rejected_inflight_an
id: "input-expired-race".into(),
turn_id: "turn-expired-race".into(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
},
@@ -13816,12 +13980,23 @@ async fn user_input_receipt_append_failure_restores_request_without_delivery() -
id: "input_retry".to_string(),
turn_id: "turn_retry".to_string(),
request: crate::tools::user_input::UserInputRequest {
questions: Vec::new(),
require_typed_answers: false,
questions: vec![crate::tools::user_input::UserInputQuestion {
detail: None,
intent: None,
header: "Choice".into(),
id: "choice".into(),
question: "Continue?".into(),
options: Vec::new(),
allow_free_text: true,
multi_select: false,
}],
},
},
);
let response = || crate::tools::user_input::UserInputResponse {
answers: vec![crate::tools::user_input::UserInputAnswer {
kind: None,
id: "choice".to_string(),
label: "Retry".to_string(),
value: SECRET.to_string(),
@@ -13887,7 +14062,17 @@ async fn user_input_settlement_outlives_canceled_api_future() -> Result<()> {
id: "input_detached".to_string(),
turn_id: "turn_detached".to_string(),
request: crate::tools::user_input::UserInputRequest {
questions: Vec::new(),
require_typed_answers: false,
questions: vec![crate::tools::user_input::UserInputQuestion {
detail: None,
intent: None,
header: "Choice".into(),
id: "choice".into(),
question: "Continue?".into(),
options: Vec::new(),
allow_free_text: true,
multi_select: false,
}],
},
},
);
@@ -13902,6 +14087,7 @@ async fn user_input_settlement_outlives_canceled_api_future() -> Result<()> {
"input_detached",
crate::tools::user_input::UserInputResponse {
answers: vec![crate::tools::user_input::UserInputAnswer {
kind: None,
id: "choice".to_string(),
label: "Continue".to_string(),
value: SECRET.to_string(),
@@ -13980,6 +14166,7 @@ async fn terminal_user_input_cancellation_is_durable_before_engine_delivery() ->
id: "input_terminal_order".to_string(),
turn_id: "turn_terminal_order".to_string(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
},
@@ -14539,7 +14726,10 @@ async fn terminal_turn_cancels_pending_user_input_and_clears_snapshot() -> Resul
.send(EngineEvent::UserInputRequired {
id: "input_terminal".to_string(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: vec![crate::tools::user_input::UserInputQuestion {
detail: None,
intent: None,
header: "Continue".to_string(),
id: "continue".to_string(),
question: "Continue?".to_string(),
@@ -14642,7 +14832,10 @@ async fn interrupted_turn_cancels_pending_user_input_and_clears_snapshot() -> Re
.send(EngineEvent::UserInputRequired {
id: "input_interrupt".to_string(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: vec![crate::tools::user_input::UserInputQuestion {
detail: None,
intent: None,
header: "Continue".to_string(),
id: "continue".to_string(),
question: "Continue?".to_string(),
@@ -16079,6 +16272,45 @@ async fn engine_withdrawal_retires_approval_and_unblocks_queued_events() -> Resu
harness.tx_event.send(request("ext-1.1")).await?;
let withdrawn_id = await_approval_identity(&manager, &thread.id, "ext-1.1").await?;
harness.tx_event.send(EngineEvent::UserInputRequired {
id: "nested-during-approval".into(),
request: serde_json::from_value(json!({"require_typed_answers":true,"questions":[{"id":"note","question":"Any note?","allow_free_text":true}]}))?,
}).await?;
harness
.tx_event
.send(EngineEvent::UserInputClosed {
id: "nested-during-approval".into(),
outcome: crate::core::events::UserInputClosedOutcome::TimedOut,
})
.await?;
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if manager.events_since(&thread.id, None)?.iter().any(|event| {
event.event == "user_input.canceled"
&& event.payload["input_id"] == "nested-during-approval"
}) {
return Ok::<_, anyhow::Error>(());
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.context("closure parked behind an unrelated approval")??;
let waiting = manager.get_thread_detail(&thread.id).await?;
assert!(waiting.pending_user_inputs.is_empty());
assert_eq!(waiting.pending_approvals.len(), 1);
assert!(
!manager
.submit_user_input(
&thread.id,
"nested-during-approval",
crate::tools::user_input::UserInputResponse {
answers: Vec::new()
}
)
.await?
);
// An unrelated event ahead of withdrawal must not park the event pump.
harness
.tx_event
@@ -18324,7 +18556,10 @@ async fn failed_turn_cancels_pending_user_input_and_clears_snapshot() -> Result<
.send(EngineEvent::UserInputRequired {
id: "input_failed_turn".to_string(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: vec![crate::tools::user_input::UserInputQuestion {
detail: None,
intent: None,
header: "Continue".to_string(),
id: "continue".to_string(),
question: "Continue?".to_string(),
@@ -198,6 +198,7 @@ async fn shutdown_drains_accepted_user_input_receipt_after_caller_disconnects()
id: "input_drain".into(),
turn_id: "turn_drain".into(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
},
+60 -5
View File
@@ -221,6 +221,13 @@ pub(crate) enum NestedCallRequest {
withdraw: CancellationToken,
reply: oneshot::Sender<Result<bool, ToolError>>,
},
UserQuestion {
caller: crate::core::events::SessionCheckpointCaller,
request: crate::tools::user_input::UserInputRequest,
withdraw: CancellationToken,
clock: Arc<Mutex<PauseClock>>,
reply: oneshot::Sender<Result<crate::tools::user_input::UserInputResponse, ToolError>>,
},
}
pub(crate) struct NestedToolCallRequest {
@@ -349,6 +356,36 @@ impl NestedCallGate {
}
}
pub(crate) async fn user_question(
&self,
caller: crate::core::events::SessionCheckpointCaller,
request: crate::tools::user_input::UserInputRequest,
withdraw: CancellationToken,
clock: Arc<Mutex<PauseClock>>,
) -> Result<crate::tools::user_input::UserInputResponse, ToolError> {
if self.extension.is_none() {
return Err(ToolError::not_available(
"user questions require a directly served extension invocation",
));
}
let (reply, answer) = oneshot::channel();
let request = NestedCallRequest::UserQuestion {
caller,
request,
withdraw: withdraw.clone(),
clock,
reply,
};
tokio::select! {
biased;
() = withdraw.cancelled() => Err(ToolError::cancelled("user question was withdrawn")),
result = async {
self.requests.send(request).await.map_err(|_| ToolError::not_available("the turn no longer serves user questions"))?;
answer.await.map_err(|_| ToolError::not_available("owning host did not answer the user question"))?
} => result,
}
}
/// Ask the serving turn loop to decide one nested call. A gate nobody
/// serves any more refuses.
pub(crate) async fn ask(&self, name: String, input: Value) -> NestedCallVerdict {
@@ -423,6 +460,12 @@ impl NestedCallGate {
while let Some(request) = requests.recv().await {
let request = match request {
NestedCallRequest::Tool(request) => request,
NestedCallRequest::UserQuestion { reply, .. } => {
let _ = reply.send(Err(ToolError::not_available(
"fixture has no user question backend",
)));
continue;
}
NestedCallRequest::Checkpoint { reply, .. } => {
let _ = reply.send(Err(ToolError::not_available(
"fixture has no session checkpoint backend",
@@ -579,7 +622,16 @@ impl PauseClock {
}
}
struct PauseGuard<'a>(&'a Mutex<PauseClock>);
pub(crate) struct PauseGuard<'a>(&'a Mutex<PauseClock>);
impl<'a> PauseGuard<'a> {
pub(crate) fn new(clock: &'a Mutex<PauseClock>) -> Self {
if let Ok(mut clock) = clock.lock() {
clock.pause();
}
Self(clock)
}
}
impl Drop for PauseGuard<'_> {
fn drop(&mut self) {
@@ -713,10 +765,7 @@ impl CodemodeInvoker {
}
fn pause(&self) -> PauseGuard<'_> {
if let Ok(mut clock) = self.clock.lock() {
clock.pause();
}
PauseGuard(&self.clock)
PauseGuard::new(&self.clock)
}
/// Time left before the deadline ([`PauseClock::remaining`]).
@@ -1649,6 +1698,12 @@ mod tests {
while let Some(request) = requests.recv().await {
let request = match request {
NestedCallRequest::Tool(request) => request,
NestedCallRequest::UserQuestion { reply, .. } => {
let _ = reply.send(Err(ToolError::not_available(
"fixture has no user question backend",
)));
continue;
}
NestedCallRequest::Checkpoint { reply, .. } => {
let _ = reply.send(Err(ToolError::not_available(
"fixture has no session checkpoint backend",
+288 -28
View File
@@ -6,6 +6,7 @@ use super::spec::{
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashSet;
/// Default ceiling on `request_user_input.questions` (#5949). Raised from the
/// former hard-coded 3 so research/planning turns that need four or more
@@ -98,17 +99,37 @@ fn clamp_with_warn(raw: Option<u32>, default: usize, min: usize, max: usize, key
clamped
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserInputOption {
pub label: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum UserInputIntent {
PlanReview {
approve: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
call_id: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserInputQuestion {
#[serde(default)]
pub header: String,
pub id: String,
pub question: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<UserInputIntent>,
#[serde(default)]
pub options: Vec<UserInputOption>,
/// When `true`, the modal offers a free-text "Other" response in addition
/// to the fixed options. Defaults to `false` for backwards compatibility
@@ -120,8 +141,11 @@ pub struct UserInputQuestion {
pub multi_select: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserInputRequest {
#[serde(default)]
pub require_typed_answers: bool,
pub questions: Vec<UserInputQuestion>,
}
@@ -137,9 +161,10 @@ impl UserInputRequest {
value: &Value,
limits: UserInputLimits,
) -> Result<Self, ToolError> {
let request: UserInputRequest = serde_json::from_value(value.clone()).map_err(|e| {
let mut request: UserInputRequest = serde_json::from_value(value.clone()).map_err(|e| {
ToolError::invalid_input(format!("Invalid request_user_input payload: {e}"))
})?;
request.require_typed_answers = false;
request.validate_with_limits(limits)?;
Ok(request)
}
@@ -150,6 +175,7 @@ impl UserInputRequest {
}
pub fn validate_with_limits(&self, limits: UserInputLimits) -> Result<(), ToolError> {
self.validate_transport()?;
if self.questions.is_empty() {
return Err(ToolError::invalid_input(
"request_user_input.questions must be non-empty",
@@ -167,21 +193,6 @@ impl UserInputRequest {
)));
}
for q in &self.questions {
if q.header.trim().is_empty() {
return Err(ToolError::invalid_input(
"request_user_input.questions.header cannot be empty",
));
}
if q.id.trim().is_empty() {
return Err(ToolError::invalid_input(
"request_user_input.questions.id cannot be empty",
));
}
if q.question.trim().is_empty() {
return Err(ToolError::invalid_input(
"request_user_input.questions.question cannot be empty",
));
}
if q.options.len() < MIN_CONFIGURABLE_OPTIONS || q.options.len() > limits.max_options {
return Err(ToolError::invalid_input(format!(
"request_user_input.questions.options must contain \
@@ -192,31 +203,171 @@ impl UserInputRequest {
got = q.options.len(),
)));
}
for opt in &q.options {
if opt.label.trim().is_empty() {
}
Ok(())
}
pub fn validate_transport(&self) -> Result<(), ToolError> {
if self.questions.is_empty() || self.questions.len() > MAX_CONFIGURABLE_QUESTIONS {
return Err(ToolError::invalid_input(
"User input must contain 1 to 10 questions",
));
}
if serde_json::to_vec(self)
.map_err(|_| ToolError::invalid_input("Invalid user input request"))?
.len()
> 1024 * 1024
{
return Err(ToolError::invalid_input("User input request exceeds 1 MiB"));
}
let mut ids = HashSet::new();
for question in &self.questions {
if question.id.trim().is_empty()
|| question.question.trim().is_empty()
|| !ids.insert(&question.id)
{
return Err(ToolError::invalid_input(
"User input questions require unique nonempty ids and nonempty text",
));
}
if question.options.len() > 1000
|| (question.options.is_empty() && !question.allow_free_text)
{
return Err(ToolError::invalid_input(
"User input supports at most 1000 choices per question; zero choices requires free text",
));
}
let mut labels = HashSet::new();
for option in &question.options {
if option.label.trim().is_empty() || !labels.insert(&option.label) {
return Err(ToolError::invalid_input(
"request_user_input option label cannot be empty",
"User input choices require unique nonempty labels",
));
}
if opt.description.trim().is_empty() {
}
if let Some(UserInputIntent::PlanReview { approve, .. }) = &question.intent {
if !labels.contains(approve)
|| question
.detail
.as_ref()
.is_none_or(|detail| detail.trim().is_empty())
{
return Err(ToolError::invalid_input(
"request_user_input option description cannot be empty",
"Plan review requires detail and an offered approve choice",
));
}
}
}
Ok(())
}
pub fn validate_response(&self, response: &UserInputResponse) -> Result<(), ToolError> {
if response.answers.len() > 10020
|| serde_json::to_vec(response)
.map_err(|_| ToolError::invalid_input("Invalid user input response"))?
.len()
> 1024 * 1024
{
return Err(ToolError::invalid_input(
"User input response exceeds its transport limits",
));
}
if response.answers.iter().any(|answer| {
!self
.questions
.iter()
.any(|question| question.id == answer.id)
}) {
return Err(ToolError::invalid_input("Answer names an unknown question"));
}
if !self.require_typed_answers
&& response.answers.iter().all(|answer| answer.kind.is_none())
{
return Ok(());
}
if response.answers.iter().any(|answer| answer.kind.is_none()) {
return Err(ToolError::invalid_input(
"This question requires typed answers; use a client that supports choice, custom and skipped answers",
));
}
for question in &self.questions {
let mut choices = HashSet::new();
let mut custom = false;
let mut skipped = false;
let mut count = 0;
for answer in response
.answers
.iter()
.filter(|answer| answer.id == question.id)
{
count += 1;
match answer.kind {
Some(UserInputAnswerKind::Choice) => {
if answer.label != answer.value
|| !question
.options
.iter()
.any(|option| option.label == answer.value)
|| !choices.insert(&answer.value)
{
return Err(ToolError::invalid_input(
"Answer must select each offered label at most once",
));
}
}
Some(UserInputAnswerKind::Custom) => {
if (self.require_typed_answers && !question.allow_free_text)
|| custom
|| !answer.label.is_empty()
|| answer.value.trim().is_empty()
{
return Err(ToolError::invalid_input("Invalid custom answer"));
}
custom = true;
}
Some(UserInputAnswerKind::Skipped) => {
if skipped || !answer.label.is_empty() || !answer.value.is_empty() {
return Err(ToolError::invalid_input("Invalid skipped answer"));
}
skipped = true;
}
None => unreachable!(),
}
}
if count == 0
|| (skipped && count != 1)
|| (!question.multi_select && choices.len() + usize::from(custom) > 1)
{
return Err(ToolError::invalid_input(
"Answer must explicitly answer or skip each question and respect single selection",
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum UserInputAnswerKind {
Choice,
Custom,
Skipped,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserInputAnswer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<UserInputAnswerKind>,
pub id: String,
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserInputResponse {
pub answers: Vec<UserInputAnswer>,
}
@@ -281,7 +432,7 @@ impl ToolSpec for RequestUserInputTool {
"label": { "type": "string" },
"description": { "type": "string" }
},
"required": ["label", "description"]
"required": ["label"]
},
"minItems": MIN_CONFIGURABLE_OPTIONS,
"maxItems": self.limits.max_options
@@ -297,7 +448,7 @@ impl ToolSpec for RequestUserInputTool {
"default": false
}
},
"required": ["header", "id", "question", "options"]
"required": ["id", "question", "options"]
},
"minItems": 1,
"maxItems": self.limits.max_questions
@@ -333,10 +484,13 @@ mod tests {
#[test]
fn validates_request_shape() {
let request = UserInputRequest {
require_typed_answers: false,
questions: vec![UserInputQuestion {
header: "Pick".to_string(),
id: "choice".to_string(),
question: "Which option?".to_string(),
detail: None,
intent: None,
options: vec![
UserInputOption {
label: "A".to_string(),
@@ -352,6 +506,102 @@ mod tests {
}],
};
assert!(request.validate().is_ok());
for count in [0, 1, 100, 1000] {
let menu: UserInputRequest = serde_json::from_value(json!({
"require_typed_answers":true,
"questions":[{"id":"menu", "question":"Choose", "allow_free_text":true,
"options":(0..count).map(|i| json!({"label":format!("Choice {i}")})).collect::<Vec<_>>() }]
})).unwrap();
assert!(menu.validate_transport().is_ok());
assert!(menu.validate().is_err());
assert!(menu.questions[0].header.is_empty());
assert!(
menu.questions[0]
.options
.iter()
.all(|option| option.description.is_empty())
);
}
let mut menu: UserInputRequest = serde_json::from_value(json!({
"require_typed_answers":true,
"questions":[{"id":"menu", "question":"Choose", "allow_free_text":true, "multi_select":true,
"options":[{"label":"Other"},{"label":"left, right"}]}]
})).unwrap();
let mut response: UserInputResponse = serde_json::from_value(json!({"answers":[
{"id":"menu","kind":"choice","label":"Other","value":"Other"},
{"id":"menu","kind":"choice","label":"left, right","value":"left, right"},
{"id":"menu","kind":"custom","label":"","value":"custom text"}
]}))
.unwrap();
assert!(menu.validate_response(&response).is_ok());
let exact = serde_json::to_value(&response).unwrap();
assert_eq!(exact["answers"][1]["value"], "left, right");
menu.questions[0].multi_select = false;
assert!(menu.validate_response(&response).is_err());
menu.questions[0].multi_select = true;
response.answers.push(response.answers[0].clone());
assert!(menu.validate_response(&response).is_err());
response.answers.pop();
response.answers[0].kind = None;
assert!(menu.validate_response(&response).is_err());
for answers in [
json!([]),
json!([{"id":"unknown","kind":"skipped","label":"","value":""}]),
json!([{"id":"menu","kind":"choice","label":"unknown","value":"unknown"}]),
json!([{"id":"menu","kind":"choice","label":"Other","value":"wrong"}]),
json!([{"id":"menu","kind":"custom","label":"Other","value":"text"}]),
json!([{"id":"menu","kind":"custom","label":"","value":" "}]),
json!([{"id":"menu","kind":"skipped","label":"","value":""},{"id":"menu","kind":"custom","label":"","value":"text"}]),
] {
let response = serde_json::from_value(json!({"answers":answers})).unwrap();
assert!(menu.validate_response(&response).is_err());
}
let skipped = serde_json::from_value(
json!({"answers":[{"id":"menu","kind":"skipped","label":"","value":""}]}),
)
.unwrap();
assert!(menu.validate_response(&skipped).is_ok());
let custom = serde_json::from_value(
json!({"answers":[{"id":"menu","kind":"custom","label":"","value":"legacy custom"}]}),
)
.unwrap();
menu.questions[0].allow_free_text = false;
assert!(menu.validate_response(&custom).is_err());
menu.require_typed_answers = false;
assert!(menu.validate_response(&custom).is_ok());
let mixed = serde_json::from_value(json!({"answers":[{"id":"menu","kind":"custom","label":"","value":"legacy custom"},{"id":"menu","label":"Other","value":"Other"}]})).unwrap();
assert!(menu.validate_response(&mixed).is_err());
menu.require_typed_answers = true;
menu.questions[0].allow_free_text = true;
menu.questions[0].intent = Some(UserInputIntent::PlanReview {
approve: "Other".into(),
call_id: Some("display-only".into()),
});
assert!(menu.validate_transport().is_err());
menu.questions[0].detail = Some("Plan detail".into());
assert!(menu.validate_transport().is_ok());
menu.questions[0].options[0].label = "Different".into();
assert!(menu.validate_transport().is_err());
menu.questions[0].intent = None;
menu.questions.push(menu.questions[0].clone());
assert!(menu.validate_transport().is_err());
menu.questions.pop();
menu.questions[0].options = vec![
UserInputOption {
label: "duplicate".into(),
description: String::new()
};
1001
];
assert!(menu.validate_transport().is_err());
menu.questions[0].options.truncate(2);
assert!(menu.validate_transport().is_err());
menu.questions[0].options.clear();
menu.questions[0].allow_free_text = false;
assert!(menu.validate_transport().is_err());
menu.questions[0].allow_free_text = true;
menu.questions[0].detail = Some("x".repeat(1024 * 1024));
assert!(menu.validate_transport().is_err());
}
#[test]
@@ -379,6 +629,13 @@ mod tests {
assert_eq!(request.questions[0].options.len(), 4);
assert!(request.questions[0].allow_free_text);
assert!(request.questions[0].multi_select);
let mut input = input;
input["require_typed_answers"] = json!(true);
assert!(
!UserInputRequest::from_value(&input)
.unwrap()
.require_typed_answers
);
}
#[test]
@@ -426,6 +683,8 @@ mod tests {
header: header.to_string(),
id: id.to_string(),
question: "?".to_string(),
detail: None,
intent: None,
options: vec![
UserInputOption {
label: "A".to_string(),
@@ -443,6 +702,7 @@ mod tests {
fn questions(count: usize) -> UserInputRequest {
UserInputRequest {
require_typed_answers: false,
questions: (1..=count)
.map(|i| yes_no_question(&format!("Q{i}"), &format!("q{i}")))
.collect(),
+4 -4
View File
@@ -77,15 +77,15 @@ enum PagerSource {
/// The display rows of one page and the column width they were wrapped to.
#[derive(Debug, Clone, Default)]
struct PagerRows {
pub(super) struct PagerRows {
/// `None` until the page is first laid out.
width: Option<usize>,
plain: Vec<String>,
/// Styled rows of a styled-line page; text pages paint `plain`.
styled: Vec<Line<'static>>,
pub(super) styled: Vec<Line<'static>>,
/// The source line each row was wrapped from, so a re-wrap can keep the
/// same line at the top of the view.
origin: Vec<usize>,
pub(super) origin: Vec<usize>,
}
impl PagerRows {
@@ -103,7 +103,7 @@ impl PagerRows {
rows
}
fn wrap_lines(lines: &[Line<'static>], width: usize) -> Self {
pub(super) fn wrap_lines(lines: &[Line<'static>], width: usize) -> Self {
let mut rows = Self {
width: Some(width),
..Self::default()
+4
View File
@@ -357,6 +357,10 @@ fn metadata(event: &Event, turn_id: Option<&str>) -> Option<String> {
} => json!({
"event":"approval_resolved","id":id,"outcome":"cancelled"
}),
Event::UserInputClosed { id, outcome } => json!({
"event":"approval_resolved", "id":id,
"outcome":if matches!(outcome, crate::core::events::UserInputClosedOutcome::Answered) { "approved" } else { "cancelled" }
}),
Event::AgentSpawned { id, .. } => json!({"event":"agent_spawned","id":id}),
Event::AgentProgress { id, activity, .. } => json!({
"event":"agent_progress",
@@ -672,6 +672,7 @@ fn clock_distinguishes_working_from_waiting_on_something() {
app.pending_user_input_prompt = Some((
"tool-call-1".to_string(),
crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
));
+4
View File
@@ -67,7 +67,10 @@ pub(crate) fn request(locale: Locale) -> UserInputRequest {
description: tr(locale, description).into_owned(),
};
UserInputRequest {
require_typed_answers: false,
questions: vec![UserInputQuestion {
detail: None,
intent: None,
header: tr(locale, MessageId::PlanHandoffHeader).into_owned(),
id: QUESTION_ID.to_string(),
question: tr(locale, MessageId::PlanHandoffQuestion).into_owned(),
@@ -126,6 +129,7 @@ mod tests {
id: QUESTION_ID.to_string(),
label: label.to_string(),
value: value.to_string(),
kind: None,
}],
}
}
+3
View File
@@ -3238,6 +3238,9 @@ pub(crate) fn settle_pending_human_requests(app: &mut App) {
/// completion cannot settle a different, inner request id.
pub(crate) fn observe_human_request_settlement(app: &mut App, event: &EngineEvent) {
match event {
EngineEvent::UserInputClosed { id, .. } => {
settle_user_input_request(app, id);
}
EngineEvent::ToolCallComplete { id, .. } => {
settle_user_input_request(app, id);
crate::tui::pending_requests::retire(app, id);
+1
View File
@@ -4517,6 +4517,7 @@ pub(crate) async fn run_event_loop(
}
// Retired by pending_requests before session/idle filters.
EngineEvent::ApprovalWithdrawn { .. } => {}
EngineEvent::UserInputClosed { .. } => {}
EngineEvent::UserInputRequired { id, request } => {
app.pending_user_input_prompt = Some((id.clone(), request.clone()));
app.view_stack
+1
View File
@@ -952,6 +952,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
.max_subagents
.clamp(1, codewhale_runtime::config::MAX_SUBAGENTS);
EngineConfig {
user_input_responder: true,
session_checkpoint_backend: Some(crate::core::events::SessionCheckpointProjection::Exact),
model: app.model.clone(),
active_route_limits: app.active_route_limits,
+2
View File
@@ -553,6 +553,7 @@ mv "$receipt" "$receipt.done"
(
"hook-fixture-question".to_string(),
crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
)
@@ -651,6 +652,7 @@ mv "$receipt" "$receipt.done"
app.pending_user_input_prompt = Some((
"q1".to_string(),
crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
));
+26
View File
@@ -1837,7 +1837,10 @@ fn scroll_test_question_view() -> UserInputView {
UserInputView::new(
"question-scroll",
UserInputRequest {
require_typed_answers: false,
questions: vec![UserInputQuestion {
detail: None,
intent: None,
header: "Choose".into(),
id: "choice".into(),
question: "Review the transcript before choosing a path.".into(),
@@ -16360,12 +16363,14 @@ fn turn_liveness_preserves_pending_user_input_beyond_tool_timeout() {
app.pending_user_input_prompt = Some((
"question-1".into(),
crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
));
app.view_stack.push(UserInputView::new(
"question-1",
crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
app.ui_locale,
@@ -16420,6 +16425,7 @@ fn turn_liveness_preserves_pending_user_input_beyond_tool_timeout() {
fn install_pending_question(app: &mut App, id: &str) {
let request = crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
};
app.pending_user_input_prompt = Some((id.into(), request.clone()));
@@ -16480,6 +16486,13 @@ fn user_input_completion_preserves_newer_question_and_dispatch() {
// Neither a previous request nor a wrapping call's different id owns it.
observe_human_request_settlement(&mut app, &completion("input-old"));
observe_human_request_settlement(&mut app, &completion("outer-call"));
observe_human_request_settlement(
&mut app,
&EngineEvent::UserInputClosed {
id: "input-old".into(),
outcome: crate::core::events::UserInputClosedOutcome::Cancelled,
},
);
apply_user_input_submission_result(&mut app, "input-old", Ok(()));
assert_eq!(app.turn_last_activity_at, Some(activity));
assert_eq!(
@@ -16507,6 +16520,16 @@ fn user_input_completion_preserves_newer_question_and_dispatch() {
assert!(app.pending_user_input_prompt.is_some());
assert!(app.view_stack.contains_kind(ModalKind::UserInput));
assert_eq!(app.status_toasts.len(), 1);
observe_human_request_settlement(
&mut app,
&EngineEvent::UserInputClosed {
id: "input-new".into(),
outcome: crate::core::events::UserInputClosedOutcome::Answered,
},
);
assert!(app.pending_user_input_prompt.is_none());
assert!(!app.view_stack.contains_kind(ModalKind::UserInput));
assert_eq!(app.turn_last_activity_at, Some(activity));
}
#[test]
@@ -28437,6 +28460,7 @@ async fn stale_parent_approval_is_resolved_unavailable_not_dropped() {
let question = EngineEvent::UserInputRequired {
id: "stale-question".to_string(),
request: crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
};
@@ -33759,6 +33783,7 @@ fn notification_input_failure_keeps_the_request_and_success_retires_only_its_act
app.pending_user_input_prompt = Some((
"input-a".into(),
crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
));
@@ -33808,6 +33833,7 @@ fn notification_input_result_never_reopens_or_clears_a_different_pending_questio
app.pending_user_input_prompt = Some((
"input-b".into(),
crate::tools::user_input::UserInputRequest {
require_typed_answers: false,
questions: Vec::new(),
},
));
+296 -48
View File
@@ -5,12 +5,13 @@ use std::cell::Cell;
use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget};
use crate::tools::user_input::{
UserInputAnswer, UserInputQuestion, UserInputRequest, UserInputResponse,
UserInputAnswer, UserInputAnswerKind, UserInputQuestion, UserInputRequest, UserInputResponse,
};
use crate::tui::menu_style;
use crate::tui::pager::PagerRows;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent, render_modal_surface};
use codewhale_localization::{Locale, MessageId, tr};
use codewhale_palette as palette;
@@ -65,10 +66,12 @@ fn push_option_lines(
format!("{prefix}{mark}{number}) {label}"),
row_style,
)));
lines.push(Line::from(Span::styled(
format!(" {description}"),
detail_style,
)));
if !description.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {description}"),
detail_style,
)));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -95,19 +98,28 @@ pub struct UserInputView {
/// Wheel browsing uses wrapped rows from the last paint. Option navigation
/// or typing returns to following focus so the next edit stays visible.
manual_scroll: bool,
scroll_offset: Cell<u16>,
max_scroll: Cell<u16>,
scroll_offset: Cell<usize>,
max_scroll: Cell<usize>,
}
impl UserInputView {
pub fn new(tool_id: impl Into<String>, request: UserInputRequest, locale: Locale) -> Self {
let mode = if request
.questions
.first()
.is_some_and(|question| question.options.is_empty())
{
InputMode::OtherInput
} else {
InputMode::Selecting
};
Self {
tool_id: tool_id.into(),
request,
locale,
question_index: 0,
selected: 0,
mode: InputMode::Selecting,
mode,
other_input: String::new(),
answered: Vec::new(),
multi_pending: Vec::new(),
@@ -122,12 +134,8 @@ impl UserInputView {
}
/// Whether the "Other" free-text row is offered for the current question.
/// Free text is ALWAYS available so the user can answer with their own
/// words even when the model did not offer it. `allow_free_text` remains
/// part of the wire request (backward-compatible) but no longer gates the
/// row: a custom response must always be reachable alongside the options.
fn offers_other(&self) -> bool {
true
!self.request.require_typed_answers || self.current_question().allow_free_text
}
fn option_count(&self) -> usize {
@@ -170,7 +178,15 @@ impl UserInputView {
let question = self.current_question();
// "Action required" banner, header line, blank, question, blank.
let mut count = 5;
count += question.options.len() * 2;
count += question
.detail
.as_ref()
.map_or(0, |detail| detail.lines().count() + 1);
count += question
.options
.iter()
.map(|option| 1 + usize::from(!option.description.is_empty()))
.sum::<usize>();
if self.offers_other() {
count += 2;
}
@@ -202,6 +218,7 @@ impl UserInputView {
id: question.id.clone(),
label: option.label.clone(),
value: option.label.clone(),
kind: Some(UserInputAnswerKind::Choice),
}]
}
@@ -209,12 +226,27 @@ impl UserInputView {
self.answered.iter().flatten().cloned().collect()
}
fn advance_question(&mut self, new_answers: Vec<UserInputAnswer>) -> ViewAction {
fn advance_question(&mut self, mut new_answers: Vec<UserInputAnswer>) -> ViewAction {
if new_answers.is_empty() {
new_answers.push(UserInputAnswer {
id: self.current_question().id.clone(),
label: String::new(),
value: String::new(),
kind: Some(UserInputAnswerKind::Skipped),
});
}
self.answered.push(new_answers);
if self.question_index + 1 >= self.request.questions.len() {
let response = UserInputResponse {
answers: self.committed_answers(),
};
if let Err(error) = self.request.validate_response(&response) {
self.answered.pop();
return ViewAction::Emit(ViewEvent::StatusMessage {
message: tr(self.locale, MessageId::NotificationInputSubmitFailed)
.replace("{error}", &error.to_string()),
});
}
return ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted {
tool_id: self.tool_id.clone(),
response,
@@ -222,7 +254,11 @@ impl UserInputView {
}
self.question_index += 1;
self.selected = 0;
self.mode = InputMode::Selecting;
self.mode = if self.current_question().options.is_empty() {
InputMode::OtherInput
} else {
InputMode::Selecting
};
self.other_input.clear();
self.multi_pending.clear();
ViewAction::None
@@ -235,7 +271,11 @@ impl UserInputView {
self.answered.pop();
self.question_index = self.question_index.saturating_sub(1);
self.selected = 0;
self.mode = InputMode::Selecting;
self.mode = if self.current_question().options.is_empty() {
InputMode::OtherInput
} else {
InputMode::Selecting
};
self.other_input.clear();
self.multi_pending.clear();
ViewAction::None
@@ -248,8 +288,24 @@ impl UserInputView {
let start = self.content_line_count().saturating_sub(4);
return (start, start.saturating_add(1));
}
let start = 5 + self.selected.saturating_mul(2);
(start, start.saturating_add(1))
let question = self.current_question();
let detail_lines = question
.detail
.as_ref()
.map_or(0, |detail| detail.lines().count() + 1);
let option_lines = question
.options
.iter()
.take(self.selected)
.map(|option| 1 + usize::from(!option.description.is_empty()))
.sum::<usize>();
let extra_rows = self.selected.saturating_sub(question.options.len());
let start = 5 + detail_lines + option_lines + extra_rows * 2;
let height = question
.options
.get(self.selected)
.map_or(2, |option| 1 + usize::from(!option.description.is_empty()));
(start, start.saturating_add(height - 1))
}
fn handle_selecting_key(&mut self, key: KeyEvent) -> ViewAction {
@@ -325,6 +381,7 @@ impl UserInputView {
id: question.id.clone(),
label: opt.label.clone(),
value: opt.label.clone(),
kind: Some(UserInputAnswerKind::Choice),
})
.collect();
return self.advance_question(answers);
@@ -354,11 +411,12 @@ impl UserInputView {
}
KeyCode::Enter => {
let question = self.current_question();
let answer = UserInputAnswer {
let answer = (!self.other_input.trim().is_empty()).then(|| UserInputAnswer {
id: question.id.clone(),
label: "Other".to_string(),
value: self.other_input.trim().to_string(),
};
label: String::new(),
value: self.other_input.clone(),
kind: Some(UserInputAnswerKind::Custom),
});
// In multi-select mode a free-text "Other" is still a single
// answer appended to whatever options were toggled.
let mut answers: Vec<UserInputAnswer> = self
@@ -369,9 +427,10 @@ impl UserInputView {
id: question.id.clone(),
label: opt.label.clone(),
value: opt.label.clone(),
kind: Some(UserInputAnswerKind::Choice),
})
.collect();
answers.push(answer);
answers.extend(answer);
self.advance_question(answers)
}
KeyCode::Backspace => {
@@ -492,6 +551,15 @@ impl ModalView for UserInputView {
)]));
lines.push(Line::from(""));
if let Some(detail) = &question.detail {
lines.extend(detail.lines().map(|line| {
Line::from(Span::styled(
line.to_owned(),
Style::default().fg(palette::TEXT_PRIMARY),
))
}));
lines.push(Line::from(""));
}
for (idx, option) in question.options.iter().enumerate() {
let number = idx + 1;
let ticked = self.is_multi_select() && self.multi_pending.contains(&idx);
@@ -505,7 +573,6 @@ impl ModalView for UserInputView {
);
}
// A custom response stays available alongside the suggested options.
if self.offers_other() {
let other_index = question.options.len();
let other_number = other_index + 1;
@@ -558,7 +625,14 @@ impl ModalView for UserInputView {
if self.mode == InputMode::OtherInput {
lines.push(Line::from(vec![
Span::styled("Enter", Style::default().fg(palette::WHALE_ACTION).bold()),
hint_label(locale, MessageId::UserInputHintSubmit),
hint_label(
locale,
if self.other_input.trim().is_empty() && self.multi_pending.is_empty() {
MessageId::SetupActionSkip
} else {
MessageId::UserInputHintSubmit
},
),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::WHALE_ACTION).bold()),
hint_label(locale, MessageId::UserInputHintBack),
@@ -615,28 +689,20 @@ impl ModalView for UserInputView {
let popup_area = sheet_rect(area, self.content_line_count());
let inner_h = popup_area.height.saturating_sub(4) as usize;
// Paragraph scroll offsets count wrapped rows, not the source Lines.
// Use the same wrapper for focus measurement and painting so long
// questions/descriptions cannot hide the highlighted choice (#6045).
let width = modal_block(&header).inner(popup_area).width.max(1);
let heights: Vec<usize> = lines
.iter()
.map(|line| {
Paragraph::new(line.clone())
.wrap(Wrap { trim: true })
.line_count(width)
})
.collect();
let rows = PagerRows::wrap_lines(&lines, usize::from(width));
let (start, end) = self.focused_line_range();
let focus_end = heights[..=end].iter().sum::<usize>().saturating_sub(1);
let focus_end = rows
.origin
.partition_point(|&line| line <= end)
.saturating_sub(1);
let focus_start = if self.mode == InputMode::OtherInput {
// Keep the typing end visible when a custom answer wraps.
focus_end
} else {
heights[..start].iter().sum()
rows.origin.partition_point(|&line| line < start)
};
let total_rows = heights.iter().sum::<usize>();
let max_scroll = u16::try_from(total_rows.saturating_sub(inner_h)).unwrap_or(u16::MAX);
let total_rows = rows.styled.len();
let max_scroll = total_rows.saturating_sub(inner_h);
let scroll = if self.manual_scroll {
self.scroll_offset.get().min(max_scroll)
} else {
@@ -644,10 +710,9 @@ impl ModalView for UserInputView {
};
self.scroll_offset.set(scroll);
self.max_scroll.set(max_scroll);
let paragraph = Paragraph::new(lines)
let visible: Vec<_> = rows.styled.into_iter().skip(scroll).take(inner_h).collect();
let paragraph = Paragraph::new(visible)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.scroll((scroll, 0))
.block(modal_block(&header));
render_modal_chrome(area, popup_area, buf);
@@ -704,7 +769,7 @@ fn scroll_to_keep_range_visible(
(focus_start, focus_end): (usize, usize),
total_lines: usize,
inner_h: usize,
) -> u16 {
) -> usize {
if inner_h == 0 || total_lines <= inner_h {
return 0;
}
@@ -716,7 +781,7 @@ fn scroll_to_keep_range_visible(
if focus_start < scroll {
scroll = focus_start;
}
u16::try_from(scroll.min(max_scroll)).unwrap_or(u16::MAX)
scroll.min(max_scroll)
}
#[cfg(test)]
@@ -739,7 +804,10 @@ mod tests {
UserInputView::new(
"tool-1",
UserInputRequest {
require_typed_answers: false,
questions: vec![UserInputQuestion {
detail: None,
intent: None,
header: "Confirm".to_string(),
id: "confirm".to_string(),
question: "What should happen next?".to_string(),
@@ -809,6 +877,60 @@ mod tests {
let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
assert!(matches!(action, ViewAction::None));
assert_eq!(view.mode, InputMode::OtherInput);
for allow_free_text in [false, true] {
let mut view = sample_view();
view.request.require_typed_answers = true;
view.request.questions[0].allow_free_text = allow_free_text;
assert_eq!(view.offers_other(), allow_free_text);
assert_eq!(view.option_count(), 2 + usize::from(allow_free_text));
let rendered = render_view(&view, 110, 36);
assert_eq!(rendered.contains("Type a custom response"), allow_free_text);
view.selected = view.option_count() - 1;
let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
let action = if allow_free_text {
assert!(matches!(action, ViewAction::None));
assert_eq!(view.mode, InputMode::OtherInput);
view.other_input = " custom answer ".into();
view.handle_other_input_key(KeyEvent::from(KeyCode::Enter))
} else {
action
};
let ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { response, .. }) = action
else {
panic!("expected valid typed answer")
};
assert_eq!(
response.answers[0].kind,
Some(if allow_free_text {
UserInputAnswerKind::Custom
} else {
UserInputAnswerKind::Choice
})
);
view.request.validate_response(&response).unwrap();
}
let mut view = sample_view();
view.request.require_typed_answers = true;
view.request.questions[0].allow_free_text = false;
view.request.questions[0].multi_select = true;
assert_eq!(view.option_count(), 3);
view.selected = view.confirm_index().unwrap();
assert!(!view.is_other_selected());
view.multi_pending = vec![0, 1];
let ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { response, .. }) =
view.handle_selecting_key(KeyEvent::from(KeyCode::Enter))
else {
panic!("expected typed choices")
};
assert_eq!(response.answers.len(), 2);
assert!(
response
.answers
.iter()
.all(|answer| answer.kind == Some(UserInputAnswerKind::Choice))
);
view.request.validate_response(&response).unwrap();
}
#[test]
@@ -874,6 +996,35 @@ mod tests {
if tool_id == "tool-1" && response.answers.first().is_some_and(|a| a.value == "Ship it")),
"Enter on confirm submits the toggled options"
);
let mut view = sample_view();
view.request.require_typed_answers = true;
view.request.questions[0].multi_select = true;
view.request.questions[0].options[0].label = "Other".into();
view.request.questions[0].options[1].label = "one, two".into();
view.multi_pending = vec![0, 1];
view.other_input = " custom reply ".into();
let action = view.handle_other_input_key(KeyEvent::from(KeyCode::Enter));
let ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { response, .. }) = action
else {
panic!("expected typed answer")
};
assert_eq!(response.answers.len(), 3);
assert_eq!(response.answers[0].label, "Other");
assert_eq!(response.answers[1].value, "one, two");
assert_eq!(response.answers[2].kind, Some(UserInputAnswerKind::Custom));
assert_eq!(response.answers[2].label, "");
assert_eq!(response.answers[2].value, " custom reply ");
view.request.validate_response(&response).unwrap();
let mut view = sample_view();
view.request.require_typed_answers = true;
view.other_input.clear();
let action = view.handle_other_input_key(KeyEvent::from(KeyCode::Enter));
let ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { response, .. }) = action
else {
panic!("expected explicit skip")
};
assert_eq!(response.answers[0].kind, Some(UserInputAnswerKind::Skipped));
view.request.validate_response(&response).unwrap();
}
#[test]
@@ -938,8 +1089,11 @@ mod tests {
UserInputView::new(
"tool-1",
UserInputRequest {
require_typed_answers: false,
questions: vec![
UserInputQuestion {
detail: None,
intent: None,
header: "Choose".to_string(),
id: "q1".to_string(),
question: "Which path?".to_string(),
@@ -955,6 +1109,8 @@ mod tests {
multi_select: false,
},
UserInputQuestion {
detail: None,
intent: None,
header: "Confirm".to_string(),
id: "q2".to_string(),
question: "Second question after the first.".to_string(),
@@ -1051,6 +1207,43 @@ mod tests {
}
assert_eq!(view.scroll_offset.get(), 0);
}
for count in [0, 1, 100] {
let mut request = sample_view().request;
request.require_typed_answers = true;
request.questions[0].header.clear();
request.questions[0].detail = Some("Supporting detail\nSecond line".into());
request.questions[0].options = (0..count)
.map(|index| UserInputOption {
label: format!("Choice {index}"),
description: String::new(),
})
.collect();
request.validate_transport().unwrap();
let mut view = UserInputView::new("menu", request, Locale::En);
assert_eq!(
view.mode,
if count == 0 {
InputMode::OtherInput
} else {
InputMode::Selecting
}
);
if count > 0 {
view.selected = count - 1;
let rendered = render_view(&view, 80, 24);
assert!(
rendered.contains(&format!("Choice {}", count - 1)),
"{rendered}"
);
let (start, end) = view.focused_line_range();
assert_eq!(start, end);
} else {
let rendered = render_view(&view, 80, 24);
assert!(rendered.contains("Supporting detail"));
assert!(rendered.contains("Custom response"));
assert!(rendered.contains("skip"));
}
}
}
#[test]
@@ -1144,6 +1337,29 @@ mod tests {
"custom-response typing end must remain visible at {width}x{height}:\n{rendered}"
);
}
for detail in ["d\n".repeat(70_000), "x".repeat(900_000)] {
let mut view = sample_view();
view.request.questions[0].detail = Some(detail);
view.request.questions[0].options[0].label = "Z".into();
view.request.questions[0].options[0].description.clear();
view.request.validate_transport().unwrap();
let rendered = render_view(&view, 16, 24);
assert!(view.scroll_offset.get() > usize::from(u16::MAX));
assert!(rendered.contains("1) Z"), "{rendered}");
view.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(view.answered[0][0].label, "Z");
}
let mut view = sample_view();
view.mode = InputMode::OtherInput;
view.other_input = format!("{} END", "x".repeat(800_000));
let rendered = render_view(&view, 16, 24);
assert!(view.scroll_offset.get() > usize::from(u16::MAX));
assert!(rendered.contains("END"), "{rendered}");
view.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(view.answered[0][0].value.len(), 800_004);
assert!(view.answered[0][0].value.ends_with(" END"));
}
#[test]
@@ -1180,6 +1396,38 @@ mod tests {
view.question_index, 0,
"back on the first question is a no-op"
);
let mut view = many_option_view();
view.request.require_typed_answers = true;
view.request.questions[1].multi_select = true;
assert!(matches!(
view.handle_key(KeyEvent::from(KeyCode::Enter)),
ViewAction::None
));
let prior = view.answered.clone();
view.mode = InputMode::OtherInput;
view.multi_pending = vec![0];
view.other_input = "🦀".repeat(262_144);
let draft = view.other_input.clone();
let action = view.handle_key(KeyEvent::from(KeyCode::Enter));
assert!(
matches!(action, ViewAction::Emit(ViewEvent::StatusMessage { message }) if message.contains("transport limits"))
);
assert_eq!(view.answered, prior);
assert_eq!(view.question_index, 1);
assert_eq!(view.mode, InputMode::OtherInput);
assert_eq!(view.multi_pending, vec![0]);
assert_eq!(view.other_input, draft);
view.other_input = " corrected answer ".into();
let ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { response, .. }) =
view.handle_key(KeyEvent::from(KeyCode::Enter))
else {
panic!("expected corrected response")
};
assert_eq!(response.answers.len(), 3);
assert_eq!(response.answers[0], prior[0][0]);
assert_eq!(response.answers[2].value, " corrected answer ");
view.request.validate_response(&response).unwrap();
}
#[test]