From edcc1a684a0e02f5a09d8d37646ff0858153e7f3 Mon Sep 17 00:00:00 2001 From: luke Date: Thu, 20 Aug 2026 17:51:32 -0400 Subject: [PATCH 1/6] feat(sdk): register every optional ACP client callback a consumer implements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored client advertised the whole `Client` surface in its callback type but wired only a few methods, so a consumer that implemented filesystem, terminal, elicitation completion, or a generic extension method saw its handler silently ignored — the request failed as unimplemented with no indication which side dropped it. Registration now follows what the consumer actually provides, including the generic `extMethod` and `extNotification` handlers, so the callback type and the wire behaviour describe the same thing. --- package.json | 3 +- pnpm-lock.yaml | 24 +++-- sdk/README.md | 17 ++++ sdk/package.json | 4 +- sdk/src/goose-client.ts | 190 ++++++++++++++++++++++++++++++++++------ sdk/src/index.ts | 10 +-- 6 files changed, 208 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index 04bd5f893..3c3950d90 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,10 @@ }, "dependencies": { "@aaif/goose-sdk": "workspace:*", - "@agentclientprotocol/sdk": "^0.19.0", + "@agentclientprotocol/sdk": "^1.3.0", "@daypicker/react": "^10.0.1", "@mcp-ui/client": "7.1.1", + "@noble/hashes": "2.3.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63d9cd67b..4061e1624 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,14 +33,17 @@ importers: specifier: workspace:* version: link:sdk '@agentclientprotocol/sdk': - specifier: ^0.19.0 - version: 0.19.2(zod@4.4.3) + specifier: ^1.3.0 + version: 1.3.0(zod@4.4.3) '@daypicker/react': specifier: ^10.0.1 version: 10.0.1(@types/react@19.2.18)(react@19.2.8) '@mcp-ui/client': specifier: 7.1.1 version: 7.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@noble/hashes': + specifier: 2.3.0 + version: 2.3.0 '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.1 @@ -407,8 +410,8 @@ importers: version: 4.4.3 devDependencies: '@agentclientprotocol/sdk': - specifier: ^0.19.0 - version: 0.19.2(zod@4.4.3) + specifier: ^1.3.0 + version: 1.3.0(zod@4.4.3) '@hey-api/openapi-ts': specifier: ^0.99.0 version: 0.99.0(magicast@0.5.4)(typescript@5.9.3) @@ -430,8 +433,8 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - '@agentclientprotocol/sdk@0.19.2': - resolution: {integrity: sha512-G1Qi50Kc2GZxYjvH6t6yG8KFZMVe5vWTzZH4c7ylb0yiqMfNQSBDHPy0FMxYLPsorMqlM+eyV4yRp4oeUjl/Lw==} + '@agentclientprotocol/sdk@1.3.0': + resolution: {integrity: sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1010,6 +1013,10 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2218,6 +2225,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.2.3': resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} @@ -5690,7 +5698,7 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@agentclientprotocol/sdk@0.19.2(zod@4.4.3)': + '@agentclientprotocol/sdk@1.3.0(zod@4.4.3)': dependencies: zod: 4.4.3 @@ -6284,6 +6292,8 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@noble/hashes@2.3.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/sdk/README.md b/sdk/README.md index 6d39996a8..8d0e2887f 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -135,3 +135,20 @@ const result = await client.someMethod({ ... }); ``` See the [main documentation](../../README.md) for more details. + +## Upgrading to `@agentclientprotocol/sdk` 1.x + +`GooseClient` is built on the 1.x client app builder. Two exports changed, both +because the upstream SDK changed, not by choice here: + +- **`unstable_setSessionModel` is gone.** ACP 1.x no longer defines the method, + so there is nothing to call. Set the model through + `setSessionConfigOption` instead. +- **`ClientSideConnection` is now `ClientConnection`, and is exported as a type + only.** The 1.x builder owns connection construction, so there is no runtime + class to re-export. Build a connection with `new GooseClient(...)`. + +`GooseClientCallbacks` still covers the whole ACP `Client` surface. Only +`requestPermission` and `sessionUpdate` are required; every other member is +optional and is registered when you supply it, so implementing a capability is +enough to start receiving it. diff --git a/sdk/package.json b/sdk/package.json index 7977fcfc9..2508a795c 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -45,10 +45,10 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@agentclientprotocol/sdk": "^0.19.0" + "@agentclientprotocol/sdk": "^1.3.0" }, "devDependencies": { - "@agentclientprotocol/sdk": "^0.19.0", + "@agentclientprotocol/sdk": "^1.3.0", "@hey-api/openapi-ts": "^0.99.0", "@types/node": "^26.2.0", "prettier": "^3.9.6", diff --git a/sdk/src/goose-client.ts b/sdk/src/goose-client.ts index c697dfcca..773a649eb 100644 --- a/sdk/src/goose-client.ts +++ b/sdk/src/goose-client.ts @@ -1,6 +1,8 @@ import { - ClientSideConnection, + client, + methods, type Client, + type ClientConnection, type Stream, type InitializeRequest, type InitializeResponse, @@ -23,23 +25,161 @@ import { type ListSessionsResponse, type ResumeSessionRequest, type ResumeSessionResponse, - type SetSessionModelRequest, - type SetSessionModelResponse, + type CreateElicitationRequest, + type CreateElicitationResponse, + type MaybePromise, } from "@agentclientprotocol/sdk"; +import { z } from "zod"; import { GooseExtClient } from "./generated/client.gen.js"; import { createHttpStream } from "./http-stream.js"; +export type GooseClientCallbacks = Omit< + Client, + "extMethod" | "extNotification" | "unstable_createElicitation" +> & { + unstable_createElicitation?: ( + params: CreateElicitationRequest, + signal: AbortSignal, + /** JSON-RPC id of this request, for correlating concurrent questions. */ + requestId?: string | number, + ) => Promise; + extensionRequests?: Record< + string, + ( + params: Record, + signal: AbortSignal, + requestId?: string | number, + ) => MaybePromise> + >; + extensionNotifications?: Record< + string, + (params: Record) => MaybePromise + >; +}; + +const extensionParamsSchema = z.record(z.string(), z.unknown()); + export class GooseClient { - private conn: ClientSideConnection; + private conn: ClientConnection; private ext: GooseExtClient; - constructor(toClient: () => Client, streamOrUrl: Stream | string) { + constructor( + toClient: () => GooseClientCallbacks, + streamOrUrl: Stream | string, + ) { const stream = typeof streamOrUrl === "string" ? createHttpStream(streamOrUrl) : streamOrUrl; - this.conn = new ClientSideConnection(toClient, stream); - this.ext = new GooseExtClient(this.conn); + const callbacks = toClient(); + let app = client({ name: "berd" }) + .onRequest(methods.client.session.requestPermission, ({ params }) => + callbacks.requestPermission(params), + ) + .onNotification(methods.client.session.update, ({ params }) => + callbacks.sessionUpdate(params), + ); + const createElicitation = callbacks.unstable_createElicitation; + if (createElicitation) { + app = app.onRequest( + methods.client.elicitation.create, + ({ params, signal, requestId }) => + createElicitation( + params, + signal, + typeof requestId === "string" || typeof requestId === "number" + ? requestId + : undefined, + ), + ); + } + // Every other Client method is optional, so register exactly the ones the + // consumer supplied. Without this a caller can implement a capability, see + // it type-check, and never be called. + const { readTextFile, writeTextFile } = callbacks; + if (readTextFile) { + app = app.onRequest(methods.client.fs.readTextFile, ({ params }) => + readTextFile(params), + ); + } + if (writeTextFile) { + app = app.onRequest( + methods.client.fs.writeTextFile, + async ({ params }) => (await writeTextFile(params)) ?? {}, + ); + } + const { + createTerminal, + terminalOutput, + releaseTerminal, + waitForTerminalExit, + killTerminal, + unstable_completeElicitation: completeElicitation, + } = callbacks; + if (createTerminal) { + app = app.onRequest(methods.client.terminal.create, ({ params }) => + createTerminal(params), + ); + } + if (terminalOutput) { + app = app.onRequest(methods.client.terminal.output, ({ params }) => + terminalOutput(params), + ); + } + if (releaseTerminal) { + app = app.onRequest( + methods.client.terminal.release, + async ({ params }) => (await releaseTerminal(params)) ?? {}, + ); + } + if (waitForTerminalExit) { + app = app.onRequest(methods.client.terminal.waitForExit, ({ params }) => + waitForTerminalExit(params), + ); + } + if (killTerminal) { + app = app.onRequest( + methods.client.terminal.kill, + async ({ params }) => (await killTerminal(params)) ?? {}, + ); + } + if (completeElicitation) { + app = app.onNotification( + methods.client.elicitation.complete, + ({ params }) => completeElicitation(params), + ); + } + for (const [method, handler] of Object.entries( + callbacks.extensionRequests ?? {}, + )) { + app = app.onRequest( + method, + extensionParamsSchema, + ({ params, signal, requestId }) => + handler( + params, + signal, + typeof requestId === "string" || typeof requestId === "number" + ? requestId + : undefined, + ), + ); + } + for (const [method, handler] of Object.entries( + callbacks.extensionNotifications ?? {}, + )) { + app = app.onNotification(method, extensionParamsSchema, ({ params }) => + handler(params), + ); + } + this.conn = app.connect(stream); + this.ext = new GooseExtClient({ + extMethod: (method, params) => + this.conn.agent.request< + Record, + Record + >(method, params), + }); } get signal(): AbortSignal { @@ -51,68 +191,68 @@ export class GooseClient { } initialize(params: InitializeRequest): Promise { - return this.conn.initialize(params); + return this.conn.agent.request(methods.agent.initialize, params); } newSession(params: NewSessionRequest): Promise { - return this.conn.newSession(params); + return this.conn.agent.request(methods.agent.session.new, params); } loadSession(params: LoadSessionRequest): Promise { - return this.conn.loadSession(params); + return this.conn.agent.request(methods.agent.session.load, params); } prompt(params: PromptRequest): Promise { - return this.conn.prompt(params); + return this.conn.agent.request(methods.agent.session.prompt, params); } cancel(params: CancelNotification): Promise { - return this.conn.cancel(params); + return this.conn.agent.notify(methods.agent.session.cancel, params); } authenticate(params: AuthenticateRequest): Promise { - return this.conn.authenticate(params); + return this.conn.agent.request(methods.agent.authenticate, params); } setSessionMode( params: SetSessionModeRequest, ): Promise { - return this.conn.setSessionMode(params); + return this.conn.agent.request(methods.agent.session.setMode, params); } setSessionConfigOption( params: SetSessionConfigOptionRequest, ): Promise { - return this.conn.setSessionConfigOption(params); + return this.conn.agent.request( + methods.agent.session.setConfigOption, + params, + ); } unstable_forkSession( params: ForkSessionRequest, ): Promise { - return this.conn.unstable_forkSession(params); + return this.conn.agent.request(methods.agent.session.fork, params); } listSessions(params: ListSessionsRequest): Promise { - return this.conn.listSessions(params); + return this.conn.agent.request(methods.agent.session.list, params); } unstable_resumeSession( params: ResumeSessionRequest, ): Promise { - return this.conn.unstable_resumeSession(params); - } - - unstable_setSessionModel( - params: SetSessionModelRequest, - ): Promise { - return this.conn.unstable_setSessionModel(params); + return this.conn.agent.request(methods.agent.session.resume, params); } extMethod( method: string, params: Record, ): Promise> { - return this.conn.extMethod(method, params); + return this.conn.agent.request< + Record, + Record + >(method, params); } get goose(): GooseExtClient { diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 5e587ed92..436d8ac76 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,11 +1,11 @@ export * from "./generated/types.gen.js"; export * from "./generated/zod.gen.js"; -export { GooseClient } from "./goose-client.js"; +export { GooseClient, type GooseClientCallbacks } from "./goose-client.js"; export { createHttpStream } from "./http-stream.js"; export * from "./mcp-apps.js"; -export { - ClientSideConnection, - type Client, - type Stream, +export type { + Client, + ClientConnection, + Stream, } from "@agentclientprotocol/sdk"; From 68ad8b765e5c29fd1ca7860b06d6dcfe0cd923e6 Mon Sep 17 00:00:00 2001 From: luke Date: Thu, 20 Aug 2026 17:54:39 -0400 Subject: [PATCH 2/6] feat(tauri): store unanswered question drafts outside the window An unanswered question can outlive the window that showed it. Keeping the draft in web storage tied it to a renderer that may be replaced, and put agent-supplied content in a store shared with unrelated state. Drafts live in a versioned file under the app data directory, written through a mutex so concurrent windows cannot interleave writes, with a size ceiling so a large form cannot grow the store without bound. Parsing is by version; an unreadable record is discarded rather than trusted. --- .../src/commands/elicitation_persistence.rs | 187 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 3 + 3 files changed, 191 insertions(+) create mode 100644 src-tauri/src/commands/elicitation_persistence.rs diff --git a/src-tauri/src/commands/elicitation_persistence.rs b/src-tauri/src/commands/elicitation_persistence.rs new file mode 100644 index 000000000..5ed021f9a --- /dev/null +++ b/src-tauri/src/commands/elicitation_persistence.rs @@ -0,0 +1,187 @@ +use serde_json::{json, Map, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use tauri::{AppHandle, Manager}; + +const ELICITATION_PERSISTENCE_FILENAME: &str = "elicitation-persistence.json"; +const ELICITATION_PERSISTENCE_VERSION: u64 = 4; +const MAX_ELICITATION_PERSISTENCE_BYTES: usize = 256 * 1024; +static ELICITATION_PERSISTENCE_LOCK: OnceLock> = OnceLock::new(); + +fn elicitation_persistence_path(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|dir| dir.join(ELICITATION_PERSISTENCE_FILENAME)) + .map_err(|error| format!("Failed to resolve app data directory: {error}")) +} + +#[tauri::command] +pub async fn load_elicitation_persistence(app: AppHandle) -> Result, String> { + let path = elicitation_persistence_path(&app)?; + match fs::read_to_string(&path) { + Ok(serialized) if serialized.len() <= MAX_ELICITATION_PERSISTENCE_BYTES => { + Ok(Some(serialized)) + } + Ok(_) => Err("Persisted elicitation data exceeds the supported size".to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("Failed to read persisted elicitations: {error}")), + } +} + +#[tauri::command] +pub async fn persist_elicitation_updates( + app: AppHandle, + serialized_updates: String, +) -> Result<(), String> { + persist_elicitation_updates_at_path(&elicitation_persistence_path(&app)?, &serialized_updates) +} + +#[tauri::command] +pub async fn clear_elicitation_persistence(app: AppHandle) -> Result<(), String> { + let _guard = ELICITATION_PERSISTENCE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "Elicitation persistence lock was poisoned".to_string())?; + remove_elicitation_persistence_at_path(&elicitation_persistence_path(&app)?) +} + +fn read_records(path: &Path) -> Result, String> { + let serialized = match fs::read_to_string(path) { + Ok(serialized) => serialized, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()), + Err(error) => return Err(format!("Failed to read persisted elicitations: {error}")), + }; + if serialized.len() > MAX_ELICITATION_PERSISTENCE_BYTES { + return Err("Persisted elicitation data exceeds the supported size".to_string()); + } + let envelope: Value = serde_json::from_str(&serialized) + .map_err(|error| format!("Failed to parse persisted elicitations: {error}"))?; + if envelope.get("version").and_then(Value::as_u64) != Some(ELICITATION_PERSISTENCE_VERSION) { + return Ok(Map::new()); + } + envelope + .get("records") + .and_then(Value::as_object) + .cloned() + .ok_or_else(|| "Persisted elicitation envelope has invalid records".to_string()) +} + +fn persist_elicitation_updates_at_path( + path: &Path, + serialized_updates: &str, +) -> Result<(), String> { + let _guard = ELICITATION_PERSISTENCE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "Elicitation persistence lock was poisoned".to_string())?; + let updates: Map = serde_json::from_str(serialized_updates) + .map_err(|error| format!("Failed to parse elicitation updates: {error}"))?; + let mut records = read_records(path)?; + for (record_key, record) in updates { + if record.is_null() { + records.remove(&record_key); + } else { + records.insert(record_key, record); + } + } + if records.is_empty() { + return remove_elicitation_persistence_at_path(path); + } + let serialized = serde_json::to_string(&json!({ + "version": ELICITATION_PERSISTENCE_VERSION, + "records": records, + })) + .map_err(|error| format!("Failed to serialize persisted elicitations: {error}"))?; + if serialized.len() > MAX_ELICITATION_PERSISTENCE_BYTES { + return Err("Persisted elicitation data exceeds the supported size".to_string()); + } + write_elicitation_persistence(path, &serialized) +} + +fn write_elicitation_persistence(path: &Path, serialized: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "Elicitation persistence path has no parent".to_string())?; + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create elicitation persistence directory: {error}"))?; + let pending_path = path.with_extension("json.pending"); + fs::write(&pending_path, serialized) + .map_err(|error| format!("Failed to write persisted elicitations: {error}"))?; + fs::rename(&pending_path, path) + .map_err(|error| format!("Failed to commit persisted elicitations: {error}")) +} + +fn remove_elicitation_persistence_at_path(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("Failed to remove persisted elicitations: {error}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merges_independent_renderer_records_transactionally() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(ELICITATION_PERSISTENCE_FILENAME); + + persist_elicitation_updates_at_path( + &path, + r#"{"main/session-a":{"identity":{"accountId":"a"},"sessionId":"session-a","queue":[1]}}"#, + ) + .unwrap(); + persist_elicitation_updates_at_path( + &path, + r#"{"secondary/session-b":{"identity":{"accountId":"a"},"sessionId":"session-b","queue":[2]}}"#, + ) + .unwrap(); + + let records = read_records(&path).unwrap(); + assert_eq!(records.len(), 2); + assert_eq!(records["main/session-a"]["queue"], json!([1])); + assert_eq!(records["secondary/session-b"]["queue"], json!([2])); + + persist_elicitation_updates_at_path(&path, r#"{"main/session-a":null}"#).unwrap(); + let records = read_records(&path).unwrap(); + assert_eq!(records.len(), 1); + assert!(records.contains_key("secondary/session-b")); + } + + #[test] + fn ignores_an_obsolete_envelope_before_applying_updates() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(ELICITATION_PERSISTENCE_FILENAME); + fs::write(&path, r#"{"version":3,"records":{"old":{}}}"#).unwrap(); + + persist_elicitation_updates_at_path(&path, r#"{"fresh":{"queue":[]}}"#).unwrap(); + + let serialized = fs::read_to_string(&path).unwrap(); + let envelope: Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!(envelope["version"], ELICITATION_PERSISTENCE_VERSION); + assert!(envelope["records"].get("old").is_none()); + assert!(envelope["records"].get("fresh").is_some()); + } + + #[test] + fn empty_updates_remove_the_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(ELICITATION_PERSISTENCE_FILENAME); + fs::write( + &path, + serde_json::to_string(&json!({ + "version": ELICITATION_PERSISTENCE_VERSION, + "records": {}, + })) + .unwrap(), + ) + .unwrap(); + + persist_elicitation_updates_at_path(&path, "{}").unwrap(); + + assert!(!path.exists()); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a806cb4f4..c700d28e3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -19,6 +19,7 @@ pub mod diagnostics; pub mod distro; #[cfg_attr(not(feature = "block-feedback"), allow(dead_code))] pub mod doctor; +pub mod elicitation_persistence; #[cfg(feature = "block-feedback")] pub mod feedback; pub mod git; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcb5ffee2..0729ab044 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -559,6 +559,9 @@ pub fn run() { commands::message_queues::load_message_queues, commands::message_queues::persist_message_queues, commands::message_queues::persist_message_queue_updates, + commands::elicitation_persistence::load_elicitation_persistence, + commands::elicitation_persistence::persist_elicitation_updates, + commands::elicitation_persistence::clear_elicitation_persistence, commands::model_setup::start_model_setup, commands::model_setup::get_model_setup_status, commands::local_mcp_inventory::list_local_mcp_inventory, From 8afc614a19b0a391c71f7e924baa5a7db8cd0ba8 Mon Sep 17 00:00:00 2001 From: luke Date: Thu, 20 Aug 2026 17:57:00 -0400 Subject: [PATCH 3/6] feat: answer ACP form elicitation requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Berd advertises `elicitation.form` and renders a form request from an agent as a real control rather than prose, returning a typed response on the originating request. The parts that are easy to get wrong, and how they are handled: - A property type this build does not understand is preserved and shown as unsupported rather than rendered as a control that misdescribes it. A required one blocks acceptance instead of being submitted empty, and no value is ever returned for a field the user could not see. - An unsupported mode or scope returns invalid params, so an agent can tell a client limitation from a considered decline. A synthetic user `cancel` would conflate the two. - Untrusted schemas are bounded before rendering — field and option counts, serialized schema bytes, message and description lengths. An oversized form is rejected at the request boundary rather than truncated, since a truncated form can be accepted while misrepresenting what was asked. - Agent-supplied `pattern`s are treated as unevaluable. A native regex cannot be interrupted, so evaluating one is an unbounded cost taken on an attacker's behalf; claiming to enforce it while timing it afterwards would be dishonest about a boundary that does not exist. - Credential-marked fields are unsupported. ACP form mode is specified for non-sensitive data, and a password input here would invite collection this surface should not perform. - Every edit is addressed by question id, so an event from a control that has just been replaced cannot land on its replacement. - Answers to a question whose responder is gone are claimed before they are sent as an ordinary message, so a reattaching responder cannot also be accepted and the backend cannot see both a message and a response. - Drafts are scoped by account, workspace, provider, and connection, and parsed through a versioned schema rather than cast. --- scripts/check-i18n-strings.mjs | 1 + .../acp/elicitationRequestHandler.test.ts | 600 +++++++ .../acp/elicitationRequestHandler.ts | 138 ++ .../lib/elicitationFieldKind.test.ts | 55 + .../elicitation/lib/elicitationFieldKind.ts | 88 + .../lib/elicitationFieldValidation.test.ts | 100 ++ .../lib/elicitationFieldValidation.ts | 98 ++ .../lib/elicitationPersistence.test.ts | 70 + .../elicitation/lib/elicitationPersistence.ts | 154 ++ .../lib/elicitationPersistenceEvents.ts | 33 + .../lib/elicitationSchemaLimits.ts | 145 ++ .../recoveredElicitationContinuation.test.ts | 130 ++ .../lib/recoveredElicitationContinuation.ts | 88 + .../stores/elicitationStore.test.ts | 1181 +++++++++++++ .../elicitation/stores/elicitationStore.ts | 1479 +++++++++++++++++ .../elicitation/ui/ElicitationField.tsx | 722 ++++++++ .../elicitation/ui/ElicitationPanel.test.tsx | 939 +++++++++++ .../elicitation/ui/ElicitationPanel.tsx | 393 +++++ .../__tests__/gooseClientElicitation.test.ts | 186 +++ src/shared/api/acpConnection.test.ts | 28 + src/shared/api/acpConnection.ts | 71 +- src/shared/i18n/locales/en/chat.json | 28 + src/shared/i18n/locales/es/chat.json | 28 + 23 files changed, 6752 insertions(+), 3 deletions(-) create mode 100644 src/features/elicitation/acp/elicitationRequestHandler.test.ts create mode 100644 src/features/elicitation/acp/elicitationRequestHandler.ts create mode 100644 src/features/elicitation/lib/elicitationFieldKind.test.ts create mode 100644 src/features/elicitation/lib/elicitationFieldKind.ts create mode 100644 src/features/elicitation/lib/elicitationFieldValidation.test.ts create mode 100644 src/features/elicitation/lib/elicitationFieldValidation.ts create mode 100644 src/features/elicitation/lib/elicitationPersistence.test.ts create mode 100644 src/features/elicitation/lib/elicitationPersistence.ts create mode 100644 src/features/elicitation/lib/elicitationPersistenceEvents.ts create mode 100644 src/features/elicitation/lib/elicitationSchemaLimits.ts create mode 100644 src/features/elicitation/lib/recoveredElicitationContinuation.test.ts create mode 100644 src/features/elicitation/lib/recoveredElicitationContinuation.ts create mode 100644 src/features/elicitation/stores/elicitationStore.test.ts create mode 100644 src/features/elicitation/stores/elicitationStore.ts create mode 100644 src/features/elicitation/ui/ElicitationField.tsx create mode 100644 src/features/elicitation/ui/ElicitationPanel.test.tsx create mode 100644 src/features/elicitation/ui/ElicitationPanel.tsx create mode 100644 src/shared/api/__tests__/gooseClientElicitation.test.ts create mode 100644 src/shared/api/acpConnection.test.ts diff --git a/scripts/check-i18n-strings.mjs b/scripts/check-i18n-strings.mjs index a11701486..f2c75291f 100644 --- a/scripts/check-i18n-strings.mjs +++ b/scripts/check-i18n-strings.mjs @@ -7,6 +7,7 @@ const CHECKED_PATHS = [ "src/features/agents", "src/features/automations", "src/features/chat/ui", + "src/features/elicitation", "src/features/experiments", "src/features/home", "src/features/projects", diff --git a/src/features/elicitation/acp/elicitationRequestHandler.test.ts b/src/features/elicitation/acp/elicitationRequestHandler.test.ts new file mode 100644 index 000000000..93d5cabe6 --- /dev/null +++ b/src/features/elicitation/acp/elicitationRequestHandler.test.ts @@ -0,0 +1,600 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + RequestError, + type CreateElicitationRequest, +} from "@agentclientprotocol/sdk"; +import { handleElicitationRequest } from "./elicitationRequestHandler"; +import { + configureElicitationPersistenceIdentity, + presentedElicitation, + useElicitationStore, +} from "../stores/elicitationStore"; +import { + MAX_ELICITATION_DESCRIPTION_BYTES, + MAX_ELICITATION_MESSAGE_BYTES, + MAX_ELICITATION_SCHEMA_BYTES, + MAX_FIELDS, + MAX_OPTIONS, +} from "../lib/elicitationSchemaLimits"; + +function headId(sessionId = "session-1"): string { + const id = presentedElicitation( + useElicitationStore.getState().pendingBySessionId[sessionId], + )?.id; + if (!id) throw new Error(`no pending elicitation for ${sessionId}`); + return id; +} + +const mocks = vi.hoisted(() => ({ + continueRecoveredElicitation: vi.fn().mockResolvedValue(undefined), + getPreparedProviderId: vi.fn().mockReturnValue("test-provider"), +})); + +vi.mock("../lib/recoveredElicitationContinuation", () => ({ + continueRecoveredElicitation: (...args: unknown[]) => + mocks.continueRecoveredElicitation(...args), +})); + +vi.mock("@/shared/api/acpSessionRegistry", () => ({ + getPreparedProviderId: (...args: unknown[]) => + mocks.getPreparedProviderId(...args), +})); + +const request: CreateElicitationRequest = { + mode: "form", + sessionId: "session-1", + message: "Choose a direction", + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + oneOf: [ + { const: "local", title: "Local proof" }, + { const: "issue", title: "Upstream issue" }, + ], + }, + }, + required: ["direction"], + }, +}; + +describe("handleElicitationRequest", () => { + beforeEach(() => { + window.localStorage.clear(); + mocks.continueRecoveredElicitation.mockClear(); + configureElicitationPersistenceIdentity({ + accountId: "account-1", + workspaceId: "workspace-1", + }); + useElicitationStore.setState({ pendingBySessionId: {} }); + }); + + it("queues a form and returns the accepted structured content", async () => { + const response = handleElicitationRequest(request); + const store = useElicitationStore.getState(); + expect(store.pendingBySessionId["session-1"]).toHaveLength(1); + + store.setValue("session-1", headId("session-1"), "direction", "local"); + store.accept("session-1", headId("session-1")); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { direction: "local" }, + }); + }); + + it("attaches provider and connection identity to persisted drafts", () => { + const response = handleElicitationRequest(request, undefined, 1, { + connectionGeneration: 3, + connectionInstanceId: "connection-3", + }); + + expect( + useElicitationStore.getState().pendingBySessionId["session-1"]?.[0] + ?.persistenceScope, + ).toMatchObject({ + providerId: "test-provider", + connectionGeneration: 3, + connectionInstanceId: "connection-3", + }); + useElicitationStore.getState().cancelAll("session-1"); + void response; + }); + + it("cancels and removes a question when its ACP request is aborted", async () => { + const controller = new AbortController(); + const response = handleElicitationRequest(request, controller.signal); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toHaveLength(1); + + controller.abort(RequestError.requestCancelled()); + + await expect(response).resolves.toEqual({ action: "cancel" }); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toBeUndefined(); + }); + + it("does not let an old transport abort remove a reattached responder", async () => { + const firstController = new AbortController(); + const first = handleElicitationRequest( + { + ...request, + _meta: { goose: { elicitationId: "question-1" } }, + }, + firstController.signal, + ); + useElicitationStore.getState().detachAll("session-1"); + + const replay = handleElicitationRequest( + { + ...request, + _meta: { + goose: { + elicitationId: "question-1", + recovered: true, + continuation: "response", + }, + }, + }, + new AbortController().signal, + ); + firstController.abort(RequestError.requestCancelled()); + + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toHaveLength(1); + useElicitationStore + .getState() + .setValue("session-1", headId("session-1"), "direction", "local"); + useElicitationStore.getState().accept("session-1", headId("session-1")); + await expect(replay).resolves.toEqual({ + action: "accept", + content: { direction: "local" }, + }); + + // The disconnected transport's responder is intentionally unreachable. + void first; + }); + + it("preserves a question when its request signal aborts with the connection", () => { + const controller = new AbortController(); + const response = handleElicitationRequest(request, controller.signal); + + controller.abort(new Error("ACP connection closed")); + + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toHaveLength(1); + useElicitationStore.getState().detachAll("session-1"); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"][0].resolve, + ).toBeNull(); + + // The disconnected transport's responder is intentionally unreachable. + void response; + }); + + it("resolves every pending request as cancelled on teardown", async () => { + const first = handleElicitationRequest(request); + const second = handleElicitationRequest(request); + useElicitationStore.getState().cancelAll("session-1"); + await expect(Promise.all([first, second])).resolves.toEqual([ + { action: "cancel" }, + { action: "cancel" }, + ]); + }); + + it("preserves a detached draft and reattaches the replayed responder", async () => { + const first = handleElicitationRequest({ + ...request, + _meta: { goose: { elicitationId: "question-1" } }, + }); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId("session-1"), "direction", "local"); + store.detachAll("session-1"); + + const replay = handleElicitationRequest({ + ...request, + _meta: { + goose: { + elicitationId: "question-1", + recovered: true, + continuation: "response", + }, + }, + }); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"][0].content, + ).toEqual({ direction: "local" }); + + useElicitationStore.getState().accept("session-1", headId("session-1")); + await expect(replay).resolves.toEqual({ + action: "accept", + content: { direction: "local" }, + }); + expect(mocks.continueRecoveredElicitation).not.toHaveBeenCalled(); + + // The disconnected transport's resolver is intentionally left unresolved. + void first; + }); + + it("continues a full-restart recovery as a normal prompt", async () => { + vi.useFakeTimers(); + const recoveredRequest = { + ...request, + _meta: { + goose: { + elicitationId: "question-2", + recovered: true, + continuation: "prompt", + }, + }, + } satisfies CreateElicitationRequest; + const response = handleElicitationRequest(recoveredRequest); + useElicitationStore + .getState() + .setValue("session-1", headId("session-1"), "direction", "issue"); + useElicitationStore.getState().accept("session-1", headId("session-1")); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { direction: "issue" }, + }); + await vi.runAllTimersAsync(); + expect(mocks.continueRecoveredElicitation).toHaveBeenCalledWith( + recoveredRequest, + { action: "accept", content: { direction: "issue" } }, + ); + vi.useRealTimers(); + }); + + it("retains failed continuation answers under the provider identity for replay", async () => { + vi.useFakeTimers(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.continueRecoveredElicitation.mockRejectedValueOnce( + new Error("transport unavailable"), + ); + const recoveredRequest = { + ...request, + _meta: { + goose: { + elicitationId: "question-retained", + recovered: true, + continuation: "prompt", + }, + }, + } satisfies CreateElicitationRequest; + const response = handleElicitationRequest(recoveredRequest, undefined, 41, { + connectionGeneration: 1, + connectionInstanceId: "connection-before", + }); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId(), "direction", "issue"); + store.accept("session-1", headId()); + await expect(response).resolves.toMatchObject({ action: "accept" }); + await vi.runAllTimersAsync(); + + const retained = + useElicitationStore.getState().pendingBySessionId["session-1"]?.[0]; + expect(retained).toMatchObject({ + content: { direction: "issue" }, + persistenceScope: { + providerId: "test-provider", + connectionInstanceId: "connection-before", + }, + resolve: null, + }); + + const replay = handleElicitationRequest(recoveredRequest, undefined, 99, { + connectionGeneration: 2, + connectionInstanceId: "connection-after", + }); + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(1); + expect(queue[0]).toMatchObject({ + content: { direction: "issue" }, + persistenceScope: { + providerId: "test-provider", + connectionInstanceId: "connection-after", + }, + }); + store.cancel("session-1", queue[0]?.id ?? ""); + await expect(replay).resolves.toEqual({ action: "cancel" }); + errorSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("rejects an unsupported mode as invalid params, not a user cancel", async () => { + // Answering "cancel" would tell the agent the user declined, when in fact + // Berd never offered the interaction. + await expect( + handleElicitationRequest({ + mode: "url", + sessionId: "session-1", + url: "https://example.test/authorize", + } as unknown as CreateElicitationRequest), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it("rejects an unsupported scope as invalid params", async () => { + await expect( + handleElicitationRequest({ + mode: "form", + requestId: "request-1", + message: "Which direction?", + requestedSchema: { type: "object", properties: {} }, + } as unknown as CreateElicitationRequest), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it("rejects a form with a required field beyond the field limit", async () => { + const properties = Object.fromEntries( + Array.from({ length: MAX_FIELDS + 1 }, (_, index) => [ + `field-${index}`, + { type: "string" as const }, + ]), + ); + await expect( + handleElicitationRequest({ + ...request, + requestedSchema: { + type: "object", + properties, + required: [`field-${MAX_FIELDS}`], + }, + }), + ).rejects.toMatchObject({ + code: -32602, + data: { elicitationLimit: { limit: "fields", maximum: MAX_FIELDS } }, + }); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toBeUndefined(); + }); + + it("rejects option lists instead of rendering a truncated answer", async () => { + await expect( + handleElicitationRequest({ + ...request, + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + enum: Array.from( + { length: MAX_OPTIONS + 1 }, + (_, index) => `option-${index}`, + ), + }, + }, + }, + }), + ).rejects.toMatchObject({ + code: -32602, + data: { elicitationLimit: { limit: "options", maximum: MAX_OPTIONS } }, + }); + }); + + it("bounds encoded schema, message, and description sizes", async () => { + const expectations = [ + handleElicitationRequest({ + ...request, + message: "m".repeat(MAX_ELICITATION_MESSAGE_BYTES + 1), + }), + handleElicitationRequest({ + ...request, + requestedSchema: { + type: "object", + properties: { + note: { + type: "string", + description: "d".repeat(MAX_ELICITATION_DESCRIPTION_BYTES + 1), + }, + }, + }, + }), + handleElicitationRequest({ + ...request, + requestedSchema: { + type: "object", + properties: { + note: { + type: "string", + title: "s".repeat(MAX_ELICITATION_SCHEMA_BYTES), + }, + }, + }, + }), + ]; + + await expect(expectations[0]).rejects.toMatchObject({ + code: -32602, + data: { elicitationLimit: { limit: "messageBytes" } }, + }); + await expect(expectations[1]).rejects.toMatchObject({ + code: -32602, + data: { elicitationLimit: { limit: "descriptionBytes" } }, + }); + await expect(expectations[2]).rejects.toMatchObject({ + code: -32602, + data: { elicitationLimit: { limit: "schemaBytes" } }, + }); + }); + + it("keeps two identical questions asked at once distinct", async () => { + // Without a wire id these hash identically, so one answer would resolve + // both JSON-RPC requests and the second agent would never be answered. + const first = handleElicitationRequest(request, undefined, 1); + const second = handleElicitationRequest(request, undefined, 2); + + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(2); + expect(queue[0]?.id).not.toBe(queue[1]?.id); + + useElicitationStore.getState().accept("session-1", queue[0]?.id ?? ""); + await expect(first).resolves.toMatchObject({ action: "accept" }); + + let secondSettled = false; + void second.then(() => { + secondSettled = true; + }); + await Promise.resolve(); + expect(secondSettled).toBe(false); + + useElicitationStore.getState().accept("session-1", queue[1]?.id ?? ""); + await expect(second).resolves.toMatchObject({ action: "accept" }); + }); + + it("does not reuse a wire id across independent generation-one clients", async () => { + void handleElicitationRequest(request, undefined, 41, { + connectionGeneration: 1, + connectionInstanceId: "renderer-main", + }); + useElicitationStore.getState().detachAll("session-1"); + const nextRequest = { + ...request, + message: "Choose a different direction", + } satisfies CreateElicitationRequest; + const next = handleElicitationRequest(nextRequest, undefined, 41, { + connectionGeneration: 1, + connectionInstanceId: "renderer-secondary", + }); + + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(2); + expect(queue[0]?.id).not.toBe(queue[1]?.id); + expect(queue[0]?.resolve).toBeNull(); + expect(queue[1]?.resolve).not.toBeNull(); + + useElicitationStore.getState().cancel("session-1", queue[1]?.id ?? ""); + await expect(next).resolves.toEqual({ action: "cancel" }); + }); + + it("requires semantic compatibility even for an exact responder id", async () => { + const connection = { + connectionGeneration: 1, + connectionInstanceId: "same-physical-connection", + }; + void handleElicitationRequest(request, undefined, 41, connection); + useElicitationStore.getState().detachAll("session-1"); + const changed = handleElicitationRequest( + { ...request, message: "Choose a different direction" }, + undefined, + 41, + connection, + ); + + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(2); + expect(queue[0]?.semanticKey).not.toBe(queue[1]?.semanticKey); + useElicitationStore.getState().cancel("session-1", queue[1]?.id ?? ""); + await expect(changed).resolves.toEqual({ action: "cancel" }); + }); + + it("reattaches a provider-stable question when its wire id changes", async () => { + const stableRequest = { + ...request, + _meta: { goose: { elicitationId: "stable-question" } }, + } satisfies CreateElicitationRequest; + void handleElicitationRequest(stableRequest, undefined, 41, { + connectionGeneration: 1, + connectionInstanceId: "connection-before", + }); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId(), "direction", "local"); + store.detachAll("session-1"); + + const replay = handleElicitationRequest(stableRequest, undefined, 99, { + connectionGeneration: 2, + connectionInstanceId: "connection-after", + }); + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(1); + expect(queue[0]?.content).toEqual({ direction: "local" }); + expect(queue[0]?.persistenceScope?.connectionGeneration).toBe(2); + + store.accept("session-1", queue[0]?.id ?? ""); + await expect(replay).resolves.toEqual({ + action: "accept", + content: { direction: "local" }, + }); + }); + + it("reattaches one legacy draft when the replay has no wire id", async () => { + const first = handleElicitationRequest(request, undefined, 41); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId(), "direction", "local"); + store.detachAll("session-1"); + + const replay = handleElicitationRequest(request); + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(1); + expect(queue[0]?.content).toEqual({ direction: "local" }); + + useElicitationStore.getState().accept("session-1", queue[0]?.id ?? ""); + await expect(replay).resolves.toMatchObject({ + action: "accept", + content: { direction: "local" }, + }); + void first; + }); + + it("keeps ambiguous legacy drafts visible instead of merging them", () => { + void handleElicitationRequest(request, undefined, 41); + void handleElicitationRequest(request, undefined, 42); + useElicitationStore.getState().detachAll("session-1"); + + void handleElicitationRequest(request); + + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(3); + expect(queue.filter((pending) => pending.resolve === null)).toHaveLength(2); + expect(queue.filter((pending) => pending.resolve !== null)).toHaveLength(1); + }); + + it("keeps questions from the same tool call distinct by wire id", () => { + const toolRequest = { ...request, toolCallId: "tool-call-1" }; + void handleElicitationRequest(toolRequest, undefined, 1); + void handleElicitationRequest(toolRequest, undefined, 2); + + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(2); + expect(queue[0]?.semanticKey).toBe(queue[1]?.semanticKey); + expect(queue[0]?.id).not.toBe(queue[1]?.id); + }); + + it("only continues as a prompt when the request is marked recovered", async () => { + // Continuation is a recovery behaviour; arbitrary metadata must not be able + // to redirect a live question into an ordinary message. + vi.useFakeTimers(); + const response = handleElicitationRequest({ + ...request, + _meta: { goose: { elicitationId: "live-1", continuation: "prompt" } }, + } as typeof request); + const pending = + useElicitationStore.getState().pendingBySessionId["session-1"]?.[0]; + expect(pending?.continuation).toBe("response"); + useElicitationStore + .getState() + .setValue("session-1", "live-1", "direction", "local"); + useElicitationStore.getState().accept("session-1", "live-1"); + await expect(response).resolves.toEqual({ + action: "accept", + content: { direction: "local" }, + }); + await vi.runAllTimersAsync(); + expect(mocks.continueRecoveredElicitation).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); diff --git a/src/features/elicitation/acp/elicitationRequestHandler.ts b/src/features/elicitation/acp/elicitationRequestHandler.ts new file mode 100644 index 000000000..be065b012 --- /dev/null +++ b/src/features/elicitation/acp/elicitationRequestHandler.ts @@ -0,0 +1,138 @@ +import { + CreateElicitationRequest, + RequestError, + type CreateElicitationResponse, + type ElicitationContentValue, +} from "@agentclientprotocol/sdk"; +import { continueRecoveredElicitation } from "@/features/elicitation/lib/recoveredElicitationContinuation"; +import { elicitationBoundsViolation } from "@/features/elicitation/lib/elicitationSchemaLimits"; +import { + getElicitationPersistenceScope, + getElicitationMetadata, + useElicitationStore, +} from "@/features/elicitation/stores/elicitationStore"; +import { getPreparedProviderId } from "@/shared/api/acpSessionRegistry"; + +function isExplicitRequestCancellation(signal: AbortSignal): boolean { + const reason: unknown = signal.reason; + if (reason instanceof RequestError) return reason.code === -32800; + // Two physical copies of the SDK would defeat instanceof and silently + // reclassify an explicit cancel as connection loss, stranding the form. + return ( + typeof reason === "object" && + reason !== null && + (reason as { code?: unknown }).code === -32800 + ); +} + +export function handleElicitationRequest( + request: CreateElicitationRequest, + signal?: AbortSignal, + wireRequestId?: string | number, + connection?: { + connectionGeneration: number; + connectionInstanceId: string; + }, +): Promise { + // A mode or scope Berd does not implement is a client limitation, not a + // decision the user made. Answering "cancel" would tell the agent its + // question was declined; invalid params tells it the truth. + if (!CreateElicitationRequest.isForm(request)) { + return Promise.reject( + RequestError.invalidParams( + { supported: { mode: "form" } }, + "Berd advertises form elicitation only", + ), + ); + } + if (!("sessionId" in request)) { + return Promise.reject( + RequestError.invalidParams( + { supported: { scope: "session" } }, + "Berd supports session-scoped form elicitation only", + ), + ); + } + const boundsViolation = elicitationBoundsViolation( + request.message, + request.requestedSchema, + ); + if (boundsViolation) { + return Promise.reject( + RequestError.invalidParams( + { elicitationLimit: boundsViolation }, + "Elicitation form exceeds Berd's supported limits", + ), + ); + } + if (signal?.aborted) return Promise.resolve({ action: "cancel" }); + + const connectionIdentity = { + providerId: getPreparedProviderId(request.sessionId) ?? "unknown", + connectionGeneration: connection?.connectionGeneration ?? 0, + connectionInstanceId: connection?.connectionInstanceId ?? "unknown", + }; + const metadata = getElicitationMetadata( + request, + wireRequestId, + connectionIdentity, + ); + const persistenceScope = getElicitationPersistenceScope( + connectionIdentity.providerId, + connectionIdentity.connectionGeneration, + connectionIdentity.connectionInstanceId, + ); + let pendingId: string; + let responderKey: symbol; + const response = new Promise((resolve) => { + const responder = useElicitationStore.getState().enqueue({ + request, + resolve, + wireRequestId, + persistenceScope, + connectionIdentity, + }); + pendingId = responder.id; + responderKey = responder.responderKey; + }); + const abort = () => { + // Incoming request signals also abort when the ACP connection closes. In + // that case the connection monitor detaches the form so its draft can be + // recovered; only an explicit JSON-RPC request cancellation is terminal. + if (!signal || !isExplicitRequestCancellation(signal)) return; + useElicitationStore + .getState() + .abort(request.sessionId, pendingId, responderKey); + }; + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + + return response + .then((result) => { + if (metadata.recovered && metadata.continuation === "prompt") { + window.setTimeout(() => { + void continueRecoveredElicitation(request, result).catch((error) => { + console.error("Failed to continue recovered elicitation:", error); + // The pending was already removed when the answer was accepted, so + // without this the answers are gone with nothing on screen. Put + // them back as a detached draft the user can retry or discard. + if (result.action === "accept") { + useElicitationStore + .getState() + .retainUndeliveredAnswers( + request, + (result.content ?? {}) as Record< + string, + ElicitationContentValue + >, + persistenceScope, + connectionIdentity, + ); + } + }); + }, 0); + } + return result; + }) + .finally(() => signal?.removeEventListener("abort", abort)); +} diff --git a/src/features/elicitation/lib/elicitationFieldKind.test.ts b/src/features/elicitation/lib/elicitationFieldKind.test.ts new file mode 100644 index 000000000..bfcda4415 --- /dev/null +++ b/src/features/elicitation/lib/elicitationFieldKind.test.ts @@ -0,0 +1,55 @@ +import type { ElicitationPropertySchema } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { elicitationFieldKind } from "./elicitationFieldKind"; + +const schema = (value: unknown) => value as ElicitationPropertySchema; + +describe("elicitationFieldKind", () => { + it("recognises the controls Berd can render", () => { + expect(elicitationFieldKind(schema({ type: "string" }))).toBe("scalar"); + expect(elicitationFieldKind(schema({ type: "number" }))).toBe("scalar"); + expect(elicitationFieldKind(schema({ type: "boolean" }))).toBe("boolean"); + expect( + elicitationFieldKind( + schema({ type: "string", oneOf: [{ const: "a" }, { const: "b" }] }), + ), + ).toBe("single-select"); + expect( + elicitationFieldKind( + schema({ type: "array", items: { enum: ["a", "b"] } }), + ), + ).toBe("multi-select"); + }); + + it("refuses to guess at a type it does not know", () => { + // A future ACP type must not be shown as a text box; answering it would + // send the agent a value it never asked for. + expect(elicitationFieldKind(schema({ type: "object" }))).toBe( + "unsupported", + ); + expect(elicitationFieldKind(schema({ type: "colour-picker" }))).toBe( + "unsupported", + ); + expect(elicitationFieldKind(schema({}))).toBe("unsupported"); + }); + + it("refuses an array whose items are not a known set of strings", () => { + expect( + elicitationFieldKind( + schema({ type: "array", items: { type: "object" } }), + ), + ).toBe("unsupported"); + expect(elicitationFieldKind(schema({ type: "array" }))).toBe("unsupported"); + }); + + it("treats credential-marked schemas as unsupported in form mode", () => { + expect( + elicitationFieldKind( + schema({ + type: "string", + _meta: { codex: { isSecret: true } }, + }), + ), + ).toBe("unsupported"); + }); +}); diff --git a/src/features/elicitation/lib/elicitationFieldKind.ts b/src/features/elicitation/lib/elicitationFieldKind.ts new file mode 100644 index 000000000..13047d416 --- /dev/null +++ b/src/features/elicitation/lib/elicitationFieldKind.ts @@ -0,0 +1,88 @@ +import type { ElicitationPropertySchema } from "@agentclientprotocol/sdk"; + +/** + * What control, if any, a property may be rendered as. + * + * A schema arrives from an agent and may describe something this version of + * Berd does not understand. Such a field is preserved but must not be shown as + * a control that misrepresents it — answering a future type through a text box + * would send the agent a value it never asked for. + */ +export type ElicitationFieldKind = + | "multi-select" + | "single-select" + | "boolean" + | "scalar" + | "unsupported"; + +function asRecord(value: unknown): Record | null { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function isSecretElicitationProperty( + schema: ElicitationPropertySchema, +): boolean { + const raw = schema as Record; + const codex = asRecord(asRecord(raw._meta)?.codex); + return codex?.isSecret === true; +} + +function hasOptions( + container: Record | null, + key: "oneOf" | "anyOf", +): boolean { + if (!container) return false; + if (Array.isArray(container.enum)) { + return container.enum.every((value) => typeof value === "string"); + } + const options = container[key]; + return ( + Array.isArray(options) && + options.length > 0 && + options.every( + (option) => + option != null && + typeof option === "object" && + typeof (option as { const?: unknown }).const === "string", + ) + ); +} + +export function elicitationFieldKind( + schema: ElicitationPropertySchema, +): ElicitationFieldKind { + const raw = schema as Record; + + // ACP form mode is only for non-sensitive data. Providers must move marked + // credential collection to URL mode rather than receiving a disguised text + // answer through this form. + if (isSecretElicitationProperty(schema)) return "unsupported"; + + if (raw.type === "array") { + const items = + raw.items != null && typeof raw.items === "object" + ? (raw.items as Record) + : null; + // An array whose items are not a known set of strings is not a + // multi-select, whatever it is. + return hasOptions(items, "anyOf") ? "multi-select" : "unsupported"; + } + + if (hasOptions(raw, "oneOf")) return "single-select"; + if (raw.type === "boolean") return "boolean"; + if ( + raw.type === "string" || + raw.type === "number" || + raw.type === "integer" + ) { + return "scalar"; + } + // Includes object, null, and any type introduced after this build. + return "unsupported"; +} + +export function isUnsupportedField(schema: ElicitationPropertySchema): boolean { + return elicitationFieldKind(schema) === "unsupported"; +} diff --git a/src/features/elicitation/lib/elicitationFieldValidation.test.ts b/src/features/elicitation/lib/elicitationFieldValidation.test.ts new file mode 100644 index 000000000..58fb004ac --- /dev/null +++ b/src/features/elicitation/lib/elicitationFieldValidation.test.ts @@ -0,0 +1,100 @@ +import type { + ElicitationContentValue, + ElicitationPropertySchema, +} from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { matchesPattern } from "./elicitationSchemaLimits"; +import { fieldHasAnswer, fieldIncomplete } from "./elicitationFieldValidation"; + +const properties = { + surfaces: { + type: "array", + title: "Surfaces", + items: { anyOf: [{ const: "desktop" }, { const: "web" }] }, + maxItems: 2, + }, + surfaces__other: { + type: "string", + title: "Other", + minLength: 5, + _meta: { codex: { isOtherAnswer: true } }, + }, +} as unknown as Record; + +function incomplete(content: Record): boolean { + return fieldIncomplete( + "surfaces", + properties.surfaces, + properties, + true, + content, + ); +} + +describe("elicitationFieldValidation", () => { + it("validates the custom answer even when options are also selected", () => { + // The companion carries its own constraints; selecting an option must not + // let a custom answer bypass them on the way to the accepted payload. + expect(incomplete({ surfaces: ["desktop"], surfaces__other: "ab" })).toBe( + true, + ); + expect( + incomplete({ surfaces: ["desktop"], surfaces__other: "internal API" }), + ).toBe(false); + }); + + it("treats a checked but empty custom answer as unfinished", () => { + expect(incomplete({ surfaces: ["desktop"], surfaces__other: "" })).toBe( + true, + ); + }); + + it("accepts a custom answer as the only answer to a required field", () => { + expect(incomplete({ surfaces: [], surfaces__other: "internal API" })).toBe( + false, + ); + }); + + it("counts the custom answer against maxItems", () => { + expect( + incomplete({ surfaces: ["desktop", "web"], surfaces__other: "an API" }), + ).toBe(true); + expect(incomplete({ surfaces: ["desktop", "web"] })).toBe(false); + }); + + it("still requires an answer when nothing is chosen", () => { + expect(incomplete({ surfaces: [] })).toBe(true); + expect(incomplete({})).toBe(true); + }); + + it("reports a custom-only answer as answered for step progress", () => { + expect( + fieldHasAnswer("surfaces", properties, { + surfaces: [], + surfaces__other: "internal API", + }), + ).toBe(true); + expect(fieldHasAnswer("surfaces", properties, { surfaces: [] })).toBe( + false, + ); + }); +}); + +describe("provider-supplied patterns", () => { + it("treats every provider pattern as unevaluable", () => { + expect(matchesPattern("^[a-z]+$", "Berd 1")).toBeNull(); + expect(matchesPattern("([unclosed", "anything")).toBeNull(); + }); + + it("never starts ambiguous alternation or nested quantifiers", () => { + const started = performance.now(); + expect(matchesPattern("^(a+)+$", `${"a".repeat(30)}b`)).toBeNull(); + expect(matchesPattern("^(a|aa)*$", `${"a".repeat(30)}b`)).toBeNull(); + expect(performance.now() - started).toBeLessThan(50); + }); + + it("never validates only a prefix of an over-long value", () => { + const value = `${"a".repeat(4096)}!`; + expect(matchesPattern("^a+$", value)).toBeNull(); + }); +}); diff --git a/src/features/elicitation/lib/elicitationFieldValidation.ts b/src/features/elicitation/lib/elicitationFieldValidation.ts new file mode 100644 index 000000000..ff8569b84 --- /dev/null +++ b/src/features/elicitation/lib/elicitationFieldValidation.ts @@ -0,0 +1,98 @@ +import type { + ElicitationContentValue, + ElicitationPropertySchema, +} from "@agentclientprotocol/sdk"; +import { isUnsupportedField } from "@/features/elicitation/lib/elicitationFieldKind"; +import { matchesPattern } from "@/features/elicitation/lib/elicitationSchemaLimits"; +import { findOtherCompanion } from "@/features/elicitation/stores/elicitationStore"; + +function stringViolatesSchema( + value: string, + schema: Record, + minimumLength: number, +): boolean { + const length = value.trim().length; + if (length < minimumLength) return true; + if (typeof schema.maxLength === "number" && length > schema.maxLength) { + return true; + } + if (typeof schema.pattern === "string") { + // Null means the pattern could not be evaluated within its budget, which + // must not be read as a failed value or the form becomes unanswerable. + if (matchesPattern(schema.pattern, value) === false) return true; + } + return false; +} + +export function fieldIncomplete( + name: string, + schema: ElicitationPropertySchema, + properties: Record, + required: boolean, + content: Record, +): boolean { + // A field Berd cannot render cannot be answered, so a required one blocks + // acceptance rather than being silently submitted as empty. + if (isUnsupportedField(schema)) return required; + const raw = schema as Record; + const value = content[name]; + const companion = findOtherCompanion(properties, name); + const otherValue = companion ? content[companion[0]] : undefined; + // A companion string is present only while "Other" is checked, so an empty + // one means the user asked for a custom answer and has not written it yet. + const customChecked = typeof otherValue === "string"; + if (customChecked) { + const companionSchema = companion?.[1] as Record; + const minimum = + typeof companionSchema.minLength === "number" + ? companionSchema.minLength + : 1; + if (stringViolatesSchema(otherValue, companionSchema, minimum)) return true; + } + + if (raw.type === "array") { + // A custom answer sits alongside the checked options rather than replacing + // them, so it counts as one more item against minItems/maxItems. + const selected = Array.isArray(value) ? value.length : 0; + const custom = customChecked && otherValue.trim() ? 1 : 0; + const total = selected + custom; + const minimum = + typeof raw.minItems === "number" ? raw.minItems : required ? 1 : 0; + const maximum = + typeof raw.maxItems === "number" + ? raw.maxItems + : Number.POSITIVE_INFINITY; + return total < minimum || total > maximum; + } + + if (customChecked && value === undefined) return false; + if (value === undefined || value === "") return required; + if (typeof value === "string") { + const minimum = + typeof raw.minLength === "number" ? raw.minLength : required ? 1 : 0; + return stringViolatesSchema(value, raw, minimum); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) return true; + if (schema.type === "integer" && !Number.isInteger(value)) return true; + if (typeof raw.minimum === "number" && value < raw.minimum) return true; + if (typeof raw.maximum === "number" && value > raw.maximum) return true; + } + return false; +} + +export function fieldHasAnswer( + name: string, + properties: Record, + content: Record, +): boolean { + const value = content[name]; + const companion = findOtherCompanion(properties, name); + const otherValue = companion ? content[companion[0]] : undefined; + if (typeof otherValue === "string" && otherValue.trim().length > 0) { + return true; + } + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + return value !== undefined; +} diff --git a/src/features/elicitation/lib/elicitationPersistence.test.ts b/src/features/elicitation/lib/elicitationPersistence.test.ts new file mode 100644 index 000000000..e335e7dd7 --- /dev/null +++ b/src/features/elicitation/lib/elicitationPersistence.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { AuthStatus } from "@/features/auth/api/auth"; +import { persistenceIdentityFromAuthStatus } from "./elicitationPersistence"; + +const authenticatedStatus: AuthStatus = { + loggedIn: true, + requiresOrg: false, + profile: "default", + kgooseBaseUrl: "http://localhost", + userId: "user-1", + org: "org-routing-context", +}; + +describe("persistenceIdentityFromAuthStatus", () => { + it("keeps auth-disabled local persistence in one explicit local scope", () => { + expect(persistenceIdentityFromAuthStatus(undefined)).toEqual({ + accountId: "local", + workspaceId: "local", + }); + }); + + it("uses an exact authenticated account and workspace boundary", () => { + expect( + persistenceIdentityFromAuthStatus({ + ...authenticatedStatus, + workspaceIdentifier: "workspace-1", + }), + ).toEqual({ accountId: "user-1", workspaceId: "workspace-1" }); + }); + + it("fails closed when workspace discovery is unavailable", () => { + expect(persistenceIdentityFromAuthStatus(authenticatedStatus)).toBeNull(); + }); + + it("does not treat a shared display name as an account boundary", () => { + const displayNameOnly = { + ...authenticatedStatus, + userId: null, + email: null, + user: "Alex", + name: "Alex", + workspaceIdentifier: "workspace-1", + }; + + expect(persistenceIdentityFromAuthStatus(displayNameOnly)).toBeNull(); + expect( + persistenceIdentityFromAuthStatus({ + ...displayNameOnly, + user: "Alex", + name: "Alex", + }), + ).toBeNull(); + }); + + it("fails closed for a signed-out authenticated profile", () => { + expect( + persistenceIdentityFromAuthStatus({ + ...authenticatedStatus, + loggedIn: false, + workspaceIdentifier: "workspace-1", + }), + ).toBeNull(); + }); + + it("accepts the authoritative workspace returned by a completed switch", () => { + expect( + persistenceIdentityFromAuthStatus(authenticatedStatus, "workspace-2"), + ).toEqual({ accountId: "user-1", workspaceId: "workspace-2" }); + }); +}); diff --git a/src/features/elicitation/lib/elicitationPersistence.ts b/src/features/elicitation/lib/elicitationPersistence.ts new file mode 100644 index 000000000..443faad90 --- /dev/null +++ b/src/features/elicitation/lib/elicitationPersistence.ts @@ -0,0 +1,154 @@ +import { z } from "zod"; +import type { AuthStatus } from "@/features/auth/api/auth"; + +export const ELICITATION_STORAGE_KEY = "berd:pending-elicitations:v4"; +export const LEGACY_ELICITATION_STORAGE_KEYS = [ + "berd:pending-elicitations:v1", + "berd:pending-elicitations:v2", + "berd:pending-elicitations:v3", +] as const; +export const ELICITATION_PERSISTENCE_VERSION = 4 as const; + +export interface ElicitationPersistenceIdentityInput { + accountId: string; + workspaceId: string; +} + +export const elicitationPersistenceScopeSchema = z.object({ + accountId: z.string().min(1), + workspaceId: z.string().min(1), + providerId: z.string().min(1), + connectionGeneration: z.number().int().nonnegative(), + connectionInstanceId: z.string().min(1), +}); + +export type ElicitationPersistenceScope = z.infer< + typeof elicitationPersistenceScopeSchema +>; + +export function persistenceIdentityFromAuthStatus( + status: AuthStatus | undefined, + workspaceOverride?: string | null, +): ElicitationPersistenceIdentityInput | null { + if (!status) return { accountId: "local", workspaceId: "local" }; + if (!status.loggedIn) return null; + // A display name is not an account boundary: two people can share it, and + // providers may change it. Persist only when auth supplies a stable id or + // email address. + const accountId = status.userId ?? status.email; + const workspaceId = workspaceOverride ?? status.workspaceIdentifier; + if (!accountId?.trim() || !workspaceId?.trim()) return null; + return { accountId, workspaceId }; +} + +const elicitationContentValueSchema = z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.array(z.string()), +]); + +const formElicitationPropertySchema = z + .object({ type: z.string().min(1) }) + .passthrough(); + +const formElicitationRequestSchema = z + .object({ + sessionId: z.string().min(1), + mode: z.literal("form"), + message: z.string(), + requestedSchema: z + .object({ + type: z.literal("object"), + properties: z + .record(z.string(), formElicitationPropertySchema) + .optional(), + required: z.array(z.string()).optional(), + }) + .passthrough(), + toolCallId: z.string().nullable().optional(), + _meta: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .passthrough(); + +export const persistedElicitationSchema = z.object({ + id: z.string().min(1), + semanticKey: z.string().min(1), + wireRequestId: z.union([z.string(), z.number()]).nullable(), + request: formElicitationRequestSchema, + content: z.record(z.string(), elicitationContentValueSchema), + step: z.number().int().nonnegative(), + recovered: z.boolean(), + continuation: z.enum(["response", "prompt"]), + savedAt: z.number().finite(), +}); + +export const persistedScopeSchema = z.object({ + identity: elicitationPersistenceScopeSchema, + queues: z.record(z.string(), z.array(persistedElicitationSchema)), +}); + +export const persistedElicitationSessionRecordSchema = z.object({ + identity: elicitationPersistenceScopeSchema, + sessionId: z.string().min(1), + queue: z.array(persistedElicitationSchema), +}); + +export const nativeElicitationPersistenceEnvelopeSchema = z.object({ + version: z.literal(ELICITATION_PERSISTENCE_VERSION), + records: z.record(z.string(), persistedElicitationSessionRecordSchema), +}); + +export type PersistedElicitationSessionRecord = z.infer< + typeof persistedElicitationSessionRecordSchema +>; + +export type NativeElicitationPersistenceEnvelope = z.infer< + typeof nativeElicitationPersistenceEnvelopeSchema +>; + +export const persistedElicitationEnvelopeSchema = z.object({ + version: z.literal(ELICITATION_PERSISTENCE_VERSION), + scopes: z.array(persistedScopeSchema), +}); + +export type PersistedElicitationEnvelope = z.infer< + typeof persistedElicitationEnvelopeSchema +>; + +export function persistenceScopeKey( + scope: ElicitationPersistenceScope, +): string { + return JSON.stringify([ + scope.accountId, + scope.workspaceId, + scope.providerId, + scope.connectionGeneration, + scope.connectionInstanceId, + ]); +} + +export function persistenceRecordKey( + scope: ElicitationPersistenceScope, + sessionId: string, +): string { + return JSON.stringify([ + scope.accountId, + scope.workspaceId, + scope.providerId, + scope.connectionGeneration, + scope.connectionInstanceId, + sessionId, + ]); +} + +export function sharesPersistenceNamespace( + left: ElicitationPersistenceScope, + right: ElicitationPersistenceScope, +): boolean { + return ( + left.accountId === right.accountId && + left.workspaceId === right.workspaceId && + left.providerId === right.providerId + ); +} diff --git a/src/features/elicitation/lib/elicitationPersistenceEvents.ts b/src/features/elicitation/lib/elicitationPersistenceEvents.ts new file mode 100644 index 000000000..ee36c50d2 --- /dev/null +++ b/src/features/elicitation/lib/elicitationPersistenceEvents.ts @@ -0,0 +1,33 @@ +import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { ElicitationPersistenceIdentityInput } from "@/features/elicitation/lib/elicitationPersistence"; + +const ELICITATION_PERSISTENCE_IDENTITY_EVENT = + "elicitation:persistence-identity-changed"; + +export interface ElicitationPersistenceIdentityEvent { + identity: ElicitationPersistenceIdentityInput | null; +} + +export async function broadcastElicitationPersistenceIdentity( + identity: ElicitationPersistenceIdentityInput | null, +): Promise { + if (!window.__TAURI_INTERNALS__) return; + try { + await emit(ELICITATION_PERSISTENCE_IDENTITY_EVENT, { identity }); + } catch (error) { + console.warn( + "Failed to broadcast elicitation persistence identity:", + error, + ); + } +} + +export function listenElicitationPersistenceIdentity( + handler: (identity: ElicitationPersistenceIdentityInput | null) => void, +): Promise { + if (!window.__TAURI_INTERNALS__) return Promise.resolve(() => {}); + return listen( + ELICITATION_PERSISTENCE_IDENTITY_EVENT, + (event) => handler(event.payload.identity), + ); +} diff --git a/src/features/elicitation/lib/elicitationSchemaLimits.ts b/src/features/elicitation/lib/elicitationSchemaLimits.ts new file mode 100644 index 000000000..a2c613ee6 --- /dev/null +++ b/src/features/elicitation/lib/elicitationSchemaLimits.ts @@ -0,0 +1,145 @@ +/** + * Bounds on what a provider-authored schema is allowed to make the renderer do. + * + * A question arrives from an agent, so its schema is untrusted input. A large + * but technically valid schema, or a pattern that backtracks catastrophically, + * must not be able to freeze the window. + */ + +/** Maximum number of properties accepted in one form. */ +export const MAX_FIELDS = 64; +/** Maximum number of choices accepted in one field. */ +export const MAX_OPTIONS = 200; +/** Maximum encoded size of the provider-authored JSON schema. */ +export const MAX_ELICITATION_SCHEMA_BYTES = 256 * 1024; +/** Maximum encoded size of the user-facing request message. */ +export const MAX_ELICITATION_MESSAGE_BYTES = 32 * 1024; +/** Maximum encoded size of any provider-authored description. */ +export const MAX_ELICITATION_DESCRIPTION_BYTES = 8 * 1024; +/** Maximum encoded size of the complete persisted draft collection. */ +export const MAX_PERSISTED_ELICITATION_BYTES = 512 * 1024; + +export interface ElicitationBoundsViolation { + limit: + | "schemaBytes" + | "messageBytes" + | "descriptionBytes" + | "fields" + | "options"; + maximum: number; + actual: number; +} + +const textEncoder = new TextEncoder(); + +export function encodedByteLength(value: string): number { + return textEncoder.encode(value).byteLength; +} + +function asRecord(value: unknown): Record | null { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function nestedSchemaViolation( + value: unknown, +): ElicitationBoundsViolation | null { + if (Array.isArray(value)) { + for (const item of value) { + const violation = nestedSchemaViolation(item); + if (violation) return violation; + } + return null; + } + + const record = asRecord(value); + if (!record) return null; + for (const [key, child] of Object.entries(record)) { + if (key === "description" && typeof child === "string") { + const actual = encodedByteLength(child); + if (actual > MAX_ELICITATION_DESCRIPTION_BYTES) { + return { + limit: "descriptionBytes", + maximum: MAX_ELICITATION_DESCRIPTION_BYTES, + actual, + }; + } + } + if ( + (key === "enum" || key === "oneOf" || key === "anyOf") && + Array.isArray(child) && + child.length > MAX_OPTIONS + ) { + return { + limit: "options", + maximum: MAX_OPTIONS, + actual: child.length, + }; + } + const violation = nestedSchemaViolation(child); + if (violation) return violation; + } + return null; +} + +/** Reject a provider form in full when it exceeds a renderer boundary. */ +export function elicitationBoundsViolation( + message: string, + schema: unknown, +): ElicitationBoundsViolation | null { + const messageBytes = encodedByteLength(message); + if (messageBytes > MAX_ELICITATION_MESSAGE_BYTES) { + return { + limit: "messageBytes", + maximum: MAX_ELICITATION_MESSAGE_BYTES, + actual: messageBytes, + }; + } + + let serializedSchema: string; + try { + const serialized = JSON.stringify(schema); + if (serialized === undefined) { + return { + limit: "schemaBytes", + maximum: MAX_ELICITATION_SCHEMA_BYTES, + actual: 0, + }; + } + serializedSchema = serialized; + } catch { + return { + limit: "schemaBytes", + maximum: MAX_ELICITATION_SCHEMA_BYTES, + actual: MAX_ELICITATION_SCHEMA_BYTES + 1, + }; + } + const schemaBytes = encodedByteLength(serializedSchema); + if (schemaBytes > MAX_ELICITATION_SCHEMA_BYTES) { + return { + limit: "schemaBytes", + maximum: MAX_ELICITATION_SCHEMA_BYTES, + actual: schemaBytes, + }; + } + + const properties = asRecord(asRecord(schema)?.properties); + const fields = properties ? Object.keys(properties).length : 0; + if (fields > MAX_FIELDS) { + return { limit: "fields", maximum: MAX_FIELDS, actual: fields }; + } + return nestedSchemaViolation(schema); +} + +/** + * Provider patterns are intentionally unevaluable. Native RegExp execution + * cannot be interrupted, and neither shape heuristics nor checking elapsed time + * after evaluation creates a real boundary. This remains null until Berd has a + * non-backtracking engine or a worker it can terminate at a deadline. + */ +export function matchesPattern(pattern: string, value: string): boolean | null { + void pattern; + void value; + return null; +} diff --git a/src/features/elicitation/lib/recoveredElicitationContinuation.test.ts b/src/features/elicitation/lib/recoveredElicitationContinuation.test.ts new file mode 100644 index 000000000..deb54d404 --- /dev/null +++ b/src/features/elicitation/lib/recoveredElicitationContinuation.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FormElicitationRequest } from "../stores/elicitationStore"; +import { + continueRecoveredElicitation, + recoveredElicitationPrompt, +} from "./recoveredElicitationContinuation"; + +const mocks = vi.hoisted(() => ({ + sendPromptInBackground: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/features/chat/lib/backgroundSend", () => ({ + sendPromptInBackground: (...args: unknown[]) => + mocks.sendPromptInBackground(...args), +})); + +const request: FormElicitationRequest = { + mode: "form", + sessionId: "session-1", + message: "Choose the next step", + requestedSchema: { + type: "object", + properties: { + direction: { type: "string", title: "Direction" }, + direction_custom: { type: "string", title: "Other" }, + surfaces: { type: "array", title: "Surfaces", items: { enum: [] } }, + }, + }, +}; + +describe("recoveredElicitationPrompt", () => { + it("turns accepted structured answers into one explicit continuation prompt", () => { + expect( + recoveredElicitationPrompt(request, { + action: "accept", + content: { direction: "local", surfaces: ["desktop", "cli"] }, + }), + ).toContain("- Direction: local\n- Surfaces: desktop, cli"); + }); + + it("continues a decline in prose but leaves cancellation terminal", () => { + expect( + recoveredElicitationPrompt(request, { action: "decline" }), + ).toContain("I declined to answer"); + expect( + recoveredElicitationPrompt(request, { action: "cancel" }), + ).toBeNull(); + }); + + it("checks delivery ownership at the final reversible prompt boundary", async () => { + const beforePromptDispatch = vi.fn(); + + await continueRecoveredElicitation( + request, + { action: "accept", content: { direction: "local" } }, + { beforePromptDispatch }, + ); + + expect(mocks.sendPromptInBackground).toHaveBeenCalledWith( + "session-1", + expect.stringContaining("- Direction: local"), + "goose", + undefined, + {}, + undefined, + beforePromptDispatch, + ); + }); + + it("labels a custom Other response with its parent question", () => { + expect( + recoveredElicitationPrompt(request, { + action: "accept", + content: { direction_custom: "A third way" }, + }), + ).toContain("- Direction: A third way"); + }); + + it("uses shared custom-answer metadata when the companion name has no suffix", () => { + const markedRequest = { + ...request, + requestedSchema: { + ...request.requestedSchema, + properties: { + direction: { type: "string", title: "Direction" }, + bespokeAnswer: { + type: "string", + title: "Other", + _meta: { + _askUserQuestionCustomAnswer: { + questionId: "direction", + isCustomAnswer: true, + }, + }, + }, + }, + }, + } satisfies FormElicitationRequest; + + expect( + recoveredElicitationPrompt(markedRequest, { + action: "accept", + content: { bespokeAnswer: "A fourth way" }, + }), + ).toContain("- Direction: A fourth way"); + }); + + it("defensively redacts marked sensitive answers from prose recovery", () => { + const sensitiveRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + privateValue: { + type: "string", + title: "Private answer", + _meta: { codex: { isSecret: true } }, + }, + }, + }, + } satisfies FormElicitationRequest; + const prompt = recoveredElicitationPrompt(sensitiveRequest, { + action: "accept", + content: { privateValue: "do-not-echo-this" }, + }); + + expect(prompt).toContain("- Private answer: [redacted]"); + expect(prompt).not.toContain("do-not-echo-this"); + }); +}); diff --git a/src/features/elicitation/lib/recoveredElicitationContinuation.ts b/src/features/elicitation/lib/recoveredElicitationContinuation.ts new file mode 100644 index 000000000..6ea04a3ed --- /dev/null +++ b/src/features/elicitation/lib/recoveredElicitationContinuation.ts @@ -0,0 +1,88 @@ +import type { CreateElicitationResponse } from "@agentclientprotocol/sdk"; +import { sendPromptInBackground } from "@/features/chat/lib/backgroundSend"; +import { gooseServeSelectionFromExecutionTarget } from "@/features/chat/lib/gooseServeExecutionTarget"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { isSecretElicitationProperty } from "@/features/elicitation/lib/elicitationFieldKind"; +import { + type FormElicitationRequest, + getOtherCompanionParent, + isOtherCompanionField, +} from "@/features/elicitation/stores/elicitationStore"; + +function fieldLabel(request: FormElicitationRequest, name: string): string { + const properties = request.requestedSchema.properties ?? {}; + let displayName = name; + if (isOtherCompanionField(properties, name)) { + displayName = + getOtherCompanionParent(properties[name]) ?? + ["__other", "_custom"].reduce( + (candidate, suffix) => + candidate.endsWith(suffix) + ? candidate.slice(0, -suffix.length) + : candidate, + name, + ); + } + const schema = properties[displayName] as Record | undefined; + return typeof schema?.title === "string" && schema.title.trim() + ? schema.title.trim() + : displayName; +} + +function formatValue(value: unknown): string { + if (Array.isArray(value)) return value.join(", "); + return String(value); +} + +export function recoveredElicitationPrompt( + request: FormElicitationRequest, + response: CreateElicitationResponse, +): string | null { + if (response.action === "cancel") return null; + if (response.action === "decline") { + return [ + "Berd recovered an interactive question after the previous agent connection restarted.", + `I declined to answer: ${request.message}`, + "Continue the prior task without that answer, or ask a different question in plain prose if it is essential.", + ].join("\n\n"); + } + if (response.action !== "accept") return null; + + const properties = request.requestedSchema.properties ?? {}; + const answers = Object.entries(response.content ?? {}).map(([name, value]) => + properties[name] && isSecretElicitationProperty(properties[name]) + ? `- ${fieldLabel(request, name)}: [redacted]` + : `- ${fieldLabel(request, name)}: ${formatValue(value)}`, + ); + return [ + "Berd recovered an interactive question after the previous agent connection restarted.", + `Original question: ${request.message}`, + answers.length > 0 + ? `My answers:\n${answers.join("\n")}` + : "I submitted the form without additional fields.", + "Continue the prior task from these answers.", + ].join("\n\n"); +} + +export async function continueRecoveredElicitation( + request: FormElicitationRequest, + response: CreateElicitationResponse, + callbacks?: { beforePromptDispatch?: () => void }, +): Promise { + const prompt = recoveredElicitationPrompt(request, response); + if (!prompt) return; + + const session = useChatSessionStore.getState().getSession(request.sessionId); + const providerId = gooseServeSelectionFromExecutionTarget( + session?.executionTarget, + ).providerId; + await sendPromptInBackground( + request.sessionId, + prompt, + providerId ?? "goose", + undefined, + {}, + undefined, + callbacks?.beforePromptDispatch, + ); +} diff --git a/src/features/elicitation/stores/elicitationStore.test.ts b/src/features/elicitation/stores/elicitationStore.test.ts new file mode 100644 index 000000000..b6c708055 --- /dev/null +++ b/src/features/elicitation/stores/elicitationStore.test.ts @@ -0,0 +1,1181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CreateElicitationResponse } from "@agentclientprotocol/sdk"; +import { + ELICITATION_STORAGE_KEY, + type FormElicitationRequest, + clearPersistedElicitations, + configureElicitationPersistenceIdentity, + flushElicitationDrafts, + getElicitationPersistenceScope, + loadPersistedForTests, + presentedElicitation, + isOtherCompanionField, + suspendElicitationPersistence, + useElicitationStore, +} from "./elicitationStore"; +import { MAX_PERSISTED_ELICITATION_BYTES } from "../lib/elicitationSchemaLimits"; + +function headId(sessionId = "session-1"): string { + const id = presentedElicitation( + useElicitationStore.getState().pendingBySessionId[sessionId], + )?.id; + if (!id) throw new Error(`no pending elicitation for ${sessionId}`); + return id; +} + +function persistedQueues(): Record> { + const raw = window.localStorage.getItem(ELICITATION_STORAGE_KEY); + if (!raw) return {}; + return JSON.parse(raw).scopes[0]?.queues ?? {}; +} + +const request = { + mode: "form", + sessionId: "session-1", + message: "Tell me what you need", + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + title: "Direction", + oneOf: [ + { const: "local", title: "Local" }, + { const: "upstream", title: "Upstream" }, + ], + }, + direction__other: { + type: "string", + title: "Other", + _meta: { codex: { isOtherAnswer: true } }, + }, + surfaces: { + type: "array", + title: "Surfaces", + items: { enum: ["desktop", "cli"] }, + }, + notes: { type: "string", title: "Notes" }, + }, + required: ["direction", "surfaces", "notes"], + }, + _meta: { goose: { elicitationId: "question-1" } }, +} satisfies FormElicitationRequest; + +function enqueue( + nextRequest: FormElicitationRequest = request, +): Promise { + return new Promise((resolve) => { + useElicitationStore.getState().enqueue({ request: nextRequest, resolve }); + }); +} + +describe("elicitationStore", () => { + beforeEach(() => { + vi.useFakeTimers(); + clearPersistedElicitations(); + window.localStorage.clear(); + configureElicitationPersistenceIdentity({ + accountId: "account-1", + workspaceId: "workspace-1", + }); + useElicitationStore.setState({ pendingBySessionId: {} }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("treats unknown persisted fields as ordinary fields", () => { + expect( + isOtherCompanionField( + request.requestedSchema.properties, + "missing_custom", + ), + ).toBe(false); + }); + + it("returns single-choice, multiple-choice, and free-text values together", async () => { + const response = enqueue(); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId("session-1"), "direction", "local"); + store.setValue("session-1", headId("session-1"), "surfaces", [ + "desktop", + "cli", + ]); + store.setValue( + "session-1", + headId("session-1"), + "notes", + "Keep it focused", + ); + store.accept("session-1", headId("session-1")); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { + direction: "local", + surfaces: ["desktop", "cli"], + notes: "Keep it focused", + }, + }); + }); + + it("submits an adapter Other companion instead of a conflicting enum value", async () => { + const response = enqueue(); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId("session-1"), "direction", undefined); + store.setValue( + "session-1", + headId("session-1"), + "direction__other", + "A hybrid path", + ); + store.accept("session-1", headId("session-1")); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { direction__other: "A hybrid path" }, + }); + }); + + it("uses the shared custom-answer marker even when the field name has no known suffix", async () => { + const sharedMarkerRequest = { + ...request, + requestedSchema: { + ...request.requestedSchema, + properties: { + direction: request.requestedSchema.properties.direction, + bespokeAnswer: { + type: "string", + title: "Other", + _meta: { + _askUserQuestionCustomAnswer: { + questionId: "direction", + isCustomAnswer: true, + }, + }, + }, + }, + }, + } satisfies FormElicitationRequest; + const response = enqueue(sharedMarkerRequest); + + expect( + isOtherCompanionField( + sharedMarkerRequest.requestedSchema.properties, + "bespokeAnswer", + ), + ).toBe(true); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId("session-1"), "direction", "local"); + store.setValue( + "session-1", + headId("session-1"), + "bespokeAnswer", + "A third path", + ); + store.accept("session-1", headId("session-1")); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { bespokeAnswer: "A third path" }, + }); + }); + + it("persists drafts without serializing the live resolver", () => { + void enqueue(); + useElicitationStore + .getState() + .setValue("session-1", headId("session-1"), "notes", "survive restart"); + vi.runOnlyPendingTimers(); + + const persisted = window.localStorage.getItem(ELICITATION_STORAGE_KEY); + expect(persisted).toContain("survive restart"); + expect(persisted).not.toContain("resolve"); + }); + + it("namespaces drafts by account, workspace, provider, and physical connection", () => { + const firstScope = getElicitationPersistenceScope( + "provider-a", + 7, + "connection-a", + ); + expect(firstScope).not.toBeNull(); + void new Promise((resolve) => { + useElicitationStore.getState().enqueue({ + request, + resolve, + persistenceScope: firstScope, + }); + }); + useElicitationStore + .getState() + .setValue("session-1", headId(), "notes", "scoped answer"); + vi.runOnlyPendingTimers(); + + const persisted = + window.localStorage.getItem(ELICITATION_STORAGE_KEY) ?? ""; + const envelope = JSON.parse(persisted); + expect(envelope).toMatchObject({ + version: 4, + scopes: [ + { + identity: { + providerId: "provider-a", + connectionGeneration: 7, + connectionInstanceId: "connection-a", + }, + }, + ], + }); + expect(persisted).not.toContain("account-1"); + expect(persisted).not.toContain("workspace-1"); + + configureElicitationPersistenceIdentity({ + accountId: "account-2", + workspaceId: "workspace-1", + }); + expect( + loadPersistedForTests( + getElicitationPersistenceScope("provider-a", 7, "connection-a"), + ), + ).toEqual({}); + + configureElicitationPersistenceIdentity({ + accountId: "account-1", + workspaceId: "workspace-2", + }); + expect( + loadPersistedForTests( + getElicitationPersistenceScope("provider-a", 7, "connection-a"), + ), + ).toEqual({}); + + configureElicitationPersistenceIdentity({ + accountId: "account-1", + workspaceId: "workspace-1", + }); + expect( + loadPersistedForTests( + getElicitationPersistenceScope("provider-b", 7, "connection-a"), + ), + ).toEqual({}); + + const recovered = loadPersistedForTests( + getElicitationPersistenceScope("provider-a", 8, "connection-b"), + ); + expect(recovered["session-1"]?.[0]).toMatchObject({ + content: { notes: "scoped answer" }, + persistenceScope: { + providerId: "provider-a", + connectionGeneration: 7, + connectionInstanceId: "connection-a", + }, + }); + }); + + it("keeps known FNV-colliding workspace identifiers in separate scopes", () => { + configureElicitationPersistenceIdentity({ + accountId: "account-1", + workspaceId: "ws-1o9b2ct-jgnobg", + }); + const first = getElicitationPersistenceScope("provider-a", 1); + configureElicitationPersistenceIdentity({ + accountId: "account-1", + workspaceId: "ws-iamgk3-8jcmra", + }); + const second = getElicitationPersistenceScope("provider-a", 1); + + expect(first?.workspaceId).not.toBe(second?.workspaceId); + }); + + it("hydrates equal request ids from separate connection scopes without deleting either", () => { + const firstScope = getElicitationPersistenceScope("provider-a", 7); + const secondScope = getElicitationPersistenceScope("provider-a", 8); + const incomingScope = getElicitationPersistenceScope("provider-a", 9); + if (!firstScope || !secondScope || !incomingScope) { + throw new Error("expected configured persistence scopes"); + } + void new Promise((resolve) => { + useElicitationStore.getState().enqueue({ + request, + resolve, + persistenceScope: firstScope, + }); + }); + vi.runOnlyPendingTimers(); + const envelope = JSON.parse( + window.localStorage.getItem(ELICITATION_STORAGE_KEY) ?? "{}", + ); + envelope.scopes.push({ + identity: secondScope, + queues: envelope.scopes[0].queues, + }); + window.localStorage.setItem( + ELICITATION_STORAGE_KEY, + JSON.stringify(envelope), + ); + useElicitationStore.setState({ pendingBySessionId: {} }); + + const hydrated = loadPersistedForTests(incomingScope); + + expect(hydrated["session-1"]).toHaveLength(2); + expect( + hydrated["session-1"] + .map((pending) => pending.persistenceScope?.connectionGeneration) + .sort(), + ).toEqual([7, 8]); + }); + + it("preserves persisted provider scopes that have not been hydrated", () => { + const providerAScope = getElicitationPersistenceScope("provider-a", 7); + const providerBScope = getElicitationPersistenceScope("provider-b", 7); + if (!providerAScope || !providerBScope) { + throw new Error("expected configured persistence scopes"); + } + + void new Promise((resolve) => { + useElicitationStore.getState().enqueue({ + request, + resolve, + persistenceScope: providerAScope, + }); + }); + void new Promise((resolve) => { + useElicitationStore.getState().enqueue({ + request: { + ...request, + sessionId: "session-2", + _meta: { goose: { elicitationId: "question-2" } }, + }, + resolve, + persistenceScope: providerBScope, + }); + }); + useElicitationStore + .getState() + .setValue("session-1", headId("session-1"), "notes", "provider a"); + useElicitationStore + .getState() + .setValue("session-2", headId("session-2"), "notes", "provider b"); + vi.runOnlyPendingTimers(); + + const persisted = window.localStorage.getItem(ELICITATION_STORAGE_KEY); + expect(persisted).not.toBeNull(); + + // Simulate an app restart that only reconnects provider A. Persisting its + // recovered queue must not erase provider B's unhydrated namespace. + suspendElicitationPersistence(); + configureElicitationPersistenceIdentity({ + accountId: "other-account", + workspaceId: "workspace-1", + }); + configureElicitationPersistenceIdentity({ + accountId: "account-1", + workspaceId: "workspace-1", + }); + useElicitationStore.setState({ pendingBySessionId: {} }); + window.localStorage.setItem(ELICITATION_STORAGE_KEY, persisted ?? ""); + useElicitationStore.setState({ + pendingBySessionId: loadPersistedForTests(providerAScope), + }); + vi.runOnlyPendingTimers(); + + const rewritten = JSON.parse( + window.localStorage.getItem(ELICITATION_STORAGE_KEY) ?? "{}", + ); + expect( + rewritten.scopes + .map( + (scope: { identity: { providerId: string } }) => + scope.identity.providerId, + ) + .sort(), + ).toEqual(["provider-a", "provider-b"]); + expect( + rewritten.scopes.find( + (scope: { identity: { providerId: string } }) => + scope.identity.providerId === "provider-b", + )?.queues["session-2"][0].content, + ).toEqual({ notes: "provider b" }); + }); + + it("removes persistence when the complete draft collection exceeds its byte budget", () => { + void enqueue(); + vi.runOnlyPendingTimers(); + expect(window.localStorage.getItem(ELICITATION_STORAGE_KEY)).not.toBeNull(); + + useElicitationStore + .getState() + .setValue( + "session-1", + headId("session-1"), + "notes", + "x".repeat(MAX_PERSISTED_ELICITATION_BYTES), + ); + vi.runOnlyPendingTimers(); + + expect(window.localStorage.getItem(ELICITATION_STORAGE_KEY)).toBeNull(); + }); + + it("drops credential-marked defaults and stale answers", async () => { + const credentialRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + credential: { + type: "string", + title: "Credential", + default: "provider-credential-value", + _meta: { codex: { isSecret: true } }, + }, + note: { type: "string", title: "Note" }, + }, + }, + } satisfies FormElicitationRequest; + const response = enqueue(credentialRequest); + const store = useElicitationStore.getState(); + + expect(store.pendingBySessionId["session-1"][0].content).not.toHaveProperty( + "credential", + ); + store.setValue( + "session-1", + headId("session-1"), + "credential", + "stale-credential-value", + ); + store.setValue("session-1", headId("session-1"), "note", "safe draft"); + vi.runOnlyPendingTimers(); + + const persisted = window.localStorage.getItem(ELICITATION_STORAGE_KEY); + expect(persisted).toContain("safe draft"); + expect(persisted).not.toContain("provider-credential-value"); + expect(persisted).not.toContain("stale-credential-value"); + + store.accept("session-1", headId("session-1")); + await expect(response).resolves.toEqual({ + action: "accept", + content: { note: "safe draft" }, + }); + }); + + it("drops hidden companion values when their parent is a credential field", async () => { + const credentialRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + credential: { + type: "string", + title: "Credential", + _meta: { codex: { isSecret: true } }, + }, + credential__other: { + type: "string", + title: "Other", + default: "provider-companion-value", + _meta: { + codex: { isOtherAnswer: true, questionId: "credential" }, + }, + }, + }, + }, + } satisfies FormElicitationRequest; + const response = enqueue(credentialRequest); + const store = useElicitationStore.getState(); + + expect(store.pendingBySessionId["session-1"][0].content).toEqual({}); + store.setValue( + "session-1", + headId("session-1"), + "credential__other", + "stale-companion-value", + ); + vi.runOnlyPendingTimers(); + const persisted = window.localStorage.getItem(ELICITATION_STORAGE_KEY); + expect(persisted).not.toContain("provider-companion-value"); + expect(persisted).not.toContain("stale-companion-value"); + + store.accept("session-1", headId("session-1")); + await expect(response).resolves.toEqual({ + action: "accept", + content: {}, + }); + }); + + it("revalidates a detached draft against a replayed request schema", async () => { + void enqueue(); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId("session-1"), "direction", "local"); + store.setValue("session-1", headId("session-1"), "surfaces", [ + "desktop", + "cli", + ]); + store.setValue( + "session-1", + headId("session-1"), + "notes", + "previously safe", + ); + store.detachAll("session-1"); + + const replayedRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + title: "Direction", + enum: ["upstream"], + }, + surfaces: { + type: "array", + title: "Surfaces", + items: { enum: ["cli"] }, + }, + notes: { type: "integer", title: "Priority" }, + confirmation: { + type: "string", + title: "Confirmation", + default: "current request", + }, + }, + }, + } satisfies FormElicitationRequest; + const response = enqueue(replayedRequest); + + expect( + useElicitationStore.getState().pendingBySessionId["session-1"][0].content, + ).toEqual({ surfaces: ["cli"], confirmation: "current request" }); + + useElicitationStore + .getState() + .setValue("session-1", headId("session-1"), "notes", 7); + useElicitationStore.getState().accept("session-1", headId("session-1")); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { + surfaces: ["cli"], + notes: 7, + confirmation: "current request", + }, + }); + }); + + it("never carries an unsupported credential answer into an ordinary replay", async () => { + const credentialFirstRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + notes: { + type: "string", + title: "Notes", + _meta: { codex: { isSecret: true } }, + }, + }, + }, + } satisfies FormElicitationRequest; + void enqueue(credentialFirstRequest); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId("session-1"), "notes", "do not replay"); + store.detachAll("session-1"); + + const response = enqueue({ + ...credentialFirstRequest, + requestedSchema: { + type: "object", + properties: { notes: { type: "string", title: "Notes" } }, + }, + }); + + expect( + useElicitationStore.getState().pendingBySessionId["session-1"][0].content, + ).toEqual({}); + useElicitationStore.getState().cancel("session-1", headId("session-1")); + await expect(response).resolves.toEqual({ action: "cancel" }); + }); + + it("never submits unknown or type-incompatible content", async () => { + const response = enqueue(); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId("session-1"), "notes", true); + store.setValue( + "session-1", + headId("session-1"), + "removed-field", + "stale answer", + ); + store.accept("session-1", headId("session-1")); + + await expect(response).resolves.toEqual({ + action: "accept", + content: {}, + }); + }); + + it("drops provider defaults and stale answers for unsupported fields", async () => { + const unsupportedRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + visual: { + type: "colour-picker", + title: "Visual", + default: "provider-default", + }, + notes: { type: "string", title: "Notes" }, + }, + }, + } as unknown as FormElicitationRequest; + const response = enqueue(unsupportedRequest); + const store = useElicitationStore.getState(); + + expect(store.pendingBySessionId["session-1"][0].content).toEqual({}); + store.setValue( + "session-1", + headId("session-1"), + "visual", + "stale persisted answer", + ); + store.setValue("session-1", headId("session-1"), "notes", "Visible"); + store.accept("session-1", headId("session-1")); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { notes: "Visible" }, + }); + }); + + it("uses the ACP tool call id as part of legacy semantic identity", () => { + void enqueue({ ...request, _meta: undefined, toolCallId: "tool-call-1" }); + void enqueue({ ...request, _meta: undefined, toolCallId: "tool-call-2" }); + + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(2); + expect(queue[0]?.semanticKey).not.toBe(queue[1]?.semanticKey); + }); + + it("lets the user dismiss a detached persisted question", () => { + void enqueue(); + useElicitationStore.getState().detachAll("session-1"); + + useElicitationStore.getState().cancel("session-1", headId("session-1")); + + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toBeUndefined(); + }); + + it("does not discard a detached answer after its responder reattaches", () => { + void enqueue(); + const store = useElicitationStore.getState(); + store.detachAll("session-1"); + void enqueue(); + + // A question whose responder came back cannot be sent as an ordinary + // message: the claim is refused, so the live path keeps ownership. + expect( + useElicitationStore + .getState() + .claimDetachedDelivery("session-1", "question-1"), + ).toBeNull(); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"]?.[0] + ?.resolve, + ).not.toBeNull(); + }); + + it("keeps decline distinct from cancel", async () => { + const response = enqueue(); + useElicitationStore.getState().decline("session-1", headId("session-1")); + await expect(response).resolves.toEqual({ action: "decline" }); + }); + + it("ignores a submit addressed to a question that is no longer the head", async () => { + const first = enqueue(); + const second = enqueue({ + ...request, + _meta: { goose: { elicitationId: "question-2" } }, + }); + + // The agent withdraws the question the user was reading. A click already + // on its way must not answer the one that moved up behind it. + useElicitationStore.getState().cancel("session-1", "question-1"); + await expect(first).resolves.toEqual({ action: "cancel" }); + + useElicitationStore.getState().accept("session-1", "question-1"); + let settled = false; + void second.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + useElicitationStore.getState().accept("session-1", "question-2"); + await expect(second).resolves.toMatchObject({ action: "accept" }); + }); + + it("keeps each draft's own age when another session is written", () => { + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + void enqueue(); + useElicitationStore + .getState() + .setValue("session-1", headId("session-1"), "notes", "first"); + vi.runOnlyPendingTimers(); + const firstSavedAt = persistedQueues()["session-1"][0].savedAt; + + vi.setSystemTime(new Date("2026-01-20T00:00:00Z")); + void enqueue({ + ...request, + sessionId: "session-2", + _meta: { goose: { elicitationId: "question-2" } }, + }); + useElicitationStore + .getState() + .setValue("session-2", headId("session-2"), "notes", "second"); + vi.runOnlyPendingTimers(); + + expect(persistedQueues()["session-1"][0].savedAt).toBe(firstSavedAt); + }); + + it("writes a debounced draft immediately when the window goes away", () => { + void enqueue(); + vi.runOnlyPendingTimers(); + window.localStorage.clear(); + + useElicitationStore + .getState() + .setValue("session-1", headId("session-1"), "notes", "unsaved"); + expect(window.localStorage.getItem(ELICITATION_STORAGE_KEY)).toBeNull(); + + flushElicitationDrafts(); + expect(window.localStorage.getItem(ELICITATION_STORAGE_KEY)).toContain( + "unsaved", + ); + }); + + it("shows a live question that arrives behind a recovered draft", async () => { + // A recovered draft nobody is waiting on must never hide a request the + // agent is actually blocked on, or the turn stalls with a usable composer. + useElicitationStore.setState({ + pendingBySessionId: { + "session-1": [ + { + id: "recovered-1", + semanticKey: "goose:session-1:recovered-1", + wireRequestId: null, + request: { + ...request, + _meta: { goose: { elicitationId: "recovered-1" } }, + }, + content: {}, + step: 0, + recovered: true, + continuation: "prompt", + resolve: null, + responderKey: null, + deliveryClaim: null, + persistenceScope: null, + savedAt: Date.now(), + }, + ], + }, + }); + + const live = enqueue({ + ...request, + _meta: { goose: { elicitationId: "live-1" } }, + }); + + const queue = () => + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue().map((pending) => pending.id)).toEqual([ + "recovered-1", + "live-1", + ]); + expect(presentedElicitation(queue())?.id).toBe("live-1"); + + // Edits and answers address the live question, not the draft in front of it. + useElicitationStore + .getState() + .setValue("session-1", headId("session-1"), "notes", "answered"); + expect(queue().find((p) => p.id === "live-1")?.content.notes).toBe( + "answered", + ); + expect( + queue().find((p) => p.id === "recovered-1")?.content.notes, + ).toBeUndefined(); + + useElicitationStore.getState().accept("session-1", "live-1"); + await expect(live).resolves.toMatchObject({ action: "accept" }); + + // The draft survives and becomes presented again once nothing is live. + expect(queue().map((pending) => pending.id)).toEqual(["recovered-1"]); + expect(presentedElicitation(queue())?.id).toBe("recovered-1"); + }); + + it("ignores an edit from a question that is no longer presented", async () => { + // A control belonging to a withdrawn question can still fire before React + // replaces the DOM; that event must not land on its replacement. + const first = enqueue(); + const second = enqueue({ + ...request, + _meta: { goose: { elicitationId: "question-2" } }, + }); + + useElicitationStore.getState().cancel("session-1", "question-1"); + await expect(first).resolves.toEqual({ action: "cancel" }); + + useElicitationStore + .getState() + .setValue("session-1", "question-1", "notes", "stale edit"); + useElicitationStore.getState().setStep("session-1", "question-1", 2); + + const presented = presentedElicitation( + useElicitationStore.getState().pendingBySessionId["session-1"], + ); + expect(presented?.id).toBe("question-2"); + expect(presented?.content.notes).toBeUndefined(); + expect(presented?.step).toBe(0); + + useElicitationStore.getState().cancel("session-1", "question-2"); + await expect(second).resolves.toEqual({ action: "cancel" }); + }); + + it("lets a responder that reattaches before prompt dispatch win", async () => { + void enqueue(); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId(), "notes", "the answer"); + store.detachAll("session-1"); + + const token = useElicitationStore + .getState() + .claimDetachedDelivery("session-1", "question-1"); + expect(token).not.toBeNull(); + + // A second send, or a discard, cannot start while the claim is held. + expect( + useElicitationStore + .getState() + .claimDetachedDelivery("session-1", "question-1"), + ).toBeNull(); + useElicitationStore.getState().cancel("session-1", "question-1"); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toHaveLength(1); + + const reattached = enqueue(); + expect( + useElicitationStore + .getState() + .beginDetachedDelivery("session-1", "question-1", token as symbol), + ).toBe(false); + + await expect(reattached).resolves.toMatchObject({ action: "accept" }); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toBeUndefined(); + }); + + it("cancels a responder that reattaches after prompt dispatch", async () => { + void enqueue(); + const store = useElicitationStore.getState(); + store.setValue("session-1", headId(), "notes", "the answer"); + store.detachAll("session-1"); + const token = store.claimDetachedDelivery( + "session-1", + "question-1", + ) as symbol; + + expect( + useElicitationStore + .getState() + .beginDetachedDelivery("session-1", "question-1", token), + ).toBe(true); + const reattached = enqueue(); + useElicitationStore + .getState() + .completeDetachedDelivery("session-1", "question-1", token); + + await expect(reattached).resolves.toEqual({ action: "cancel" }); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toBeUndefined(); + }); + + it("never reopens delivery when the prompt result is unknown after dispatch", async () => { + void enqueue(); + const store = useElicitationStore.getState(); + store.detachAll("session-1"); + const token = store.claimDetachedDelivery( + "session-1", + "question-1", + ) as symbol; + expect(store.beginDetachedDelivery("session-1", "question-1", token)).toBe( + true, + ); + + expect( + useElicitationStore + .getState() + .failDetachedDelivery("session-1", "question-1", token), + ).toBe("indeterminate"); + const reattached = enqueue(); + + await expect(reattached).resolves.toEqual({ action: "cancel" }); + expect( + useElicitationStore + .getState() + .claimDetachedDelivery("session-1", "question-1"), + ).toBeNull(); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"]?.[0] + ?.deliveryClaim, + ).toMatchObject({ phase: "indeterminate" }); + + useElicitationStore.getState().cancelAll("session-1"); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toBeUndefined(); + }); + + it("settles but retains an in-flight delivery until teardown completes", async () => { + void enqueue(); + const store = useElicitationStore.getState(); + store.detachAll("session-1"); + const token = store.claimDetachedDelivery( + "session-1", + "question-1", + ) as symbol; + expect( + useElicitationStore + .getState() + .beginDetachedDelivery("session-1", "question-1", token), + ).toBe(true); + const reattached = enqueue(); + + useElicitationStore.getState().cancelAll("session-1"); + + await expect(reattached).resolves.toEqual({ action: "cancel" }); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"]?.[0] + ?.deliveryClaim, + ).toMatchObject({ phase: "prompt-dispatched", cancelled: true }); + + useElicitationStore + .getState() + .completeDetachedDelivery("session-1", "question-1", token); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toBeUndefined(); + }); + + it("settles an in-flight responder before account persistence is cleared", async () => { + void enqueue(); + const store = useElicitationStore.getState(); + store.detachAll("session-1"); + const token = store.claimDetachedDelivery( + "session-1", + "question-1", + ) as symbol; + expect( + useElicitationStore + .getState() + .beginDetachedDelivery("session-1", "question-1", token), + ).toBe(true); + const reattached = enqueue(); + + clearPersistedElicitations(); + + await expect(reattached).resolves.toEqual({ action: "cancel" }); + expect(window.localStorage.length).toBe(0); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"]?.[0] + ?.deliveryClaim, + ).toMatchObject({ phase: "prompt-dispatched", cancelled: true }); + + useElicitationStore + .getState() + .completeDetachedDelivery("session-1", "question-1", token); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toBeUndefined(); + }); + + it("lets the user retry after a failed send", () => { + void enqueue(); + useElicitationStore.getState().detachAll("session-1"); + const token = useElicitationStore + .getState() + .claimDetachedDelivery("session-1", "question-1") as symbol; + + useElicitationStore + .getState() + .failDetachedDelivery("session-1", "question-1", token); + + expect( + useElicitationStore + .getState() + .claimDetachedDelivery("session-1", "question-1"), + ).not.toBeNull(); + }); + + it("settles live responders before forgetting an account's drafts", async () => { + const response = enqueue(); + useElicitationStore + .getState() + .setValue("session-1", headId(), "notes", "private to this account"); + vi.runOnlyPendingTimers(); + expect(window.localStorage.getItem(ELICITATION_STORAGE_KEY)).toContain( + "private to this account", + ); + + clearPersistedElicitations(); + + await expect(response).resolves.toEqual({ action: "cancel" }); + + // Session ids are not unique across accounts, so a draft that outlived one + // could surface under another. + expect(window.localStorage.getItem(ELICITATION_STORAGE_KEY)).toBeNull(); + expect(useElicitationStore.getState().pendingBySessionId).toEqual({}); + }); + + it("drops persisted state it cannot parse instead of rereading it", () => { + window.localStorage.setItem(ELICITATION_STORAGE_KEY, "{not json"); + useElicitationStore.setState({ + pendingBySessionId: loadPersistedForTests(), + }); + expect(window.localStorage.getItem(ELICITATION_STORAGE_KEY)).toBeNull(); + }); + + it("rejects a persistence envelope from another schema version", () => { + window.localStorage.setItem( + ELICITATION_STORAGE_KEY, + JSON.stringify({ version: 1, scopes: [] }), + ); + + expect(loadPersistedForTests()).toEqual({}); + expect(window.localStorage.getItem(ELICITATION_STORAGE_KEY)).toBeNull(); + }); + + it("rejects malformed persisted property schemas instead of casting them", () => { + void enqueue(); + vi.runOnlyPendingTimers(); + const envelope = JSON.parse( + window.localStorage.getItem(ELICITATION_STORAGE_KEY) ?? "{}", + ); + envelope.scopes[0].queues[ + "session-1" + ][0].request.requestedSchema.properties.notes = "not a property schema"; + window.localStorage.setItem( + ELICITATION_STORAGE_KEY, + JSON.stringify(envelope), + ); + + expect(loadPersistedForTests()).toEqual({}); + expect(window.localStorage.getItem(ELICITATION_STORAGE_KEY)).toBeNull(); + }); + + it("reattaches a draft when the same question returns on a new connection", async () => { + // The real transport always carries a wire id, and a reconnect always + // brings a fresh one. If reattachment needed the id to be absent, an + // ordinary reconnect would strand every draft. + const legacy = { + ...request, + _meta: undefined, + } as FormElicitationRequest; + + void new Promise((resolve) => { + useElicitationStore.getState().enqueue({ + request: legacy, + resolve, + wireRequestId: 41, + connectionIdentity: { + providerId: "claude-acp", + connectionGeneration: 1, + connectionInstanceId: "conn-1", + }, + }); + }); + const firstId = useElicitationStore.getState().pendingBySessionId[ + "session-1" + ]?.[0]?.id as string; + useElicitationStore + .getState() + .setValue("session-1", firstId, "notes", "typed before the drop"); + + // The connection goes away; the draft survives detached. + useElicitationStore.getState().detachAll("session-1"); + + void new Promise((resolve) => { + useElicitationStore.getState().enqueue({ + request: legacy, + resolve, + wireRequestId: 1, + connectionIdentity: { + providerId: "claude-acp", + connectionGeneration: 2, + connectionInstanceId: "conn-2", + }, + }); + }); + + const queue = + useElicitationStore.getState().pendingBySessionId["session-1"] ?? []; + expect(queue).toHaveLength(1); + expect(queue[0]?.content.notes).toBe("typed before the drop"); + expect(queue[0]?.resolve).not.toBeNull(); + }); + + it("keeps two ambiguous drafts separate rather than guessing", async () => { + // Same shape, two detached drafts: reattaching either one would be a + // coin flip, so a returning question becomes its own entry instead. + const legacy = { ...request, _meta: undefined } as FormElicitationRequest; + for (const [gen, wire] of [ + [1, 11], + [1, 12], + ] as const) { + void new Promise((resolve) => { + useElicitationStore.getState().enqueue({ + request: legacy, + resolve, + wireRequestId: wire, + connectionIdentity: { + providerId: "claude-acp", + connectionGeneration: gen, + connectionInstanceId: `conn-${gen}`, + }, + }); + }); + } + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toHaveLength(2); + useElicitationStore.getState().detachAll("session-1"); + + void new Promise((resolve) => { + useElicitationStore.getState().enqueue({ + request: legacy, + resolve, + wireRequestId: 21, + connectionIdentity: { + providerId: "claude-acp", + connectionGeneration: 2, + connectionInstanceId: "conn-2", + }, + }); + }); + + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toHaveLength(3); + }); +}); diff --git a/src/features/elicitation/stores/elicitationStore.ts b/src/features/elicitation/stores/elicitationStore.ts new file mode 100644 index 000000000..91557f72c --- /dev/null +++ b/src/features/elicitation/stores/elicitationStore.ts @@ -0,0 +1,1479 @@ +import { create } from "zustand"; +import { invoke } from "@tauri-apps/api/core"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js"; +import type { + CreateElicitationResponse, + ElicitationContentValue, + ElicitationPropertySchema, + ElicitationSchema, +} from "@agentclientprotocol/sdk"; +import { + encodedByteLength, + MAX_PERSISTED_ELICITATION_BYTES, +} from "@/features/elicitation/lib/elicitationSchemaLimits"; +import { + elicitationFieldKind, + isSecretElicitationProperty, +} from "@/features/elicitation/lib/elicitationFieldKind"; +import { + ELICITATION_PERSISTENCE_VERSION, + ELICITATION_STORAGE_KEY, + type ElicitationPersistenceIdentityInput, + type ElicitationPersistenceScope, + LEGACY_ELICITATION_STORAGE_KEYS, + type NativeElicitationPersistenceEnvelope, + nativeElicitationPersistenceEnvelopeSchema, + persistedElicitationSchema, + persistedElicitationEnvelopeSchema, + type PersistedElicitationSessionRecord, + persistenceRecordKey, + persistenceScopeKey, + sharesPersistenceNamespace, +} from "@/features/elicitation/lib/elicitationPersistence"; + +const ELICITATION_PERSIST_DELAY_MS = 100; +const ELICITATION_MAX_PERSISTED_AGE_MS = 30 * 24 * 60 * 60 * 1000; +let persistenceTimer: number | null = null; +let nativePersistenceEnvelope: NativeElicitationPersistenceEnvelope | null = + null; +let nativeWriteChain = Promise.resolve(); + +export { ELICITATION_STORAGE_KEY } from "@/features/elicitation/lib/elicitationPersistence"; + +let persistenceIdentity: ElicitationPersistenceIdentityInput | null = null; +const hydratedPersistenceScopes = new Set(); + +function identityFingerprint(value: string): string { + return bytesToHex(sha256(utf8ToBytes(value))); +} + +export function configureElicitationPersistenceIdentity( + identity: ElicitationPersistenceIdentityInput, +): void { + const next = { + accountId: `account:sha256:${identityFingerprint(identity.accountId.trim())}`, + workspaceId: `workspace:sha256:${identityFingerprint(identity.workspaceId.trim())}`, + }; + if ( + persistenceIdentity?.accountId === next.accountId && + persistenceIdentity.workspaceId === next.workspaceId + ) { + return; + } + persistenceIdentity = next; + hydratedPersistenceScopes.clear(); +} + +function usesNativePersistence(): boolean { + return typeof window !== "undefined" && Boolean(window.__TAURI_INTERNALS__); +} + +/** Establish the exact identity and load the app-global native persistence + * snapshot before an ACP runtime can receive a request. */ +export async function prepareElicitationPersistenceIdentity( + identity: ElicitationPersistenceIdentityInput, +): Promise { + configureElicitationPersistenceIdentity(identity); + if (!usesNativePersistence()) return; + await nativeWriteChain; + try { + const serialized = await invoke( + "load_elicitation_persistence", + ); + if ( + !serialized || + encodedByteLength(serialized) > MAX_PERSISTED_ELICITATION_BYTES + ) { + nativePersistenceEnvelope = null; + return; + } + const parsed = nativeElicitationPersistenceEnvelopeSchema.safeParse( + JSON.parse(serialized), + ); + nativePersistenceEnvelope = parsed.success ? parsed.data : null; + } catch { + // Failing closed keeps a renderer-local cache from becoming authoritative + // when the app-global persistence owner is unavailable. + nativePersistenceEnvelope = null; + } +} + +export function getElicitationPersistenceScope( + providerId: string, + connectionGeneration: number, + connectionInstanceId = `test-connection:${Math.max(0, Math.floor(connectionGeneration))}`, +): ElicitationPersistenceScope | null { + if (!persistenceIdentity) return null; + return { + ...persistenceIdentity, + providerId: providerId.trim() || "unknown", + connectionGeneration: Math.max(0, Math.floor(connectionGeneration)), + connectionInstanceId: connectionInstanceId.trim() || "unknown", + }; +} + +function scopesCanReplay( + existing: ElicitationPersistenceScope | null, + incoming: ElicitationPersistenceScope | null, +): boolean { + if (!existing || !incoming) return true; + return sharesPersistenceNamespace(existing, incoming); +} + +export type FormElicitationRequest = { + sessionId: string; + mode: "form"; + message: string; + requestedSchema: ElicitationSchema; + toolCallId?: string | null; + _meta?: Record | null; +}; + +export type ElicitationContinuation = "response" | "prompt"; + +export interface ElicitationConnectionIdentity { + providerId: string; + connectionGeneration: number; + connectionInstanceId: string; +} + +interface DetachedDeliveryClaim { + token: symbol; + phase: "claiming" | "prompt-dispatched" | "indeterminate"; + cancelled: boolean; +} + +export interface PendingElicitation { + id: string; + /** Stable across reconnects; never includes the JSON-RPC request id. */ + semanticKey: string; + /** Identifies the live responder, not the persisted question. */ + wireRequestId: string | number | null; + request: FormElicitationRequest; + content: Record; + step: number; + recovered: boolean; + continuation: ElicitationContinuation; + resolve: ((response: CreateElicitationResponse) => void) | null; + responderKey: symbol | null; + /** Held while this question's answers are in flight as an ordinary message. */ + deliveryClaim: DetachedDeliveryClaim | null; + /** Identity boundary used for persistence; null drafts remain memory-only. */ + persistenceScope: ElicitationPersistenceScope | null; + /** When this question was first seen, for expiry. Never refreshed by a write. */ + savedAt: number; +} + +interface ElicitationState { + pendingBySessionId: Record; + enqueue: (pending: { + request: FormElicitationRequest; + resolve: (response: CreateElicitationResponse) => void; + wireRequestId?: string | number; + persistenceScope?: ElicitationPersistenceScope | null; + connectionIdentity?: ElicitationConnectionIdentity; + }) => { id: string; responderKey: symbol }; + setValue: ( + sessionId: string, + id: string, + key: string, + value: ElicitationContentValue | undefined, + ) => void; + setStep: (sessionId: string, id: string, step: number) => void; + accept: (sessionId: string, id: string) => void; + decline: (sessionId: string, id: string) => void; + cancel: (sessionId: string, id: string) => void; + retainUndeliveredAnswers: ( + request: FormElicitationRequest, + content: Record, + persistenceScope?: ElicitationPersistenceScope | null, + connectionIdentity?: ElicitationConnectionIdentity, + ) => void; + claimDetachedDelivery: (sessionId: string, id: string) => symbol | null; + beginDetachedDelivery: ( + sessionId: string, + id: string, + token: symbol, + ) => boolean; + completeDetachedDelivery: ( + sessionId: string, + id: string, + token: symbol, + ) => void; + failDetachedDelivery: ( + sessionId: string, + id: string, + token: symbol, + ) => "retryable" | "indeterminate" | "ignored"; + abort: (sessionId: string, id: string, responderKey: symbol) => void; + detachAll: (sessionId?: string) => void; + cancelAll: (sessionId?: string) => void; +} + +function asRecord(value: unknown): Record | null { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function schemaWithoutDefaults(value: unknown): unknown { + if (Array.isArray(value)) return value.map(schemaWithoutDefaults); + const record = asRecord(value); + if (!record) return value; + return Object.fromEntries( + Object.entries(record).flatMap(([key, nested]) => + key === "default" ? [] : [[key, schemaWithoutDefaults(nested)]], + ), + ); +} + +function stableDigest(value: unknown): string { + return bytesToHex(sha256(utf8ToBytes(JSON.stringify(value)))); +} + +function stableLegacyId( + request: FormElicitationRequest, + providerId: string, +): string { + // Older ACP clients provide no stable request identity. Hash the complete + // semantic shape with a collision-resistant digest; defaults are excluded so + // secret-default sanitation cannot change identity during persistence. + return `legacy:${stableDigest([ + providerId, + request.sessionId, + request.toolCallId ?? null, + request.message, + schemaWithoutDefaults(request.requestedSchema), + ])}`; +} + +export function getElicitationMetadata( + request: FormElicitationRequest, + /** + * JSON-RPC id of the live request, when there is one. Two identical questions + * asked at once hash the same, so without this they would share one entry and + * a single answer would resolve both. + */ + wireRequestId?: string | number, + connectionIdentity: ElicitationConnectionIdentity = { + providerId: "unknown", + connectionGeneration: 0, + connectionInstanceId: "unknown", + }, +): { + id: string; + semanticKey: string; + wireRequestId: string | number | null; + recovered: boolean; + continuation: ElicitationContinuation; +} { + const goose = asRecord(asRecord(request._meta)?.goose); + const recovered = goose?.recovered === true; + const elicitationId = + typeof goose?.elicitationId === "string" && goose.elicitationId.length > 0 + ? goose.elicitationId + : null; + const semanticKey = elicitationId + ? `goose:${stableDigest([ + connectionIdentity.providerId, + request.sessionId, + elicitationId, + ])}` + : stableLegacyId(request, connectionIdentity.providerId); + const wireId = wireRequestId ?? null; + return { + id: + wireId !== null + ? `wire:${stableDigest([ + connectionIdentity.providerId, + Math.max(0, Math.floor(connectionIdentity.connectionGeneration)), + connectionIdentity.connectionInstanceId, + request.sessionId, + typeof wireId, + String(wireId), + ])}` + : (elicitationId ?? semanticKey), + semanticKey, + wireRequestId: wireId, + recovered, + // Continuing as a prompt is a recovery behaviour. Honouring it on a request + // that is not marked recovered would let arbitrary metadata redirect a live + // question into an ordinary message. + continuation: + recovered && goose?.continuation === "prompt" ? "prompt" : "response", + }; +} + +function uniquePendingId( + queue: PendingElicitation[], + preferred: string, +): string { + if (!queue.some((pending) => pending.id === preferred)) return preferred; + let suffix = 2; + while (queue.some((pending) => pending.id === `${preferred}:${suffix}`)) { + suffix += 1; + } + return `${preferred}:${suffix}`; +} + +export function getOtherCompanionParent( + schema: ElicitationPropertySchema, +): string | null { + const raw = schema as Record; + const meta = asRecord(raw._meta); + const shared = asRecord(meta?._askUserQuestionCustomAnswer); + if ( + shared?.isCustomAnswer === true && + typeof shared.questionId === "string" && + shared.questionId.length > 0 + ) { + return shared.questionId; + } + const codex = asRecord(meta?.codex); + return codex?.isOtherAnswer === true && + typeof codex.questionId === "string" && + codex.questionId.length > 0 + ? codex.questionId + : null; +} + +function isOtherCompanion( + name: string, + schema: ElicitationPropertySchema, +): boolean { + const raw = schema as Record; + const meta = asRecord(raw._meta); + const codex = asRecord(meta?.codex); + const title = typeof raw.title === "string" ? raw.title.toLowerCase() : ""; + return ( + getOtherCompanionParent(schema) !== null || + codex?.isOtherAnswer === true || + ((name.endsWith("__other") || name.endsWith("_custom")) && + title === "other") + ); +} + +function otherCompanionParentName( + properties: Record, + name: string, + schema: ElicitationPropertySchema, +): string | null { + const explicitParent = getOtherCompanionParent(schema); + if (explicitParent) return explicitParent; + if (!isOtherCompanion(name, schema)) return null; + for (const suffix of ["__other", "_custom"]) { + if (name.endsWith(suffix)) { + const parentName = name.slice(0, -suffix.length); + return parentName in properties ? parentName : null; + } + } + return null; +} + +function hasUnsupportedCompanionParent( + properties: Record, + name: string, + schema: ElicitationPropertySchema, +): boolean { + const parentName = otherCompanionParentName(properties, name, schema); + const parent = parentName ? properties[parentName] : undefined; + return Boolean(parent && elicitationFieldKind(parent) === "unsupported"); +} + +export function findOtherCompanion( + properties: Record, + fieldName: string, +): [string, ElicitationPropertySchema] | null { + const parent = properties[fieldName]; + if (!parent || elicitationFieldKind(parent) === "unsupported") return null; + for (const [name, schema] of Object.entries(properties)) { + if ( + elicitationFieldKind(schema) !== "unsupported" && + getOtherCompanionParent(schema) === fieldName + ) { + return [name, schema]; + } + } + for (const suffix of ["__other", "_custom"]) { + const name = `${fieldName}${suffix}`; + const schema = properties[name]; + if ( + schema && + elicitationFieldKind(schema) !== "unsupported" && + isOtherCompanion(name, schema) + ) { + return [name, schema]; + } + } + return null; +} + +export function isOtherCompanionField( + properties: Record, + fieldName: string, +): boolean { + const schema = properties[fieldName]; + if ( + !schema || + elicitationFieldKind(schema) === "unsupported" || + !isOtherCompanion(fieldName, schema) + ) { + return false; + } + const explicitParent = getOtherCompanionParent(schema); + if (explicitParent) return explicitParent in properties; + return ["__other", "_custom"].some((suffix) => { + if (!fieldName.endsWith(suffix)) return false; + return fieldName.slice(0, -suffix.length) in properties; + }); +} + +function initialContent( + request: FormElicitationRequest, +): Record { + const content: Record = {}; + for (const [name, schema] of Object.entries( + request.requestedSchema.properties ?? {}, + )) { + if (isSecretElicitationProperty(schema)) continue; + const value = (schema as Record).default; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + (Array.isArray(value) && value.every((item) => typeof item === "string")) + ) { + content[name] = value as ElicitationContentValue; + } + } + // Provider defaults are not guaranteed to satisfy the schema they ship with, + // so they go through the same filter restored content gets rather than being + // trusted verbatim: an out-of-enum default would otherwise read as answered + // and then be dropped from the accepted payload. + return contentForRequest(request, content, false); +} + +function allowedStringValues( + schema: Record, + optionsKey: "oneOf" | "anyOf", +): Set | null { + if (Array.isArray(schema.enum)) { + return new Set( + schema.enum.filter((value): value is string => typeof value === "string"), + ); + } + const options = schema[optionsKey]; + if (!Array.isArray(options)) return null; + return new Set( + options.flatMap((option) => { + const raw = asRecord(option); + return typeof raw?.const === "string" ? [raw.const] : []; + }), + ); +} + +function contentForRequest( + request: FormElicitationRequest, + content: Record, + includeSecrets: boolean, +): Record { + const properties = request.requestedSchema.properties ?? {}; + const next: Record = {}; + for (const [name, value] of Object.entries(content)) { + const schema = properties[name]; + if (!schema || (!includeSecrets && isSecretElicitationProperty(schema))) { + continue; + } + if (elicitationFieldKind(schema) === "unsupported") continue; + if (hasUnsupportedCompanionParent(properties, name, schema)) continue; + + const raw = schema as Record; + if (schema.type === "string") { + if (typeof value !== "string") continue; + const allowed = allowedStringValues(raw, "oneOf"); + if (!allowed || allowed.has(value)) next[name] = value; + continue; + } + if (schema.type === "number") { + if (typeof value === "number" && Number.isFinite(value)) { + next[name] = value; + } + continue; + } + if (schema.type === "integer") { + if (typeof value === "number" && Number.isInteger(value)) { + next[name] = value; + } + continue; + } + if (schema.type === "boolean") { + if (typeof value === "boolean") next[name] = value; + continue; + } + if (schema.type === "array") { + if (!Array.isArray(value)) continue; + const items = asRecord(raw.items); + const allowed = items ? allowedStringValues(items, "anyOf") : null; + const selected = allowed + ? value.filter((item) => allowed.has(item)) + : value; + if (value.length === 0 || selected.length > 0) next[name] = selected; + } + } + return next; +} + +function normalizedContent( + pending: PendingElicitation, +): Record { + const properties = pending.request.requestedSchema.properties ?? {}; + const content = Object.fromEntries( + Object.entries( + contentForRequest(pending.request, pending.content, true), + ).filter(([, value]) => { + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + return true; + }), + ) as Record; + + for (const fieldName of Object.keys(properties)) { + const companion = findOtherCompanion(properties, fieldName); + if (!companion) continue; + const [companionName] = companion; + const custom = content[companionName]; + if (typeof custom === "string" && custom.trim()) { + if (properties[fieldName]?.type !== "array") { + delete content[fieldName]; + } + content[companionName] = custom.trim(); + } else { + delete content[companionName]; + } + } + + return content; +} + +export function acceptedElicitationResponse( + pending: PendingElicitation, +): CreateElicitationResponse { + return { action: "accept", content: normalizedContent(pending) }; +} + +/** The question a session should be showing. A live request always wins over a + * recovered draft: nothing is waiting on the draft, and letting it sit in front + * would hide a request the agent is actually blocked on. */ +export function presentedElicitation( + queue: PendingElicitation[] | undefined, +): PendingElicitation | null { + if (!queue?.length) return null; + return queue.find((pending) => pending.resolve !== null) ?? queue[0]; +} + +function removeById( + queues: Record, + sessionId: string, + id: string, +) { + const next = { ...queues }; + const remaining = (next[sessionId] ?? []).filter( + (pending) => pending.id !== id, + ); + if (remaining.length) next[sessionId] = remaining; + else delete next[sessionId]; + return next; +} + +function contentWithoutSecrets( + request: FormElicitationRequest, + content: Record, +): Record { + return contentForRequest(request, content, false); +} + +function requestWithoutSecretDefaults( + request: FormElicitationRequest, +): FormElicitationRequest { + const properties = request.requestedSchema.properties ?? {}; + const sanitizedProperties = Object.fromEntries( + Object.entries(properties).map(([name, schema]) => { + if ( + !isSecretElicitationProperty(schema) && + !hasUnsupportedCompanionParent(properties, name, schema) + ) { + return [name, schema]; + } + const { default: _default, ...sanitized } = schema as Record< + string, + unknown + >; + return [name, sanitized as ElicitationPropertySchema]; + }), + ); + return { + ...request, + requestedSchema: { + ...request.requestedSchema, + properties: sanitizedProperties, + }, + }; +} + +function recordsFromBrowserEnvelope( + envelope: ReturnType | null, +): Record { + if (!envelope) return {}; + return Object.fromEntries( + envelope.scopes.flatMap((stored) => + Object.entries(stored.queues).map(([sessionId, queue]) => [ + persistenceRecordKey(stored.identity, sessionId), + { identity: stored.identity, sessionId, queue }, + ]), + ), + ); +} + +function browserEnvelopeFromRecords( + records: Record, +): ReturnType { + const scopes = new Map< + string, + { + identity: ElicitationPersistenceScope; + queues: Record; + } + >(); + for (const record of Object.values(records)) { + const key = persistenceScopeKey(record.identity); + const stored = scopes.get(key) ?? { identity: record.identity, queues: {} }; + stored.queues[record.sessionId] = record.queue; + scopes.set(key, stored); + } + return { + version: ELICITATION_PERSISTENCE_VERSION, + scopes: [...scopes.values()], + }; +} + +function readPersistedEnvelope(): ReturnType< + typeof persistedElicitationEnvelopeSchema.parse +> | null { + if (typeof window === "undefined") return null; + if (usesNativePersistence()) { + return nativePersistenceEnvelope + ? browserEnvelopeFromRecords(nativePersistenceEnvelope.records) + : null; + } + try { + for (const key of LEGACY_ELICITATION_STORAGE_KEYS) { + window.localStorage.removeItem(key); + } + const raw = window.localStorage.getItem(ELICITATION_STORAGE_KEY); + if (!raw) return null; + if (encodedByteLength(raw) > MAX_PERSISTED_ELICITATION_BYTES) { + window.localStorage.removeItem(ELICITATION_STORAGE_KEY); + return null; + } + const parsed = persistedElicitationEnvelopeSchema.safeParse( + JSON.parse(raw), + ); + if (parsed.success) return parsed.data; + window.localStorage.removeItem(ELICITATION_STORAGE_KEY); + return null; + } catch { + // Storage that cannot be parsed will not repair itself on the next launch. + try { + window.localStorage.removeItem(ELICITATION_STORAGE_KEY); + } catch { + // Nothing more to do if storage itself is unavailable. + } + return null; + } +} + +function latestSavedAt( + queues: Record>, +): number { + return Math.max( + 0, + ...Object.values(queues) + .flat() + .map((pending) => pending.savedAt), + ); +} + +function sameScopedPending( + left: Pick, + right: Pick, +): boolean { + if (left.id !== right.id) return false; + if (!left.persistenceScope || !right.persistenceScope) { + return left.persistenceScope === right.persistenceScope; + } + return ( + persistenceScopeKey(left.persistenceScope) === + persistenceScopeKey(right.persistenceScope) + ); +} + +function loadPersisted( + scope: ElicitationPersistenceScope | null, +): Record { + if (!scope) return {}; + const envelope = readPersistedEnvelope(); + if (!envelope) return {}; + + const matchingScopes = envelope.scopes + .filter((stored) => sharesPersistenceNamespace(stored.identity, scope)) + .sort((left, right) => { + const leftExact = + persistenceScopeKey(left.identity) === persistenceScopeKey(scope); + const rightExact = + persistenceScopeKey(right.identity) === persistenceScopeKey(scope); + if (leftExact !== rightExact) return leftExact ? -1 : 1; + return latestSavedAt(right.queues) - latestSavedAt(left.queues); + }); + for (const stored of matchingScopes) { + hydratedPersistenceScopes.add(persistenceScopeKey(stored.identity)); + } + const queues: Record = {}; + const oldestAllowed = Date.now() - ELICITATION_MAX_PERSISTED_AGE_MS; + for (const stored of matchingScopes) { + for (const [sessionId, persistedQueue] of Object.entries(stored.queues)) { + const queue = queues[sessionId] ?? []; + for (const item of persistedQueue) { + if ( + item.request.sessionId !== sessionId || + item.savedAt < oldestAllowed || + queue.some((pending) => + sameScopedPending(pending, { + id: item.id, + persistenceScope: stored.identity, + }), + ) + ) { + continue; + } + const loadedRequest = item.request as unknown as FormElicitationRequest; + queue.push({ + id: item.id, + semanticKey: item.semanticKey, + wireRequestId: item.wireRequestId, + request: loadedRequest, + content: contentWithoutSecrets( + loadedRequest, + item.content as Record, + ), + step: item.step, + recovered: item.recovered, + continuation: item.continuation, + resolve: null, + responderKey: null, + deliveryClaim: null, + persistenceScope: stored.identity, + savedAt: item.savedAt, + }); + } + if (queue.length) queues[sessionId] = queue; + } + } + return queues; +} + +/** Test only: production hydration occurs when a scoped request first arrives. */ +export function loadPersistedForTests( + scope = getElicitationPersistenceScope("unknown", 0), +): Record { + return loadPersisted(scope); +} + +interface PersistenceDescriptor { + scope: ElicitationPersistenceScope; + sessionId: string; +} + +function collectPersistenceDescriptors( + queues: Record, +): Map { + const descriptors = new Map(); + for (const [sessionId, queue] of Object.entries(queues)) { + for (const pending of queue) { + if (!pending.persistenceScope) continue; + descriptors.set( + persistenceRecordKey(pending.persistenceScope, sessionId), + { scope: pending.persistenceScope, sessionId }, + ); + } + } + return descriptors; +} + +function buildPersistedRecord( + queues: Record, + descriptor: PersistenceDescriptor, +): PersistedElicitationSessionRecord | null { + const expectedScopeKey = persistenceScopeKey(descriptor.scope); + const queue = (queues[descriptor.sessionId] ?? []).flatMap((pending) => { + if ( + pending.deliveryClaim || + !pending.persistenceScope || + persistenceScopeKey(pending.persistenceScope) !== expectedScopeKey + ) { + return []; + } + const parsed = persistedElicitationSchema.safeParse({ + id: pending.id, + semanticKey: pending.semanticKey, + wireRequestId: pending.wireRequestId, + request: requestWithoutSecretDefaults(pending.request), + content: contentWithoutSecrets(pending.request, pending.content), + step: pending.step, + recovered: pending.recovered, + continuation: pending.continuation, + savedAt: pending.savedAt, + }); + return parsed.success ? [parsed.data] : []; + }); + return queue.length + ? { identity: descriptor.scope, sessionId: descriptor.sessionId, queue } + : null; +} + +function applyPersistenceUpdates( + records: Record, + updates: Record, +): Record { + const next = { ...records }; + for (const [key, record] of Object.entries(updates)) { + if (record) next[key] = record; + else delete next[key]; + } + return next; +} + +function persist( + queues: Record, + descriptors: Map, +): void { + if (typeof window === "undefined") return; + try { + const updates = Object.fromEntries( + [...descriptors.entries()].map(([key, descriptor]) => [ + key, + buildPersistedRecord(queues, descriptor), + ]), + ) as Record; + if (!Object.keys(updates).length) return; + + if (usesNativePersistence()) { + const serializedUpdates = JSON.stringify(updates); + if ( + encodedByteLength(serializedUpdates) > MAX_PERSISTED_ELICITATION_BYTES + ) { + return; + } + nativeWriteChain = nativeWriteChain + .then(async () => { + await invoke("persist_elicitation_updates", { serializedUpdates }); + const records = applyPersistenceUpdates( + nativePersistenceEnvelope?.records ?? {}, + updates, + ); + nativePersistenceEnvelope = Object.keys(records).length + ? { version: ELICITATION_PERSISTENCE_VERSION, records } + : null; + }) + .catch(() => { + // Best effort; the live responder remains authoritative. + }); + return; + } + + for (const key of LEGACY_ELICITATION_STORAGE_KEYS) { + window.localStorage.removeItem(key); + } + const records = applyPersistenceUpdates( + recordsFromBrowserEnvelope(readPersistedEnvelope()), + updates, + ); + if (Object.keys(records).length === 0) { + window.localStorage.removeItem(ELICITATION_STORAGE_KEY); + return; + } + const serialized = JSON.stringify(browserEnvelopeFromRecords(records)); + if (encodedByteLength(serialized) > MAX_PERSISTED_ELICITATION_BYTES) { + window.localStorage.removeItem(ELICITATION_STORAGE_KEY); + return; + } + window.localStorage.setItem(ELICITATION_STORAGE_KEY, serialized); + } catch { + // Persistence is best-effort; the live responder remains authoritative. + } +} + +let pendingPersistQueues: Record | null = null; +let pendingPersistDescriptors = new Map(); + +function schedulePersist( + queues: Record, + previousQueues: Record, +): void { + if (typeof window === "undefined") return; + pendingPersistQueues = queues; + for (const [key, descriptor] of [ + ...collectPersistenceDescriptors(previousQueues), + ...collectPersistenceDescriptors(queues), + ]) { + pendingPersistDescriptors.set(key, descriptor); + } + if (persistenceTimer !== null) window.clearTimeout(persistenceTimer); + persistenceTimer = window.setTimeout(() => { + persistenceTimer = null; + pendingPersistQueues = null; + const descriptors = pendingPersistDescriptors; + pendingPersistDescriptors = new Map(); + persist(queues, descriptors); + }, ELICITATION_PERSIST_DELAY_MS); +} + +/** Write any debounced draft immediately. The debounce is reset on every + * keystroke, so without this a fast typist loses everything since the last + * idle gap when the window goes away. */ +export function flushElicitationDrafts(): void { + if (typeof window === "undefined" || persistenceTimer === null) return; + window.clearTimeout(persistenceTimer); + persistenceTimer = null; + const queues = pendingPersistQueues; + const descriptors = pendingPersistDescriptors; + pendingPersistQueues = null; + pendingPersistDescriptors = new Map(); + if (queues) persist(queues, descriptors); +} + +if (typeof window !== "undefined") { + window.addEventListener("pagehide", flushElicitationDrafts); + window.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flushElicitationDrafts(); + }); +} + +export const useElicitationStore = create((set, get) => ({ + pendingBySessionId: {}, + enqueue: ({ + request, + resolve, + wireRequestId, + persistenceScope, + connectionIdentity, + }) => { + const responderKey = Symbol("elicitation-responder"); + const resolvedPersistenceScope = + persistenceScope ?? getElicitationPersistenceScope("unknown", 0); + const resolvedConnectionIdentity = connectionIdentity ?? { + providerId: resolvedPersistenceScope?.providerId ?? "unknown", + connectionGeneration: resolvedPersistenceScope?.connectionGeneration ?? 0, + connectionInstanceId: + resolvedPersistenceScope?.connectionInstanceId ?? "unknown", + }; + const metadata = getElicitationMetadata( + request, + wireRequestId, + resolvedConnectionIdentity, + ); + if (resolvedPersistenceScope) { + const scopeKey = persistenceScopeKey(resolvedPersistenceScope); + if (!hydratedPersistenceScopes.has(scopeKey)) { + hydratedPersistenceScopes.add(scopeKey); + const hydrated = loadPersisted(resolvedPersistenceScope); + set((state) => { + const next = { ...state.pendingBySessionId }; + for (const [sessionId, persistedQueue] of Object.entries(hydrated)) { + const queue = [...(next[sessionId] ?? [])]; + for (const pending of persistedQueue) { + if ( + !queue.some((candidate) => + sameScopedPending(candidate, pending), + ) + ) { + queue.push(pending); + } + } + if (queue.length) next[sessionId] = queue; + } + return { pendingBySessionId: next }; + }); + } + } + let attachedId = metadata.id; + let promptAlreadyOwnsResponse = false; + set((state) => { + const queue = state.pendingBySessionId[request.sessionId] ?? []; + const exactIndex = queue.findIndex( + (pending) => + pending.id === metadata.id && + pending.semanticKey === metadata.semanticKey && + scopesCanReplay(pending.persistenceScope, resolvedPersistenceScope), + ); + // A reconnect always brings a fresh wire id, so the exact match misses and + // the question is found by what it *is* rather than which request carried + // it. Only a detached entry is eligible, and only when exactly one + // matches, so an ambiguous pair stays visible instead of being merged. + const detachedSemanticMatches = queue.flatMap((pending, index) => + pending.resolve === null && + pending.semanticKey === metadata.semanticKey && + scopesCanReplay(pending.persistenceScope, resolvedPersistenceScope) + ? [index] + : [], + ); + const existingIndex = + exactIndex >= 0 + ? exactIndex + : detachedSemanticMatches.length === 1 + ? detachedSemanticMatches[0] + : -1; + attachedId = + existingIndex >= 0 + ? queue[existingIndex].id + : uniquePendingId(queue, metadata.id); + const existingClaim = + existingIndex >= 0 ? queue[existingIndex].deliveryClaim : null; + if ( + existingClaim?.phase === "prompt-dispatched" || + existingClaim?.phase === "indeterminate" + ) { + promptAlreadyOwnsResponse = true; + return state; + } + const pending: PendingElicitation = + existingIndex >= 0 + ? { + ...queue[existingIndex], + request, + content: { + ...initialContent(request), + ...contentWithoutSecrets( + request, + contentWithoutSecrets( + queue[existingIndex].request, + queue[existingIndex].content, + ), + ), + }, + recovered: metadata.recovered, + continuation: metadata.continuation, + wireRequestId: metadata.wireRequestId, + persistenceScope: resolvedPersistenceScope, + resolve: queue[existingIndex].resolve + ? (response) => { + queue[existingIndex].resolve?.(response); + resolve(response); + } + : resolve, + responderKey, + } + : { + id: attachedId, + semanticKey: metadata.semanticKey, + wireRequestId: metadata.wireRequestId, + request, + content: initialContent(request), + step: 0, + recovered: metadata.recovered, + continuation: metadata.continuation, + resolve, + responderKey, + deliveryClaim: null, + persistenceScope: resolvedPersistenceScope, + savedAt: Date.now(), + }; + const nextQueue = [...queue]; + if (existingIndex >= 0) nextQueue[existingIndex] = pending; + else nextQueue.push(pending); + return { + pendingBySessionId: { + ...state.pendingBySessionId, + [request.sessionId]: nextQueue, + }, + }; + }); + if (promptAlreadyOwnsResponse) resolve({ action: "cancel" }); + return { id: attachedId, responderKey }; + }, + setValue: (sessionId, id, key, value) => + set((state) => { + const queue = state.pendingBySessionId[sessionId]; + const presented = presentedElicitation(queue); + // An event from a question that has since been withdrawn or replaced + // must not land on whichever question took its place. + if (!queue || presented?.id !== id || presented.deliveryClaim) + return state; + const content = { ...presented.content }; + if (value === undefined) delete content[key]; + else content[key] = value; + return { + pendingBySessionId: { + ...state.pendingBySessionId, + [sessionId]: queue.map((pending) => + pending.id === presented.id ? { ...pending, content } : pending, + ), + }, + }; + }), + setStep: (sessionId, id, step) => + set((state) => { + const queue = state.pendingBySessionId[sessionId]; + const presented = presentedElicitation(queue); + if (!queue || presented?.id !== id || presented.deliveryClaim) + return state; + return { + pendingBySessionId: { + ...state.pendingBySessionId, + [sessionId]: queue.map((pending) => + pending.id === presented.id + ? { ...pending, step: Math.max(0, step) } + : pending, + ), + }, + }; + }), + accept: (sessionId, id) => { + const pending = get().pendingBySessionId[sessionId]?.find( + (candidate) => candidate.id === id, + ); + if (!pending?.resolve || pending.deliveryClaim) return; + set((state) => ({ + pendingBySessionId: removeById(state.pendingBySessionId, sessionId, id), + })); + pending.resolve(acceptedElicitationResponse(pending)); + }, + decline: (sessionId, id) => { + const pending = get().pendingBySessionId[sessionId]?.find( + (candidate) => candidate.id === id, + ); + if (!pending?.resolve || pending.deliveryClaim) return; + set((state) => ({ + pendingBySessionId: removeById(state.pendingBySessionId, sessionId, id), + })); + pending.resolve({ action: "decline" }); + }, + cancel: (sessionId, id) => { + const pending = get().pendingBySessionId[sessionId]?.find( + (candidate) => candidate.id === id, + ); + // Discarding a question whose answers are already on their way would leave + // the message sent and the record gone. + if (!pending || pending.deliveryClaim) return; + set((state) => ({ + pendingBySessionId: removeById(state.pendingBySessionId, sessionId, id), + })); + pending.resolve?.({ action: "cancel" }); + }, + retainUndeliveredAnswers: ( + request, + content, + persistenceScope, + connectionIdentity, + ) => { + const resolvedPersistenceScope = + persistenceScope ?? getElicitationPersistenceScope("unknown", 0); + const resolvedConnectionIdentity = connectionIdentity ?? { + providerId: resolvedPersistenceScope?.providerId ?? "unknown", + connectionGeneration: resolvedPersistenceScope?.connectionGeneration ?? 0, + connectionInstanceId: + resolvedPersistenceScope?.connectionInstanceId ?? "unknown", + }; + const metadata = getElicitationMetadata( + request, + undefined, + resolvedConnectionIdentity, + ); + set((state) => { + const queue = state.pendingBySessionId[request.sessionId] ?? []; + if (queue.some((pending) => pending.id === metadata.id)) return state; + const restored: PendingElicitation = { + id: uniquePendingId(queue, metadata.id), + semanticKey: metadata.semanticKey, + wireRequestId: null, + request, + content: contentWithoutSecrets(request, content), + step: 0, + recovered: true, + continuation: "prompt", + resolve: null, + responderKey: null, + deliveryClaim: null, + persistenceScope: resolvedPersistenceScope, + savedAt: Date.now(), + }; + return { + pendingBySessionId: { + ...state.pendingBySessionId, + [request.sessionId]: [...queue, restored], + }, + }; + }); + }, + claimDetachedDelivery: (sessionId, id) => { + const pending = get().pendingBySessionId[sessionId]?.find( + (candidate) => candidate.id === id, + ); + // Only a detached question can be sent this way, and only once: taking the + // claim here is what stops a second send and a discard racing the first. + if (!pending || pending.resolve !== null || pending.deliveryClaim) { + return null; + } + const token = Symbol("elicitation-delivery"); + set((state) => ({ + pendingBySessionId: { + ...state.pendingBySessionId, + [sessionId]: (state.pendingBySessionId[sessionId] ?? []).map( + (candidate) => + candidate.id === id + ? { + ...candidate, + deliveryClaim: { + token, + phase: "claiming", + cancelled: false, + }, + } + : candidate, + ), + }, + })); + return token; + }, + beginDetachedDelivery: (sessionId, id, token) => { + const pending = get().pendingBySessionId[sessionId]?.find( + (candidate) => candidate.id === id, + ); + if ( + !pending || + pending.deliveryClaim?.token !== token || + pending.deliveryClaim.phase !== "claiming" + ) { + return false; + } + + // The live responder wins until the final reversible send boundary. Once + // that boundary is crossed, the ordinary prompt owns the answer instead. + if (pending.resolve) { + set((state) => ({ + pendingBySessionId: removeById(state.pendingBySessionId, sessionId, id), + })); + pending.resolve(acceptedElicitationResponse(pending)); + return false; + } + + set((state) => ({ + pendingBySessionId: { + ...state.pendingBySessionId, + [sessionId]: (state.pendingBySessionId[sessionId] ?? []).map( + (candidate) => + candidate.id === id && candidate.deliveryClaim?.token === token + ? { + ...candidate, + deliveryClaim: { + ...candidate.deliveryClaim, + phase: "prompt-dispatched", + }, + } + : candidate, + ), + }, + })); + return true; + }, + completeDetachedDelivery: (sessionId, id, token) => { + const pending = get().pendingBySessionId[sessionId]?.find( + (candidate) => candidate.id === id, + ); + if ( + !pending || + pending.deliveryClaim?.token !== token || + pending.deliveryClaim.phase !== "prompt-dispatched" + ) { + return; + } + set((state) => ({ + pendingBySessionId: removeById(state.pendingBySessionId, sessionId, id), + })); + // The prompt already owns the answer. A responder that arrived afterward + // must be settled without receiving a second accepted response. + pending.resolve?.({ action: "cancel" }); + }, + failDetachedDelivery: (sessionId, id, token) => { + const pending = get().pendingBySessionId[sessionId]?.find( + (candidate) => candidate.id === id, + ); + if (!pending || pending.deliveryClaim?.token !== token) return "ignored"; + if (pending.deliveryClaim.cancelled) { + set((state) => ({ + pendingBySessionId: removeById(state.pendingBySessionId, sessionId, id), + })); + return "ignored"; + } + if ( + pending.deliveryClaim.phase === "prompt-dispatched" || + pending.deliveryClaim.phase === "indeterminate" + ) { + set((state) => ({ + pendingBySessionId: { + ...state.pendingBySessionId, + [sessionId]: (state.pendingBySessionId[sessionId] ?? []).map( + (candidate) => + candidate.id === id && candidate.deliveryClaim?.token === token + ? { + ...candidate, + resolve: null, + responderKey: null, + deliveryClaim: { + ...candidate.deliveryClaim, + phase: "indeterminate", + }, + } + : candidate, + ), + }, + })); + // Crossing the dispatch boundary gives the prompt permanent ownership, + // even when the transport cannot confirm its result. A late responder is + // cancelled rather than becoming a second winner. + pending.resolve?.({ action: "cancel" }); + return "indeterminate"; + } + set((state) => ({ + pendingBySessionId: { + ...state.pendingBySessionId, + [sessionId]: (state.pendingBySessionId[sessionId] ?? []).map( + (candidate) => + candidate.id === id && candidate.deliveryClaim?.token === token + ? { ...candidate, deliveryClaim: null } + : candidate, + ), + }, + })); + return "retryable"; + }, + abort: (sessionId, id, responderKey) => { + const pending = get().pendingBySessionId[sessionId]?.find( + (candidate) => candidate.id === id, + ); + if (!pending || pending.responderKey !== responderKey) return; + set((state) => + pending.deliveryClaim + ? { + pendingBySessionId: { + ...state.pendingBySessionId, + [sessionId]: (state.pendingBySessionId[sessionId] ?? []).map( + (candidate) => + candidate.id === id + ? { ...candidate, resolve: null, responderKey: null } + : candidate, + ), + }, + } + : { + pendingBySessionId: removeById( + state.pendingBySessionId, + sessionId, + id, + ), + }, + ); + pending.resolve?.({ action: "cancel" }); + }, + detachAll: (sessionId) => + set((state) => { + const pendingBySessionId = Object.fromEntries( + Object.entries(state.pendingBySessionId).map(([id, queue]) => [ + id, + !sessionId || id === sessionId + ? queue.map((pending) => ({ + ...pending, + resolve: null, + responderKey: null, + })) + : queue, + ]), + ); + return { pendingBySessionId }; + }), + cancelAll: (sessionId) => { + const queues = get().pendingBySessionId; + const targets = sessionId + ? { [sessionId]: queues[sessionId] ?? [] } + : queues; + set((state) => { + const next = { ...state.pendingBySessionId }; + for (const [id, queue] of Object.entries(state.pendingBySessionId)) { + if (sessionId && id !== sessionId) continue; + const inFlight = queue.flatMap((pending) => + pending.deliveryClaim?.phase === "prompt-dispatched" + ? [ + { + ...pending, + resolve: null, + responderKey: null, + deliveryClaim: { + ...pending.deliveryClaim, + cancelled: true, + }, + }, + ] + : [], + ); + if (inFlight.length) next[id] = inFlight; + else delete next[id]; + } + return { pendingBySessionId: next }; + }); + for (const pending of Object.values(targets).flat()) { + pending.resolve?.({ action: "cancel" }); + } + }, +})); + +useElicitationStore.subscribe((state, previousState) => + schedulePersist(state.pendingBySessionId, previousState.pendingBySessionId), +); + +/** Settle live work and stop persistence when an exact account/workspace + * boundary is unavailable, without deleting drafts owned by known scopes. */ +export function suspendElicitationPersistence(): void { + useElicitationStore.getState().cancelAll(); + persistenceIdentity = null; + hydratedPersistenceScopes.clear(); + if (typeof window !== "undefined" && persistenceTimer !== null) { + window.clearTimeout(persistenceTimer); + persistenceTimer = null; + } + pendingPersistQueues = null; + pendingPersistDescriptors = new Map(); +} + +/** Forget every in-memory and persisted draft before the account boundary changes. */ +export async function clearPersistedElicitations(): Promise { + suspendElicitationPersistence(); + try { + window.localStorage.removeItem(ELICITATION_STORAGE_KEY); + for (const key of LEGACY_ELICITATION_STORAGE_KEYS) { + window.localStorage.removeItem(key); + } + } catch { + // Best effort; the in-memory reset is what this session sees either way. + } + nativePersistenceEnvelope = null; + if (usesNativePersistence()) { + nativeWriteChain = nativeWriteChain + .then(async () => { + await invoke("clear_elicitation_persistence"); + }) + .catch(() => { + // Best effort; the in-memory boundary still changes immediately. + }); + await nativeWriteChain; + } +} diff --git a/src/features/elicitation/ui/ElicitationField.tsx b/src/features/elicitation/ui/ElicitationField.tsx new file mode 100644 index 000000000..b2bbbbd3a --- /dev/null +++ b/src/features/elicitation/ui/ElicitationField.tsx @@ -0,0 +1,722 @@ +import { useId } from "react"; +import type { ReactNode } from "react"; +import type { + ElicitationContentValue, + ElicitationPropertySchema, +} from "@agentclientprotocol/sdk"; +import { useTranslation } from "react-i18next"; +import { findOtherCompanion } from "@/features/elicitation/stores/elicitationStore"; +import { elicitationFieldKind } from "@/features/elicitation/lib/elicitationFieldKind"; +import { cn } from "@/shared/lib/cn"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { Input } from "@/shared/ui/input"; +import { Label } from "@/shared/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/shared/ui/radio-group"; + +type Option = { value: string; label: string; description?: string }; + +interface ElicitationFieldProps { + name: string; + schema: ElicitationPropertySchema; + properties: Record; + content: Record; + controlScope: string; + required: boolean; + promoteDescription?: boolean; + onChange: (key: string, value: ElicitationContentValue | undefined) => void; +} + +interface ConcreteFieldProps extends ElicitationFieldProps { + fieldId: string; +} + +function isOtherOption(option: Option): boolean { + return ( + option.value.trim().toLowerCase() === "other" || + option.label.trim().toLowerCase() === "other" + ); +} + +function optionsFrom( + source: Record | null, + oneOfKey: "oneOf" | "anyOf", +): Option[] { + if (!source) return []; + const oneOf = source[oneOfKey]; + if (Array.isArray(oneOf)) { + return oneOf.map((option) => { + const item = option as Record; + return { + value: String(item.const ?? ""), + label: String(item.title ?? item.const ?? ""), + description: + typeof item.description === "string" ? item.description : undefined, + }; + }); + } + if (Array.isArray(source.enum)) { + const names = Array.isArray(source.enumNames) ? source.enumNames : []; + return source.enum.map((value, index) => ({ + value: String(value), + label: String(names[index] ?? value), + })); + } + return []; +} + +function singleSelectOptions(schema: ElicitationPropertySchema): Option[] { + return optionsFrom(schema as Record, "oneOf"); +} + +function multiSelectOptions(schema: ElicitationPropertySchema): Option[] { + const raw = schema as Record; + const items = + raw.items != null && typeof raw.items === "object" + ? (raw.items as Record) + : null; + return optionsFrom(items, "anyOf"); +} + +function OptionCard({ + checked, + htmlFor, + children, + align = "start", +}: { + checked: boolean; + htmlFor: string; + children: ReactNode; + align?: "start" | "center"; +}) { + return ( + + ); +} + +function MultiSelectField({ + name, + schema, + properties, + content, + controlScope, + onChange, + fieldId, + promoteDescription, +}: ConcreteFieldProps) { + const { t } = useTranslation("chat"); + const raw = schema as Record; + const explicitTitle = typeof raw.title === "string" ? raw.title : null; + const title = explicitTitle ?? name; + const description = + typeof raw.description === "string" ? raw.description : null; + const promotedDescription = promoteDescription ? description : null; + const descriptionId = + description && !promotedDescription ? `${fieldId}-description` : undefined; + const options = multiSelectOptions(schema); + const value = content[name]; + const selected = Array.isArray(value) ? value : []; + const selectedSet = new Set(selected); + const maxItems = typeof raw.maxItems === "number" ? raw.maxItems : null; + const limitDescriptionId = + maxItems != null ? `${fieldId}-limit-description` : undefined; + const fieldDescriptionIds = [descriptionId, limitDescriptionId] + .filter(Boolean) + .join(" "); + const companion = findOtherCompanion(properties, name); + const companionName = companion?.[0]; + const companionSchema = companion?.[1] as Record | undefined; + const explicitOtherOption = companionName + ? options.find(isOtherOption) + : undefined; + const visibleOptions = explicitOtherOption + ? options.filter((option) => option !== explicitOtherOption) + : options; + const otherValue = companionName ? content[companionName] : undefined; + const otherDescription = + explicitOtherOption?.description ?? + (typeof companionSchema?.description === "string" + ? companionSchema.description + : undefined); + const otherDescriptionId = otherDescription + ? `${fieldId}-other-description` + : undefined; + const explicitOtherSelected = + explicitOtherOption != null && selectedSet.has(explicitOtherOption.value); + const customOtherSelected = + companionName != null && typeof otherValue === "string"; + const otherSelected = explicitOtherSelected || customOtherSelected; + const visibleSelected = explicitOtherOption + ? selected.filter((item) => item !== explicitOtherOption.value) + : selected; + const visibleSelectedSet = new Set(visibleSelected); + const controlName = `${controlScope}:${name}`; + + return ( +
+ + {promotedDescription ? ( + <> + {explicitTitle ? ( + + {explicitTitle} + + ) : null} + + {promotedDescription} + + + ) : ( + title + )} + + {description && !promotedDescription ? ( +

+ {description} +

+ ) : null} + {maxItems != null ? ( +

+ {t("elicitation.chooseUpTo", { count: maxItems })} +

+ ) : null} +
+ {visibleOptions.map((option, index) => { + const checked = visibleSelectedSet.has(option.value); + const atLimit = + maxItems != null && + visibleSelected.length + (otherSelected ? 1 : 0) >= maxItems; + const optionId = `${fieldId}-option-${index}`; + const optionDescriptionId = option.description + ? `${optionId}-description` + : undefined; + return ( + + { + onChange( + name, + checked + ? visibleSelected.filter((item) => item !== option.value) + : [...visibleSelected, option.value], + ); + }} + className="mt-0.5" + /> + + {option.label} + {option.description ? ( + + {option.description} + + ) : null} + + + ); + })} + {companionName ? ( +
+ + {otherSelected ? ( + { + if (explicitOtherSelected) { + onChange(name, visibleSelected); + } + onChange(companionName, event.target.value); + }} + className="mt-3" + /> + ) : null} +
+ ) : null} +
+
+ ); +} + +function SingleSelectField({ + name, + schema, + properties, + content, + controlScope, + required, + onChange, + fieldId, + promoteDescription, +}: ConcreteFieldProps) { + const { t } = useTranslation("chat"); + const raw = schema as Record; + const explicitTitle = typeof raw.title === "string" ? raw.title : null; + const title = explicitTitle ?? name; + const description = + typeof raw.description === "string" ? raw.description : null; + const promotedDescription = promoteDescription ? description : null; + const descriptionId = + description && !promotedDescription ? `${fieldId}-description` : undefined; + const options = singleSelectOptions(schema); + const value = content[name]; + const companion = findOtherCompanion(properties, name); + const companionName = companion?.[0]; + const companionSchema = companion?.[1] as Record | undefined; + const otherValue = companionName ? content[companionName] : undefined; + const explicitOtherOption = companionName + ? options.find(isOtherOption) + : undefined; + const visibleOptions = explicitOtherOption + ? options.filter((option) => option !== explicitOtherOption) + : options; + const otherDescription = + explicitOtherOption?.description ?? + (typeof companionSchema?.description === "string" + ? companionSchema.description + : undefined); + const otherDescriptionId = otherDescription + ? `${fieldId}-other-description` + : undefined; + const explicitOtherSelected = + explicitOtherOption != null && value === explicitOtherOption.value; + const customOtherSelected = + companionName != null && + typeof otherValue === "string" && + value === undefined; + const otherSelected = explicitOtherSelected || customOtherSelected; + const otherOptionValue = `${controlScope}:${name}:other`; + const selectedValue = otherSelected + ? otherOptionValue + : typeof value === "string" + ? value + : ""; + + return ( +
+ + {promotedDescription ? ( + <> + {explicitTitle ? ( + + {explicitTitle} + + ) : null} + + {promotedDescription} + + + ) : ( + title + )} + + {description && !promotedDescription ? ( +

+ {description} +

+ ) : null} + { + if (nextValue === otherOptionValue && companionName) { + onChange(name, undefined); + onChange(companionName, ""); + return; + } + onChange(name, nextValue); + if (companionName) onChange(companionName, undefined); + }} + className="gap-2" + > + {visibleOptions.map((option, index) => { + const checked = value === option.value; + const optionId = `${fieldId}-option-${index}`; + const optionDescriptionId = option.description + ? `${optionId}-description` + : undefined; + return ( + + + + {option.label} + {option.description ? ( + + {option.description} + + ) : null} + + + ); + })} + {companionName ? ( +
+ + {otherDescription ? ( +

+ {otherDescription} +

+ ) : null} + {otherSelected ? ( + { + if (explicitOtherSelected) onChange(name, undefined); + onChange(companionName, event.target.value); + }} + className="mt-3" + /> + ) : null} +
+ ) : null} +
+
+ ); +} + +function BooleanField({ + name, + schema, + content, + controlScope, + required, + onChange, + fieldId, + promoteDescription, +}: ConcreteFieldProps) { + const { t } = useTranslation("chat"); + const raw = schema as Record; + const explicitTitle = typeof raw.title === "string" ? raw.title : null; + const title = explicitTitle ?? name; + const description = + typeof raw.description === "string" ? raw.description : null; + const promotedDescription = promoteDescription ? description : null; + const descriptionId = + description && !promotedDescription ? `${fieldId}-description` : undefined; + const value = content[name]; + + return ( +
+ + {promotedDescription ? ( + <> + {explicitTitle ? ( + + {explicitTitle} + + ) : null} + + {promotedDescription} + + + ) : ( + title + )} + + {description && !promotedDescription ? ( +

+ {description} +

+ ) : null} + onChange(name, nextValue === "true")} + className="gap-2" + > + {[ + { value: "true", label: t("elicitation.yes") }, + { value: "false", label: t("elicitation.no") }, + ].map((option, index) => { + const checked = value === (option.value === "true"); + const optionId = `${fieldId}-option-${index}`; + return ( + + + {option.label} + + ); + })} + +
+ ); +} + +function ScalarField({ + name, + schema, + content, + controlScope, + required, + onChange, + fieldId, + promoteDescription, +}: ConcreteFieldProps) { + const raw = schema as Record; + const explicitTitle = typeof raw.title === "string" ? raw.title : null; + const title = explicitTitle ?? name; + const description = + typeof raw.description === "string" ? raw.description : null; + const promotedDescription = promoteDescription ? description : null; + const descriptionId = + description && !promotedDescription ? `${fieldId}-description` : undefined; + const value = content[name]; + const numeric = schema.type === "number" || schema.type === "integer"; + + return ( +
+ + {description && !promotedDescription ? ( +

+ {description} +

+ ) : null} + { + if (event.target.value === "") { + onChange(name, undefined); + return; + } + onChange( + name, + numeric ? Number(event.target.value) : event.target.value, + ); + }} + /> +
+ ); +} + +function UnsupportedField({ + schema, + fieldId, +}: ElicitationFieldProps & { fieldId: string }) { + const { t } = useTranslation("chat"); + const raw = schema as Record; + const title = typeof raw.title === "string" ? raw.title : undefined; + return ( +
+ {title ? ( +

{title}

+ ) : null} +

+ {t("elicitation.unsupportedField")} +

+
+ ); +} + +export function ElicitationField(props: ElicitationFieldProps) { + const fieldId = useId(); + switch (elicitationFieldKind(props.schema)) { + case "multi-select": + return ; + case "single-select": + return ; + case "boolean": + return ; + case "scalar": + return ; + default: + // Preserved in the request and the response, but never dressed up as a + // control that would misdescribe what the agent asked for. + return ; + } +} diff --git a/src/features/elicitation/ui/ElicitationPanel.test.tsx b/src/features/elicitation/ui/ElicitationPanel.test.tsx new file mode 100644 index 000000000..dfffbdb09 --- /dev/null +++ b/src/features/elicitation/ui/ElicitationPanel.test.tsx @@ -0,0 +1,939 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CreateElicitationResponse } from "@agentclientprotocol/sdk"; +import { + type FormElicitationRequest, + presentedElicitation, + useElicitationStore, +} from "../stores/elicitationStore"; +import { ElicitationPanel } from "./ElicitationPanel"; + +function headId(sessionId = "session-1"): string { + const id = presentedElicitation( + useElicitationStore.getState().pendingBySessionId[sessionId], + )?.id; + if (!id) throw new Error(`no pending elicitation for ${sessionId}`); + return id; +} + +const mocks = vi.hoisted(() => ({ + continueRecoveredElicitation: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../lib/recoveredElicitationContinuation", () => ({ + continueRecoveredElicitation: (...args: unknown[]) => + mocks.continueRecoveredElicitation(...args), +})); + +function enqueue( + request: FormElicitationRequest, +): Promise { + return new Promise((resolve) => { + useElicitationStore.getState().enqueue({ request, resolve }); + }); +} + +describe("ElicitationPanel", () => { + beforeEach(() => { + window.localStorage.clear(); + mocks.continueRecoveredElicitation.mockReset(); + mocks.continueRecoveredElicitation.mockImplementation( + async ( + _request: unknown, + _response: unknown, + callbacks?: { beforePromptDispatch?: () => void }, + ) => { + callbacks?.beforePromptDispatch?.(); + }, + ); + useElicitationStore.setState({ pendingBySessionId: {} }); + }); + + it("renders adapter Other, multi-select, and free-text fields as one form", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Shape the plan", + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + title: "Direction", + oneOf: [ + { const: "local", title: "Local" }, + { const: "upstream", title: "Upstream" }, + ], + }, + direction__other: { + type: "string", + title: "Other", + _meta: { codex: { isOtherAnswer: true } }, + }, + surfaces: { + type: "array", + title: "Surfaces", + items: { + anyOf: [ + { const: "desktop", title: "Desktop" }, + { const: "cli", title: "CLI" }, + ], + }, + }, + notes: { type: "string", title: "Notes" }, + }, + required: ["direction", "surfaces", "notes"], + }, + _meta: { goose: { elicitationId: "question-1" } }, + }); + render(); + + await user.click(screen.getByRole("radio", { name: "Other" })); + await user.type( + screen.getByRole("textbox", { name: "Other answer for Direction" }), + "Hybrid", + ); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(screen.getByRole("checkbox", { name: "Desktop" })); + await user.click(screen.getByRole("checkbox", { name: "CLI" })); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.type(screen.getByRole("textbox", { name: "Notes" }), "Ship it"); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { + direction__other: "Hybrid", + surfaces: ["desktop", "cli"], + notes: "Ship it", + }, + }); + }); + + it("does not coerce a cleared required number to zero", async () => { + const user = userEvent.setup(); + void enqueue({ + mode: "form", + sessionId: "session-1", + message: "Choose a count", + requestedSchema: { + type: "object", + properties: { + count: { type: "number", title: "Count" }, + }, + required: ["count"], + }, + }); + render(); + + const input = screen.getByRole("spinbutton", { name: "Count" }); + await user.type(input, "12"); + await user.clear(input); + + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"][0].content, + ).not.toHaveProperty("count"); + expect(screen.queryByText("Question 1 of 1")).not.toBeInTheDocument(); + }); + + it("accepts decimal numbers without native step validation", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Choose a ratio", + requestedSchema: { + type: "object", + properties: { + ratio: { type: "number", title: "Ratio" }, + }, + required: ["ratio"], + }, + }); + render(); + + const form = screen.getByRole("form", { name: "Choose a ratio" }); + expect(form).toHaveAttribute("novalidate"); + const input = screen.getByRole("spinbutton", { name: "Ratio" }); + expect(input).toHaveAttribute("step", "any"); + await user.type(input, "2.5"); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { ratio: 2.5 }, + }); + }); + + it("treats provider patterns as unevaluable in Berd's submit gate", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Enter a code", + requestedSchema: { + type: "object", + properties: { + code: { type: "string", title: "Code", pattern: "[A-Z]{2}" }, + }, + required: ["code"], + }, + }); + render(); + + const input = screen.getByRole("textbox", { name: "Code" }); + await user.type(input, "aa"); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { code: "aa" }, + }); + }); + + it("uses request-scoped form controls and confirms Other with Enter", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Choose a direction", + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + title: "Direction", + oneOf: [{ const: "local", title: "Local" }], + }, + direction__other: { + type: "string", + title: "Other", + _meta: { codex: { isOtherAnswer: true } }, + }, + }, + required: ["direction"], + }, + _meta: { goose: { elicitationId: "request-42" } }, + }); + render(); + + const form = screen.getByRole("form", { name: "Choose a direction" }); + expect(form).toHaveAttribute("autocomplete", "off"); + await user.click(screen.getByRole("radio", { name: "Other" })); + const other = screen.getByRole("textbox", { + name: "Other answer for Direction", + }); + expect(other).toHaveAttribute( + "name", + "elicitation:request-42:direction__other", + ); + expect(other).toHaveAttribute("autocomplete", "off"); + + await user.type(other, "A third way{Enter}"); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { direction__other: "A third way" }, + }); + }); + + it("exposes and enforces custom Other constraints", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Choose a code", + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + title: "Direction", + oneOf: [{ const: "local", title: "Local" }], + }, + direction__other: { + type: "string", + title: "Other", + description: "Use two uppercase letters.", + minLength: 2, + maxLength: 2, + pattern: "[A-Z]{2}", + _meta: { codex: { isOtherAnswer: true } }, + }, + }, + required: ["direction"], + }, + }); + render(); + + const otherChoice = screen.getByRole("radio", { name: "Other" }); + const description = screen.getByText("Use two uppercase letters."); + expect(otherChoice).toHaveAttribute("aria-describedby", description.id); + await user.click(otherChoice); + + const otherInput = screen.getByRole("textbox", { + name: "Other answer for Direction", + }); + expect(otherInput).toHaveAttribute("minlength", "2"); + expect(otherInput).toHaveAttribute("maxlength", "2"); + // The pattern is deliberately not mirrored onto the control or evaluated + // in-process: native regex execution has no interruptible boundary. + expect(otherInput).not.toHaveAttribute("pattern"); + expect(otherInput).toHaveAttribute("aria-describedby", description.id); + + await user.type(otherInput, "n"); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + await user.clear(otherInput); + await user.type(otherInput, "OK"); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { direction__other: "OK" }, + }); + }); + + it("merges an agent-provided Other option with its companion input", async () => { + const user = userEvent.setup(); + void enqueue({ + mode: "form", + sessionId: "session-1", + message: "Choose a direction", + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + title: "Direction", + oneOf: [ + { const: "local", title: "Local" }, + { + const: "Other", + title: "Other", + description: "Describe a different direction.", + }, + ], + }, + direction_custom: { + type: "string", + title: "Other", + }, + }, + required: ["direction"], + }, + }); + render(); + + expect(screen.getAllByRole("radio", { name: "Other" })).toHaveLength(1); + expect(screen.getByText("Describe a different direction.")).toBeVisible(); + await user.click(screen.getByRole("radio", { name: "Other" })); + expect( + screen.getByRole("textbox", { name: "Other answer for Direction" }), + ).toBeVisible(); + }); + + it("keeps an agent-provided Other default visible and editable", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Choose a direction", + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + title: "Direction", + default: "Other", + oneOf: [ + { const: "local", title: "Local" }, + { const: "Other", title: "Other" }, + ], + }, + direction_custom: { type: "string", title: "Other" }, + }, + required: ["direction"], + }, + }); + render(); + + expect(screen.getByRole("radio", { name: "Other" })).toBeChecked(); + await user.type( + screen.getByRole("textbox", { name: "Other answer for Direction" }), + "Hybrid", + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { direction_custom: "Hybrid" }, + }); + }); + + it("allows a custom answer alongside multi-select choices", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Choose surfaces", + requestedSchema: { + type: "object", + properties: { + surfaces: { + type: "array", + title: "Surfaces", + items: { + anyOf: [ + { const: "desktop", title: "Desktop" }, + { const: "cli", title: "CLI" }, + ], + }, + }, + surfaces_custom: { + type: "string", + title: "Other", + }, + }, + }, + }); + render(); + + await user.click(screen.getByRole("checkbox", { name: "Desktop" })); + await user.click(screen.getByRole("checkbox", { name: /Other/ })); + await user.type( + screen.getByRole("textbox", { name: "Other answer for Surfaces" }), + "API", + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { surfaces: ["desktop"], surfaces_custom: "API" }, + }); + }); + + it("merges an agent-provided multi-select Other option with its companion input", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Choose surfaces", + requestedSchema: { + type: "object", + properties: { + surfaces: { + type: "array", + title: "Surfaces", + items: { + anyOf: [ + { const: "desktop", title: "Desktop" }, + { + const: "Other", + title: "Other", + description: "Name another surface.", + }, + ], + }, + }, + surfaces_custom: { + type: "string", + title: "Other", + _meta: { + _askUserQuestionCustomAnswer: { + questionId: "surfaces", + isCustomAnswer: true, + }, + }, + }, + }, + }, + }); + render(); + + expect(screen.getAllByRole("checkbox", { name: /Other/ })).toHaveLength(1); + expect(screen.getByText("Name another surface.")).toBeVisible(); + + await user.click(screen.getByRole("checkbox", { name: /Other/ })); + await user.type( + screen.getByRole("textbox", { name: "Other answer for Surfaces" }), + "Web", + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { surfaces_custom: "Web" }, + }); + }); + + it("keeps an agent-provided multi-select Other default visible and editable", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Choose surfaces", + requestedSchema: { + type: "object", + properties: { + surfaces: { + type: "array", + title: "Surfaces", + default: ["Other"], + items: { + anyOf: [ + { const: "desktop", title: "Desktop" }, + { const: "Other", title: "Other" }, + ], + }, + }, + surfaces_custom: { type: "string", title: "Other" }, + }, + }, + }); + render(); + + expect(screen.getByRole("checkbox", { name: /Other/ })).toBeChecked(); + await user.type( + screen.getByRole("textbox", { name: "Other answer for Surfaces" }), + "Web", + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { surfaces_custom: "Web" }, + }); + }); + + it("supports direct question navigation without allowing partial submit", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Shape the rollout", + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + title: "Direction", + enum: ["local", "upstream"], + enumNames: ["Local", "Upstream"], + }, + note: { type: "string", title: "Note" }, + }, + required: ["direction", "note"], + }, + }); + render(); + + expect(screen.getByText("Question 1 of 2")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Question 2" })); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + await user.type(screen.getByRole("textbox", { name: "Note" }), "Ready"); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "Question 1" })); + await user.click(screen.getByRole("radio", { name: "Local" })); + await user.click( + screen.getByRole("button", { name: "Question 2, answered" }), + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { direction: "local", note: "Ready" }, + }); + }); + + it("keeps a detached draft editable and sends it as a normal message", async () => { + const user = userEvent.setup(); + void enqueue({ + mode: "form", + sessionId: "session-1", + message: "Shape the recovery", + requestedSchema: { + type: "object", + properties: { + first: { type: "string", title: "First" }, + second: { type: "string", title: "Second" }, + }, + }, + }); + render(); + act(() => useElicitationStore.getState().detachAll("session-1")); + + await user.type(screen.getByRole("textbox", { name: "First" }), "One"); + expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(); + await user.keyboard("{Enter}"); + await user.type(screen.getByRole("textbox", { name: "Second" }), "Two"); + await user.click(screen.getByRole("button", { name: "Back" })); + + expect(screen.getByRole("textbox", { name: "First" })).toHaveValue("One"); + await user.click( + screen.getByRole("button", { name: "Question 2, answered" }), + ); + expect(screen.getByRole("textbox", { name: "Second" })).toHaveValue("Two"); + expect( + screen.getByText( + "The agent is no longer waiting for this answer. You can send it as a message instead.", + ), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Decline to answer" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Discard" })).toBeEnabled(); + + await user.click( + screen.getByRole("button", { name: "Send answers as message" }), + ); + + await waitFor(() => + expect(mocks.continueRecoveredElicitation).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + message: "Shape the recovery", + }), + { + action: "accept", + content: { first: "One", second: "Two" }, + }, + { beforePromptDispatch: expect.any(Function) }, + ), + ); + await waitFor(() => + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toBeUndefined(), + ); + }); + + it("keeps a detached draft when sending it as a message fails", async () => { + const user = userEvent.setup(); + mocks.continueRecoveredElicitation.mockRejectedValueOnce( + new Error("transport unavailable"), + ); + void enqueue({ + mode: "form", + sessionId: "session-1", + message: "Keep this answer", + requestedSchema: { + type: "object", + properties: { note: { type: "string", title: "Note" } }, + }, + }); + render(); + act(() => useElicitationStore.getState().detachAll("session-1")); + + await user.type(screen.getByRole("textbox", { name: "Note" }), "Draft"); + await user.click( + screen.getByRole("button", { name: "Send answers as message" }), + ); + + expect( + await screen.findByText( + "That message couldn’t be sent. Your answers are still here.", + ), + ).toBeVisible(); + expect(screen.getByRole("textbox", { name: "Note" })).toHaveValue("Draft"); + expect( + useElicitationStore.getState().pendingBySessionId["session-1"], + ).toHaveLength(1); + }); + + it("does not offer a retry when delivery fails after prompt dispatch", async () => { + const user = userEvent.setup(); + mocks.continueRecoveredElicitation.mockImplementationOnce( + async ( + _request: unknown, + _response: unknown, + callbacks?: { beforePromptDispatch?: () => void }, + ) => { + callbacks?.beforePromptDispatch?.(); + throw new Error("connection closed after dispatch"); + }, + ); + void enqueue({ + mode: "form", + sessionId: "session-1", + message: "Keep this answer", + requestedSchema: { + type: "object", + properties: { note: { type: "string", title: "Note" } }, + }, + }); + const view = render(); + act(() => useElicitationStore.getState().detachAll("session-1")); + + await user.type(screen.getByRole("textbox", { name: "Note" }), "Draft"); + await user.click( + screen.getByRole("button", { name: "Send answers as message" }), + ); + + expect( + await screen.findByText( + "Delivery couldn’t be confirmed. To avoid sending twice, Berd won’t retry these answers.", + ), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Send answers as message" }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Discard" })).toBeDisabled(); + + view.unmount(); + render(); + expect( + screen.getByText( + "Delivery couldn’t be confirmed. To avoid sending twice, Berd won’t retry these answers.", + ), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Send answers as message" }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Discard" })).toBeDisabled(); + }); + + it("promotes each real question above bridge boilerplate", () => { + void enqueue({ + mode: "form", + sessionId: "session-1", + message: "Please answer the following questions.", + requestedSchema: { + type: "object", + properties: { + direction: { + type: "string", + title: "Direction", + description: "Which direction should we take?", + }, + rationale: { + type: "string", + title: "Rationale", + description: "What makes that direction right?", + }, + }, + }, + }); + render(); + + expect( + screen.queryByText("Please answer the following questions."), + ).not.toBeInTheDocument(); + expect(screen.getByText("Question 1 of 2")).toBeVisible(); + expect(screen.getByText("Direction")).toHaveClass("uppercase"); + expect(screen.getByText("Which direction should we take?")).toHaveClass( + "font-display", + "text-base", + ); + }); + + it("shows credential-marked form fields as unsupported", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Provide a credential", + requestedSchema: { + type: "object", + properties: { + credential: { + type: "string", + title: "Credential", + _meta: { codex: { isSecret: true } }, + }, + }, + required: ["credential"], + }, + }); + render(); + + expect(screen.queryByLabelText("Credential")).not.toBeInTheDocument(); + expect( + screen.getByText( + "Berd can't show this answer type yet. You can still decline or cancel.", + ), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Decline to answer" })); + + await expect(response).resolves.toEqual({ action: "decline" }); + }); + + it("does not render a supported companion for an unsupported credential parent", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Provide a credential", + requestedSchema: { + type: "object", + properties: { + credential: { + type: "string", + title: "Credential", + _meta: { codex: { isSecret: true } }, + }, + credential__other: { + type: "string", + title: "Other", + _meta: { + codex: { isOtherAnswer: true, questionId: "credential" }, + }, + }, + }, + required: ["credential"], + }, + }); + render(); + + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Decline to answer" })); + + await expect(response).resolves.toEqual({ action: "decline" }); + }); + + it("records an explicit false boolean answer", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "Confirm the setting", + requestedSchema: { + type: "object", + properties: { + enabled: { type: "boolean", title: "Enable previews?" }, + }, + required: ["enabled"], + }, + }); + render(); + + await user.click(screen.getByRole("radio", { name: "No" })); + await user.click(screen.getByRole("button", { name: "Submit" })); + + await expect(response).resolves.toEqual({ + action: "accept", + content: { enabled: false }, + }); + }); + + it("preserves a draft on Escape and requires explicit cancellation", async () => { + const user = userEvent.setup(); + const response = enqueue({ + mode: "form", + sessionId: "session-1", + message: "One more thing", + requestedSchema: { + type: "object", + properties: { note: { type: "string", title: "Note" } }, + }, + }); + render(); + + await waitFor(() => + expect( + screen.getByRole("form", { name: "One more thing" }), + ).toHaveFocus(), + ); + await user.type(screen.getByRole("textbox", { name: "Note" }), "Draft"); + await user.keyboard("{Escape}"); + + expect(screen.getByRole("textbox", { name: "Note" })).toHaveValue("Draft"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + await expect(response).resolves.toEqual({ action: "cancel" }); + }); + + it("returns focus to the composer after the final answer", async () => { + const user = userEvent.setup(); + render( + <> +