From d99cccc2945cea507360b7b491e11446f7cf078e Mon Sep 17 00:00:00 2001 From: Leonardo Araujo dos Santos Date: Thu, 29 Oct 2026 21:30:34 -0400 Subject: [PATCH] Replay provider streams interrupted after partial output A retryable failure (transport, idle timeout, in-stream overload, rate limit) after partial text, reasoning or tool calls now discards the attempt and re-sends the same request within the existing retry budget, instead of settling the Turn as interrupted. Replay ends at protocol completion; tool calls only dispatch after the stream completes. - cyber-llm: open_with_retry returns a replaying stream that yields LlmEvent::Discarded before the new attempt; retries before and after output share one budget. collect drops discarded output, keeps usage. - Drain records reasoning items and tool calls with the text when the stream ends, so nothing of a discarded attempt reaches history. Usage a discarded attempt reported is recorded as a failed turn usage.recorded.1 unless a reservation already billed it. - session.retry carries message_id and discarded; the TUI and web dashboard clear the partial answer, exec stream-json prints a retry line. - OpenSpec change retry-interrupted-streams (Retry policy, eager execution durability, exec stream-json schema), docs and ROADMAP. --- .config/nextest.toml | 2 +- ROADMAP.md | 2 +- crates/cyber-app/tests/server.rs | 29 +- crates/cyber-cli/src/commands/exec/mod.rs | 1 + crates/cyber-cli/src/commands/exec/report.rs | 53 ++++ crates/cyber-llm/examples/turn.rs | 7 +- crates/cyber-llm/src/lib.rs | 2 +- crates/cyber-llm/src/retry.rs | 142 ++++++++-- crates/cyber-llm/src/turn.rs | 14 +- crates/cyber-llm/src/types.rs | 9 +- crates/cyber-llm/tests/adapters.rs | 100 +++++-- crates/cyber-llm/tests/live.rs | 33 +-- crates/cyber-llm/tests/transport_faults.rs | 216 ++++++++++----- crates/cyber-server/src/runtime/auto.rs | 3 +- crates/cyber-server/src/runtime/bus.rs | 6 +- crates/cyber-server/src/runtime/compaction.rs | 2 +- crates/cyber-server/src/runtime/drain.rs | 151 +++++++---- .../src/runtime/reservations/mod.rs | 16 ++ crates/cyber-server/tests/runtime.rs | 247 ++++++++++++++++-- crates/cyber-tools/src/hook_prompt.rs | 3 +- crates/cyber-tools/src/mcp/sampler.rs | 3 +- crates/cyber-tui/src/app.rs | 7 + crates/cyber-tui/src/tests.rs | 22 ++ docs/local-core-guide.md | 6 +- .../provider-stream-reliability-follow-up.md | 2 + .../specs/provider-catalog/spec.md | 4 +- .../retry-interrupted-streams/design.md | 61 +++++ .../retry-interrupted-streams/proposal.md | 43 +++ .../specs/exec-mode/spec.md | 28 ++ .../specs/provider-catalog/spec.md | 48 ++++ .../specs/session-runtime/spec.md | 12 + .../specs/tool-registry/spec.md | 13 + .../retry-interrupted-streams/tasks.md | 36 +++ web/dist/app.js | 2 +- web/src/model.ts | 2 + web/test/model.test.ts | 14 + 36 files changed, 1100 insertions(+), 241 deletions(-) create mode 100644 openspec/changes/retry-interrupted-streams/design.md create mode 100644 openspec/changes/retry-interrupted-streams/proposal.md create mode 100644 openspec/changes/retry-interrupted-streams/specs/exec-mode/spec.md create mode 100644 openspec/changes/retry-interrupted-streams/specs/provider-catalog/spec.md create mode 100644 openspec/changes/retry-interrupted-streams/specs/session-runtime/spec.md create mode 100644 openspec/changes/retry-interrupted-streams/specs/tool-registry/spec.md create mode 100644 openspec/changes/retry-interrupted-streams/tasks.md diff --git a/.config/nextest.toml b/.config/nextest.toml index d60c1c6c2..e0a7882ee 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -138,7 +138,7 @@ binary_id(cyber-account) | binary_id(cyber-server::pty) | binary_id(cyber-server::requests) | binary_id(cyber-server::resume_children) -| binary_id(cyber-server::runtime) & (test(loaded_) | test(registration) | test(complete_tool_call_is_durable_before_the_stream_finishes) | test(transport_failure_preserves_tool_outcomes_without_replaying_actions) | test(delete_) | test(shutdown_)) +| binary_id(cyber-server::runtime) & (test(loaded_) | test(registration) | test(a_complete_tool_call_is_recorded_when_its_stream_completes) | test(a_mid_stream_retry_records_only_the_replayed_attempt) | test(transport_failure_preserves_tool_outcomes_without_replaying_actions) | test(delete_) | test(shutdown_)) | binary_id(cyber-server::structured_results) | binary_id(cyber-server::subagent_names) | binary_id(cyber-server::subtask_cancellation) diff --git a/ROADMAP.md b/ROADMAP.md index e930fa096..47af9d5df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,7 +43,7 @@ The first release prioritizes reliable editing, recovery and measurable coding q **Provider base prompts and output styles (`system-context`): Status: implemented** (change `add-output-styles`). Base prompts follow the catalog model's family rather than the provider id; built-in, user and trust-gated project output styles are selected per primary Session (`/output-style`, `cyber exec --output-style`, `output_style`) and apply through a new Context Epoch. Plugin-distributed styles and `cyber import` of `.claude/output-styles` are deferred. -**Model protocol fidelity (`session-runtime`, `provider-catalog`, `system-context`, `builtin-tools`, `tool-registry`): Status: in progress** (change `improve-model-protocol-fidelity`). Reasoning is kept as ordered items with opaque provider state and replayed natively to the model that produced it; per-model behavior profiles replace family checks; requests gain the Responses fields and routing headers the models use; streams have an idle timeout; known provider error codes map to typed reasons with server-advised delays; the base prompts are rewritten per family, versioned, and checked clean-room against a reference product's prompts. Live tool-loop tests for OpenAI, Anthropic, DeepSeek and Gemini 3 are written and run with keys (`cargo test -p cyber-llm --test live -- --ignored`); they have not yet been run. The profile's `edit_tool` picks the edit tools, with a freeform `apply_patch` constrained by our own envelope grammar on OpenAI Responses; hunks match tolerantly (whitespace, typographic punctuation) and fail as ambiguous rather than guess; a shell `apply_patch` heredoc runs as the patch tool when no such program is installed; and the profile's token budget with `middle` truncation keeps both ends of long output. Remaining: a run of the live tests with provider keys and the before/after pinned-repository eval on `openai/gpt-6-sol`. The Responses WebSocket transport, zstd bodies and server-side compaction are separate follow-up changes. +**Model protocol fidelity (`session-runtime`, `provider-catalog`, `system-context`, `builtin-tools`, `tool-registry`): Status: in progress** (change `improve-model-protocol-fidelity`). Reasoning is kept as ordered items with opaque provider state and replayed natively to the model that produced it; per-model behavior profiles replace family checks; requests gain the Responses fields and routing headers the models use; streams have an idle timeout; known provider error codes map to typed reasons with server-advised delays; the base prompts are rewritten per family, versioned, and checked clean-room against a reference product's prompts. Live tool-loop tests for OpenAI, Anthropic, DeepSeek and Gemini 3 are written and run with keys (`cargo test -p cyber-llm --test live -- --ignored`); they have not yet been run. The profile's `edit_tool` picks the edit tools, with a freeform `apply_patch` constrained by our own envelope grammar on OpenAI Responses; hunks match tolerantly (whitespace, typographic punctuation) and fail as ambiguous rather than guess; a shell `apply_patch` heredoc runs as the patch tool when no such program is installed; and the profile's token budget with `middle` truncation keeps both ends of long output. A retryable stream failure after partial output now discards the partial answer and replays the request, within the same retry budget and only while none of its tool calls has been dispatched (change `retry-interrupted-streams`, which followed a pilot task lost to a mid-answer transport error). Remaining: a run of the live tests with provider keys and the before/after pinned-repository eval on `openai/gpt-6-sol`. The Responses WebSocket transport, zstd bodies and server-side compaction are separate follow-up changes. **Exit criteria** - Runs a 50-turn coding task end-to-end with OpenAI, Anthropic and a local model through the compatible adapter. Each P0 adapter also passes the published live coding baseline in `harness-evaluation`; turn count alone is insufficient. diff --git a/crates/cyber-app/tests/server.rs b/crates/cyber-app/tests/server.rs index 6c4415077..498bb356e 100644 --- a/crates/cyber-app/tests/server.rs +++ b/crates/cyber-app/tests/server.rs @@ -2310,10 +2310,11 @@ fn read_json_request(stream: &mut std::net::TcpStream) -> Option| { format!( @@ -2365,6 +2367,7 @@ async fn openai_compatible_call_is_durable_while_its_stream_is_still_open() { chunk(read(1, "call_b", "b.txt"), None) ); let _ = stream.flush(); + sent.store(true, Ordering::SeqCst); while !open.load(Ordering::SeqCst) { std::thread::sleep(Duration::from_millis(5)); } @@ -2413,19 +2416,19 @@ async fn openai_compatible_call_is_durable_while_its_stream_is_still_open() { .map(|e| e.data["call_id"].as_str().unwrap().to_owned()) .collect() }; - let early = tokio::time::timeout(Duration::from_secs(20), async { - loop { - let calls = called(&application.store); - if !calls.is_empty() { - break calls; - } + tokio::time::timeout(Duration::from_secs(20), async { + while !streamed.load(Ordering::SeqCst) { tokio::time::sleep(Duration::from_millis(10)).await; } }) .await - .expect("no call became durable while the stream was open"); - assert_eq!(early, ["call_a"], "only the completed call is recorded"); - assert!(!release.load(Ordering::SeqCst)); + .expect("the provider did not stream the calls"); + // `call_a` is complete once `call_b` starts; give the Drain time to have seen it. + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + called(&application.store).is_empty(), + "nothing is durable while the stream can be replayed" + ); release.store(true, Ordering::SeqCst); application.runtime.wait_idle(&id).await; assert_eq!(called(&application.store), ["call_a", "call_b"]); diff --git a/crates/cyber-cli/src/commands/exec/mod.rs b/crates/cyber-cli/src/commands/exec/mod.rs index 811b1ce9d..0c60380a1 100644 --- a/crates/cyber-cli/src/commands/exec/mod.rs +++ b/crates/cyber-cli/src/commands/exec/mod.rs @@ -526,6 +526,7 @@ async fn handle( out.error(&message); run.error = Some(message); } + "session.retry" => out.retry(&event.data), "session.deleted" => { run.error = Some("the Session was deleted".into()); return Step::End; diff --git a/crates/cyber-cli/src/commands/exec/report.rs b/crates/cyber-cli/src/commands/exec/report.rs index f7d39ca3c..8245d1698 100644 --- a/crates/cyber-cli/src/commands/exec/report.rs +++ b/crates/cyber-cli/src/commands/exec/report.rs @@ -272,6 +272,17 @@ pub struct Out { review: Option, } +/// The `retry` stream-json payload of a `session.retry` event. +fn retry_payload(data: &Value) -> Value { + json!({ + "message_id": data["message_id"], + "attempt": data["attempt"], + "delay_ms": data["delay_ms"], + "error": data["error"], + "discarded": data["discarded"] == true, + }) +} + fn now_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -535,6 +546,24 @@ impl Out { } } + /// `session.retry`: a provider retry. A `discarded` attempt never reached the output, + /// which holds recorded content only (`provider-catalog` → Retry policy). + pub fn retry(&self, data: &Value) { + let payload = retry_payload(data); + self.note(&format!( + "retry {} in {} ms: {}{}", + payload["attempt"], + payload["delay_ms"], + data["error"].as_str().unwrap_or_default(), + if payload["discarded"] == true { + " (partial answer discarded)" + } else { + "" + } + )); + self.line("retry", payload); + } + /// A durable session event. pub fn durable(&mut self, run: &mut Run, kind: &str, data: &Value) { let base = kind.rsplit_once('.').map_or(kind, |(b, _)| b); @@ -981,6 +1010,30 @@ mod tests { ); } + /// A discarded attempt is reported as a `retry` line; only recorded content becomes + /// `assistant_text`, so the run's result is the replayed answer alone. + #[test] + fn a_discarded_attempt_is_a_retry_line_and_never_the_result() { + let retry = json!({ "session_id": "ses_1", "message_id": "msg_1", "attempt": 1, + "delay_ms": 900, "error": "transport: reset", "discarded": true }); + assert_eq!( + retry_payload(&retry), + json!({ "message_id": "msg_1", "attempt": 1, "delay_ms": 900, + "error": "transport: reset", "discarded": true }) + ); + let args = Wrapper::parse_from(["exec", "hi"]).args; + let mut out = Out::new(Format::Json, &args, &json!({ "id": "ses_1" })); + let mut run = Run::new(&args, Instant::now()); + out.retry(&retry); + out.durable( + &mut run, + "session.text.ended.1", + &json!({ "message_id": "msg_1", "text": "Done." }), + ); + assert_eq!(run.text, "Done."); + assert_eq!(out.summary(&run, 0)["result"], "Done."); + } + #[test] fn only_forbidden_refusals_become_review_denials() { let api = |tag: &str| ClientError::Api { diff --git a/crates/cyber-llm/examples/turn.rs b/crates/cyber-llm/examples/turn.rs index 1d6104495..bced495a5 100644 --- a/crates/cyber-llm/examples/turn.rs +++ b/crates/cyber-llm/examples/turn.rs @@ -75,8 +75,11 @@ async fn main() -> Result<(), Box> { adapter.as_ref(), &request, &RetryPolicy::default(), - |n, d, e| { - eprintln!("retry {n} in {d:?}: {e}"); + |retry| { + eprintln!( + "retry {} in {:?}: {}", + retry.attempt, retry.delay, retry.error + ); }, ) .await?; diff --git a/crates/cyber-llm/src/lib.rs b/crates/cyber-llm/src/lib.rs index ee869c7c0..0b43c1ef4 100644 --- a/crates/cyber-llm/src/lib.rs +++ b/crates/cyber-llm/src/lib.rs @@ -20,7 +20,7 @@ mod types; pub use adapters::{Adapter, ApiKind, Endpoint, EventStream}; pub use error::{ErrorKind, LlmError, classify}; -pub use retry::{RetryPolicy, open_with_retry}; +pub use retry::{RetryNotice, RetryPolicy, RetryingStream, open_with_retry}; pub use sse::{SseEvent, SseParser}; pub use turn::{TurnOutput, collect}; pub use types::{ diff --git a/crates/cyber-llm/src/retry.rs b/crates/cyber-llm/src/retry.rs index c9fc6d386..e712d2271 100644 --- a/crates/cyber-llm/src/retry.rs +++ b/crates/cyber-llm/src/retry.rs @@ -1,16 +1,26 @@ //! Retry policy (`provider-catalog` → Retry policy). //! -//! Retries happen only before any assistant output: a stream that already produced output -//! and then fails is returned to the caller, which settles the Turn as interrupted. +//! A retryable failure before any assistant output reopens the request silently. One after +//! partial output discards that attempt: the stream yields [`LlmEvent::Discarded`] and then +//! the events of a fresh attempt of the same request. Retries before and after output share +//! one budget. Nothing is replayed after protocol completion, since a completed stream ends. +//! +//! The stream never knows about tool dispatch: a consumer SHALL NOT start a tool call of the +//! answer while the stream can still be replayed, which the Drain guarantees by dispatching +//! only after the stream ended. +use std::pin::Pin; use std::time::Duration; -use futures::stream::{self, StreamExt}; +use futures::stream::{self, Stream, StreamExt}; use crate::adapters::{Adapter, EventStream}; use crate::error::LlmError; use crate::types::{LlmEvent, LlmRequest}; +/// A stream that may replay its request; it borrows the adapter it reopens with. +pub type RetryingStream<'a> = Pin> + Send + 'a>>; + #[derive(Debug, Clone)] pub struct RetryPolicy { pub max_retries: u32, @@ -44,30 +54,120 @@ impl RetryPolicy { } } -/// Open a stream, retrying retryable failures that happen before any output. -/// `on_retry(attempt, delay, error)` is called before each wait. -pub async fn open_with_retry( - adapter: &dyn Adapter, +/// One retry, announced before its wait. +#[derive(Debug)] +pub struct RetryNotice<'e> { + /// 1-based retry number. + pub attempt: u32, + pub delay: Duration, + pub error: &'e LlmError, + /// The failed attempt had streamed output, which the consumer must drop. + pub discarded: bool, +} + +/// Open a stream, retrying retryable failures before any output, and replaying the request +/// when a retryable failure interrupts partial output. `on_retry` is called before each wait. +pub async fn open_with_retry<'a>( + adapter: &'a dyn Adapter, request: &LlmRequest, policy: &RetryPolicy, - mut on_retry: impl FnMut(u32, Duration, &LlmError), -) -> Result { - let mut attempt = 0; - loop { - let failure = match adapter.stream(request.clone()).await { - Ok(stream) => match first_output(stream).await { - Ok(stream) => return Ok(stream), + on_retry: impl FnMut(&RetryNotice<'_>) + Send + 'a, +) -> Result, LlmError> { + let mut replay = Replay { + adapter, + request: request.clone(), + policy: policy.clone(), + on_retry, + attempt: 0, + }; + let first = replay.open().await?; + Ok(stream::unfold((replay, Phase::Live(first)), step).boxed()) +} + +struct Replay<'a, F> { + adapter: &'a dyn Adapter, + request: LlmRequest, + policy: RetryPolicy, + on_retry: F, + /// Retries used so far. + attempt: u32, +} + +enum Phase { + Live(EventStream), + /// The attempt failed after output and was discarded; the next one opens after the wait. + Reopen(LlmError), + Done, +} + +impl)> Replay<'_, F> { + /// Open an attempt that reaches its first output, retrying failures before it. + async fn open(&mut self) -> Result { + loop { + let failure = match self.adapter.stream(self.request.clone()).await { + Ok(stream) => match first_output(stream).await { + Ok(stream) => return Ok(stream), + Err(e) => e, + }, Err(e) => e, - }, - Err(e) => e, - }; - if !failure.kind.is_retryable() || attempt >= policy.max_retries { + }; + self.wait(failure, false).await?; + } + } + + fn may_retry(&self, failure: &LlmError) -> bool { + failure.kind.is_retryable() && self.attempt < self.policy.max_retries + } + + /// Announce the next retry and wait for it, or give the failure back. + async fn wait(&mut self, failure: LlmError, discarded: bool) -> Result<(), LlmError> { + if !self.may_retry(&failure) { return Err(failure); } - attempt += 1; - let delay = policy.delay(attempt, &failure); - on_retry(attempt, delay, &failure); + self.attempt += 1; + let delay = self.policy.delay(self.attempt, &failure); + (self.on_retry)(&RetryNotice { + attempt: self.attempt, + delay, + error: &failure, + discarded, + }); tokio::time::sleep(delay).await; + Ok(()) + } +} + +type Item = Result; + +/// The next item of a replaying stream. +async fn step)>( + (mut replay, phase): (Replay<'_, F>, Phase), +) -> Option<(Item, (Replay<'_, F>, Phase))> { + match phase { + Phase::Done => None, + Phase::Live(mut stream) => match stream.next().await { + None => None, + Some(Ok(event)) => Some((Ok(event), (replay, Phase::Live(stream)))), + // The attempt already streamed output (`first_output`), so a retry discards it. + Some(Err(e)) if replay.may_retry(&e) => { + Some((Ok(LlmEvent::Discarded), (replay, Phase::Reopen(e)))) + } + Some(Err(e)) => Some((Err(e), (replay, Phase::Done))), + }, + Phase::Reopen(failure) => { + let reopened = match replay.wait(failure, true).await { + Ok(()) => replay.open().await, + Err(e) => Err(e), + }; + match reopened { + Ok(mut stream) => { + // `first_output` returns a stream with at least one item unless it ended. + let first = stream.next().await?; + Some((first, (replay, Phase::Live(stream)))) + } + Err(e) => Some((Err(e), (replay, Phase::Done))), + } + } } } diff --git a/crates/cyber-llm/src/turn.rs b/crates/cyber-llm/src/turn.rs index 14c134cf9..c2ea1c2c2 100644 --- a/crates/cyber-llm/src/turn.rs +++ b/crates/cyber-llm/src/turn.rs @@ -1,8 +1,7 @@ //! Collect a stream into the result of one Turn. -use futures::StreamExt; +use futures::{Stream, StreamExt}; -use crate::adapters::EventStream; use crate::error::LlmError; use crate::types::{FinishReason, LlmEvent, ToolCall, Usage}; @@ -15,9 +14,10 @@ pub struct TurnOutput { pub finish: Option, } -/// Drain a stream, calling `on_event` for each event (for live rendering). +/// Drain a stream, calling `on_event` for each event (for live rendering). A discarded +/// attempt's output is dropped; its usage was spent and stays counted. pub async fn collect( - mut stream: EventStream, + mut stream: impl Stream> + Unpin, mut on_event: impl FnMut(&LlmEvent), ) -> Result { let mut out = TurnOutput::default(); @@ -36,6 +36,12 @@ pub async fn collect( LlmEvent::Usage(usage) => out.usage.add(&usage), LlmEvent::Finish { reason } => out.finish = Some(reason), LlmEvent::ToolCallDelta { .. } => {} + LlmEvent::Discarded => { + out = TurnOutput { + usage: out.usage, + ..TurnOutput::default() + }; + } } } Ok(out) diff --git a/crates/cyber-llm/src/types.rs b/crates/cyber-llm/src/types.rs index 1597a538a..3a0164435 100644 --- a/crates/cyber-llm/src/types.rs +++ b/crates/cyber-llm/src/types.rs @@ -494,12 +494,17 @@ pub enum LlmEvent { Finish { reason: FinishReason, }, + /// Never sent by an adapter: [`crate::open_with_retry`] discarded the attempt streamed + /// so far after a retryable failure and replays the request. Consumers drop every + /// event since the stream opened or the previous `Discarded`; usage they saw stays spent. + #[serde(skip)] + Discarded, } impl LlmEvent { - /// Whether the event is assistant output (anything but usage). + /// Whether the event is assistant output (anything but usage and retry markers). pub fn is_output(&self) -> bool { - !matches!(self, Self::Usage(_)) + !matches!(self, Self::Usage(_) | Self::Discarded) } } diff --git a/crates/cyber-llm/tests/adapters.rs b/crates/cyber-llm/tests/adapters.rs index 6bda211d8..5fd39f0ee 100644 --- a/crates/cyber-llm/tests/adapters.rs +++ b/crates/cyber-llm/tests/adapters.rs @@ -545,7 +545,7 @@ async fn context_overflow_is_detected_and_never_retried() { .mount(&server) .await; let a = adapter(ApiKind::OpenaiCompatible, Endpoint::new(server.uri(), None)); - let err = open_with_retry(a.as_ref(), &request(), &fast_policy(), |_, _, _| {}) + let err = open_with_retry(a.as_ref(), &request(), &fast_policy(), |_| {}) .await .err() .unwrap(); @@ -579,8 +579,8 @@ async fn overloaded_provider_is_retried_then_succeeds() { let a = adapter(ApiKind::OpenaiCompatible, Endpoint::new(server.uri(), None)); let mut retries = Vec::new(); - let stream = open_with_retry(a.as_ref(), &request(), &fast_policy(), |n, d, e| { - retries.push((n, d, e.kind)) + let stream = open_with_retry(a.as_ref(), &request(), &fast_policy(), |retry| { + retries.push((retry.attempt, retry.delay, retry.error.kind)) }) .await .unwrap(); @@ -595,40 +595,63 @@ async fn overloaded_provider_is_retried_then_succeeds() { assert_eq!(server.received_requests().await.unwrap().len(), 3); } +/// `provider-catalog` → Retry policy: an in-stream overload after partial text discards the +/// attempt and replays the request; the consumer sees only the replayed answer. #[tokio::test] -async fn failure_after_output_is_not_replayed() { +async fn overload_after_output_discards_the_attempt_and_replays() { let server = MockServer::start().await; - let body = sse(&[ - ( - "message_start", - json!({"message": {"usage": {"input_tokens": 1}}}), - ), + let start = ( + "message_start", + json!({"message": {"usage": {"input_tokens": 1}}}), + ); + let text = |t: &str| { ( "content_block_delta", - json!({"index": 0, "delta": {"type": "text_delta", "text": "partial"}}), - ), + json!({"index": 0, "delta": {"type": "text_delta", "text": t}}), + ) + }; + let failed = sse(&[ + start.clone(), + text("partial"), ( "error", json!({"error": {"type": "overloaded_error", "message": "Overloaded"}}), ), ]); + let completed = sse(&[ + start, + text("whole answer"), + ( + "message_delta", + json!({"delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 2}}), + ), + ("message_stop", json!({})), + ]); Mock::given(method("POST")) - .respond_with(ok_stream(body)) + .respond_with(ok_stream(failed)) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .respond_with(ok_stream(completed)) .mount(&server) .await; let a = adapter(ApiKind::Anthropic, Endpoint::new(server.uri(), None)); - let stream = open_with_retry(a.as_ref(), &request(), &fast_policy(), |_, _, _| { - panic!("must not retry") + let mut notices = Vec::new(); + let stream = open_with_retry(a.as_ref(), &request(), &fast_policy(), |retry| { + notices.push((retry.attempt, retry.discarded, retry.error.kind)) }) .await .unwrap(); let mut seen = Vec::new(); - let err = collect(stream, |e| seen.push(e.clone())).await.unwrap_err(); - assert_eq!(err.kind, ErrorKind::ProviderInternal); - assert!(seen.contains(&LlmEvent::TextDelta { + let out = collect(stream, |e| seen.push(e.clone())).await.unwrap(); + let discarded = seen.iter().position(|e| *e == LlmEvent::Discarded).unwrap(); + assert!(seen[..discarded].contains(&LlmEvent::TextDelta { text: "partial".into() })); - assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert_eq!(out.text, "whole answer"); + assert_eq!(notices, [(1, true, ErrorKind::ProviderInternal)]); + assert_eq!(server.received_requests().await.unwrap().len(), 2); } #[tokio::test] @@ -650,13 +673,52 @@ async fn error_before_output_in_stream_is_retried() { }), ], ]); - let stream = open_with_retry(&adapter, &request(), &fast_policy(), |_, _, _| {}) + let stream = open_with_retry(&adapter, &request(), &fast_policy(), |_| {}) .await .unwrap(); assert_eq!(collect(stream, |_| {}).await.unwrap().text, "fine"); assert_eq!(adapter.requests().len(), 2); } +/// `collect` drops a discarded attempt's output but keeps the usage it reported: those +/// tokens were spent. +#[tokio::test] +async fn a_discarded_attempt_keeps_its_reported_usage() { + let usage = |input| { + ScriptStep::Event(LlmEvent::Usage(Usage { + input, + ..Usage::default() + })) + }; + let adapter = ScriptedAdapter::new(vec![ + vec![ + usage(5), + ScriptStep::Event(LlmEvent::TextDelta { + text: "partial".into(), + }), + ScriptStep::Error { + error: ErrorKind::Transport, + message: "reset".into(), + }, + ], + vec![ + ScriptStep::Event(LlmEvent::TextDelta { + text: "fine".into(), + }), + usage(7), + ScriptStep::Event(LlmEvent::Finish { + reason: FinishReason::Stop, + }), + ], + ]); + let stream = open_with_retry(&adapter, &request(), &fast_policy(), |_| {}) + .await + .unwrap(); + let out = collect(stream, |_| {}).await.unwrap(); + assert_eq!((out.text.as_str(), out.usage.input), ("fine", 12)); + assert_eq!(adapter.requests().len(), 2); +} + #[tokio::test] async fn truncated_stream_is_a_transport_error() { let server = MockServer::start().await; diff --git a/crates/cyber-llm/tests/live.rs b/crates/cyber-llm/tests/live.rs index 09cf23857..423927310 100644 --- a/crates/cyber-llm/tests/live.rs +++ b/crates/cyber-llm/tests/live.rs @@ -79,14 +79,9 @@ async fn round_trip(catalog: &Catalog, model: &str) { )); let first = collect( - open_with_retry( - adapter.as_ref(), - &request, - &RetryPolicy::default(), - |_, _, _| {}, - ) - .await - .unwrap(), + open_with_retry(adapter.as_ref(), &request, &RetryPolicy::default(), |_| {}) + .await + .unwrap(), |_| {}, ) .await @@ -116,14 +111,9 @@ async fn round_trip(catalog: &Catalog, model: &str) { }], }); let second = collect( - open_with_retry( - adapter.as_ref(), - &request, - &RetryPolicy::default(), - |_, _, _| {}, - ) - .await - .unwrap(), + open_with_retry(adapter.as_ref(), &request, &RetryPolicy::default(), |_| {}) + .await + .unwrap(), |_| {}, ) .await @@ -220,14 +210,9 @@ async fn reasoning_tool_loop( for step in 0..MAX_STEPS { let mut items = Vec::new(); let mut streamed = String::new(); - let stream = open_with_retry( - adapter.as_ref(), - &request, - &RetryPolicy::default(), - |_, _, _| {}, - ) - .await - .unwrap_or_else(|e| panic!("{model} step {step} rejected: {e:?}")); + let stream = open_with_retry(adapter.as_ref(), &request, &RetryPolicy::default(), |_| {}) + .await + .unwrap_or_else(|e| panic!("{model} step {step} rejected: {e:?}")); // An item without text closes the reasoning streamed since the last item. let out = collect(stream, |event| match event { LlmEvent::ReasoningDelta { text } => streamed.push_str(text), diff --git a/crates/cyber-llm/tests/transport_faults.rs b/crates/cyber-llm/tests/transport_faults.rs index 3b31785a4..27ac6e6bd 100644 --- a/crates/cyber-llm/tests/transport_faults.rs +++ b/crates/cyber-llm/tests/transport_faults.rs @@ -8,6 +8,7 @@ use std::time::Duration; use cyber_llm::adapters::{ApiKind, Endpoint, adapter}; use cyber_llm::{ErrorKind, LlmEvent, LlmRequest, RetryPolicy, collect, open_with_retry}; +use futures::StreamExt; use serde_json::json; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -23,12 +24,20 @@ impl Drop for FaultServer { } } -impl FaultServer { - async fn start(responses: Vec<(String, bool)>) -> Self { - Self::start_with_held_tail(responses, false).await - } +/// How a response body ends after its events. +#[derive(Clone, Copy, PartialEq)] +enum Tail { + /// The terminating chunk, then close. + Complete, + /// Close without the terminating chunk. + Broken, + /// Keep the connection open and send nothing more. + Held, +} - async fn start_with_held_tail(responses: Vec<(String, bool)>, held: bool) -> Self { +impl FaultServer { + /// Each request gets the next response; the last one repeats. + async fn start(responses: Vec<(String, Tail)>) -> Self { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let requests = Arc::new(AtomicUsize::new(0)); @@ -59,7 +68,7 @@ impl FaultServer { request.resize(header_end + length, 0); socket.read_exact(&mut request[header_end..]).await.unwrap(); let index = count.fetch_add(1, Ordering::SeqCst); - let (body, broken) = &responses[index.min(responses.len() - 1)]; + let (body, tail) = &responses[index.min(responses.len() - 1)]; let headers = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n{:x}\r\n", body.len() @@ -67,10 +76,15 @@ impl FaultServer { socket.write_all(headers.as_bytes()).await.unwrap(); socket.write_all(body.as_bytes()).await.unwrap(); socket.write_all(b"\r\n").await.unwrap(); - if held { - std::future::pending::<()>().await; + if *tail == Tail::Held { + // Hold this connection open while later requests are served. + tokio::spawn(async move { + let _socket = socket; + std::future::pending::<()>().await + }); + continue; } - if !broken { + if *tail == Tail::Complete { socket.write_all(b"0\r\n\r\n").await.unwrap(); } socket.shutdown().await.unwrap(); @@ -118,7 +132,7 @@ fn model(server: &FaultServer) -> Box { #[tokio::test] async fn completed_response_survives_broken_http_tail() { - let server = FaultServer::start(vec![(text() + &completed(), true)]).await; + let server = FaultServer::start(vec![(text() + &completed(), Tail::Broken)]).await; let output = collect( model(&server).stream(LlmRequest::default()).await.unwrap(), |_| {}, @@ -132,8 +146,7 @@ async fn completed_response_survives_broken_http_tail() { #[tokio::test] async fn completed_response_does_not_wait_for_http_eof() { - let server = - FaultServer::start_with_held_tail(vec![(text() + &completed(), false)], true).await; + let server = FaultServer::start(vec![(text() + &completed(), Tail::Held)]).await; let output = tokio::time::timeout(Duration::from_secs(10), async { collect( model(&server).stream(LlmRequest::default()).await.unwrap(), @@ -154,9 +167,9 @@ async fn broken_body_before_output_retries_then_succeeds() { let server = FaultServer::start(vec![ ( event("response.created", json!({"response":{"id":"r1"}})), - true, + Tail::Broken, ), - (text() + &completed(), false), + (text() + &completed(), Tail::Complete), ]) .await; let adapter = model(&server); @@ -165,72 +178,98 @@ async fn broken_body_before_output_retries_then_succeeds() { adapter.as_ref(), &LlmRequest::default(), &policy(), - |_, _, error| { + |retry| { retries += 1; - assert_eq!(error.kind, ErrorKind::Transport); + assert!(!retry.discarded, "nothing was streamed"); + assert_eq!(retry.error.kind, ErrorKind::Transport); assert!( - error.message.contains("response stream interrupted after"), - "{error}" + retry + .error + .message + .contains("response stream interrupted after"), + "{}", + retry.error ); }, ) .await .unwrap(); - assert_eq!(collect(stream, |_| {}).await.unwrap().text, "hello"); + let mut seen = Vec::new(); + let output = collect(stream, |event| seen.push(event.clone())) + .await + .unwrap(); + assert_eq!(output.text, "hello"); + assert!(!seen.contains(&LlmEvent::Discarded)); assert_eq!(retries, 1); assert_eq!(server.requests.load(Ordering::SeqCst), 2); } -#[tokio::test] -async fn broken_body_after_partial_output_is_never_retried() { - for prefix in [ +/// What an interrupted attempt can have streamed before its body broke. +fn partial_prefixes() -> [String; 4] { + let call = event( + "response.output_item.added", + json!({"item":{"type":"function_call","id":"i1","call_id":"c1","name":"write"}}), + ); + [ text(), event( "response.reasoning_summary_text.delta", json!({"delta":"thinking"}), ), - event( - "response.output_item.added", - json!({"item":{"type":"function_call","id":"i1","call_id":"c1","name":"write"}}), - ) + &event( - "response.function_call_arguments.delta", - json!({"item_id":"i1","delta":"{\"path\":"}), - ), - event( - "response.output_item.added", - json!({"item":{"type":"function_call","id":"i1","call_id":"c1","name":"write"}}), - ) + &event( + call.clone() + + &event( + "response.function_call_arguments.delta", + json!({"item_id":"i1","delta":"{\"path\":"}), + ), + // Complete but never dispatched: the stream had not ended. + call + &event( "response.output_item.done", json!({"item":{"type":"function_call","id":"i1","arguments":"{}"}}), ), - ] { - let server = FaultServer::start(vec![(prefix, true)]).await; + ] +} + +/// `provider-catalog` → Retry policy: partial text, reasoning, streamed tool arguments or a +/// completed but undispatched call are discarded, and the same request is replayed. +#[tokio::test] +async fn broken_body_after_partial_output_is_discarded_and_replayed() { + for prefix in partial_prefixes() { + let server = FaultServer::start(vec![ + (prefix, Tail::Broken), + (text() + &completed(), Tail::Complete), + ]) + .await; let adapter = model(&server); + let mut notices = Vec::new(); let stream = open_with_retry( adapter.as_ref(), &LlmRequest::default(), &policy(), - |_, _, _| panic!("partial output must not be replayed"), + |retry| { + notices.push((retry.attempt, retry.discarded, retry.error.kind)); + }, ) .await .unwrap(); let mut seen = Vec::new(); - let error = collect(stream, |event| seen.push(event.clone())) + let output = collect(stream, |event| seen.push(event.clone())) .await - .unwrap_err(); - assert_eq!(error.kind, ErrorKind::Transport); - assert!( - error.message.contains("response stream interrupted after"), - "{error}" - ); - assert!(seen.iter().any(LlmEvent::is_output)); - assert_eq!(server.requests.load(Ordering::SeqCst), 1); + .unwrap(); + let discarded = seen + .iter() + .position(|event| *event == LlmEvent::Discarded) + .expect("the partial attempt is discarded"); + assert!(seen[..discarded].iter().any(LlmEvent::is_output)); + assert_eq!(notices, [(1, true, ErrorKind::Transport)]); + assert_eq!(output.text, "hello"); + assert!(output.reasoning.is_empty() && output.tool_calls.is_empty()); + assert_eq!(server.requests.load(Ordering::SeqCst), 2); } } #[tokio::test] async fn clean_http_eof_before_protocol_completion_is_an_error() { - let server = FaultServer::start(vec![(text(), false)]).await; + let server = FaultServer::start(vec![(text(), Tail::Complete)]).await; let error = collect( model(&server).stream(LlmRequest::default()).await.unwrap(), |_| {}, @@ -248,17 +287,12 @@ async fn clean_http_eof_before_protocol_completion_is_an_error() { #[tokio::test] async fn repeated_body_faults_stop_at_the_retry_limit() { let body = event("response.created", json!({"response":{"id":"r1"}})); - let server = FaultServer::start(vec![(body.clone(), true)]).await; + let server = FaultServer::start(vec![(body.clone(), Tail::Broken)]).await; let adapter = model(&server); let mut retries = 0; - let error = open_with_retry( - adapter.as_ref(), - &LlmRequest::default(), - &policy(), - |_, _, _| { - retries += 1; - }, - ) + let error = open_with_retry(adapter.as_ref(), &LlmRequest::default(), &policy(), |_| { + retries += 1; + }) .await .err() .expect("all five attempts must fail"); @@ -273,11 +307,56 @@ async fn repeated_body_faults_stop_at_the_retry_limit() { ); } +/// Failures before and after output share one budget of four retries; the last attempt's +/// failure is returned with its partial output, so the Turn settles as interrupted. +#[tokio::test] +async fn retries_before_and_after_output_share_one_budget() { + let created = event("response.created", json!({"response":{"id":"r1"}})); + let server = FaultServer::start(vec![ + (created.clone(), Tail::Broken), + (text(), Tail::Broken), + (created, Tail::Broken), + (text(), Tail::Broken), + ]) + .await; + let adapter = model(&server); + let mut notices = Vec::new(); + let stream = open_with_retry( + adapter.as_ref(), + &LlmRequest::default(), + &policy(), + |retry| { + notices.push(retry.discarded); + }, + ) + .await + .unwrap(); + let mut seen = Vec::new(); + let error = collect(stream, |event| seen.push(event.clone())) + .await + .unwrap_err(); + assert_eq!(error.kind, ErrorKind::Transport); + assert_eq!(notices, [false, true, false, true]); + assert_eq!(server.requests.load(Ordering::SeqCst), 5); + let last = seen + .iter() + .rposition(|e| *e == LlmEvent::Discarded) + .unwrap(); + assert!( + seen[last..].iter().any(LlmEvent::is_output), + "the final attempt's partial output reaches the caller" + ); +} + /// `provider-catalog` → "Stalled stream": headers and one event, then silence for the -/// idle window, fails as a transport error, and the partial output is not replayed. +/// idle window, fails as a transport error; the partial output is discarded and replayed. #[tokio::test] -async fn a_stalled_stream_fails_after_the_idle_window() { - let server = FaultServer::start_with_held_tail(vec![(text(), false)], true).await; +async fn a_stalled_stream_fails_after_the_idle_window_and_is_replayed() { + let server = FaultServer::start(vec![ + (text(), Tail::Held), + (text() + &completed(), Tail::Complete), + ]) + .await; // No client-wide timeout: under the paused clock only the idle timer may fire. let mut endpoint = Endpoint::new(server.url.clone(), None); endpoint.client = reqwest::Client::builder().no_proxy().build().unwrap(); @@ -286,27 +365,28 @@ async fn a_stalled_stream_fails_after_the_idle_window() { Some(cyber_llm::adapters::DEFAULT_IDLE_TIMEOUT) ); let adapter = adapter(ApiKind::OpenaiResponses, endpoint); + let mut errors = Vec::new(); let mut stream = open_with_retry( adapter.as_ref(), &LlmRequest::default(), &policy(), - |_, _, _| panic!("partial output must not be replayed"), + |retry| { + errors.push(retry.error.message.clone()); + }, ) .await .unwrap(); - use futures::StreamExt; let first = stream.next().await.unwrap().unwrap(); assert!(first.is_output(), "{first:?}"); tokio::time::pause(); let started = tokio::time::Instant::now(); - let error = stream.next().await.unwrap().unwrap_err(); - assert_eq!(error.kind, ErrorKind::Transport); - assert_eq!(error.message, "stream idle for 300s"); + assert_eq!(stream.next().await.unwrap().unwrap(), LlmEvent::Discarded); assert!(started.elapsed() >= Duration::from_secs(300)); - assert!( - stream.next().await.is_none(), - "the stream ends with the failure" - ); - assert_eq!(server.requests.load(Ordering::SeqCst), 1); + // The replay runs on real time: a paused clock would fire its idle timer at once. + tokio::time::resume(); + let output = collect(stream, |_| {}).await.unwrap(); + assert_eq!(output.text, "hello"); + assert_eq!(errors, ["stream idle for 300s"]); + assert_eq!(server.requests.load(Ordering::SeqCst), 2); } diff --git a/crates/cyber-server/src/runtime/auto.rs b/crates/cyber-server/src/runtime/auto.rs index 96608a6c9..a358c5e2c 100644 --- a/crates/cyber-server/src/runtime/auto.rs +++ b/crates/cyber-server/src/runtime/auto.rs @@ -395,7 +395,8 @@ async fn infer( } LlmEvent::Finish { reason } => finish = Some(reason), LlmEvent::ToolCallDone(_) | LlmEvent::ToolCallDelta { .. } => invalid = true, - LlmEvent::ReasoningItem { .. } => {} + // A bare adapter stream is never replayed. + LlmEvent::ReasoningItem { .. } | LlmEvent::Discarded => {} } if chars > 65_536 { return Err("Evaluator output exceeds the review limit".into()); diff --git a/crates/cyber-server/src/runtime/bus.rs b/crates/cyber-server/src/runtime/bus.rs index 25c29f821..6f0fe283d 100644 --- a/crates/cyber-server/src/runtime/bus.rs +++ b/crates/cyber-server/src/runtime/bus.rs @@ -84,12 +84,16 @@ pub enum LiveEvent { job_id: String, text: String, }, - /// `session.retry`: the attempt number and delay before it. + /// `session.retry`: the attempt number and delay before it. `discarded` means the + /// failed attempt of `message_id` had streamed output: clients drop its live text, + /// reasoning and tool input, since the request is replayed from the start. Retry { session_id: String, + message_id: String, attempt: u32, delay_ms: u64, error: String, + discarded: bool, }, /// `session.usage`: usage of the last step and context-window utilization. Usage { diff --git a/crates/cyber-server/src/runtime/compaction.rs b/crates/cyber-server/src/runtime/compaction.rs index 3a1262a54..46b9abccf 100644 --- a/crates/cyber-server/src/runtime/compaction.rs +++ b/crates/cyber-server/src/runtime/compaction.rs @@ -367,7 +367,7 @@ impl Inner { let handle = self.handle(&state.info.id).await?; self.check_budget(&handle).await?; let gated = super::budget::GatedAdapter::new(self, &handle, &resolved, "compaction"); - let stream = open_with_retry(&gated, &request, &self.options.retry, |_, _, _| {}) + let stream = open_with_retry(&gated, &request, &self.options.retry, |_| {}) .await .map_err(|error| { gated diff --git a/crates/cyber-server/src/runtime/drain.rs b/crates/cyber-server/src/runtime/drain.rs index 1b2ebfdba..b67e364ad 100644 --- a/crates/cyber-server/src/runtime/drain.rs +++ b/crates/cyber-server/src/runtime/drain.rs @@ -6,8 +6,8 @@ use std::time::Duration; use cyber_llm::catalog::compute_cost; use cyber_llm::{ - ErrorKind, EventStream, FinishReason, LlmError, LlmEvent, Opaque, ToolCall, Usage, - open_with_retry, + ErrorKind, FinishReason, LlmError, LlmEvent, Opaque, RetryNotice, RetryingStream, ToolCall, + Usage, open_with_retry, }; use futures::future::join_all; use futures::{FutureExt, StreamExt}; @@ -701,6 +701,9 @@ struct Accum { blocks: u32, /// Whether this step already warned about dropped opaque state. dropped_warned: bool, + /// Reasoning items and complete calls, recorded when the stream ends: until then the + /// attempt may still be discarded and replayed (`provider-catalog` → Retry policy). + pending: Vec, calls: Vec, usage: Usage, finish: Option, @@ -929,22 +932,25 @@ impl Inner { /// Open a Turn's stream through the retry policy, announcing each retry for the /// Session; `None` when the Drain is cancelled first. - async fn open_turn( + async fn open_turn<'a>( &self, handle: &Handle, - adapter: &dyn cyber_llm::Adapter, + message_id: &str, + adapter: &'a dyn cyber_llm::Adapter, request: &cyber_llm::LlmRequest, cancel: &CancellationToken, - ) -> Option> { + ) -> Option, cyber_llm::LlmError>> { // Not the cache key: a subagent's is `:`. let session_id = handle.state.lock().await.info.id.clone(); - let bus = self.bus.clone(); - let announce = |attempt, delay: Duration, e: &cyber_llm::LlmError| { + let (bus, message_id) = (self.bus.clone(), message_id.to_string()); + let announce = move |retry: &RetryNotice<'_>| { bus.publish(LiveEvent::Retry { session_id: session_id.clone(), - attempt, - delay_ms: delay.as_millis() as u64, - error: e.to_string(), + message_id: message_id.clone(), + attempt: retry.attempt, + delay_ms: retry.delay.as_millis() as u64, + error: retry.error.to_string(), + discarded: retry.discarded, }); }; tokio::select! { @@ -969,7 +975,10 @@ impl Inner { let resolved = &prepared.model; let gated = super::budget::GatedAdapter::new(self, handle, resolved, "turn") .for_call(message_id.clone()); - let stream = match self.open_turn(handle, &gated, &request, cancel).await { + let stream = match self + .open_turn(handle, &message_id, &gated, &request, cancel) + .await + { None => { gated.flush().await?; return self @@ -1148,7 +1157,7 @@ impl Inner { handle: &Handle, message_id: &str, defs: &[ToolDef], - mut stream: EventStream, + mut stream: RetryingStream<'_>, cancel: &CancellationToken, resolved: &ResolvedModel, ) -> Result, RuntimeError> { @@ -1173,8 +1182,12 @@ impl Inner { return Ok(Err(end)); } Some(None) => return Ok(Ok(acc)), + Some(Some(Ok(LlmEvent::Discarded))) => { + self.discard_attempt(handle, message_id, &mut acc, resolved) + .await?; + } Some(Some(Err(e))) => { - // Output already exists, so the request is not replayed; the Turn is settled. + // The retry policy gave up on this output; the Turn is settled with it. self.bus.publish(LiveEvent::Error { session_id: session_id.clone(), kind: kind_name(e.kind).into(), @@ -1201,22 +1214,22 @@ impl Inner { .await?; return Ok(Err(end)); } - self.on_stream_event(handle, &session_id, message_id, defs, &mut acc, event) - .await? + self.on_stream_event(&session_id, message_id, defs, &mut acc, event); } } } } - async fn on_stream_event( + /// Publish a streamed event and add it to the step; nothing is durable before the stream + /// ends. + fn on_stream_event( &self, - handle: &Handle, session_id: &str, message_id: &str, defs: &[ToolDef], acc: &mut Accum, event_: LlmEvent, - ) -> Result<(), RuntimeError> { + ) { let live = |text: String, reasoning: bool| { let (session_id, message_id) = (session_id.to_string(), message_id.to_string()); if reasoning { @@ -1246,8 +1259,7 @@ impl Inner { self.bus.publish(live(text, true)); } LlmEvent::ReasoningItem { text, opaque } => { - self.close_reasoning(handle, message_id, acc, text, opaque) - .await?; + Self::close_reasoning(message_id, acc, text, opaque); } LlmEvent::ToolCallDelta { id, @@ -1255,7 +1267,7 @@ impl Inner { arguments, } => { if name.is_empty() || name == "computer" { - return Ok(()); + return; } self.bus.publish(LiveEvent::ToolInputDelta { session_id: session_id.into(), @@ -1265,8 +1277,8 @@ impl Inner { } LlmEvent::ToolCallDone(mut call) => { // Reasoning streamed before the call is recorded ahead of it. - if let Some(events) = acc.take_reasoning(message_id) { - self.commit(handle, vec![events]).await?; + if let Some(event) = acc.take_reasoning(message_id) { + acc.pending.push(event); } call.opaque = acc.stamped(call.opaque.take()); let retry_safety = @@ -1288,34 +1300,64 @@ impl Inner { opaque: call.opaque.clone(), retry_safety, }; - self.commit(handle, vec![event(TOOL_CALLED, &payload)]) - .await?; + acc.pending.push(event(TOOL_CALLED, &payload)); acc.calls.push(call); } LlmEvent::Usage(usage) => acc.usage.add(&usage), LlmEvent::Finish { reason } => acc.finish = Some(reason), + // `consume` handles it before any other event. + LlmEvent::Discarded => {} } - Ok(()) } - /// Record the reasoning block streamed so far, completed by an item or a signature. - async fn close_reasoning( + /// Drop what a failed attempt streamed before its request is replayed. Usage it reported + /// was spent: without a reservation, which bills each attempt itself, it is recorded as + /// a failed `turn` call so no token is lost. + async fn discard_attempt( &self, handle: &Handle, message_id: &str, acc: &mut Accum, - text: String, - opaque: Option, + resolved: &ResolvedModel, ) -> Result<(), RuntimeError> { + let spent = acc.discard(); + if spent == Usage::default() { + return Ok(()); + } + { + let mut state = handle.state.lock().await; + if super::reservations::reserved_call(&self.store, &state.info.id, message_id)? { + return Ok(()); + } + let record = AttributedUsage { + usage: AuxiliaryUsage { + provider: resolved.provider.clone(), + model: resolved.model.clone(), + purpose: "turn".into(), + call_id: Some(message_id.into()), + duration_ms: 0, + usage: spent, + cost: compute_cost(resolved.cost.as_ref(), &spent), + }, + origin: state.step_origin.clone(), + }; + let mut recorded = event(AUXILIARY_USAGE, &record); + recorded.data["failed"] = true.into(); + self.commit_locked(&mut state, vec![recorded])?; + } + self.observe_budget(handle).await + } + + /// Close the reasoning block streamed so far, completed by an item or a signature. + fn close_reasoning(message_id: &str, acc: &mut Accum, text: String, opaque: Option) { let block = acc.reasoning.get_or_insert_with(Reasoned::default); if !text.is_empty() { block.text = text; } block.opaque = opaque; if let Some(event) = acc.take_reasoning(message_id) { - self.commit(handle, vec![event]).await?; + acc.pending.push(event); } - Ok(()) } async fn end_step( @@ -1421,6 +1463,18 @@ impl Inner { message: &str, ) -> Result { let mut state = handle.state.lock().await; + let never_dispatched: Vec<_> = acc + .calls + .iter() + .map(|call| { + settled( + &call.id, + CallStatus::Interrupted, + INTERRUPTED, + Some("turn stopped before dispatch"), + ) + }) + .collect(); let mut events = content_events(message_id, &mut acc); let mut failed = event( STEP_FAILED, @@ -1433,20 +1487,7 @@ impl Inner { ); super::reservations::mark_for_call(&self.store, &state.info.id, message_id, &mut failed)?; events.push(failed); - events.extend( - state - .calls - .values() - .filter(|c| c.message_id == message_id && c.status == CallStatus::Called) - .map(|c| { - settled( - &c.call_id, - CallStatus::Interrupted, - INTERRUPTED, - Some("turn stopped before dispatch"), - ) - }), - ); + events.extend(never_dispatched); self.commit_locked(&mut state, events)?; Ok(TurnEnd::Stopped) } @@ -1989,6 +2030,7 @@ impl Accum { reasoning: None, blocks: 0, dropped_warned: false, + pending: Vec::new(), calls: Vec::new(), usage: Usage::default(), finish: None, @@ -2025,6 +2067,18 @@ impl Accum { Some(opaque) } + /// Forget the attempt streamed so far, keeping the producer; returns its reported usage. + fn discard(&mut self) -> Usage { + let spent = self.usage; + *self = Self { + provider: std::mem::take(&mut self.provider), + model: std::mem::take(&mut self.model), + dropped_warned: self.dropped_warned, + ..Self::empty() + }; + spent + } + /// The `session.reasoning.ended.1` of the streamed block, if it has any content. fn take_reasoning(&mut self, message_id: &str) -> Option { let block = self.reasoning.take()?; @@ -2044,8 +2098,11 @@ impl Accum { } } +/// The step's content in stream order: reasoning items and calls, then the open reasoning +/// block and the text. fn content_events(message_id: &str, acc: &mut Accum) -> Vec { - let mut events: Vec<_> = acc.take_reasoning(message_id).into_iter().collect(); + let mut events = std::mem::take(&mut acc.pending); + events.extend(acc.take_reasoning(message_id)); if !acc.text.is_empty() { events.push(event( TEXT_ENDED, diff --git a/crates/cyber-server/src/runtime/reservations/mod.rs b/crates/cyber-server/src/runtime/reservations/mod.rs index 2e92e3fab..64fb40c2c 100644 --- a/crates/cyber-server/src/runtime/reservations/mod.rs +++ b/crates/cyber-server/src/runtime/reservations/mod.rs @@ -53,6 +53,22 @@ pub(super) fn mark_for_call( Ok(()) } +/// Whether a reservation billed an attempt of `call`: its receipts already carry the usage. +pub(super) fn reserved_call( + store: &cyber_store::Store, + session: &str, + call: &str, +) -> Result { + let (session, call) = (session.to_string(), call.to_string()); + Ok(store.read(move |db| { + Ok(db.query_row( + "SELECT EXISTS(SELECT 1 FROM budget_reservation WHERE session_id=?1 AND call_id=?2)", + rusqlite::params![session, call], + |row| row.get::<_, bool>(0), + )?) + })?) +} + pub(super) fn error(error: StoreError) -> RuntimeError { if let StoreError::Projector { kind, reason } = &error { let value: Value = serde_json::from_str(reason).unwrap_or(Value::Null); diff --git a/crates/cyber-server/tests/runtime.rs b/crates/cyber-server/tests/runtime.rs index a6c77e85a..e813309c7 100644 --- a/crates/cyber-server/tests/runtime.rs +++ b/crates/cyber-server/tests/runtime.rs @@ -16,8 +16,9 @@ fn admit(text: &str, delivery: Delivery) -> Admission { Admission::text(text, delivery) } -/// A transport failure after a prior mutation and a newly streamed call must not -/// replay either Turn. Restart and explicit continuation retain both settlements. +/// A transport failure after a prior mutation and a newly streamed call is replayed until +/// the retry budget is spent, then settles; the dispatched mutation never runs again. +/// Restart and explicit continuation retain both settlements. #[tokio::test] async fn transport_failure_preserves_tool_outcomes_without_replaying_actions() { use cyber_llm::adapters::ScriptStep; @@ -42,7 +43,16 @@ async fn transport_failure_preserves_tool_outcomes_without_replaying_actions() { let h = Harness::new(Setup { scripts: vec![( "test/main", - vec![tools(&[("c1", "write", "{}")]), partial, text("resumed")], + // The original attempt and its 4 retries all break after the call. + vec![ + tools(&[("c1", "write", "{}")]), + partial.clone(), + partial.clone(), + partial.clone(), + partial.clone(), + partial, + text("resumed"), + ], )], ..Setup::default() }); @@ -55,8 +65,8 @@ async fn transport_failure_preserves_tool_outcomes_without_replaying_actions() { let state = h.state(&id).await; assert_eq!( h.models.requests("test/main").len(), - 2, - "no automatic request replay" + 6, + "replays stop at the retry budget" ); assert_eq!(h.tools.executed_names(), ["write"]); assert_eq!(state.calls["c1"].status, CallStatus::Ok); @@ -74,7 +84,7 @@ async fn transport_failure_preserves_tool_outcomes_without_replaying_actions() { restarted.wait_idle(&id).await; assert_eq!( h.models.requests("test/main").len(), - 2, + 6, "restart cannot replay a settled failure" ); assert_eq!(h.tools.executed_names(), ["write"]); @@ -85,8 +95,8 @@ async fn transport_failure_preserves_tool_outcomes_without_replaying_actions() { .unwrap(); restarted.wait_idle(&id).await; let requests = h.models.requests("test/main"); - assert_eq!(requests.len(), 3); - let continued = transcript(&requests[2]); + assert_eq!(requests.len(), 7); + let continued = transcript(&requests[6]); assert!( continued.contains("c1") && continued.contains("c2"), "{continued}" @@ -100,6 +110,177 @@ async fn transport_failure_preserves_tool_outcomes_without_replaying_actions() { restarted.shutdown().await; } +/// An attempt that streamed reasoning, text and a complete call, interrupted by `step`. +fn interrupted( + thought: &str, + text: &str, + step: cyber_llm::adapters::ScriptStep, +) -> Vec { + use cyber_llm::adapters::ScriptStep; + use cyber_llm::{LlmEvent, Opaque, OpaqueKind, ToolCall, Usage}; + vec![ + ScriptStep::Event(LlmEvent::ReasoningDelta { + text: thought.into(), + }), + ScriptStep::Event(LlmEvent::ReasoningItem { + text: String::new(), + opaque: Some(Opaque::new(OpaqueKind::Signature, "stale-sig")), + }), + ScriptStep::Event(LlmEvent::TextDelta { text: text.into() }), + ScriptStep::Event(LlmEvent::ToolCallDone(ToolCall { + id: "c1".into(), + name: "write".into(), + arguments: "{}".into(), + input: Some(serde_json::json!({})), + opaque: None, + })), + ScriptStep::Event(LlmEvent::Usage(Usage { + input: 50, + ..Usage::default() + })), + step, + ] +} + +/// `provider-catalog` → Retry policy, "Partial answer replaced by the replay": a stream +/// broken after reasoning, text and a complete call is discarded and replayed. History holds +/// only the replayed attempt, before and after a restart; the call runs once; the +/// discarded attempt's usage is recorded as a failed `turn` call, outside the step usage. +#[tokio::test] +async fn a_mid_stream_retry_records_only_the_replayed_attempt() { + use cyber_llm::LlmEvent; + use cyber_llm::adapters::ScriptStep; + let reset = ScriptStep::Error { + error: ErrorKind::Transport, + message: "connection reset".into(), + }; + let mut replayed = reasoning_then_text("fresh thought", "Done."); + replayed.retain(|s| !matches!(s, ScriptStep::Event(LlmEvent::Finish { .. }))); + replayed.extend(tools(&[("c1", "write", "{}")])); + let partial_answer = vec![ + ScriptStep::Event(LlmEvent::TextDelta { + text: "stale follow-up".into(), + }), + reset.clone(), + ]; + let h = Harness::new(Setup { + scripts: vec![( + "test/main", + vec![ + interrupted("stale thought", "stale text", reset), + replayed, + // The answer after the dispatched call is interrupted and replayed too; + // the call it follows is not run again. + partial_answer, + text("after"), + ], + )], + ..Setup::default() + }); + let mut live = h.runtime.subscribe(); + let id = h.session().await; + h.runtime + .admit(&id, admit("go", Delivery::Steer)) + .await + .unwrap(); + h.settle(&id).await; + + assert_eq!(h.models.requests("test/main").len(), 4); + assert_eq!(h.tools.executed_names(), ["write"], "the call runs once"); + let mut retries = Vec::new(); + while let Ok(e) = live.try_recv() { + if let LiveEvent::Retry { + message_id, + discarded, + .. + } = e + { + retries.push((message_id, discarded)); + } + } + let assistants = |state: &cyber_server::runtime::SessionState| -> Vec { + state + .entries + .iter() + .filter_map(|e| match e { + Entry::Assistant(a) => Some(a.clone()), + _ => None, + }) + .collect() + }; + let state = h.state(&id).await; + let answers = assistants(&state); + assert_eq!( + retries, + [(answers[0].id.clone(), true), (answers[1].id.clone(), true)] + ); + let check = |answers: &[cyber_server::runtime::AssistantEntry]| { + assert_eq!(answers.len(), 2); + assert_eq!(answers[0].text, "Done."); + assert_eq!(answers[0].calls, ["c1"]); + assert_eq!(answers[0].reasoning_items.len(), 1); + assert_eq!(answers[0].reasoning_items[0].text, "fresh thought"); + assert_eq!(answers[1].text, "after"); + assert!(answers.iter().all(|a| a.error.is_none())); + }; + check(&answers); + assert_eq!(state.calls["c1"].status, CallStatus::Ok); + let events = h.store.read_events(&id, -1, 500).unwrap().events; + let all = serde_json::to_string(&events.iter().map(|e| &e.data).collect::>()).unwrap(); + assert!( + !all.contains("stale"), + "nothing of a discarded attempt is recorded: {all}" + ); + assert_eq!( + events + .iter() + .filter(|e| e.kind == "session.tool.called.1") + .count(), + 1 + ); + let wasted: Vec<_> = events + .iter() + .filter(|e| e.kind == "usage.recorded.1") + .collect(); + assert_eq!( + wasted.len(), + 1, + "only the first discarded attempt reported usage" + ); + assert_eq!( + ( + &wasted[0].data["purpose"], + &wasted[0].data["failed"], + &wasted[0].data["call_id"] + ), + ( + &serde_json::json!("turn"), + &serde_json::json!(true), + &serde_json::json!(answers[0].id) + ) + ); + assert_eq!(wasted[0].data["usage"]["input"], 50); + let step = events + .iter() + .find(|e| e.kind == "session.step.ended.1") + .unwrap(); + assert_eq!( + step.data["usage"]["input"], 200, + "the step holds its own attempt" + ); + assert_eq!( + state.totals.usage.input, 350, + "wasted tokens count toward totals" + ); + let request = transcript(&h.models.requests("test/main")[3]); + assert!(!request.contains("stale"), "{request}"); + h.runtime.shutdown().await; + + let restarted = h.restart(); + check(&assistants(&restarted.state(&id).await.unwrap())); + restarted.shutdown().await; +} + #[tokio::test] async fn admitted_prompt_survives_restart_and_runs_on_wake() { let h = Harness::new(Setup { @@ -2435,11 +2616,12 @@ impl cyber_llm::Adapter for HeldStreamModel { } } -/// `tool-registry` → Eager execution during streaming: a complete tool call is durable -/// as soon as its input finishes streaming, while the provider stream is still open, and -/// its result is present before the next Turn. +/// `tool-registry` → Eager execution during streaming: while the stream may still be +/// replayed, a complete call is not durable (`provider-catalog` → Retry policy). It is +/// recorded when the stream completes, before it is dispatched, and its result is present +/// before the next Turn. #[tokio::test] -async fn complete_tool_call_is_durable_before_the_stream_finishes() { +async fn a_complete_tool_call_is_recorded_when_its_stream_completes() { use cyber_server::runtime::*; let h = Harness::new(Setup::default()); let release = Arc::new(tokio::sync::Notify::new()); @@ -2477,38 +2659,47 @@ async fn complete_tool_call_is_durable_before_the_stream_finishes() { .admit(&id, admit("read eagerly", Delivery::Queue)) .await .unwrap(); - let called = tokio::time::timeout(Duration::from_secs(10), async { - loop { - let events = h.store.read_events(&id, -1, 500).unwrap().events; - if let Some(event) = events - .into_iter() - .find(|e| e.kind == "session.tool.called.1") - { - break event; - } + tokio::time::timeout(Duration::from_secs(10), async { + while requests.lock().unwrap().is_empty() { tokio::time::sleep(Duration::from_millis(5)).await; } }) .await - .expect("the complete call was not recorded while the stream was open"); - assert_eq!(called.data["call_id"], "call_eager"); - // The stream is still held open: only one request, and the step is not finished. - assert_eq!(requests.lock().unwrap().len(), 1); + .expect("the stream did not open"); + // The call has streamed and the stream is held open: nothing of it is durable yet, also + // for a second store handle (as after a crash). + tokio::time::sleep(Duration::from_millis(100)).await; assert!(runtime.is_running(&id)); - // A second store handle (as after a crash) already sees the committed call. let reopened = open_store(&h.dir.path().join("cyber.db")); assert!( - reopened + !reopened .read_events(&id, -1, 500) .unwrap() .events .iter() - .any(|e| e.kind == "session.tool.called.1" && e.data["call_id"] == "call_eager") + .any(|e| e.kind == "session.tool.called.1") ); release.notify_one(); tokio::time::timeout(Duration::from_secs(30), runtime.wait_idle(&id)) .await .expect("drain did not settle"); + let kinds: Vec = h + .store + .read_events(&id, -1, 500) + .unwrap() + .events + .into_iter() + .map(|e| e.kind) + .filter(|k| k.starts_with("session.tool.") || k.starts_with("session.step.ended")) + .collect(); + assert_eq!( + kinds[..3], + [ + "session.tool.called.1", + "session.step.ended.1", + "session.tool.dispatched.1" + ] + ); let requests = requests.lock().unwrap().clone(); assert_eq!(requests.len(), 2); let next = transcript(&requests[1]); diff --git a/crates/cyber-tools/src/hook_prompt.rs b/crates/cyber-tools/src/hook_prompt.rs index d127acc7b..9c094809e 100644 --- a/crates/cyber-tools/src/hook_prompt.rs +++ b/crates/cyber-tools/src/hook_prompt.rs @@ -563,7 +563,8 @@ async fn infer( } LlmEvent::Finish { reason } => finish = Some(reason), LlmEvent::ToolCallDone(_) | LlmEvent::ToolCallDelta { .. } => tools = true, - LlmEvent::ReasoningItem { .. } => {} + // A bare adapter stream is never replayed. + LlmEvent::ReasoningItem { .. } | LlmEvent::Discarded => {} } if bytes > LIMIT { return Err("prompt hook response exceeds 1 MiB; completion is unverified".into()); diff --git a/crates/cyber-tools/src/mcp/sampler.rs b/crates/cyber-tools/src/mcp/sampler.rs index e71fa4cd6..5bfcf7d01 100644 --- a/crates/cyber-tools/src/mcp/sampler.rs +++ b/crates/cyber-tools/src/mcp/sampler.rs @@ -359,7 +359,8 @@ async fn infer( LlmEvent::ToolCallDone(_) | LlmEvent::ToolCallDelta { .. } => { return Err("Sampling provider attempted a tool call"); } - LlmEvent::ReasoningItem { .. } => {} + // A bare adapter stream is never replayed. + LlmEvent::ReasoningItem { .. } | LlmEvent::Discarded => {} } if bytes > super::LIMIT / 2 { return Err("Sampling response exceeds its byte allowance"); diff --git a/crates/cyber-tui/src/app.rs b/crates/cyber-tui/src/app.rs index ddc9ac74f..fd944340e 100644 --- a/crates/cyber-tui/src/app.rs +++ b/crates/cyber-tui/src/app.rs @@ -2275,6 +2275,13 @@ impl App { .push_str(data["text"].as_str().unwrap_or_default()); Vec::new() } + // `provider-catalog` → Retry policy: the request is replayed from the start. + "session.retry" if data["discarded"] == true => { + if let Some(id) = data["message_id"].as_str() { + self.streaming.remove(id); + } + Vec::new() + } "session.idle" => { self.session.running = false; self.alert(crate::sound::Alert::Done, "cyber: the session is idle"); diff --git a/crates/cyber-tui/src/tests.rs b/crates/cyber-tui/src/tests.rs index 43dd75310..e065effc9 100644 --- a/crates/cyber-tui/src/tests.rs +++ b/crates/cyber-tui/src/tests.rs @@ -313,6 +313,28 @@ fn streaming_text_shows_until_the_message_is_durable() { assert!(!app.session.running); } +/// `provider-catalog` → Retry policy: a retry that discarded a partial answer clears its +/// streamed text, so the replay is not shown after the old text. +#[test] +fn a_discarded_attempt_clears_its_streamed_text() { + let mut app = App::new(session(true), Vec::new(), "cyber"); + let delta = |text: &str| json!({ "session_id": "ses_1", "message_id": "m9", "text": text }); + app.on_event("session.text.delta", &delta("first half")); + let retry = |discarded: bool| { + json!({ "session_id": "ses_1", "message_id": "m9", "attempt": 1, "delay_ms": 900, + "error": "transport: connection reset", "discarded": discarded }) + }; + app.on_event("session.retry", &retry(false)); + assert!( + screen(&app).contains("first half"), + "a retry before output keeps it" + ); + assert!(app.on_event("session.retry", &retry(true)).is_empty()); + app.on_event("session.text.delta", &delta("Done.")); + let s = screen(&app); + assert!(s.contains("Done.") && !s.contains("first half"), "{s}"); +} + /// A refresh during a step brings the in-progress assistant message with no text yet; /// its streamed text must show once, inside that message, not again after it. #[test] diff --git a/docs/local-core-guide.md b/docs/local-core-guide.md index 64444a8c8..fc3fffea3 100644 --- a/docs/local-core-guide.md +++ b/docs/local-core-guide.md @@ -55,13 +55,15 @@ cargo run -p cyber-tools --example agent -- openai/gpt-6-luna path/to/repo "Fix ## Provider stream interruptions -Cyber retries retryable provider failures up to four times before assistant output starts. Once text, reasoning or tool input has arrived, a failure preserves the partial response and settles the turn without replaying its request. Completed tool outcomes remain recorded; calls that never dispatched are marked interrupted. Continuing the conversation uses that recorded history. +Cyber retries retryable provider failures (`RateLimit`, `ProviderInternal`, `Transport`) up to four times per provider request. A failure before any output simply reopens the request. A failure after partial text, reasoning, streamed tool input or complete tool calls also counts as one of those four retries. Cyber discards that partial answer and sends the same request again. Clients get `session.retry` with `discarded: true` and the step's `message_id`, and drop the text they showed. The TUI and the dashboard clear it, and `cyber exec --format stream-json` prints a `retry` line. This is safe because nothing in the answer has had an effect yet. Tool calls are dispatched only after the stream completes. Reasoning, text and calls are recorded only when the stream ends, so a discarded attempt leaves nothing in history, and none of it is sent to the model afterwards. If the provider reported usage for the discarded attempt, it is recorded as a failed `turn` call (`usage.recorded.1`). It counts toward totals and budgets, but not toward the step's own usage or the context meter. + +When the four retries are spent, or the failure cannot be retried, the final attempt's partial response is kept and the turn is settled as interrupted. Completed tool outcomes remain recorded, and calls that never dispatched are marked interrupted. Continuing the conversation uses that recorded history. A request is never sent again after protocol completion, or after one of its tool calls was dispatched. An HTTP body failure reports `provider response stream interrupted after N body bytes` followed by the credential-redacted transport error. This identifies where the failure happened; it does not identify whether the provider, network or client caused it. A valid protocol completion is final: Cyber retains its result and usage even if the HTTP tail later stalls or closes incorrectly. A stream that ends before protocol completion still fails. ### Stream idle timeout -A response stream that delivers no bytes for `providers.

.timeout.idle_ms` milliseconds (default 300 000, five minutes) fails with a `Transport` error `stream idle for s`. It is retried like any other transport failure when no output had arrived; after output, the partial response is kept as described above. `0` turns the timer off for that provider: +A response stream that delivers no bytes for `providers.

.timeout.idle_ms` milliseconds (default 300 000, five minutes) fails with a `Transport` error `stream idle for s`. It is retried like any other transport failure. If output had already arrived, the partial answer is discarded and the request is replayed, as described above. `0` turns the timer off for that provider: ```jsonc { "providers": { "ollama": { "timeout": { "idle_ms": 0 } } } } diff --git a/docs/measurements/provider-stream-reliability-follow-up.md b/docs/measurements/provider-stream-reliability-follow-up.md index 2dfd93ec6..64f7a2312 100644 --- a/docs/measurements/provider-stream-reliability-follow-up.md +++ b/docs/measurements/provider-stream-reliability-follow-up.md @@ -10,6 +10,8 @@ Before protocol completion, a body failure remains a transport error. Diagnostic Automatic retry behavior is unchanged: up to four retries before output, none after text, reasoning, streamed tool arguments or a completed call. There is no transparent replay of partially completed work. +Update (2026-10-29, change `retry-interrupted-streams`): this paragraph describes the behaviour measured here. A retryable failure after partial output is now discarded and the request is replayed, within the same four retries, as long as none of the answer's tool calls has been dispatched (see `provider-catalog` → Retry policy). The results below predate that change. + ## Local verification Six real HTTP fault tests cover completed output followed by broken framing, completed output with a held-open connection, failure before output followed by success, failure after partial output, clean HTTP EOF without protocol completion, and retry exhaustion. The partial-output case checks text, reasoning, incomplete tool arguments and completed calls independently. The initial broken-tail regression fails against the original decoder with the same generic error observed in the pilot; it passes with the fix. diff --git a/openspec/changes/improve-model-protocol-fidelity/specs/provider-catalog/spec.md b/openspec/changes/improve-model-protocol-fidelity/specs/provider-catalog/spec.md index 9a4273b5e..b8f805df7 100644 --- a/openspec/changes/improve-model-protocol-fidelity/specs/provider-catalog/spec.md +++ b/openspec/changes/improve-model-protocol-fidelity/specs/provider-catalog/spec.md @@ -66,11 +66,11 @@ The first `x-codex-turn-state` response header of a Turn SHALL be sent back on e - **THEN** both follow-up requests send `x-codex-turn-state: abc`, and the first request of the new Turn sends none ### Requirement: Stream idle timeout -(P1) Every streaming adapter SHALL fail a response stream that delivers no bytes for `provider.timeout.idle_ms` (default 300000; `0` turns it off) with a `Transport` error `stream idle for s`. Retry Policy SHALL then treat it like any other transport failure: retried when no output was received, otherwise handled as a mid-stream failure. +(P1) Every streaming adapter SHALL fail a response stream that delivers no bytes for `provider.timeout.idle_ms` (default 300000; `0` turns it off) with a `Transport` error `stream idle for s`. Retry Policy SHALL then treat it like any other transport failure: retried when no output was received, and otherwise discarded and replayed as a mid-stream failure. #### Scenario: Stalled stream - **WHEN** a provider sends headers and one event, then nothing for 300 seconds -- **THEN** the step fails with `Transport` `stream idle for 300s`, and the partial output prevents automatic replay +- **THEN** the attempt fails with `Transport` `stream idle for 300s`, and its partial output is discarded and the request is replayed under Retry policy ### Requirement: Provider-specific error codes (P1) The system SHALL map known provider error codes, from in-stream errors and HTTP bodies, onto the existing typed reasons: diff --git a/openspec/changes/retry-interrupted-streams/design.md b/openspec/changes/retry-interrupted-streams/design.md new file mode 100644 index 000000000..2797cb9b5 --- /dev/null +++ b/openspec/changes/retry-interrupted-streams/design.md @@ -0,0 +1,61 @@ +## Context + +Where we stand: +- `cyber-llm/src/retry.rs`: `open_with_retry` buffers each attempt until its first output event (`first_output`). A failure before that is retried. After it, the stream is handed to the caller, and any later error ends that stream. +- `cyber-server/src/runtime/drain.rs` (`consume`, `on_stream_event`) publishes live deltas and, while the stream is still open, commits `session.reasoning.ended.1` for every closed reasoning block and `session.tool.called.1` for every complete call. Text is committed at the end (`end_step`, or `stop_step` on failure). +- Tool calls are dispatched only in `execute_tools`, which runs after `end_step`, so after the stream has completed. Nothing in the Drain starts a call while a stream is open. `tool-registry` → "Eager execution during streaming" allows early starts (MAY), but none is implemented. +- Each attempt goes through `GatedAdapter::stream` (`reservations/dispatch.rs`). It checks the Session budget for `turn` and `compaction`, reserves the request when reserved budgets apply, and settles the reservation when that attempt's stream ends or fails (`reservations/stream.rs`). +- `TurnState` (`x-codex-turn-state`) is shared by every request of a Turn, retries included. It keeps the first value it receives. + +Codex CLI, by behaviour only: a disconnected stream re-sends the whole request and drops the partial answer, within its stream retry budget. + +## Decisions + +### D1. Replay inside the retrying stream +`open_with_retry` opens the first attempt as before, then returns a stream that can reopen itself. When a retryable failure interrupts an attempt with output, the stream yields `LlmEvent::Discarded`. It then waits the policy delay and yields the events of a new attempt of the same request. Retries before output (inside `open`) and after output share one counter, so the total stays at 4 retries. The callback receives a `RetryNotice { attempt, delay, error, discarded }`. When the budget runs out, or the error is not retryable, the error is returned after the final attempt's partial output. The caller then settles the Turn as interrupted, as today. + +Keeping the replay in `cyber-llm` means compaction, which uses `open_with_retry` and `collect`, gets it without its own code. Title, recap, side chat, evaluator, prompt hooks and MCP sampling call `Adapter::stream` directly and never retried. They do not change. + +`Discarded` is a variant of `LlmEvent` with `#[serde(skip)]`, so adapters and scripts cannot produce it. `collect` resets its output on it and keeps the summed usage. + +### D2. A completed but undispatched tool call does not block the replay +Decision: only a dispatched call (or one whose execution started) blocks replay. A call that is complete in the stream but not dispatched is discarded with the rest of the attempt. + +Why this is safe: a call has no effect until it is dispatched. In the Drain, dispatch (`execute_tools` → `dispatch`, which records `session.tool.dispatched.1` before running anything) happens only after `consume` has returned the completed stream. By then the retrying stream has ended and cannot replay. So no call of a replayed answer can have run. This is what makes discarding the call safe, and the spec states it as a rule: the Drain SHALL NOT start a call of an answer while its stream can still be replayed. If early starts are built later, starting a call records the answer so far and ends replay for that answer. `cyber-llm` cannot see dispatch, so the rule is enforced where dispatch happens, not by a flag inside the stream. + +Protocol completion (`Finish`) ends the stream, and the adapter does not wait for the HTTP tail (`completed_response_survives_broken_http_tail`). So a completed answer is never replayed. + +### D3. Nothing durable from a discarded attempt +If closed reasoning blocks and complete calls were committed while the stream is open, a discarded attempt would leave `session.reasoning.ended.1` and `session.tool.called.1` events in history. Every projection would then need to undo them: the session fold, the SQL projections, MCP serve, telemetry, exec and eval. Instead, the Drain keeps them in the step accumulator (`Accum::pending`, in stream order) and commits them in the same batch as the text: in `end_step` when the stream completes, or in `stop_step` when it fails for good. A discarded attempt resets the accumulator, so nothing of it is ever written. That covers reasoning items with opaque state, calls, text and the reasoning block index. + +Trade-offs: +- `tool-registry` → "Eager execution during streaming" required each complete call to be durable "as soon as its input finishes streaming". It now says: no later than stream completion, and before the call starts. Calls are still recorded before any dispatch. +- A crash while a stream is open now loses the step's streamed reasoning and calls as well as its text. Recovery records the open step as `interrupted`, as before. None of those calls had been dispatched, so nothing with an effect is lost. Before, recovery settled them as "recovered: never dispatched". +- Clients see `session.tool.called.1` when the stream ends, not when the call completes. The live `session.tool.input.delta` still shows calls while they stream. + +`stop_step` now settles the step's undispatched calls from the accumulator, because they are not in Session state yet. + +### D4. Usage of a discarded attempt +Decision: when a discarded attempt reported usage (Anthropic sends input tokens in `message_start`, and some gateways send usage early), the Drain records it at once as `usage.recorded.1`: purpose `turn`, `call_id` = the step's message ID, `failed: true`, the step's origin, and the price from the catalog. It counts toward Session totals, budgets and the usage ledger. It is not added to `session.step.ended.1`, whose usage drives the context meter: adding a second copy of the input tokens there would overstate context use and could trigger compaction early. + +When a reservation billed the attempt, the reservation's own receipt already records that attempt's usage (`usage.recorded.1`, `failed: true`). The Drain then records nothing, so no token is counted twice. Most interrupted streams report no usage, and then nothing is recorded. + +Not changed: a failed attempt before any output (its buffered usage is dropped, as today) and the last failed attempt of a step. Reserved budgets already bill both. + +### D5. Budgets and reservations +Each replay calls `GatedAdapter::stream` again. Main-turn and compaction attempts run `check_budget` again, so a hard cap reached by the discarded attempt stops the replay with the budget error. When reserved budgets apply, each attempt is reserved on its own, and the discarded attempt's reservation is settled (`flush`) before its error reaches the retrying stream. So there are never two open reservations for one step, and nothing escapes the cap. `session.step.ended.1` is still marked accounted for every reservation of the step (`mark_for_call`). + +### D6. Turn state and reasoning indices +The replay reuses the same `LlmRequest`, so its `Routing.turn_state` is the Turn's shared `TurnState`. A turn state that the discarded attempt received is kept and sent on the replay, as it is for retries before output. Each Turn has one value, the first one received. The reset of the accumulator also resets the reasoning block counter, so the recorded items of the replayed attempt are numbered from 0. + +### D7. Clients +- `session.retry` gains `message_id` and `discarded`. It is a live-only event: no OpenAPI schema or SDK type describes it. +- TUI: on `discarded`, remove the message's entry from `streaming` (`crates/cyber-tui/src/app.rs`). +- Web: on `discarded` for the live message, clear `liveText` (`web/src/model.ts`). +- `cyber exec`: prints only recorded content, so a discarded attempt never reaches stdout. Stream-json adds a `retry` line (additive, schema version 1). +- SDK `prompt()` reads only recorded events. No change. + +## Risks / Trade-offs + +- A provider that charges for the discarded attempt now charges for one more full request. The retry budget limits this, the hard caps are checked before each attempt, and the wasted usage is recorded where the provider reports it. +- When an answer is long, the user may see part of it, then see it cleared, then see it streamed again. The `session.retry` notice explains why. diff --git a/openspec/changes/retry-interrupted-streams/proposal.md b/openspec/changes/retry-interrupted-streams/proposal.md new file mode 100644 index 000000000..a3bb857f8 --- /dev/null +++ b/openspec/changes/retry-interrupted-streams/proposal.md @@ -0,0 +1,43 @@ +## Why + +A same-model pilot against Codex CLI lost a task to `transport: error decoding response body` in the middle of an answer ([pilot](../../../docs/measurements/gpt61-sol-chatgpt-pilot.md), [follow-up](../../../docs/measurements/provider-stream-reliability-follow-up.md)). `provider-catalog` → Retry policy only retries before any assistant output. Once the first text, reasoning or tool-input event arrives, a dropped connection, an idle timeout or an in-stream overload ends the Turn as interrupted, even though nothing of that answer has had any effect yet. Codex CLI handles this case by sending the whole request again and dropping the partial answer. We adopt the same behaviour, written from scratch. + +## What Changes + +- **Replay of an interrupted answer** (`provider-catalog`, MODIFIED "Retry policy"). + - A retryable failure (`Transport`, `ProviderInternal`, `RateLimit`, including the idle timeout and in-stream overload errors) after partial text, reasoning, streamed tool arguments or complete tool calls discards that attempt and sends the same request again. + - Retries before and after output share the existing budget of 4 retries, backoff and server-advised delays. + - Replay stops for good once a tool call of the answer has been dispatched and after protocol completion. + - `session.retry` gains `message_id` and `discarded`. Clients drop the partial answer of that message when `discarded` is true. + - A discarded attempt's reported usage is recorded as a failed `turn` call (`usage.recorded.1`). It is never folded into the step's usage, and never lost. +- **Durable content waits for the end of the stream** (`tool-registry`, MODIFIED "Eager execution during streaming"; `session-runtime`, MODIFIED "Eager tool execution"). Reasoning items and complete tool calls are recorded together with the text, when the stream completes or fails for good. Before that the attempt may still be discarded, and nothing durable may outlive it. Starting a call early still records it first, and ends replay for that answer. +- **`cyber exec` stream-json** (`exec-mode`, MODIFIED "Stream JSON event schema"). Adds a `retry` line. Exec only prints recorded content, so a discarded attempt never appears in its output. +- TUI and web dashboard drop the live text of a discarded attempt. The TypeScript SDK reads only recorded events and needs no change. + +## Non-goals + +- Resuming a broken stream from where it stopped (`previous_response_id`, WebSocket transport). That is part of the later native-transport change. +- Replaying an answer after one of its calls was dispatched. That stays a settled interruption, as before. +- Recording usage for the last failed attempt of an unreserved request, or for a failed attempt before any output. Their accounting does not change. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `provider-catalog`: "Retry policy". +- `tool-registry`: "Eager execution during streaming". +- `session-runtime`: "Eager tool execution". +- `exec-mode`: "Stream JSON event schema". + +The open change `improve-model-protocol-fidelity` adds "Stream idle timeout". Its "Stalled stream" scenario now describes a replay instead of a settled interruption. + +## Impact + +- `crates/cyber-llm`: `open_with_retry` returns a replaying stream (`RetryingStream`), the callback receives a `RetryNotice`, and `LlmEvent::Discarded` is added. `collect` drops a discarded attempt's output. +- `crates/cyber-server`: the Drain records step content when the stream ends, handles `Discarded` (reset, wasted usage), and publishes the new `session.retry` fields. Compaction uses the same path. +- Clients: TUI (`crates/cyber-tui`), `cyber exec` (`crates/cyber-cli`) and the web dashboard (`web/src/model.ts`). +- Docs: `docs/local-core-guide.md`, ROADMAP model-fidelity entry, a dated note in the reliability follow-up. diff --git a/openspec/changes/retry-interrupted-streams/specs/exec-mode/spec.md b/openspec/changes/retry-interrupted-streams/specs/exec-mode/spec.md new file mode 100644 index 000000000..7729ce2dd --- /dev/null +++ b/openspec/changes/retry-interrupted-streams/specs/exec-mode/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Stream JSON event schema +(P0) In `stream-json` mode, each line SHALL be an object with `type`, `ts` (epoch ms) and `session_id`. The types are: + +| type | Payload | When | +|---|---|---| +| `system` (`subtype: "init"`) | model, agent, mode, cwd, tool names, MCP server statuses | first | +| `assistant_text` | message ID, text | each finished text part | +| `reasoning` | reasoning text | finished reasoning parts, only with `--thinking` | +| `tool_use` | call ID, tool, input | — | +| `tool_result` | call ID, status, output (truncated to 2,000 chars) | — | +| `retry` | message ID, attempt, delay_ms, error, discarded | each provider retry | +| `permission_denied` | — | each denial | +| `goal_verdict` | — | goal-driven runs | +| `workflow_progress` | — | workflow runs | +| `error` | — | — | +| `result` | same shape as the json-mode result | last | + +The schema SHALL be versioned by `schema_version` in the `init` event (starting at `1`), and changes SHALL be additive within a major version. Only recorded content SHALL produce `assistant_text`, `reasoning` and `tool_use` lines, so an attempt the retry policy discarded never appears. + +#### Scenario: Event order +- **WHEN** a stream-json run makes one bash call and finishes +- **THEN** stdout lines are, in order: `system`, `tool_use`, `tool_result`, `assistant_text`, `result` + +#### Scenario: Answer replayed after a dropped connection +- **WHEN** the provider connection drops after part of the answer and the replayed request completes +- **THEN** stdout has one `retry` line with `discarded: true`, then a single `assistant_text` holding only the replayed answer diff --git a/openspec/changes/retry-interrupted-streams/specs/provider-catalog/spec.md b/openspec/changes/retry-interrupted-streams/specs/provider-catalog/spec.md new file mode 100644 index 000000000..81ea44b9c --- /dev/null +++ b/openspec/changes/retry-interrupted-streams/specs/provider-catalog/spec.md @@ -0,0 +1,48 @@ +## MODIFIED Requirements + +### Requirement: Retry policy +(P0) The system SHALL retry `RateLimit`, `ProviderInternal`, and `Transport` failures up to 4 times, waiting `retry-after-ms` or `retry-after` when present (capped at 60 seconds) and otherwise using jittered exponential backoff starting at 1 second (factor 2, cap 30 seconds), and SHALL never retry `ContextOverflow`, `Authentication`, `ContentPolicy`, or `InvalidRequest`. Each retry SHALL publish a live `session.retry` event with the step's `message_id`, the attempt number, the delay, the error and `discarded`. Retries before and after assistant output SHALL share these 4 retries. + +A retryable failure after partial output SHALL discard that attempt and send the same request again, with `discarded: true`. Partial output covers text, reasoning, streamed tool-call arguments and complete tool calls of the answer. This replay SHALL apply only before protocol completion and before any tool call of the answer has been dispatched. The idle timeout and in-stream overload errors are such failures. The runtime SHALL NOT dispatch a tool call of an answer while its stream can still be replayed. Calls SHALL dispatch after the stream completes, and starting a call early (`tool-registry` → Eager execution during streaming) SHALL end replay for that answer. + +No event of a discarded attempt SHALL be recorded as part of the answer. Reasoning items, text and tool calls SHALL be recorded only when the stream completes or fails for good. A restart, the next request and every client SHALL see only the attempt that ended the step, with reasoning items numbered from 0. + +Usage that a discarded attempt reported SHALL be recorded as a failed `usage.recorded.1`, with purpose `turn` and the step's message ID as `call_id`, unless a budget reservation already billed that attempt. It SHALL count toward Session totals and budgets, but not toward the step's own usage or the context meter. + +Each attempt SHALL pass the same budget checks and reservation admission as a new request, and no attempt SHALL hold a reservation while another attempt of the same step does. The Turn's `x-codex-turn-state` value SHALL be kept across the replay. + +Clients SHALL drop the live text, reasoning and tool input of `message_id` when they receive `discarded: true`. + +When retries are exhausted, or a failure after partial output is not retryable, the Turn SHALL be settled as interrupted with the final attempt's partial output. Undispatched calls SHALL be settled as interrupted, and tool outcomes SHALL be reconciled before a new Turn. The system SHALL NOT replay a request after any of its tool calls was dispatched or after protocol completion. + +#### Scenario: Retry-after honored +- **WHEN** a 429 response carries `retry-after: 7` +- **THEN** the next attempt starts after 7 seconds + +#### Scenario: Overflow not retried +- **WHEN** the error reason is `ContextOverflow` +- **THEN** no retry is attempted and the compaction capability decides what happens next + +#### Scenario: HTTP body fails after protocol completion +- **WHEN** a provider stream has emitted its protocol completion, including final usage +- **AND** the HTTP body stalls or closes incorrectly afterward +- **THEN** the completed response remains successful without waiting for the HTTP tail or replaying the request + +#### Scenario: Body interruption before protocol completion +- **WHEN** an HTTP response body fails before protocol completion +- **THEN** the transport diagnostic identifies the response-stream stage and received body-byte count without adding response content or credentials +- **AND** failures before assistant output are retried within the bounded retry policy +- **AND** after partial text, reasoning, tool arguments or completed but undispatched tool calls, the attempt is discarded and the same request is replayed within the same budget +- **AND** once the budget is spent, the final attempt's partial output is kept, its undispatched calls are settled as interrupted, and completed tool outcomes stay settled across restart and explicit continuation + +#### Scenario: Partial answer replaced by the replay +- **WHEN** a Turn streams "Here is the first half" and a completed `write` call, and then the connection resets +- **AND** the replayed request answers "Done." and the same `write` call +- **THEN** clients receive `session.retry` with `discarded: true` and drop the first half +- **AND** the recorded step holds only "Done." and one `write` call, before and after a restart +- **AND** `write` runs once + +#### Scenario: Usage of a discarded attempt +- **WHEN** an attempt without a budget reservation reports 1,200 input tokens and then fails mid-answer with `ProviderInternal` `overloaded_error` +- **THEN** a failed `usage.recorded.1` with purpose `turn` records the 1,200 input tokens and their cost +- **AND** the step's `session.step.ended.1` usage and the context meter hold only the replayed attempt's usage diff --git a/openspec/changes/retry-interrupted-streams/specs/session-runtime/spec.md b/openspec/changes/retry-interrupted-streams/specs/session-runtime/spec.md new file mode 100644 index 000000000..fac156f12 --- /dev/null +++ b/openspec/changes/retry-interrupted-streams/specs/session-runtime/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Eager tool execution +(P1) The system SHALL start each complete tool call while the provider stream continues, when the tool is marked `concurrency_safe` and its permission evaluates to `allow`. Before starting it, the system SHALL durably record the answer streamed so far, which ends automatic replay of that answer (`provider-catalog` → Retry policy). It SHALL await all started calls after the stream closes, and run non-safe calls sequentially in emitted order. + +#### Scenario: Parallel reads during streaming +- **WHEN** the model emits three `read` calls followed by more text +- **THEN** all three reads start before the stream finishes + +#### Scenario: Started read ends replay +- **WHEN** a `read` call has started during streaming and the stream then fails with a `Transport` error +- **THEN** the request is not replayed and the Turn is settled as interrupted with the recorded `read` outcome diff --git a/openspec/changes/retry-interrupted-streams/specs/tool-registry/spec.md b/openspec/changes/retry-interrupted-streams/specs/tool-registry/spec.md new file mode 100644 index 000000000..928fd4936 --- /dev/null +++ b/openspec/changes/retry-interrupted-streams/specs/tool-registry/spec.md @@ -0,0 +1,13 @@ +## MODIFIED Requirements + +### Requirement: Eager execution during streaming +(P1) The system SHALL durably record each complete tool call no later than when the provider stream completes, and always before the call starts. It MAY start `concurrency_safe` calls before the provider stream ends. Starting one SHALL first record the answer streamed so far, and SHALL end automatic replay of that answer (`provider-catalog` → Retry policy). It SHALL await all started calls before the next Turn. A complete call of an attempt that is discarded before any call started SHALL NOT be recorded. + +#### Scenario: Read starts before stream ends +- **WHEN** the model streams a complete `read` call followed by more text +- **THEN** the read may begin immediately and its result is present before the next Turn + +#### Scenario: Complete call of a discarded attempt +- **WHEN** the model streams a complete `write` call, and the body fails with a `Transport` error before protocol completion +- **THEN** no `session.tool.called.1` is recorded for that attempt +- **AND** the replayed attempt's `write` call is recorded once, before it is dispatched diff --git a/openspec/changes/retry-interrupted-streams/tasks.md b/openspec/changes/retry-interrupted-streams/tasks.md new file mode 100644 index 000000000..d88015cdb --- /dev/null +++ b/openspec/changes/retry-interrupted-streams/tasks.md @@ -0,0 +1,36 @@ +## 1. Replaying stream (`cyber-llm`) + +- [x] 1.1 `open_with_retry` returns a `RetryingStream`. A retryable failure after output yields `LlmEvent::Discarded`, waits, and replays the same request. Retries before and after output share one budget. The callback receives a `RetryNotice` with `discarded`. + - `LlmEvent::Discarded` is `#[serde(skip)]` and is not output. `collect` resets its output on it and keeps the summed usage. + - Bare-adapter consumers (evaluator, prompt hook, MCP sampling) match the variant and never receive it. +- [x] 1.2 Real HTTP fault tests (`tests/transport_faults.rs`), covering: + - partial text, reasoning, streamed arguments, and a complete call that is replayed and then succeeds; + - one shared budget before and after output, with the final partial output returned; + - a stalled stream replayed after the idle window; + - no replay after protocol completion. + + Also in `tests/adapters.rs`: an in-stream Anthropic overload is replayed, and a discarded attempt's usage is kept by `collect`. + +## 2. Runtime (`cyber-server`) + +- [x] 2.1 The Drain keeps closed reasoning blocks and complete calls in `Accum::pending`, and commits them with the text in `end_step`/`stop_step`. `stop_step` settles undispatched calls from the accumulator. +- [x] 2.2 `Discarded` resets the accumulator. Usage reported by the discarded attempt becomes a failed `usage.recorded.1` (purpose `turn`, origin of the step), unless a reservation billed it (`reservations::reserved_call`). +- [x] 2.3 `session.retry` carries `message_id` and `discarded`. +- [x] 2.4 Tests: + - history after a replay holds only the replayed attempt, before and after a restart; + - the dispatched call runs once; + - wasted usage is outside the step usage; + - retry exhaustion still settles as interrupted (updated `transport_failure_preserves_tool_outcomes_without_replaying_actions`); + - a complete call is recorded when its stream completes, before dispatch (replaces the eager durability test). + +## 3. Clients + +- [x] 3.1 TUI drops the streamed text of a discarded attempt (`app.rs`, test in `tests.rs`). +- [x] 3.2 Web dashboard clears `liveText` for the discarded live message (`model.ts`, `model.test.ts`). +- [x] 3.3 `cyber exec` stream-json `retry` line, plus a verbose text note. Recorded content only, so the result is the replayed answer. +- [x] 3.4 SDK: `prompt()` reads only recorded events. No change. + +## 4. Docs and specs + +- [x] 4.1 `docs/local-core-guide.md` retry paragraph, ROADMAP model-fidelity note, dated note in `docs/measurements/provider-stream-reliability-follow-up.md`. +- [x] 4.2 The "Stalled stream" scenario of `improve-model-protocol-fidelity` now describes the replay. diff --git a/web/dist/app.js b/web/dist/app.js index 37bf1ceb7..5dba5b693 100644 --- a/web/dist/app.js +++ b/web/dist/app.js @@ -1,6 +1,6 @@ var P=class{buffer="";pendingCr=!1;event="";data=[];id;push(e){let t=e;this.pendingCr&&t.startsWith(` `)&&(t=t.slice(1)),this.pendingCr=t.endsWith("\r"),this.buffer+=t;let s=this.buffer.split(/\r\n|\r|\n/);this.buffer=s.pop()??"";let i=[];for(let n of s){let o=this.line(n);o&&i.push(o)}return i}line(e){if(e==="")return this.dispatch();if(e.startsWith(":"))return;let t=e.indexOf(":"),s=t===-1?e:e.slice(0,t),i=t===-1?"":e.slice(t+1);i.startsWith(" ")&&(i=i.slice(1)),s==="data"?this.data.push(i):s==="event"?this.event=i:s==="id"&&!i.includes("\0")&&(this.id=i)}dispatch(){let e=this.data.length>0?{event:this.event||"message",data:this.data.join(` -`),id:this.id}:void 0;return this.event="",this.data=[],e}};var l=class extends Error{constructor(t,s=0){super(t==="auth"?"Sign in again to continue.":t==="network"?"Connection interrupted. The result may be unknown.":t==="changed"?"This item changed or was already answered.":t==="missing"?"This server does not offer this view.":"Server policy refused this action.");this.kind=t;this.status=s}kind;status},k=()=>crypto.randomUUID(),M=class{constructor(e,t,s=(i,n)=>globalThis.fetch(i,n)){this.unauthorized=t;this.fetcher=s;this.origin=new URL(e).origin}unauthorized;fetcher;#e;#t=new Set;#s=0;origin;password(e){this.clear();let t=new TextEncoder().encode(`cyber:${e}`);this.#e=`Basic ${btoa(Array.from(t,s=>String.fromCharCode(s)).join(""))}`}clear(){this.#s++,this.#e=void 0;for(let e of this.#t)e.abort();this.#t.clear()}async request(e,t={}){let s=new AbortController;t.finishOnLogout||this.#t.add(s);let i=this.#s,n=setTimeout(()=>s.abort(),t.finishOnLogout?3e3:3e4);try{let o=await this.fetcher(this.url(e),this.init(t,s.signal));if(this.status(o.status,i),o.status===204)return;let c=await re(o,16*1024*1024);try{return JSON.parse(c)}catch{throw new l("network")}}catch(o){throw o instanceof l?o:new l("network")}finally{clearTimeout(n),this.#t.delete(s)}}async bytes(e,t={}){if(t.method&&t.method!=="GET")throw new l("refused");let s=new AbortController;this.#t.add(s);let i=this.#s,n=setTimeout(()=>s.abort(),3e4);try{let o=await this.fetcher(this.url(e),this.init({...t,method:"GET",finishOnLogout:!1},s.signal));if(this.status(o.status,i),o.headers.get("content-type")?.split(";")[0]!=="image/png"||!o.body||Number(o.headers.get("content-length"))>8*1024*1024)throw new l("refused");let c=o.body.getReader(),d=[],h=0;try{for(;;){let b=await c.read();if(b.done)break;if(h+=b.value.length,h>8*1024*1024)throw new l("refused");d.push(b.value)}}finally{await c.cancel().catch(()=>{})}if(i!==this.#s)throw new l("auth",401);let u=new Uint8Array(h),p=0;for(let b of d)u.set(b,p),p+=b.length;return u}catch(o){throw i!==this.#s?new l("auth",401):o instanceof l?o:new l("network")}finally{clearTimeout(n),this.#t.delete(s)}}async*stream(e,t){let s=new AbortController;this.#t.add(s);let i=this.#s,n;try{let o=this.init({directory:t.directory,signal:t.signal},s.signal);o.headers.set("accept","text/event-stream");let c=await this.fetcher(this.url(e),o);if(this.status(c.status,i),!c.headers.get("content-type")?.includes("text/event-stream")||!c.body)throw new l("network");t.opened(),n=c.body.getReader();let d=new TextDecoder,h=new P,u=0;for(;;){let p=await n.read();if(p.done)throw new l("network");if(u+=p.value.byteLength,u>4*1024*1024)throw new l("network");let b=h.push(d.decode(p.value,{stream:!0}));b.length&&(u=0);for(let v of b)try{let L=JSON.parse(v.data);typeof L.type=="string"&&typeof L.id=="string"&&(yield L)}catch{throw new l("network")}}}catch(o){throw o instanceof l?o:new l("network")}finally{await n?.cancel().catch(()=>{}),this.#t.delete(s)}}url(e){if(!e.startsWith("/")||e.startsWith("//")||e.includes("#"))throw new l("refused");let t=new URL(`/api/v1${e}`,this.origin);if(t.origin!==this.origin||!t.pathname.startsWith("/api/v1/"))throw new l("refused");return t.toString()}init(e,t){if(!this.#e)throw new l("auth",401);let s=new Headers({authorization:this.#e,"x-cyber-client":"web",accept:"application/json"});e.directory!==void 0&&s.set("x-cyber-directory",encodeURIComponent(e.directory));let i=e.method??"GET";return i!=="GET"&&s.set("idempotency-key",e.key??k()),e.body!==void 0&&s.set("content-type","application/json"),{method:i,headers:s,...e.finishOnLogout&&{keepalive:!0},credentials:"omit",redirect:"error",cache:"no-store",signal:e.signal?AbortSignal.any([t,e.signal]):t,...e.body!==void 0&&{body:JSON.stringify(e.body)}}}status(e,t){if(e===401)throw t===this.#s&&(this.clear(),this.unauthorized()),new l("auth",e);if(e===403)throw new l("refused",e);if(e===404||e===405||e===501)throw new l("missing",e);if(e===409)throw new l("changed",e);if(e<200||e>=300)throw new l(e>=500?"network":"refused",e)}};async function re(r,e){if(!r.body)return"";let t=r.body.getReader(),s=new TextDecoder,i="",n=0;try{for(;;){let o=await t.read();if(o.done)return i+s.decode();if(n+=o.value.byteLength,n>e)throw new l("network");i+=s.decode(o.value,{stream:!0})}}finally{await t.cancel().catch(()=>{})}}var z='{"contributions":[{"label":"api","commit":"FULL_LOCAL_COMMIT_HASH"}],"checks":[{"argv":["cargo","test"],"timeout_seconds":300}],"reviewer_model":"verify"}',A=class{constructor(e,t,s){this.api=e;this.context=t;this.changed=s}api;context;changed;rows=[];selected;busy=!1;reviewed=!1;notice="";manifest=z;publication={remote:"origin",repository:"",title:"",body:"",acknowledge_review_unavailable:!1};#e=0;#t;get uncertain(){return!!this.#t&&!this.busy}reset(){this.#e++,this.#t=void 0,this.manifest=z,this.rows=[],this.selected=void 0,this.reviewed=!1,this.notice="",this.busy=!1,this.publication={remote:"origin",repository:"",title:"",body:"",acknowledge_review_unavailable:!1}}async load(e){let t=this.#e,s=this.context();if(!s.enabled)return!1;try{let i=e?`/workflows/integrations/${encodeURIComponent(e)}/report`:"/workflows/integrations",n=await this.api.request(i,{directory:s.directory});return t!==this.#e||s.directory!==this.context().directory?!1:(Array.isArray(n.data)?this.rows=n.data:(this.selected=n.data,this.reviewed=!1),this.changed(),!0)}catch(i){return t===this.#e&&(this.notice=String(i)),this.changed(),!1}}async submit(){let e=this.context().session;if(e)try{await this.action("",{session_id:e,manifest:JSON.parse(this.manifest)})}catch(t){this.notice=String(t),this.changed()}}async execute(e){let t=this.selected;if(!t||e==="publish"&&!this.reviewed)return;let s=e==="publish"?{...this.publication,operation_id:k(),expected_revision:t.revision}:{expected_revision:t.revision};await this.action(`/${encodeURIComponent(t.id)}/${e}`,s)}get pendingTarget(){let e=this.#t;if(!e)return"";let t=e.body;return e.suffix?`${e.suffix.split("/")[1]} \xB7 ${e.suffix.split("/")[2]} \xB7 revision ${t.expected_revision}`:`Submission for owner Session ${t.session_id}`}async discardRetry(){let e=this.#t;!e||this.busy||await this.load()&&this.#t===e&&(this.#t=void 0,this.notice="Queue refreshed and local retry cleared. This does not cancel an original action that is still running.",this.changed())}async retry(){this.#t&&await this.send(this.#t)}async action(e,t){this.#t||await this.send({suffix:e,body:structuredClone(t),key:k(),directory:this.context().directory})}async send(e){let t=this.context(),s=this.#e;if(!(!t.enabled||this.busy||e.directory!==t.directory)){this.#t=e,this.busy=!0,this.notice="Awaiting server acknowledgement\u2026",this.changed();try{let i=await this.api.request(`/workflows/integrations${e.suffix}`,{method:"POST",directory:e.directory,key:e.key,body:e.body});if(s!==this.#e)return;this.#t=void 0,this.selected=i.data,this.reviewed=!1,this.notice="Server acknowledged. Refresh for current host evidence.",await this.load()}catch(i){if(s!==this.#e)return;i instanceof l&&["refused","missing"].includes(i.kind)&&(this.#t=void 0),this.notice=`${String(i)} Refresh current evidence. An uncertain retry keeps the original action, revision and request key.`}finally{s===this.#e&&(this.busy=!1,this.changed())}}}};var I=class{constructor(e,t,s,i=Date.now){this.api=e;this.context=t;this.changed=s;this.now=i}api;context;changed;now;report;preview;latestFrame;imageURL;frameBusy=!1;notice="";busy=!1;uncertain=!1;reviewed=!1;acknowledgeUnknown=!1;selector="";display=!1;ttl=300;expanded=!1;#e=0;#t=!1;#s;reset(){this.clearImage(),this.latestFrame=void 0,this.frameBusy=!1,this.#e++,clearTimeout(this.#s),this.#t=!1,this.busy=!1,this.preview=void 0,this.reviewed=!1,this.acknowledgeUnknown=!1,this.uncertain=!!this.report?.grant,this.changed()}clear(){this.reset(),this.report=void 0,this.uncertain=!1,this.notice=""}get active(){let e=this.report?.grant;return e&&(e.expires_at>this.now()||this.uncertain)?e:void 0}get canReview(){let e=this.context();return!!e?.session&&e.actionable&&!!this.report?.enabled&&this.report.health.supported&&!this.busy}get canAllow(){let e=this.context();return!!this.report?.enabled&&!!this.report.health.supported&&!!e?.actionable&&!!this.preview&&this.preview.session_id===e.session&&this.preview.expires_at>this.now()&&this.reviewed&&(!this.preview.unknown_operations.length||this.acknowledgeUnknown)&&!this.busy&&!this.uncertain}async refresh(){let e=this.context();if(!e||this.#t)return;let t=this.#e;this.#t=!0;try{let s=await this.api.request("/tools/computer/health",{directory:e.directory,signal:e.signal});t===this.#e&&(this.report=s.data,this.uncertain=!1,await this.refreshFrame(e,t))}catch{t===this.#e&&(this.uncertain=!!this.report?.grant)}finally{t===this.#e&&(this.#t=!1,this.changed(),clearTimeout(this.#s),this.#s=setTimeout(()=>{this.refresh()},2e3))}}async refreshFrame(e,t){if(!e.session){this.clearImage(),this.latestFrame=void 0;return}try{let s=await this.api.request(`/sessions/${encodeURIComponent(e.session)}/computer`,{directory:e.directory,signal:e.signal});if(t!==this.#e)return;let i=s.data.latest_frame??void 0;i?.id!==this.latestFrame?.id&&this.clearImage(),this.latestFrame=i}catch{t===this.#e&&(this.clearImage(),this.latestFrame=void 0)}}clearImage(){this.imageURL&&URL.revokeObjectURL(this.imageURL),this.imageURL=void 0}async previewFrame(){let e=this.context(),t=this.latestFrame;if(!e?.actionable||!t||t.session_id!==e.session||this.frameBusy)return;let s=this.#e;this.frameBusy=!0,this.changed();try{let i=await this.api.bytes(`/sessions/${encodeURIComponent(e.session)}/computer/frames/${encodeURIComponent(t.id)}`,{directory:e.directory,signal:e.signal});if(s!==this.#e||this.latestFrame?.id!==t.id)return;this.clearImage(),this.imageURL=URL.createObjectURL(new Blob([new Uint8Array(i).buffer],{type:"image/png"}))}catch{s===this.#e&&(this.notice="Private capture is unavailable or expired. Request a fresh authorized capture.")}finally{s===this.#e&&(this.frameBusy=!1,this.changed())}}async setup(){await this.mutate("/tools/computer/setup",{confirm:!0})}async reviewTarget(){let e=this.context();if(!e?.actionable||this.busy)return;let t=this.selector.trim(),s=this.display?Number(t):null;if(!t||this.display&&(!Number.isInteger(s)||s<0||s>4294967295)){this.notice="Enter an application bundle ID or a native display ID (0 selects the main display).",this.changed();return}this.preview=void 0,this.reviewed=!1,this.acknowledgeUnknown=!1,await this.mutate(`/sessions/${encodeURIComponent(e.session)}/computer/preview`,{app:this.display?null:t,display:s},!0)}async allow(){let e=this.context(),t=this.preview;!e||!t||!this.canAllow||(await this.mutate(`/sessions/${encodeURIComponent(e.session)}/computer/allow`,{preview_id:t.id,confirm:!0,ttl_seconds:this.ttl,acknowledge_unknown:this.acknowledgeUnknown}),this.preview=void 0,this.reviewed=!1)}async stop(){let e=this.context();if(!e)return;let t=++this.#e;this.#t=!1,this.busy=!1,clearTimeout(this.#s),this.preview=void 0,this.reviewed=!1;try{if(await this.api.request("/tools/computer/stop",{method:"POST",directory:e.directory,finishOnLogout:!0}),t!==this.#e)return;this.report=this.report?{...this.report,grant:null}:void 0,this.uncertain=!1,this.notice="Native computer control stopped."}catch{if(t!==this.#e)return;this.uncertain=!0,this.notice="Stop could not be confirmed. Use cyber computer stop on the host; do not assume input stopped."}this.changed(),this.refresh()}async mutate(e,t,s=!1){let i=this.context();if(!i||this.busy||e!=="/tools/computer/setup"&&!i.actionable)return;let n=this.#e;this.busy=!0,this.notice="",this.changed();try{let o=await this.api.request(e,{method:"POST",body:t,directory:i.directory,signal:i.signal});if(n!==this.#e)return;s&&(this.preview=o.data),this.notice=s?"Review this exact native target; this review expires in 30 seconds.":"Native owner action completed."}catch{n===this.#e&&(this.preview=void 0,this.uncertain=!0,this.notice="Native action was not confirmed. Refresh status and review the target again; input is never automatically replayed.")}finally{n===this.#e&&(this.busy=!1,this.changed(),await this.refresh())}}};var N=encodeURIComponent,oe=(r,e)=>JSON.stringify([r.message_id,r.digest,r.parts,r.source,r.admitted_seq,e.from,e.to,e.session_id,e.origin_kind,e.collaboration,e.sha256]),H=class{constructor(e,t,s){this.api=e;this.context=t;this.changed=s}api;context;changed;rows=[];messages=new Map;reviewed=new Map;cursor;notice="";loading=!1;decision;#e=0;#t=!1;#s=!1;#i;reset(){this.#e++,this.#t=!1,this.#s=!1,this.rows=[],this.messages.clear(),this.reviewed.clear(),this.cursor=void 0,this.#i=void 0,this.notice="",this.loading=!1,this.decision=void 0}get held(){let e=this.context()?.session;return this.rows.flatMap(t=>{let s=this.messages.get(t.message_id);return t.status!=="held"||!s||s.direction!=="in"||s.session_id!==e||s.outcome!=="held"?[]:s.origin_kind==="collaboration"&&!s.collaboration?[]:[{row:t,message:s,identity:oe(t,s)}]})}get pendingMetadata(){return this.rows.filter(e=>e.status==="held").length-this.held.length}review(e,t){this.current(e.row.message_id,e.identity)&&(t?this.reviewed.set(e.row.message_id,e.identity):this.reviewed.delete(e.row.message_id),this.changed())}current(e,t){return this.held.find(s=>s.row.message_id===e&&s.identity===t)}matches(e,t){let s=this.context();return t===this.#e&&s?.session===e.session&&s.directory===e.directory}async refresh(e=!1){let t=this.context();if(!t||this.#t||e&&!this.cursor)return;let s=this.#e,i=e?this.cursor:void 0,n=new URLSearchParams({direction:"in",limit:"200"});i&&n.set("cursor",i),this.#t=!0,this.loading=!0,this.changed();try{let o={directory:t.directory,signal:t.signal},[c,d]=await Promise.all([this.api.request(`/sessions/${N(t.session)}/inbox`,o),this.api.request(`/sessions/${N(t.session)}/messaging?${n}`,o)]);if(!this.matches(t,s))return;this.merge(c.data,d,e)}catch(o){if(!this.matches(t,s))return;this.rows=[],this.messages.clear(),this.reviewed.clear(),this.#s=!0,this.notice=o instanceof l?o.message:"The message inbox could not be refreshed."}finally{this.matches(t,s)&&(this.#t=!1,this.loading=!1,this.changed())}}merge(e,t,s){this.#s&&(this.notice="",this.#s=!1),this.rows=e;let i=new Set(e.filter(n=>n.status==="held").map(n=>n.message_id));for(let n of this.messages.keys())i.has(n)||this.messages.delete(n);for(let n of t.data)i.has(n.message_id)&&this.messages.set(n.message_id,n);this.advance(t,s),this.reconcile()}advance(e,t){if(t){this.cursor=e.cursor?.next??void 0;return}let s=JSON.stringify(e.data.map(i=>i.message_id));s!==this.#i&&(this.#i=s,this.cursor=e.cursor?.next??void 0)}reconcile(){for(let[e,t]of this.reviewed)this.current(e,t)||this.reviewed.delete(e);this.decision&&!this.current(this.decision.message,this.decision.identity)&&(this.decision=void 0,this.notice="The message changed or was already handled. Review the current inbox.")}async decide(e,t){!this.context()?.actionable||this.loading||this.decision||!this.current(e.row.message_id,e.identity)||t==="release"&&this.reviewed.get(e.row.message_id)!==e.identity||(this.decision={message:e.row.message_id,identity:e.identity,action:t,key:k(),status:"submitting"},await this.submit())}async retry(){this.decision?.status==="uncertain"&&await this.submit()}async submit(){let e=this.context(),t=this.decision;if(!e?.actionable||!t||!this.current(t.message,t.identity))return;let s=this.#e;t.status="submitting",this.changed();try{if(await this.api.request(`/sessions/${N(e.session)}/inbox/${N(t.message)}/${t.action}`,{directory:e.directory,signal:e.signal,method:"POST",key:t.key,...t.action==="release"&&{body:{delivery:"queue"}}}),!this.matches(e,s))return;this.decision=void 0,this.notice=t.action==="release"?"Release acknowledged. The server rechecks current sender authorization and local policy.":"Message dropped. It will not be admitted.",await this.refresh()}catch(i){if(!this.matches(e,s))return;!(i instanceof l)||i.kind==="network"?t.status="uncertain":(this.decision=void 0,this.reviewed.delete(t.message),await this.refresh()),this.notice=i instanceof l?i.message:"Message decision outcome is unknown. Reconcile before retrying."}finally{this.matches(e,s)&&this.changed()}}};var Y=()=>({authenticated:!1,authBusy:!1,retryAt:0,connection:"authentication required",directory:"",sessions:[],messages:[],diffs:[],cards:[],reservations:[],reservationError:"",runs:[],details:new Map,jobs:[],unavailable:new Set,notice:"",loading:!1,olderTranscript:!1,tab:"conversation",draft:"",delivery:"steer",reviewed:!1,answers:new Map,customs:new Map,liveText:"",feedbackDraft:""}),S=r=>r.data,Q=r=>`${r.session_id}/${r.id}`;function O(r){return Array.isArray(r)?`[${r.map(O).join(",")}]`:r&&typeof r=="object"?`{${Object.entries(r).sort(([e],[t])=>e.localeCompare(t)).map(([e,t])=>`${JSON.stringify(e)}:${O(t)}`).join(",")}}`:JSON.stringify(r)??"null"}var m=encodeURIComponent,X=(r,e)=>new Promise(t=>{if(e.aborted)return t();let s=setTimeout(t,r);e.addEventListener("abort",()=>{clearTimeout(s),t()},{once:!0})}),D=class{constructor(e,t=Date.now){this.api=e;this.now=t;this.computer=new I(e,()=>this.state.authenticated?{session:this.state.session?.id??"",directory:this.state.directory,actionable:this.actionable,signal:this.#i.signal}:void 0,()=>this.emit(),t),this.integrations=new A(e,()=>({directory:this.state.directory,session:this.state.session?.id,enabled:this.actionable}),()=>this.emit()),this.inbox=new H(e,()=>this.state.authenticated&&this.state.session?{session:this.state.session.id,directory:this.state.session.directory,actionable:this.actionable,signal:this.#i.signal}:void 0,()=>this.emit())}api;now;state=Y();changed=()=>{};#e=0;#t=0;#s;#i=new AbortController;#o;#p;#f;#a;#r;#u=new Set;#l=new Map;#n=new Map;#d=new Map;#c;#m;#h=0;integrations;inbox;computer;emit(){this.changed()}get actionable(){return this.state.connection==="connected"&&!!this.state.lease&&this.state.lease.expires_at>this.now()}async login(e){if(this.state.authBusy||this.now(){this.#c===t&&(this.#c=void 0)})}async initialRecovery(e){let t=this.#i.signal,s=500;for(;!t.aborted&&e===this.#e;){if(await X(s,t),t.aborted||e!==this.#e)return;try{await this.recover(e),this.scheduleRenewal(e);return}catch(i){this.showError(i),s=Math.min(15e3,s*2)}}}setTab(e){this.state.tab=e,e==="integrations"&&this.integrations.load(),this.emit()}setDraft(e){this.state.draft=e}async olderMessages(){this.state.messageCursor&&await this.transcriptPage(this.state.messageCursor)}async latestMessages(){await this.transcriptPage()}transcriptPath(e,t){return`/sessions/${m(e)}/messages?limit=50&order=desc${t?`&cursor=${m(t)}`:""}`}async transcriptPage(e){let t=this.state.session,s=this.#e;if(!t||!this.state.authenticated)return;this.#m=e;let i=++this.#h;this.state.messages=[],this.state.messageCursor=void 0,this.state.olderTranscript=!!e,this.emit();try{let n=await this.api.request(this.transcriptPath(t.id,e),this.options());this.applyTranscript(n,s,i,e)}catch(n){s===this.#e&&i===this.#h&&this.showError(n)}}applyTranscript(e,t,s,i){if(!(t!==this.#e||s!==this.#h)){if(i&&!e.data.length){this.latestMessages();return}this.state.messages=e.data.reverse(),this.state.messageCursor=e.cursor.next??void 0,this.state.olderTranscript=!!i,this.emit()}}refresh(){if(this.#a)return this.#a;let e=this.readSnapshot();return this.#a=e,e.finally(()=>{this.#a===e&&(this.#a=void 0)})}async readSnapshot(){let e=this.state.session,t=this.#e;if(!e||!this.state.authenticated)return;let s=`/sessions/${m(e.id)}`,i=this.options(),n=this.#m,o=this.#h,[c,d,h,u,p,b,v,L]=await Promise.all([this.api.request(s,i),this.api.request(this.transcriptPath(e.id,n),i),this.api.request(`/permissions/requests?session_id=${m(e.id)}`,i),this.api.request(`/questions/requests?session_id=${m(e.id)}`,i),this.optional(`${s}/diff`,"Diffs",i),this.optional(`/workflows/runs?session_id=${m(e.id)}&limit=50`,"Workflows",i),this.optional(`/jobs?session_id=${m(e.id)}&limit=50`,"Jobs",i),this.optional(`${s}/goals`,"Goals",i)]);if(t===this.#e){if(c.data.directory!==e.directory){this.selectSession(e.id);return}this.state.session=c.data,this.applyTranscript(d,t,o,n),this.state.diffs=p?.data??[],this.state.runs=b?.data??[],this.state.jobs=v?.data??[],this.state.goals=L?.data,this.reconcileCards([...h.data,...u.data],t),(c.data.status!=="running"||this.finishedMessage(this.state.liveMessage))&&(this.state.liveText="",this.state.liveMessage=void 0),this.inbox.refresh(),this.computer.refresh(),this.readReservations(e.id,t),this.emit()}}async readReservations(e,t){try{let s=await this.optional(`/budgets/reservations?session_id=${m(e)}&limit=50`,"Budget reservations",this.options());if(t!==this.#e||this.state.session?.id!==e)return;this.state.reservations=s?.data?.reservations??[],this.state.reservationError=this.state.unavailable.has("Budget reservations")?"Budget allocation diagnostics are unavailable on this server.":""}catch{if(t!==this.#e||this.state.session?.id!==e)return;this.state.reservations=[],this.state.reservationError="Budget allocation evidence could not be refreshed. Reconnect before relying on these limits."}this.emit()}async optional(e,t,s){let i=this.#e;try{let n=await this.api.request(e,s);return i===this.#e&&this.state.unavailable.delete(t),n}catch(n){if(n instanceof l&&(n.kind==="missing"||n.status===503)){i===this.#e&&this.state.unavailable.add(t);return}throw n}}reconcileCards(e,t){let s=new Map(this.state.cards.map(n=>[n.key,n]));this.state.cards=e.map(n=>{let o=Q(n),c=O(n),d=s.get(o),h=this.#n.get(o);return d?.fingerprint===c?d:(h&&h.fingerprint!==c&&this.#n.delete(o),{request:structuredClone(n),key:o,fingerprint:c,generation:t,...h?.fingerprint===c&&{decision:h}})});let i=new Set(this.state.cards.map(n=>n.key));for(let n of s.values())i.has(n.key)||(this.#n.delete(n.key),this.state.notice="Already answered. The current request list was reconciled with the server.");this.state.review&&!this.currentCard(this.state.review)&&(this.state.review=void 0),this.state.feedback&&!this.currentCard(this.state.feedback)&&(this.state.feedback=void 0)}currentCard(e){return e.generation===this.#e&&this.state.cards.some(t=>t===e&&t.fingerprint===e.fingerprint)}review(e){this.currentCard(e)&&(this.state.review=e,this.state.reviewed=!1,this.emit())}feedback(e){this.currentCard(e)&&(this.state.feedback=e,this.state.feedbackDraft="",this.emit())}async answer(e,t,s=!1){if(!this.actionable||!this.currentCard(e)||e.decision?.status==="submitting"||e.decision&&!s||!s&&!this.validAnswer(e,t))return;let i=e.decision??{key:k(),body:structuredClone(t),fingerprint:e.fingerprint,status:"submitting"};if(i.fingerprint!==e.fingerprint)return;e.decision=i,i.status="submitting",this.#n.set(e.key,i),this.state.review=void 0,this.state.feedback=void 0,this.emit();let n=this.#e;try{let o=e.request.kind==="permission"?"permissions":"questions",d=S(await this.api.request(`/${o}/requests?session_id=${m(this.state.session.id)}`,this.options())).find(h=>Q(h)===e.key);if(!d||O(d)!==i.fingerprint){await this.refresh();return}if(!this.actionable||!this.currentCard(e)||(await this.api.request(`/sessions/${m(e.request.session_id)}/${o}/${m(e.request.id)}/reply`,{...this.options(),method:"POST",body:i.body,key:i.key}),n!==this.#e))return;this.#n.delete(e.key),this.state.cards=this.state.cards.filter(h=>h!==e),this.state.notice="Answer acknowledged by the server.",await this.refresh()}catch(o){if(!(o instanceof l)||o.kind==="network"?i.status="uncertain":(this.#n.delete(e.key),delete e.decision),n!==this.#e)return;o instanceof l&&(o.kind==="missing"||o.kind==="changed")?(await this.refresh().catch(()=>{}),this.state.notice="Already answered or changed. Review the current requests."):this.showError(o)}finally{n===this.#e&&this.emit()}}validAnswer(e,t){if(!t||typeof t!="object")return!1;let s=t;return e.request.kind==="permission"?s.reply==="always"?this.state.review===e&&this.state.reviewed&&e.request.always_patterns.length>0:s.reply==="once"||s.reply==="reject"&&(s.message===void 0||typeof s.message=="string"):s.answers===void 0||s.answers===null?!0:!Array.isArray(s.answers)||s.answers.length!==e.request.questions.length?!1:s.answers.every((i,n)=>{let o=e.request.kind==="question"?e.request.questions[n]:void 0;return!!o&&Array.isArray(i)&&i.length>0&&(o.multi_select||i.length===1)&&i.every(c=>typeof c=="string"&&(o.allow_custom!==!1||o.options.some(d=>d.label===c)))})}async createSession(){if(!this.state.authenticated||this.state.connection!=="connected"||this.state.creation?.status==="submitting")return;let e=this.#e,t=this.state.creation??{key:k(),directory:this.state.directory,status:"submitting"};t.status="submitting",this.state.creation=t,this.emit();try{let s=S(await this.api.request("/sessions",{method:"POST",body:{},key:t.key,directory:t.directory}));if(e!==this.#e)return;this.state.creation=void 0,await this.loadSessions(),await this.selectSession(s.id)}catch(s){if(e!==this.#e)return;!(s instanceof l)||s.kind==="network"?t.status="uncertain":this.state.creation=void 0,this.showError(s)}finally{e===this.#e&&this.emit()}}async prompt(){let e=this.state.session,t=this.state.draft.trim();!e||!t||!this.actionable||this.state.command||await this.command({path:`/sessions/${m(e.id)}/prompt`,body:{parts:[{type:"text",text:t}],delivery:this.state.delivery},description:"Message",method:"POST"})}async interrupt(){let e=this.state.session;!e||!this.actionable||await this.command({path:`/sessions/${m(e.id)}/interrupt`,method:"POST",description:"Interrupt"})}async control(e,t,s="POST"){this.actionable&&await this.command({path:e,method:s,description:t})}async retryCommand(){this.state.command?.status==="uncertain"&&this.actionable&&await this.command(this.state.command)}async command(e){let t=this.state.session,s=this.#e;if(!t||!this.actionable||this.state.command&&(this.state.command.status==="submitting"||e.key!==this.state.command.key))return;let i={...e,key:e.key??k(),session:t.id,status:"submitting"};this.state.command=i,this.#d.set(t.id,i),this.emit();try{if(await this.api.request(i.path,{...this.options(),method:i.method,...i.body!==void 0&&{body:i.body},key:i.key}),this.#d.delete(i.session),s!==this.#e)return;this.state.command=void 0,i.description==="Message"&&(this.state.draft=""),this.state.notice=`${i.description} acknowledged by the server.`,await this.refresh()}catch(n){let o=!(n instanceof l)||n.kind==="network";o?i.status="uncertain":this.#d.delete(i.session),s===this.#e&&(o||(this.state.command=void 0),this.showError(n))}finally{s===this.#e&&this.emit()}}async runDetail(e){if(!this.state.session||!this.state.runs.some(s=>s.id===e))return;let t=this.#e;try{let s=S(await this.api.request(`/workflows/runs/${m(e)}`,this.options()));t===this.#e&&(this.state.details.set(e,s),this.emit())}catch(s){t===this.#e&&this.showError(s)}}async jobOutput(e){if(!this.state.jobs.some(i=>i.id===e))return;let t=this.#e,s=this.state.session?.id;try{let i=S(await this.api.request(`/jobs/${m(e)}/output`,this.options()));if(t!==this.#e||this.state.session?.id!==s)return;this.state.notice="Job output loaded.",this.jobLog={id:e,text:i.text},this.emit()}catch(i){t===this.#e&&this.showError(i)}}jobLog;wake(){if(this.state.authenticated){if(this.state.lease&&this.state.lease.expires_at<=this.now()){this.authenticationRequired("Browser attachment expired. Sign in to reconcile current work.");return}this.actionable||(this.state.connection="reconnecting"),this.emit(),this.recover(this.#e).catch(e=>this.showError(e))}}options(){return{directory:this.state.session?.directory??this.state.directory,signal:this.#i.signal}}async checkIdentity(){let e=await this.api.request("/health");if(this.#s&&e.id&&e.id!==this.#s)throw this.authenticationRequired("Server restarted. Sign in again to obtain a fresh attachment."),new l("auth");this.#s=e.id}async attachLease(e){let t=this.state.session;if(!t)return;this.#o??=k();let s=S(await this.api.request(`/sessions/${m(t.id)}/interactive-clients`,{...this.options(),method:"POST",body:{},key:this.#o}));if(e===this.#e){if(s.session_id!==t.id||s.expires_at<=this.now())throw this.#o=void 0,new l("changed");this.state.lease=s,this.#o=void 0}}scheduleRenewal(e){clearTimeout(this.#p),this.#p=setTimeout(()=>{this.renew(e)},15e3)}async renew(e){let t=this.state.lease;if(!(!t||e!==this.#e))try{if(t.expires_at<=this.now()){this.authenticationRequired("Browser attachment expired. Sign in to reconcile current work.");return}let s=S(await this.api.request(`/sessions/${m(t.session_id)}/interactive-clients/${m(t.client_id)}`,{...this.options(),method:"PUT",body:{},key:k()}));if(e!==this.#e)return;this.state.lease=s,await this.refresh(),this.scheduleRenewal(e)}catch(s){if(e!==this.#e)return;s instanceof l&&s.kind==="missing"?this.authenticationRequired("Browser attachment expired or the server restarted. Sign in again."):(this.showError(s),this.scheduleRenewal(e))}}releaseLease(){let e=this.state.lease;e&&(this.api.request(`/sessions/${m(e.session_id)}/interactive-clients/${m(e.client_id)}`,{directory:this.state.session?.directory??this.state.directory,method:"DELETE",key:k(),finishOnLogout:!0}).catch(()=>{}),this.state.lease=void 0)}stopAttachment(){this.#m=void 0,this.#h++,this.state.olderTranscript=!1,this.state.messageCursor=void 0,this.#e++,this.#i.abort(),this.#i=new AbortController,clearTimeout(this.#p),clearTimeout(this.#f),this.#a=void 0,this.#r=void 0,this.#c=void 0,this.#u.clear(),this.#o=void 0}startStreams(e){let t=this.state.session;t&&(this.follow("durable",t.id,e),this.follow("live",t.id,e))}async follow(e,t,s){let i=this.#i.signal,n=500;for(;!i.aborted&&s===this.#e;){try{let o=this.#l.get(t)??this.state.session?.seq??-1,c=e==="durable"?`/sessions/${m(t)}/events?after=${o}`:"/event";for await(let d of this.api.stream(c,{directory:this.state.session.directory,signal:i,opened:()=>{s===this.#e&&(this.#u.add(e),this.connected())}})){if(s!==this.#e)return;n=500,this.event(d,t)}}catch(o){if(i.aborted||s!==this.#e||(this.#u.delete(e),this.showError(o),s!==this.#e))return;this.state.connection=n>=4e3?"offline":"reconnecting",this.emit()}if(await X(n,i),n=Math.min(15e3,n*2),i.aborted||s!==this.#e)return;try{await this.recover(s)}catch(o){this.showError(o)}}}recover(e){if(this.#r)return this.#r;let t=(async()=>{if(await this.checkIdentity(),e===this.#e){if(this.state.lease&&this.state.lease.expires_at<=this.now()){this.authenticationRequired("Browser attachment expired. Sign in again.");return}if(await this.refresh(),!this.state.session){await this.loadSessions(),e===this.#e&&(this.state.connection="connected");return}this.state.lease||await this.attachLease(e),this.connected()}})();return this.#r=t,t.finally(()=>{this.#r===t&&(this.#r=void 0)})}connected(){this.#u.size===2&&this.state.lease&&this.state.lease.expires_at>this.now()&&(this.state.connection="connected"),this.emit()}event(e,t){e.durable?.aggregateID===t&&this.#l.set(t,Math.max(this.#l.get(t)??-1,e.durable.seq));let s=e.data;if(e.type==="session.text.delta"&&s?.session_id===t&&typeof s.text=="string"&&typeof s.message_id=="string"){this.textDelta(s.message_id,s.text);return}clearTimeout(this.#f),this.#f=setTimeout(()=>{this.refresh().catch(i=>this.showError(i))},100)}finishedMessage(e){return!!e&&this.state.messages.some(t=>t.id===e&&t.kind==="assistant"&&!!t.finished)}textDelta(e,t){this.finishedMessage(e)||(this.state.liveMessage!==e&&(this.state.liveMessage=e,this.state.liveText=""),this.state.liveText+=t,this.emit())}showError(e){if(e instanceof l&&e.kind==="auth"){this.state.authenticated&&this.authenticationRequired();return}this.state.notice=e instanceof l?e.message:"This action could not be completed. Refresh current state before trying again.",(!(e instanceof l)||e.kind==="network")&&(this.state.connection="reconnecting",this.state.authenticated&&this.ensureRecovery(this.#e)),this.emit()}};function g(r,e=""){let t=document.createElement(r);return t.textContent=e,t}function $(r,e,t=!1){let s=g("button",r);return s.type="button",s.className="button",s.disabled=t,s.addEventListener("click",e),s}function J(r,e,t){let s=g("label"),i=g("input");return i.type="checkbox",i.checked=e,i.addEventListener("change",()=>t(i.checked)),s.append(i,document.createTextNode(r)),s}function Z(r){let e=g("section");e.className=`authority computer-controls${r.active||r.uncertain?" native-active":""}`,e.setAttribute("aria-label","Native computer controls");let t=F(r);t&&e.append(t);let s=g("details");s.open=r.expanded,s.addEventListener("toggle",()=>{r.expanded=s.open}),s.append(g("summary","Native computer use (experimental)")),e.append(s);let i=r.report;if(!i)return s.append(g("p","Native health is unavailable."),$("Refresh native status",()=>{r.refresh()})),e;if(de(s,r,i),ce(s,r),he(s,r),r.notice){let n=g("p",r.notice);n.setAttribute("role","status"),s.append(n)}return e}function F(r){let e=r.active;if(!e&&!r.uncertain)return;let t=g("div");return t.className="notice",t.setAttribute("role","alert"),e?t.append(g("strong",`Computer ${r.uncertain?"status unknown":"ARMED"}: ${e.target.identity}`),g("p",`Session ${e.session_id} \xB7 expires ${new Date(e.expires_at).toLocaleTimeString()}`)):t.append(g("p","Native computer status is unknown. Confirm Stop on the host before continuing.")),t.append($("STOP native input",()=>{r.stop()})),t}function de(r,e,t){let s=t.health,i=t.enabled?s.supported?`Screen Recording: ${s.screen_capture?"granted":"missing"}; Accessibility: ${s.accessibility?"granted":"missing"}.`:`Unsupported: requires ${s.minimum_version}; host is ${s.platform}.`:"Disabled. Enable computer_use in trusted global configuration and restart the server.";if(r.append(g("p",i),g("p",`Running server binary: ${s.binary}`)),!t.enabled||!s.supported)return;r.append($("Request OS consent (opens prompts on the host)",()=>{e.setup()},e.busy));let n=g("input");n.value=e.selector,n.placeholder="com.apple.TextEdit",n.setAttribute("aria-label","Application bundle ID or display ID"),n.dataset.focus="computer-target",n.addEventListener("input",()=>{e.selector=n.value}),r.append(n,J("Explicit whole display scope",e.display,o=>{e.display=o,e.preview=void 0,e.changed()}),$("Review native target",()=>{e.reviewTarget()},!e.canReview)),r.append(g("p","Select and attach a Session before reviewing a native target."))}function ce(r,e){let t=e.latestFrame;if(t&&r.append(g("p",`Latest private capture: ${t.id} \xB7 ${new Date(t.created_ms).toLocaleTimeString()} \xB7 ${t.bytes} bytes`),$("View authorized capture",()=>{e.previewFrame()},e.frameBusy)),e.imageURL){let s=g("img");s.className="computer-capture",s.alt="Authorized private native screenshot",s.src=e.imageURL,r.append(s)}}function he(r,e){let t=e.preview;t&&(r.append(g("p",t.warning),g("pre",JSON.stringify(t.target,null,2)),g("p",`Review expires ${new Date(t.expires_at).toLocaleTimeString()}. Grant lasts ${e.ttl} seconds.`)),t.unknown_operations.length&&r.append(g("pre",JSON.stringify(t.unknown_operations,null,2)),J("I checked these unresolved operations on the host; they may already have happened",e.acknowledgeUnknown,s=>{e.acknowledgeUnknown=s,e.changed()})),r.append(J("I reviewed this exact target and authorize native capture/input",e.reviewed,s=>{e.reviewed=s,e.changed()}),$("Arm reviewed target",()=>{e.allow()},!e.canAllow)))}var w=(r,e="")=>{let t=document.createElement(r);return t.textContent=e,t};function E(r,e,t){let s=w("button",r);return s.type="button",s.className="button",s.disabled=t,s.addEventListener("click",e),s}function ee(r,e,t,s){let i=w("label"),n=w("textarea");return i.className="field",n.value=e,n.dataset.focus=t,n.setAttribute("aria-label",r),n.addEventListener("input",()=>s(n.value)),i.append(w("span",r),n),i}function te(r,e){let t=w("section"),s=!e||r.busy||r.uncertain;if(t.append(w("h2","Verified integration queue"),w("p","Combine local commits in an isolated checkout. Host checks verify the combined source; an independent read-only reviewer advises. Publication requires your review."),E("Refresh queue",()=>{r.load()},s),ee("Integration manifest (JSON)",r.manifest,"integration-manifest",o=>{r.manifest=o}),E("Submit integration",()=>{r.submit()},s)),r.pendingTarget&&t.append(w("p",`Original action: ${r.pendingTarget}`)),r.uncertain&&t.append(E("Retry original integration action",()=>{r.retry()},!e||r.busy),E("Refresh queue and clear local retry",()=>{r.discardRetry()},!e||r.busy)),r.notice){let o=w("p",r.notice);o.setAttribute("role","status"),t.append(o)}for(let o of r.rows)t.append(E(`${o.id} \xB7 ${o.status} \xB7 revision ${o.revision}`,()=>{r.load(o.id)},s));let i=r.selected;if(!i)return t;let n=w("pre",JSON.stringify({manifest:i.manifest,report:i.report,publication:i.publication},null,2));return n.className="code",n.tabIndex=0,t.append(w("h3",`${i.id} \xB7 ${i.status} \xB7 revision ${i.revision}`),E("Refresh selected evidence",()=>{r.load(i.id)},s),n,E("Run a new attempt",()=>{r.execute("run")},s||!!i.run_id&&["queued","preparing","checking","reviewing"].includes(i.status)),E("Cancel integration",()=>{r.execute("cancel")},s||i.status==="cancelled")),i.status==="ready"&&t.append(ue(r,s)),t}function ue(r,e){let t=w("section");t.append(w("h3","Publish a branch and pull request"),w("p","Review the exact manifest, combined patch, checks, reviewer limitations and cost above. Publication pushes the integration branch and opens a PR; it never merges."));for(let d of["remote","repository","title","body"])t.append(ee(`Publication ${d}`,r.publication[d],`integration-${d}`,h=>{r.publication[d]=h,r.reviewed=!1,r.changed()}));let s=w("input");s.type="checkbox",s.checked=r.publication.acknowledge_review_unavailable,s.addEventListener("change",()=>{r.publication.acknowledge_review_unavailable=s.checked});let i=w("label","Acknowledge reviewer unavailable, if reported");i.prepend(s),t.append(i);let n=w("input");n.type="checkbox",n.checked=r.reviewed;let o=E("Publish reviewed integration",()=>{r.execute("publish")},e||!r.reviewed);n.addEventListener("change",()=>{r.reviewed=n.checked,o.disabled=e||!n.checked});let c=w("label","I reviewed this exact revision and destination");return c.prepend(n),t.append(c,o),t}function x(r,e,t=""){let s=document.createElement(r);return s.textContent=e,s.className=t,s}function _(r,e,t){let s=document.createElement("button");return s.type="button",s.textContent=r,s.className="button",s.disabled=t,s.addEventListener("click",e),s}function se(r,e,t){let s=x("section","","message-inbox");s.append(x("h2","Held messages"),x("p","Incoming agent messages are untrusted. Inspect the sender, target and full content before releasing a message. Release queues that exact message; it grants no tool permission.","muted")),s.append(_("Refresh message inbox",()=>{r.refresh()},!e||r.loading)),r.notice&&s.append(x("p",r.notice,"notice")),r.decision?.status==="uncertain"&&s.append(x("p","The last decision has an unknown outcome. Refresh to reconcile, or retry the same action with its original identity.","notice"),_("Retry message decision",()=>{r.retry()},!e||r.loading)),!r.held.length&&!r.loading&&s.append(x("p","No held messages with loaded sender details.","muted"));for(let i of r.held){let n=x("article","","message");n.setAttribute("aria-label",`Held message ${i.row.message_id}`),n.append(x("h3",`From ${i.message.from}`),x("p",`Target Session: ${i.message.session_id} \xB7 Message: ${i.row.message_id} \xB7 Source: ${i.message.origin_kind}`,"mono"));let o=i.message.collaboration;o&&n.append(x("p",`Hub: ${o.hub_id} \xB7 Project: ${o.project_id} \xB7 Owner: ${o.member_id} \xB7 Agent: ${o.agent_id??"human"} \xB7 Membership: ${o.membership_kind}`,"mono"));for(let u of i.row.parts)n.append(t(u));let c=x("label","","field"),d=document.createElement("input");d.type="checkbox",d.checked=r.reviewed.get(i.row.message_id)===i.identity,d.dataset.focus=`held-review-${i.row.message_id}`,d.addEventListener("change",()=>r.review(i,d.checked)),c.append(d,document.createTextNode("I reviewed this sender, target and message content")),n.append(c);let h=!e||r.loading||!!r.decision;n.append(_("Release to queue",()=>{r.decide(i,"release")},h||!d.checked),_("Drop message",()=>{r.decide(i,"drop")},h)),s.append(n)}return r.pendingMetadata&&s.append(x("p",`${r.pendingMetadata} held input(s) need sender details or are ordinary held prompts. Message decisions are available only for immutable incoming messages with loaded provenance.`,"muted")),r.cursor&&s.append(_("Load older sender details",()=>{r.refresh(!0)},!e||r.loading)),s}function a(r,e="",t){let s=document.createElement(r);return s.className=e,t!==void 0&&(s.textContent=t),s}function f(r,e,t=!1,s="button"){let i=a("button",s,r);return i.type="button",i.disabled=t,i.addEventListener("click",e),i}var le=r=>typeof r=="string"?r:JSON.stringify(r,null,2)??"",G=r=>Number.isFinite(r)?`$${r.toFixed(3)} known priced subtotal`:"Cost unavailable",pe=r=>r.length>20?`${r.slice(0,9)}\u2026${r.slice(-6)}`:r,W=r=>r.replaceAll("_"," ");function T(r,e=""){return a("span",`badge ${e}`,W(r))}function y(r,e){let t=a("pre","code",le(r));return e&&t.setAttribute("aria-label",e),t.tabIndex=0,t}function U(r,e){let t=a("div","section-heading");return t.append(a("p","eyebrow",r),a("h2","",e)),t}function C(r,e,t){let s=a("label","field");return s.append(a("span","field-label",r),e),e.setAttribute("aria-label",r),e.dataset.focus=t,s}var j=class{constructor(e,t){this.root=e;this.model=t}root;model;render(){let e=document.activeElement,t=e?.dataset.focus,s=e&&"selectionStart"in e?[e.selectionStart,e.selectionEnd]:void 0,i=this.model.state.authenticated?this.dashboard():this.login();if(this.root.replaceChildren(i),t){let o=this.root.querySelector(`[data-focus="${CSS.escape(t)}"]`);if(o?.focus({preventScroll:!0}),s&&o&&["INPUT","TEXTAREA"].includes(o.tagName))try{o.setSelectionRange(s[0]??0,s[1]??0)}catch{}}let n=this.root.querySelector("dialog");n&&!n.open&&n.showModal(),t&&n&&n.querySelector(`[data-focus="${CSS.escape(t)}"]`)?.focus({preventScroll:!0})}login(){let e=this.model.state,t=a("main","login-shell"),s=a("form","login-card"),i=a("div","brand");i.append(a("span","brand-mark","C"),a("span","","CYBER"),T("Local dashboard")),s.append(i,a("p","eyebrow","YOUR MACHINE. YOUR CONTROL."),a("h1","",`Your agents, +`),id:this.id}:void 0;return this.event="",this.data=[],e}};var l=class extends Error{constructor(t,s=0){super(t==="auth"?"Sign in again to continue.":t==="network"?"Connection interrupted. The result may be unknown.":t==="changed"?"This item changed or was already answered.":t==="missing"?"This server does not offer this view.":"Server policy refused this action.");this.kind=t;this.status=s}kind;status},k=()=>crypto.randomUUID(),M=class{constructor(e,t,s=(i,n)=>globalThis.fetch(i,n)){this.unauthorized=t;this.fetcher=s;this.origin=new URL(e).origin}unauthorized;fetcher;#e;#t=new Set;#s=0;origin;password(e){this.clear();let t=new TextEncoder().encode(`cyber:${e}`);this.#e=`Basic ${btoa(Array.from(t,s=>String.fromCharCode(s)).join(""))}`}clear(){this.#s++,this.#e=void 0;for(let e of this.#t)e.abort();this.#t.clear()}async request(e,t={}){let s=new AbortController;t.finishOnLogout||this.#t.add(s);let i=this.#s,n=setTimeout(()=>s.abort(),t.finishOnLogout?3e3:3e4);try{let o=await this.fetcher(this.url(e),this.init(t,s.signal));if(this.status(o.status,i),o.status===204)return;let c=await re(o,16*1024*1024);try{return JSON.parse(c)}catch{throw new l("network")}}catch(o){throw o instanceof l?o:new l("network")}finally{clearTimeout(n),this.#t.delete(s)}}async bytes(e,t={}){if(t.method&&t.method!=="GET")throw new l("refused");let s=new AbortController;this.#t.add(s);let i=this.#s,n=setTimeout(()=>s.abort(),3e4);try{let o=await this.fetcher(this.url(e),this.init({...t,method:"GET",finishOnLogout:!1},s.signal));if(this.status(o.status,i),o.headers.get("content-type")?.split(";")[0]!=="image/png"||!o.body||Number(o.headers.get("content-length"))>8*1024*1024)throw new l("refused");let c=o.body.getReader(),d=[],h=0;try{for(;;){let b=await c.read();if(b.done)break;if(h+=b.value.length,h>8*1024*1024)throw new l("refused");d.push(b.value)}}finally{await c.cancel().catch(()=>{})}if(i!==this.#s)throw new l("auth",401);let u=new Uint8Array(h),p=0;for(let b of d)u.set(b,p),p+=b.length;return u}catch(o){throw i!==this.#s?new l("auth",401):o instanceof l?o:new l("network")}finally{clearTimeout(n),this.#t.delete(s)}}async*stream(e,t){let s=new AbortController;this.#t.add(s);let i=this.#s,n;try{let o=this.init({directory:t.directory,signal:t.signal},s.signal);o.headers.set("accept","text/event-stream");let c=await this.fetcher(this.url(e),o);if(this.status(c.status,i),!c.headers.get("content-type")?.includes("text/event-stream")||!c.body)throw new l("network");t.opened(),n=c.body.getReader();let d=new TextDecoder,h=new P,u=0;for(;;){let p=await n.read();if(p.done)throw new l("network");if(u+=p.value.byteLength,u>4*1024*1024)throw new l("network");let b=h.push(d.decode(p.value,{stream:!0}));b.length&&(u=0);for(let v of b)try{let L=JSON.parse(v.data);typeof L.type=="string"&&typeof L.id=="string"&&(yield L)}catch{throw new l("network")}}}catch(o){throw o instanceof l?o:new l("network")}finally{await n?.cancel().catch(()=>{}),this.#t.delete(s)}}url(e){if(!e.startsWith("/")||e.startsWith("//")||e.includes("#"))throw new l("refused");let t=new URL(`/api/v1${e}`,this.origin);if(t.origin!==this.origin||!t.pathname.startsWith("/api/v1/"))throw new l("refused");return t.toString()}init(e,t){if(!this.#e)throw new l("auth",401);let s=new Headers({authorization:this.#e,"x-cyber-client":"web",accept:"application/json"});e.directory!==void 0&&s.set("x-cyber-directory",encodeURIComponent(e.directory));let i=e.method??"GET";return i!=="GET"&&s.set("idempotency-key",e.key??k()),e.body!==void 0&&s.set("content-type","application/json"),{method:i,headers:s,...e.finishOnLogout&&{keepalive:!0},credentials:"omit",redirect:"error",cache:"no-store",signal:e.signal?AbortSignal.any([t,e.signal]):t,...e.body!==void 0&&{body:JSON.stringify(e.body)}}}status(e,t){if(e===401)throw t===this.#s&&(this.clear(),this.unauthorized()),new l("auth",e);if(e===403)throw new l("refused",e);if(e===404||e===405||e===501)throw new l("missing",e);if(e===409)throw new l("changed",e);if(e<200||e>=300)throw new l(e>=500?"network":"refused",e)}};async function re(r,e){if(!r.body)return"";let t=r.body.getReader(),s=new TextDecoder,i="",n=0;try{for(;;){let o=await t.read();if(o.done)return i+s.decode();if(n+=o.value.byteLength,n>e)throw new l("network");i+=s.decode(o.value,{stream:!0})}}finally{await t.cancel().catch(()=>{})}}var z='{"contributions":[{"label":"api","commit":"FULL_LOCAL_COMMIT_HASH"}],"checks":[{"argv":["cargo","test"],"timeout_seconds":300}],"reviewer_model":"verify"}',A=class{constructor(e,t,s){this.api=e;this.context=t;this.changed=s}api;context;changed;rows=[];selected;busy=!1;reviewed=!1;notice="";manifest=z;publication={remote:"origin",repository:"",title:"",body:"",acknowledge_review_unavailable:!1};#e=0;#t;get uncertain(){return!!this.#t&&!this.busy}reset(){this.#e++,this.#t=void 0,this.manifest=z,this.rows=[],this.selected=void 0,this.reviewed=!1,this.notice="",this.busy=!1,this.publication={remote:"origin",repository:"",title:"",body:"",acknowledge_review_unavailable:!1}}async load(e){let t=this.#e,s=this.context();if(!s.enabled)return!1;try{let i=e?`/workflows/integrations/${encodeURIComponent(e)}/report`:"/workflows/integrations",n=await this.api.request(i,{directory:s.directory});return t!==this.#e||s.directory!==this.context().directory?!1:(Array.isArray(n.data)?this.rows=n.data:(this.selected=n.data,this.reviewed=!1),this.changed(),!0)}catch(i){return t===this.#e&&(this.notice=String(i)),this.changed(),!1}}async submit(){let e=this.context().session;if(e)try{await this.action("",{session_id:e,manifest:JSON.parse(this.manifest)})}catch(t){this.notice=String(t),this.changed()}}async execute(e){let t=this.selected;if(!t||e==="publish"&&!this.reviewed)return;let s=e==="publish"?{...this.publication,operation_id:k(),expected_revision:t.revision}:{expected_revision:t.revision};await this.action(`/${encodeURIComponent(t.id)}/${e}`,s)}get pendingTarget(){let e=this.#t;if(!e)return"";let t=e.body;return e.suffix?`${e.suffix.split("/")[1]} \xB7 ${e.suffix.split("/")[2]} \xB7 revision ${t.expected_revision}`:`Submission for owner Session ${t.session_id}`}async discardRetry(){let e=this.#t;!e||this.busy||await this.load()&&this.#t===e&&(this.#t=void 0,this.notice="Queue refreshed and local retry cleared. This does not cancel an original action that is still running.",this.changed())}async retry(){this.#t&&await this.send(this.#t)}async action(e,t){this.#t||await this.send({suffix:e,body:structuredClone(t),key:k(),directory:this.context().directory})}async send(e){let t=this.context(),s=this.#e;if(!(!t.enabled||this.busy||e.directory!==t.directory)){this.#t=e,this.busy=!0,this.notice="Awaiting server acknowledgement\u2026",this.changed();try{let i=await this.api.request(`/workflows/integrations${e.suffix}`,{method:"POST",directory:e.directory,key:e.key,body:e.body});if(s!==this.#e)return;this.#t=void 0,this.selected=i.data,this.reviewed=!1,this.notice="Server acknowledged. Refresh for current host evidence.",await this.load()}catch(i){if(s!==this.#e)return;i instanceof l&&["refused","missing"].includes(i.kind)&&(this.#t=void 0),this.notice=`${String(i)} Refresh current evidence. An uncertain retry keeps the original action, revision and request key.`}finally{s===this.#e&&(this.busy=!1,this.changed())}}}};var I=class{constructor(e,t,s,i=Date.now){this.api=e;this.context=t;this.changed=s;this.now=i}api;context;changed;now;report;preview;latestFrame;imageURL;frameBusy=!1;notice="";busy=!1;uncertain=!1;reviewed=!1;acknowledgeUnknown=!1;selector="";display=!1;ttl=300;expanded=!1;#e=0;#t=!1;#s;reset(){this.clearImage(),this.latestFrame=void 0,this.frameBusy=!1,this.#e++,clearTimeout(this.#s),this.#t=!1,this.busy=!1,this.preview=void 0,this.reviewed=!1,this.acknowledgeUnknown=!1,this.uncertain=!!this.report?.grant,this.changed()}clear(){this.reset(),this.report=void 0,this.uncertain=!1,this.notice=""}get active(){let e=this.report?.grant;return e&&(e.expires_at>this.now()||this.uncertain)?e:void 0}get canReview(){let e=this.context();return!!e?.session&&e.actionable&&!!this.report?.enabled&&this.report.health.supported&&!this.busy}get canAllow(){let e=this.context();return!!this.report?.enabled&&!!this.report.health.supported&&!!e?.actionable&&!!this.preview&&this.preview.session_id===e.session&&this.preview.expires_at>this.now()&&this.reviewed&&(!this.preview.unknown_operations.length||this.acknowledgeUnknown)&&!this.busy&&!this.uncertain}async refresh(){let e=this.context();if(!e||this.#t)return;let t=this.#e;this.#t=!0;try{let s=await this.api.request("/tools/computer/health",{directory:e.directory,signal:e.signal});t===this.#e&&(this.report=s.data,this.uncertain=!1,await this.refreshFrame(e,t))}catch{t===this.#e&&(this.uncertain=!!this.report?.grant)}finally{t===this.#e&&(this.#t=!1,this.changed(),clearTimeout(this.#s),this.#s=setTimeout(()=>{this.refresh()},2e3))}}async refreshFrame(e,t){if(!e.session){this.clearImage(),this.latestFrame=void 0;return}try{let s=await this.api.request(`/sessions/${encodeURIComponent(e.session)}/computer`,{directory:e.directory,signal:e.signal});if(t!==this.#e)return;let i=s.data.latest_frame??void 0;i?.id!==this.latestFrame?.id&&this.clearImage(),this.latestFrame=i}catch{t===this.#e&&(this.clearImage(),this.latestFrame=void 0)}}clearImage(){this.imageURL&&URL.revokeObjectURL(this.imageURL),this.imageURL=void 0}async previewFrame(){let e=this.context(),t=this.latestFrame;if(!e?.actionable||!t||t.session_id!==e.session||this.frameBusy)return;let s=this.#e;this.frameBusy=!0,this.changed();try{let i=await this.api.bytes(`/sessions/${encodeURIComponent(e.session)}/computer/frames/${encodeURIComponent(t.id)}`,{directory:e.directory,signal:e.signal});if(s!==this.#e||this.latestFrame?.id!==t.id)return;this.clearImage(),this.imageURL=URL.createObjectURL(new Blob([new Uint8Array(i).buffer],{type:"image/png"}))}catch{s===this.#e&&(this.notice="Private capture is unavailable or expired. Request a fresh authorized capture.")}finally{s===this.#e&&(this.frameBusy=!1,this.changed())}}async setup(){await this.mutate("/tools/computer/setup",{confirm:!0})}async reviewTarget(){let e=this.context();if(!e?.actionable||this.busy)return;let t=this.selector.trim(),s=this.display?Number(t):null;if(!t||this.display&&(!Number.isInteger(s)||s<0||s>4294967295)){this.notice="Enter an application bundle ID or a native display ID (0 selects the main display).",this.changed();return}this.preview=void 0,this.reviewed=!1,this.acknowledgeUnknown=!1,await this.mutate(`/sessions/${encodeURIComponent(e.session)}/computer/preview`,{app:this.display?null:t,display:s},!0)}async allow(){let e=this.context(),t=this.preview;!e||!t||!this.canAllow||(await this.mutate(`/sessions/${encodeURIComponent(e.session)}/computer/allow`,{preview_id:t.id,confirm:!0,ttl_seconds:this.ttl,acknowledge_unknown:this.acknowledgeUnknown}),this.preview=void 0,this.reviewed=!1)}async stop(){let e=this.context();if(!e)return;let t=++this.#e;this.#t=!1,this.busy=!1,clearTimeout(this.#s),this.preview=void 0,this.reviewed=!1;try{if(await this.api.request("/tools/computer/stop",{method:"POST",directory:e.directory,finishOnLogout:!0}),t!==this.#e)return;this.report=this.report?{...this.report,grant:null}:void 0,this.uncertain=!1,this.notice="Native computer control stopped."}catch{if(t!==this.#e)return;this.uncertain=!0,this.notice="Stop could not be confirmed. Use cyber computer stop on the host; do not assume input stopped."}this.changed(),this.refresh()}async mutate(e,t,s=!1){let i=this.context();if(!i||this.busy||e!=="/tools/computer/setup"&&!i.actionable)return;let n=this.#e;this.busy=!0,this.notice="",this.changed();try{let o=await this.api.request(e,{method:"POST",body:t,directory:i.directory,signal:i.signal});if(n!==this.#e)return;s&&(this.preview=o.data),this.notice=s?"Review this exact native target; this review expires in 30 seconds.":"Native owner action completed."}catch{n===this.#e&&(this.preview=void 0,this.uncertain=!0,this.notice="Native action was not confirmed. Refresh status and review the target again; input is never automatically replayed.")}finally{n===this.#e&&(this.busy=!1,this.changed(),await this.refresh())}}};var N=encodeURIComponent,oe=(r,e)=>JSON.stringify([r.message_id,r.digest,r.parts,r.source,r.admitted_seq,e.from,e.to,e.session_id,e.origin_kind,e.collaboration,e.sha256]),H=class{constructor(e,t,s){this.api=e;this.context=t;this.changed=s}api;context;changed;rows=[];messages=new Map;reviewed=new Map;cursor;notice="";loading=!1;decision;#e=0;#t=!1;#s=!1;#i;reset(){this.#e++,this.#t=!1,this.#s=!1,this.rows=[],this.messages.clear(),this.reviewed.clear(),this.cursor=void 0,this.#i=void 0,this.notice="",this.loading=!1,this.decision=void 0}get held(){let e=this.context()?.session;return this.rows.flatMap(t=>{let s=this.messages.get(t.message_id);return t.status!=="held"||!s||s.direction!=="in"||s.session_id!==e||s.outcome!=="held"?[]:s.origin_kind==="collaboration"&&!s.collaboration?[]:[{row:t,message:s,identity:oe(t,s)}]})}get pendingMetadata(){return this.rows.filter(e=>e.status==="held").length-this.held.length}review(e,t){this.current(e.row.message_id,e.identity)&&(t?this.reviewed.set(e.row.message_id,e.identity):this.reviewed.delete(e.row.message_id),this.changed())}current(e,t){return this.held.find(s=>s.row.message_id===e&&s.identity===t)}matches(e,t){let s=this.context();return t===this.#e&&s?.session===e.session&&s.directory===e.directory}async refresh(e=!1){let t=this.context();if(!t||this.#t||e&&!this.cursor)return;let s=this.#e,i=e?this.cursor:void 0,n=new URLSearchParams({direction:"in",limit:"200"});i&&n.set("cursor",i),this.#t=!0,this.loading=!0,this.changed();try{let o={directory:t.directory,signal:t.signal},[c,d]=await Promise.all([this.api.request(`/sessions/${N(t.session)}/inbox`,o),this.api.request(`/sessions/${N(t.session)}/messaging?${n}`,o)]);if(!this.matches(t,s))return;this.merge(c.data,d,e)}catch(o){if(!this.matches(t,s))return;this.rows=[],this.messages.clear(),this.reviewed.clear(),this.#s=!0,this.notice=o instanceof l?o.message:"The message inbox could not be refreshed."}finally{this.matches(t,s)&&(this.#t=!1,this.loading=!1,this.changed())}}merge(e,t,s){this.#s&&(this.notice="",this.#s=!1),this.rows=e;let i=new Set(e.filter(n=>n.status==="held").map(n=>n.message_id));for(let n of this.messages.keys())i.has(n)||this.messages.delete(n);for(let n of t.data)i.has(n.message_id)&&this.messages.set(n.message_id,n);this.advance(t,s),this.reconcile()}advance(e,t){if(t){this.cursor=e.cursor?.next??void 0;return}let s=JSON.stringify(e.data.map(i=>i.message_id));s!==this.#i&&(this.#i=s,this.cursor=e.cursor?.next??void 0)}reconcile(){for(let[e,t]of this.reviewed)this.current(e,t)||this.reviewed.delete(e);this.decision&&!this.current(this.decision.message,this.decision.identity)&&(this.decision=void 0,this.notice="The message changed or was already handled. Review the current inbox.")}async decide(e,t){!this.context()?.actionable||this.loading||this.decision||!this.current(e.row.message_id,e.identity)||t==="release"&&this.reviewed.get(e.row.message_id)!==e.identity||(this.decision={message:e.row.message_id,identity:e.identity,action:t,key:k(),status:"submitting"},await this.submit())}async retry(){this.decision?.status==="uncertain"&&await this.submit()}async submit(){let e=this.context(),t=this.decision;if(!e?.actionable||!t||!this.current(t.message,t.identity))return;let s=this.#e;t.status="submitting",this.changed();try{if(await this.api.request(`/sessions/${N(e.session)}/inbox/${N(t.message)}/${t.action}`,{directory:e.directory,signal:e.signal,method:"POST",key:t.key,...t.action==="release"&&{body:{delivery:"queue"}}}),!this.matches(e,s))return;this.decision=void 0,this.notice=t.action==="release"?"Release acknowledged. The server rechecks current sender authorization and local policy.":"Message dropped. It will not be admitted.",await this.refresh()}catch(i){if(!this.matches(e,s))return;!(i instanceof l)||i.kind==="network"?t.status="uncertain":(this.decision=void 0,this.reviewed.delete(t.message),await this.refresh()),this.notice=i instanceof l?i.message:"Message decision outcome is unknown. Reconcile before retrying."}finally{this.matches(e,s)&&this.changed()}}};var Y=()=>({authenticated:!1,authBusy:!1,retryAt:0,connection:"authentication required",directory:"",sessions:[],messages:[],diffs:[],cards:[],reservations:[],reservationError:"",runs:[],details:new Map,jobs:[],unavailable:new Set,notice:"",loading:!1,olderTranscript:!1,tab:"conversation",draft:"",delivery:"steer",reviewed:!1,answers:new Map,customs:new Map,liveText:"",feedbackDraft:""}),S=r=>r.data,Q=r=>`${r.session_id}/${r.id}`;function O(r){return Array.isArray(r)?`[${r.map(O).join(",")}]`:r&&typeof r=="object"?`{${Object.entries(r).sort(([e],[t])=>e.localeCompare(t)).map(([e,t])=>`${JSON.stringify(e)}:${O(t)}`).join(",")}}`:JSON.stringify(r)??"null"}var m=encodeURIComponent,X=(r,e)=>new Promise(t=>{if(e.aborted)return t();let s=setTimeout(t,r);e.addEventListener("abort",()=>{clearTimeout(s),t()},{once:!0})}),D=class{constructor(e,t=Date.now){this.api=e;this.now=t;this.computer=new I(e,()=>this.state.authenticated?{session:this.state.session?.id??"",directory:this.state.directory,actionable:this.actionable,signal:this.#i.signal}:void 0,()=>this.emit(),t),this.integrations=new A(e,()=>({directory:this.state.directory,session:this.state.session?.id,enabled:this.actionable}),()=>this.emit()),this.inbox=new H(e,()=>this.state.authenticated&&this.state.session?{session:this.state.session.id,directory:this.state.session.directory,actionable:this.actionable,signal:this.#i.signal}:void 0,()=>this.emit())}api;now;state=Y();changed=()=>{};#e=0;#t=0;#s;#i=new AbortController;#o;#p;#f;#a;#r;#u=new Set;#l=new Map;#n=new Map;#d=new Map;#c;#m;#h=0;integrations;inbox;computer;emit(){this.changed()}get actionable(){return this.state.connection==="connected"&&!!this.state.lease&&this.state.lease.expires_at>this.now()}async login(e){if(this.state.authBusy||this.now(){this.#c===t&&(this.#c=void 0)})}async initialRecovery(e){let t=this.#i.signal,s=500;for(;!t.aborted&&e===this.#e;){if(await X(s,t),t.aborted||e!==this.#e)return;try{await this.recover(e),this.scheduleRenewal(e);return}catch(i){this.showError(i),s=Math.min(15e3,s*2)}}}setTab(e){this.state.tab=e,e==="integrations"&&this.integrations.load(),this.emit()}setDraft(e){this.state.draft=e}async olderMessages(){this.state.messageCursor&&await this.transcriptPage(this.state.messageCursor)}async latestMessages(){await this.transcriptPage()}transcriptPath(e,t){return`/sessions/${m(e)}/messages?limit=50&order=desc${t?`&cursor=${m(t)}`:""}`}async transcriptPage(e){let t=this.state.session,s=this.#e;if(!t||!this.state.authenticated)return;this.#m=e;let i=++this.#h;this.state.messages=[],this.state.messageCursor=void 0,this.state.olderTranscript=!!e,this.emit();try{let n=await this.api.request(this.transcriptPath(t.id,e),this.options());this.applyTranscript(n,s,i,e)}catch(n){s===this.#e&&i===this.#h&&this.showError(n)}}applyTranscript(e,t,s,i){if(!(t!==this.#e||s!==this.#h)){if(i&&!e.data.length){this.latestMessages();return}this.state.messages=e.data.reverse(),this.state.messageCursor=e.cursor.next??void 0,this.state.olderTranscript=!!i,this.emit()}}refresh(){if(this.#a)return this.#a;let e=this.readSnapshot();return this.#a=e,e.finally(()=>{this.#a===e&&(this.#a=void 0)})}async readSnapshot(){let e=this.state.session,t=this.#e;if(!e||!this.state.authenticated)return;let s=`/sessions/${m(e.id)}`,i=this.options(),n=this.#m,o=this.#h,[c,d,h,u,p,b,v,L]=await Promise.all([this.api.request(s,i),this.api.request(this.transcriptPath(e.id,n),i),this.api.request(`/permissions/requests?session_id=${m(e.id)}`,i),this.api.request(`/questions/requests?session_id=${m(e.id)}`,i),this.optional(`${s}/diff`,"Diffs",i),this.optional(`/workflows/runs?session_id=${m(e.id)}&limit=50`,"Workflows",i),this.optional(`/jobs?session_id=${m(e.id)}&limit=50`,"Jobs",i),this.optional(`${s}/goals`,"Goals",i)]);if(t===this.#e){if(c.data.directory!==e.directory){this.selectSession(e.id);return}this.state.session=c.data,this.applyTranscript(d,t,o,n),this.state.diffs=p?.data??[],this.state.runs=b?.data??[],this.state.jobs=v?.data??[],this.state.goals=L?.data,this.reconcileCards([...h.data,...u.data],t),(c.data.status!=="running"||this.finishedMessage(this.state.liveMessage))&&(this.state.liveText="",this.state.liveMessage=void 0),this.inbox.refresh(),this.computer.refresh(),this.readReservations(e.id,t),this.emit()}}async readReservations(e,t){try{let s=await this.optional(`/budgets/reservations?session_id=${m(e)}&limit=50`,"Budget reservations",this.options());if(t!==this.#e||this.state.session?.id!==e)return;this.state.reservations=s?.data?.reservations??[],this.state.reservationError=this.state.unavailable.has("Budget reservations")?"Budget allocation diagnostics are unavailable on this server.":""}catch{if(t!==this.#e||this.state.session?.id!==e)return;this.state.reservations=[],this.state.reservationError="Budget allocation evidence could not be refreshed. Reconnect before relying on these limits."}this.emit()}async optional(e,t,s){let i=this.#e;try{let n=await this.api.request(e,s);return i===this.#e&&this.state.unavailable.delete(t),n}catch(n){if(n instanceof l&&(n.kind==="missing"||n.status===503)){i===this.#e&&this.state.unavailable.add(t);return}throw n}}reconcileCards(e,t){let s=new Map(this.state.cards.map(n=>[n.key,n]));this.state.cards=e.map(n=>{let o=Q(n),c=O(n),d=s.get(o),h=this.#n.get(o);return d?.fingerprint===c?d:(h&&h.fingerprint!==c&&this.#n.delete(o),{request:structuredClone(n),key:o,fingerprint:c,generation:t,...h?.fingerprint===c&&{decision:h}})});let i=new Set(this.state.cards.map(n=>n.key));for(let n of s.values())i.has(n.key)||(this.#n.delete(n.key),this.state.notice="Already answered. The current request list was reconciled with the server.");this.state.review&&!this.currentCard(this.state.review)&&(this.state.review=void 0),this.state.feedback&&!this.currentCard(this.state.feedback)&&(this.state.feedback=void 0)}currentCard(e){return e.generation===this.#e&&this.state.cards.some(t=>t===e&&t.fingerprint===e.fingerprint)}review(e){this.currentCard(e)&&(this.state.review=e,this.state.reviewed=!1,this.emit())}feedback(e){this.currentCard(e)&&(this.state.feedback=e,this.state.feedbackDraft="",this.emit())}async answer(e,t,s=!1){if(!this.actionable||!this.currentCard(e)||e.decision?.status==="submitting"||e.decision&&!s||!s&&!this.validAnswer(e,t))return;let i=e.decision??{key:k(),body:structuredClone(t),fingerprint:e.fingerprint,status:"submitting"};if(i.fingerprint!==e.fingerprint)return;e.decision=i,i.status="submitting",this.#n.set(e.key,i),this.state.review=void 0,this.state.feedback=void 0,this.emit();let n=this.#e;try{let o=e.request.kind==="permission"?"permissions":"questions",d=S(await this.api.request(`/${o}/requests?session_id=${m(this.state.session.id)}`,this.options())).find(h=>Q(h)===e.key);if(!d||O(d)!==i.fingerprint){await this.refresh();return}if(!this.actionable||!this.currentCard(e)||(await this.api.request(`/sessions/${m(e.request.session_id)}/${o}/${m(e.request.id)}/reply`,{...this.options(),method:"POST",body:i.body,key:i.key}),n!==this.#e))return;this.#n.delete(e.key),this.state.cards=this.state.cards.filter(h=>h!==e),this.state.notice="Answer acknowledged by the server.",await this.refresh()}catch(o){if(!(o instanceof l)||o.kind==="network"?i.status="uncertain":(this.#n.delete(e.key),delete e.decision),n!==this.#e)return;o instanceof l&&(o.kind==="missing"||o.kind==="changed")?(await this.refresh().catch(()=>{}),this.state.notice="Already answered or changed. Review the current requests."):this.showError(o)}finally{n===this.#e&&this.emit()}}validAnswer(e,t){if(!t||typeof t!="object")return!1;let s=t;return e.request.kind==="permission"?s.reply==="always"?this.state.review===e&&this.state.reviewed&&e.request.always_patterns.length>0:s.reply==="once"||s.reply==="reject"&&(s.message===void 0||typeof s.message=="string"):s.answers===void 0||s.answers===null?!0:!Array.isArray(s.answers)||s.answers.length!==e.request.questions.length?!1:s.answers.every((i,n)=>{let o=e.request.kind==="question"?e.request.questions[n]:void 0;return!!o&&Array.isArray(i)&&i.length>0&&(o.multi_select||i.length===1)&&i.every(c=>typeof c=="string"&&(o.allow_custom!==!1||o.options.some(d=>d.label===c)))})}async createSession(){if(!this.state.authenticated||this.state.connection!=="connected"||this.state.creation?.status==="submitting")return;let e=this.#e,t=this.state.creation??{key:k(),directory:this.state.directory,status:"submitting"};t.status="submitting",this.state.creation=t,this.emit();try{let s=S(await this.api.request("/sessions",{method:"POST",body:{},key:t.key,directory:t.directory}));if(e!==this.#e)return;this.state.creation=void 0,await this.loadSessions(),await this.selectSession(s.id)}catch(s){if(e!==this.#e)return;!(s instanceof l)||s.kind==="network"?t.status="uncertain":this.state.creation=void 0,this.showError(s)}finally{e===this.#e&&this.emit()}}async prompt(){let e=this.state.session,t=this.state.draft.trim();!e||!t||!this.actionable||this.state.command||await this.command({path:`/sessions/${m(e.id)}/prompt`,body:{parts:[{type:"text",text:t}],delivery:this.state.delivery},description:"Message",method:"POST"})}async interrupt(){let e=this.state.session;!e||!this.actionable||await this.command({path:`/sessions/${m(e.id)}/interrupt`,method:"POST",description:"Interrupt"})}async control(e,t,s="POST"){this.actionable&&await this.command({path:e,method:s,description:t})}async retryCommand(){this.state.command?.status==="uncertain"&&this.actionable&&await this.command(this.state.command)}async command(e){let t=this.state.session,s=this.#e;if(!t||!this.actionable||this.state.command&&(this.state.command.status==="submitting"||e.key!==this.state.command.key))return;let i={...e,key:e.key??k(),session:t.id,status:"submitting"};this.state.command=i,this.#d.set(t.id,i),this.emit();try{if(await this.api.request(i.path,{...this.options(),method:i.method,...i.body!==void 0&&{body:i.body},key:i.key}),this.#d.delete(i.session),s!==this.#e)return;this.state.command=void 0,i.description==="Message"&&(this.state.draft=""),this.state.notice=`${i.description} acknowledged by the server.`,await this.refresh()}catch(n){let o=!(n instanceof l)||n.kind==="network";o?i.status="uncertain":this.#d.delete(i.session),s===this.#e&&(o||(this.state.command=void 0),this.showError(n))}finally{s===this.#e&&this.emit()}}async runDetail(e){if(!this.state.session||!this.state.runs.some(s=>s.id===e))return;let t=this.#e;try{let s=S(await this.api.request(`/workflows/runs/${m(e)}`,this.options()));t===this.#e&&(this.state.details.set(e,s),this.emit())}catch(s){t===this.#e&&this.showError(s)}}async jobOutput(e){if(!this.state.jobs.some(i=>i.id===e))return;let t=this.#e,s=this.state.session?.id;try{let i=S(await this.api.request(`/jobs/${m(e)}/output`,this.options()));if(t!==this.#e||this.state.session?.id!==s)return;this.state.notice="Job output loaded.",this.jobLog={id:e,text:i.text},this.emit()}catch(i){t===this.#e&&this.showError(i)}}jobLog;wake(){if(this.state.authenticated){if(this.state.lease&&this.state.lease.expires_at<=this.now()){this.authenticationRequired("Browser attachment expired. Sign in to reconcile current work.");return}this.actionable||(this.state.connection="reconnecting"),this.emit(),this.recover(this.#e).catch(e=>this.showError(e))}}options(){return{directory:this.state.session?.directory??this.state.directory,signal:this.#i.signal}}async checkIdentity(){let e=await this.api.request("/health");if(this.#s&&e.id&&e.id!==this.#s)throw this.authenticationRequired("Server restarted. Sign in again to obtain a fresh attachment."),new l("auth");this.#s=e.id}async attachLease(e){let t=this.state.session;if(!t)return;this.#o??=k();let s=S(await this.api.request(`/sessions/${m(t.id)}/interactive-clients`,{...this.options(),method:"POST",body:{},key:this.#o}));if(e===this.#e){if(s.session_id!==t.id||s.expires_at<=this.now())throw this.#o=void 0,new l("changed");this.state.lease=s,this.#o=void 0}}scheduleRenewal(e){clearTimeout(this.#p),this.#p=setTimeout(()=>{this.renew(e)},15e3)}async renew(e){let t=this.state.lease;if(!(!t||e!==this.#e))try{if(t.expires_at<=this.now()){this.authenticationRequired("Browser attachment expired. Sign in to reconcile current work.");return}let s=S(await this.api.request(`/sessions/${m(t.session_id)}/interactive-clients/${m(t.client_id)}`,{...this.options(),method:"PUT",body:{},key:k()}));if(e!==this.#e)return;this.state.lease=s,await this.refresh(),this.scheduleRenewal(e)}catch(s){if(e!==this.#e)return;s instanceof l&&s.kind==="missing"?this.authenticationRequired("Browser attachment expired or the server restarted. Sign in again."):(this.showError(s),this.scheduleRenewal(e))}}releaseLease(){let e=this.state.lease;e&&(this.api.request(`/sessions/${m(e.session_id)}/interactive-clients/${m(e.client_id)}`,{directory:this.state.session?.directory??this.state.directory,method:"DELETE",key:k(),finishOnLogout:!0}).catch(()=>{}),this.state.lease=void 0)}stopAttachment(){this.#m=void 0,this.#h++,this.state.olderTranscript=!1,this.state.messageCursor=void 0,this.#e++,this.#i.abort(),this.#i=new AbortController,clearTimeout(this.#p),clearTimeout(this.#f),this.#a=void 0,this.#r=void 0,this.#c=void 0,this.#u.clear(),this.#o=void 0}startStreams(e){let t=this.state.session;t&&(this.follow("durable",t.id,e),this.follow("live",t.id,e))}async follow(e,t,s){let i=this.#i.signal,n=500;for(;!i.aborted&&s===this.#e;){try{let o=this.#l.get(t)??this.state.session?.seq??-1,c=e==="durable"?`/sessions/${m(t)}/events?after=${o}`:"/event";for await(let d of this.api.stream(c,{directory:this.state.session.directory,signal:i,opened:()=>{s===this.#e&&(this.#u.add(e),this.connected())}})){if(s!==this.#e)return;n=500,this.event(d,t)}}catch(o){if(i.aborted||s!==this.#e||(this.#u.delete(e),this.showError(o),s!==this.#e))return;this.state.connection=n>=4e3?"offline":"reconnecting",this.emit()}if(await X(n,i),n=Math.min(15e3,n*2),i.aborted||s!==this.#e)return;try{await this.recover(s)}catch(o){this.showError(o)}}}recover(e){if(this.#r)return this.#r;let t=(async()=>{if(await this.checkIdentity(),e===this.#e){if(this.state.lease&&this.state.lease.expires_at<=this.now()){this.authenticationRequired("Browser attachment expired. Sign in again.");return}if(await this.refresh(),!this.state.session){await this.loadSessions(),e===this.#e&&(this.state.connection="connected");return}this.state.lease||await this.attachLease(e),this.connected()}})();return this.#r=t,t.finally(()=>{this.#r===t&&(this.#r=void 0)})}connected(){this.#u.size===2&&this.state.lease&&this.state.lease.expires_at>this.now()&&(this.state.connection="connected"),this.emit()}event(e,t){e.durable?.aggregateID===t&&this.#l.set(t,Math.max(this.#l.get(t)??-1,e.durable.seq));let s=e.data;if(e.type==="session.text.delta"&&s?.session_id===t&&typeof s.text=="string"&&typeof s.message_id=="string"){this.textDelta(s.message_id,s.text);return}if(e.type==="session.retry"&&s?.session_id===t&&s.discarded===!0&&s.message_id===this.state.liveMessage){this.state.liveText="",this.emit();return}clearTimeout(this.#f),this.#f=setTimeout(()=>{this.refresh().catch(i=>this.showError(i))},100)}finishedMessage(e){return!!e&&this.state.messages.some(t=>t.id===e&&t.kind==="assistant"&&!!t.finished)}textDelta(e,t){this.finishedMessage(e)||(this.state.liveMessage!==e&&(this.state.liveMessage=e,this.state.liveText=""),this.state.liveText+=t,this.emit())}showError(e){if(e instanceof l&&e.kind==="auth"){this.state.authenticated&&this.authenticationRequired();return}this.state.notice=e instanceof l?e.message:"This action could not be completed. Refresh current state before trying again.",(!(e instanceof l)||e.kind==="network")&&(this.state.connection="reconnecting",this.state.authenticated&&this.ensureRecovery(this.#e)),this.emit()}};function g(r,e=""){let t=document.createElement(r);return t.textContent=e,t}function $(r,e,t=!1){let s=g("button",r);return s.type="button",s.className="button",s.disabled=t,s.addEventListener("click",e),s}function J(r,e,t){let s=g("label"),i=g("input");return i.type="checkbox",i.checked=e,i.addEventListener("change",()=>t(i.checked)),s.append(i,document.createTextNode(r)),s}function Z(r){let e=g("section");e.className=`authority computer-controls${r.active||r.uncertain?" native-active":""}`,e.setAttribute("aria-label","Native computer controls");let t=F(r);t&&e.append(t);let s=g("details");s.open=r.expanded,s.addEventListener("toggle",()=>{r.expanded=s.open}),s.append(g("summary","Native computer use (experimental)")),e.append(s);let i=r.report;if(!i)return s.append(g("p","Native health is unavailable."),$("Refresh native status",()=>{r.refresh()})),e;if(de(s,r,i),ce(s,r),he(s,r),r.notice){let n=g("p",r.notice);n.setAttribute("role","status"),s.append(n)}return e}function F(r){let e=r.active;if(!e&&!r.uncertain)return;let t=g("div");return t.className="notice",t.setAttribute("role","alert"),e?t.append(g("strong",`Computer ${r.uncertain?"status unknown":"ARMED"}: ${e.target.identity}`),g("p",`Session ${e.session_id} \xB7 expires ${new Date(e.expires_at).toLocaleTimeString()}`)):t.append(g("p","Native computer status is unknown. Confirm Stop on the host before continuing.")),t.append($("STOP native input",()=>{r.stop()})),t}function de(r,e,t){let s=t.health,i=t.enabled?s.supported?`Screen Recording: ${s.screen_capture?"granted":"missing"}; Accessibility: ${s.accessibility?"granted":"missing"}.`:`Unsupported: requires ${s.minimum_version}; host is ${s.platform}.`:"Disabled. Enable computer_use in trusted global configuration and restart the server.";if(r.append(g("p",i),g("p",`Running server binary: ${s.binary}`)),!t.enabled||!s.supported)return;r.append($("Request OS consent (opens prompts on the host)",()=>{e.setup()},e.busy));let n=g("input");n.value=e.selector,n.placeholder="com.apple.TextEdit",n.setAttribute("aria-label","Application bundle ID or display ID"),n.dataset.focus="computer-target",n.addEventListener("input",()=>{e.selector=n.value}),r.append(n,J("Explicit whole display scope",e.display,o=>{e.display=o,e.preview=void 0,e.changed()}),$("Review native target",()=>{e.reviewTarget()},!e.canReview)),r.append(g("p","Select and attach a Session before reviewing a native target."))}function ce(r,e){let t=e.latestFrame;if(t&&r.append(g("p",`Latest private capture: ${t.id} \xB7 ${new Date(t.created_ms).toLocaleTimeString()} \xB7 ${t.bytes} bytes`),$("View authorized capture",()=>{e.previewFrame()},e.frameBusy)),e.imageURL){let s=g("img");s.className="computer-capture",s.alt="Authorized private native screenshot",s.src=e.imageURL,r.append(s)}}function he(r,e){let t=e.preview;t&&(r.append(g("p",t.warning),g("pre",JSON.stringify(t.target,null,2)),g("p",`Review expires ${new Date(t.expires_at).toLocaleTimeString()}. Grant lasts ${e.ttl} seconds.`)),t.unknown_operations.length&&r.append(g("pre",JSON.stringify(t.unknown_operations,null,2)),J("I checked these unresolved operations on the host; they may already have happened",e.acknowledgeUnknown,s=>{e.acknowledgeUnknown=s,e.changed()})),r.append(J("I reviewed this exact target and authorize native capture/input",e.reviewed,s=>{e.reviewed=s,e.changed()}),$("Arm reviewed target",()=>{e.allow()},!e.canAllow)))}var w=(r,e="")=>{let t=document.createElement(r);return t.textContent=e,t};function E(r,e,t){let s=w("button",r);return s.type="button",s.className="button",s.disabled=t,s.addEventListener("click",e),s}function ee(r,e,t,s){let i=w("label"),n=w("textarea");return i.className="field",n.value=e,n.dataset.focus=t,n.setAttribute("aria-label",r),n.addEventListener("input",()=>s(n.value)),i.append(w("span",r),n),i}function te(r,e){let t=w("section"),s=!e||r.busy||r.uncertain;if(t.append(w("h2","Verified integration queue"),w("p","Combine local commits in an isolated checkout. Host checks verify the combined source; an independent read-only reviewer advises. Publication requires your review."),E("Refresh queue",()=>{r.load()},s),ee("Integration manifest (JSON)",r.manifest,"integration-manifest",o=>{r.manifest=o}),E("Submit integration",()=>{r.submit()},s)),r.pendingTarget&&t.append(w("p",`Original action: ${r.pendingTarget}`)),r.uncertain&&t.append(E("Retry original integration action",()=>{r.retry()},!e||r.busy),E("Refresh queue and clear local retry",()=>{r.discardRetry()},!e||r.busy)),r.notice){let o=w("p",r.notice);o.setAttribute("role","status"),t.append(o)}for(let o of r.rows)t.append(E(`${o.id} \xB7 ${o.status} \xB7 revision ${o.revision}`,()=>{r.load(o.id)},s));let i=r.selected;if(!i)return t;let n=w("pre",JSON.stringify({manifest:i.manifest,report:i.report,publication:i.publication},null,2));return n.className="code",n.tabIndex=0,t.append(w("h3",`${i.id} \xB7 ${i.status} \xB7 revision ${i.revision}`),E("Refresh selected evidence",()=>{r.load(i.id)},s),n,E("Run a new attempt",()=>{r.execute("run")},s||!!i.run_id&&["queued","preparing","checking","reviewing"].includes(i.status)),E("Cancel integration",()=>{r.execute("cancel")},s||i.status==="cancelled")),i.status==="ready"&&t.append(ue(r,s)),t}function ue(r,e){let t=w("section");t.append(w("h3","Publish a branch and pull request"),w("p","Review the exact manifest, combined patch, checks, reviewer limitations and cost above. Publication pushes the integration branch and opens a PR; it never merges."));for(let d of["remote","repository","title","body"])t.append(ee(`Publication ${d}`,r.publication[d],`integration-${d}`,h=>{r.publication[d]=h,r.reviewed=!1,r.changed()}));let s=w("input");s.type="checkbox",s.checked=r.publication.acknowledge_review_unavailable,s.addEventListener("change",()=>{r.publication.acknowledge_review_unavailable=s.checked});let i=w("label","Acknowledge reviewer unavailable, if reported");i.prepend(s),t.append(i);let n=w("input");n.type="checkbox",n.checked=r.reviewed;let o=E("Publish reviewed integration",()=>{r.execute("publish")},e||!r.reviewed);n.addEventListener("change",()=>{r.reviewed=n.checked,o.disabled=e||!n.checked});let c=w("label","I reviewed this exact revision and destination");return c.prepend(n),t.append(c,o),t}function x(r,e,t=""){let s=document.createElement(r);return s.textContent=e,s.className=t,s}function _(r,e,t){let s=document.createElement("button");return s.type="button",s.textContent=r,s.className="button",s.disabled=t,s.addEventListener("click",e),s}function se(r,e,t){let s=x("section","","message-inbox");s.append(x("h2","Held messages"),x("p","Incoming agent messages are untrusted. Inspect the sender, target and full content before releasing a message. Release queues that exact message; it grants no tool permission.","muted")),s.append(_("Refresh message inbox",()=>{r.refresh()},!e||r.loading)),r.notice&&s.append(x("p",r.notice,"notice")),r.decision?.status==="uncertain"&&s.append(x("p","The last decision has an unknown outcome. Refresh to reconcile, or retry the same action with its original identity.","notice"),_("Retry message decision",()=>{r.retry()},!e||r.loading)),!r.held.length&&!r.loading&&s.append(x("p","No held messages with loaded sender details.","muted"));for(let i of r.held){let n=x("article","","message");n.setAttribute("aria-label",`Held message ${i.row.message_id}`),n.append(x("h3",`From ${i.message.from}`),x("p",`Target Session: ${i.message.session_id} \xB7 Message: ${i.row.message_id} \xB7 Source: ${i.message.origin_kind}`,"mono"));let o=i.message.collaboration;o&&n.append(x("p",`Hub: ${o.hub_id} \xB7 Project: ${o.project_id} \xB7 Owner: ${o.member_id} \xB7 Agent: ${o.agent_id??"human"} \xB7 Membership: ${o.membership_kind}`,"mono"));for(let u of i.row.parts)n.append(t(u));let c=x("label","","field"),d=document.createElement("input");d.type="checkbox",d.checked=r.reviewed.get(i.row.message_id)===i.identity,d.dataset.focus=`held-review-${i.row.message_id}`,d.addEventListener("change",()=>r.review(i,d.checked)),c.append(d,document.createTextNode("I reviewed this sender, target and message content")),n.append(c);let h=!e||r.loading||!!r.decision;n.append(_("Release to queue",()=>{r.decide(i,"release")},h||!d.checked),_("Drop message",()=>{r.decide(i,"drop")},h)),s.append(n)}return r.pendingMetadata&&s.append(x("p",`${r.pendingMetadata} held input(s) need sender details or are ordinary held prompts. Message decisions are available only for immutable incoming messages with loaded provenance.`,"muted")),r.cursor&&s.append(_("Load older sender details",()=>{r.refresh(!0)},!e||r.loading)),s}function a(r,e="",t){let s=document.createElement(r);return s.className=e,t!==void 0&&(s.textContent=t),s}function f(r,e,t=!1,s="button"){let i=a("button",s,r);return i.type="button",i.disabled=t,i.addEventListener("click",e),i}var le=r=>typeof r=="string"?r:JSON.stringify(r,null,2)??"",G=r=>Number.isFinite(r)?`$${r.toFixed(3)} known priced subtotal`:"Cost unavailable",pe=r=>r.length>20?`${r.slice(0,9)}\u2026${r.slice(-6)}`:r,W=r=>r.replaceAll("_"," ");function T(r,e=""){return a("span",`badge ${e}`,W(r))}function y(r,e){let t=a("pre","code",le(r));return e&&t.setAttribute("aria-label",e),t.tabIndex=0,t}function U(r,e){let t=a("div","section-heading");return t.append(a("p","eyebrow",r),a("h2","",e)),t}function C(r,e,t){let s=a("label","field");return s.append(a("span","field-label",r),e),e.setAttribute("aria-label",r),e.dataset.focus=t,s}var j=class{constructor(e,t){this.root=e;this.model=t}root;model;render(){let e=document.activeElement,t=e?.dataset.focus,s=e&&"selectionStart"in e?[e.selectionStart,e.selectionEnd]:void 0,i=this.model.state.authenticated?this.dashboard():this.login();if(this.root.replaceChildren(i),t){let o=this.root.querySelector(`[data-focus="${CSS.escape(t)}"]`);if(o?.focus({preventScroll:!0}),s&&o&&["INPUT","TEXTAREA"].includes(o.tagName))try{o.setSelectionRange(s[0]??0,s[1]??0)}catch{}}let n=this.root.querySelector("dialog");n&&!n.open&&n.showModal(),t&&n&&n.querySelector(`[data-focus="${CSS.escape(t)}"]`)?.focus({preventScroll:!0})}login(){let e=this.model.state,t=a("main","login-shell"),s=a("form","login-card"),i=a("div","brand");i.append(a("span","brand-mark","C"),a("span","","CYBER"),T("Local dashboard")),s.append(i,a("p","eyebrow","YOUR MACHINE. YOUR CONTROL."),a("h1","",`Your agents, within reach.`),a("p","lede","Follow the work. Review the details. Keep the final say."));let n=a("div","authority");n.append(a("strong","","Owner access to this server"),a("p","","This password can control local Sessions, tools and approvals. It is not a limited device grant. Use a trusted HTTPS endpoint or your own SSH forward.")),s.append(n);let o=a("input");o.type="password",o.autocomplete="off",o.required=!0,o.maxLength=4096,s.append(C("Password",o,"login-password"));let c=a("button","button primary wide",e.authBusy?"Connecting\u2026":"Sign in");if(c.type="submit",c.disabled=e.authBusy||Date.now()Date.now()&&setTimeout(()=>this.model.emit(),e.retryAt-Date.now()+10),e.notice){let h=a("p","notice",e.notice);h.setAttribute("role","status"),s.append(h)}let d=a("p","login-origin",window.location.origin);return s.append(d,a("p","fine-print","Password stays in page memory. Reloading signs you out. Closing this page does not stop the server.")),s.addEventListener("submit",h=>{h.preventDefault();let u=o.value;o.value="",this.model.login(u)}),t.append(s,a("p","login-footer","PRIVATE WORKSPACE \xB7 NO ACCOUNT OR RELAY REQUIRED")),t}dashboard(){let e=this.model.state,t=a("div","shell"),s=a("header","topbar"),i=a("div","brand");i.append(a("span","brand-mark","C"),a("span","","CYBER"),a("span","brand-divider","/"),a("span","top-label","Workspace"));let n=a("div",`connection ${e.connection==="connected"?"online":""}`);n.setAttribute("role","status"),n.setAttribute("aria-label","Connection status"),n.append(a("span","status-dot"),a("span","",W(e.connection)));let o=a("div","top-actions");o.append(n,f("Reconnect",()=>this.model.wake(),e.connection==="connecting","button quiet"),f("Logout",()=>this.model.logout(),!1,"button quiet")),s.append(i,o);let c=a("div","workspace");return c.append(this.sidebar(),this.main()),t.append(s,Z(this.model.computer),c),e.review&&t.append(this.review(e.review)),e.feedback&&t.append(this.feedback(e.feedback)),t}sidebar(){let e=this.model.state,t=a("aside","sidebar");t.append(U("WORKSPACE","Sessions"));let s=a("form","location-form"),i=a("input");i.type="text",i.value=e.directory,i.placeholder="/path/to/project",s.append(C("Location",i,"location"));let n=a("button","button location-button","Open location");n.type="submit",n.disabled=e.loading||!!e.creation,s.append(n),s.addEventListener("submit",d=>{d.preventDefault(),this.model.switchLocation(i.value)}),t.append(s);let o=a("nav","session-list");o.setAttribute("aria-label","Sessions");for(let d of e.sessions){let h=d.id===e.session?.id,u=f("",()=>{this.model.selectSession(d.id)},!1,`session-item ${h?"selected":""}`);u.setAttribute("aria-label",d.title||pe(d.id)),u.setAttribute("aria-pressed",String(h)),h&&u.setAttribute("aria-current","page"),u.append(a("span","session-icon",h?"\u25C8":"\u25C7"));let p=a("span","session-item-body");p.append(a("span","session-title",d.title||"Untitled Session"),a("span","session-subtitle",`${d.agent} \xB7 ${d.model}`)),u.append(p),o.append(u)}e.sessions.length||o.append(a("p","empty-small","No Sessions in this Location. Create one with New Session.")),e.sessionCursor&&o.append(f("Load more Sessions",()=>{this.model.moreSessions()},e.loading,"button quiet wide")),t.append(f(e.creation?.status==="uncertain"?"Retry creating Session":e.creation?"Creating Session\u2026":"New Session",()=>{this.model.createSession()},e.connection!=="connected"||e.creation?.status==="submitting","button wide"),o);let c=a("div","sidebar-footer");return c.append(T("Owner client"),a("p","","Execution stays on this server. Browser presence expires after 45 seconds without renewal.")),t.append(c),t}main(){let e=this.model.state,t=a("main","main"),s=a("div","session-header"),i=a("div");i.append(a("p","eyebrow",e.session?.directory??e.directory),a("h1","session-heading",e.session?.title||"Your workspace"));let n=a("div","session-meta");e.session&&n.append(T(e.session.status,e.session.status==="running"?"accent":""),T(e.session.effective_mode??e.session.mode),a("span","muted mono",e.session.model)),s.append(i,n),t.append(s);let o=e.cards.length,c=a("nav","tabs");c.setAttribute("aria-label","Session views");for(let[h,u]of[["conversation","Conversation"],["approvals","Approvals"],["runs","Runs"],["inbox","Message inbox"],["integrations","Integrations"]]){let p=f(u,()=>this.model.setTab(h),!1,`tab ${e.tab===h?"active":""}`);p.setAttribute("aria-current",e.tab===h?"page":"false"),h==="approvals"&&p.append(a("span","count",String(o))),c.append(p)}if(t.append(c),e.notice){let h=a("div","notice",e.notice);h.setAttribute("role","status"),t.append(h)}if(e.connection!=="connected"&&t.append(a("p","stale-banner","Live state is being reconciled. Approvals and execution controls are disabled until the connection and browser attachment are current.")),e.command){let h=a("div","notice");h.append(a("span","",e.command.status==="submitting"?`${e.command.description}: awaiting server acknowledgement\u2026`:`${e.command.description}: result unknown. Retrying preserves its original request identity.`)),e.command.status==="uncertain"&&h.append(f("Retry same action",()=>{this.model.retryCommand()},!this.model.actionable)),t.append(h)}let d=a("section","content");return e.loading&&!e.session?d.append(a("p","empty","Loading authenticated Session state\u2026")):e.tab==="approvals"?d.append(this.approvals()):e.tab==="integrations"?d.append(te(this.model.integrations,this.model.actionable)):e.tab==="runs"?d.append(this.runs()):e.tab==="inbox"?d.append(se(this.model.inbox,this.model.actionable,h=>this.part(h))):d.append(this.conversation()),t.append(d),e.session&&e.tab==="conversation"&&t.append(this.composer()),t}conversation(){let e=this.model.state,t=a("div","conversation");if(e.olderTranscript&&t.append(a("p","muted","Viewing an older transcript page. New messages are on the latest page."),f("Latest messages",()=>{this.model.latestMessages()},!this.model.actionable,"button quiet")),e.messageCursor&&t.append(f("Load older messages",()=>{this.model.olderMessages()},!this.model.actionable,"button quiet")),!e.messages.length){let s=a("div","empty-state");s.append(a("span","empty-glyph","\u2733"),a("h2","",e.session?"A clear place to start":"Choose a Session"),a("p","",e.session?"Send a direction or queue the next step. Requests that need your judgement appear in Approvals.":"Open an existing Session to follow its work and review requests.")),t.append(s)}for(let s of e.messages)t.append(this.message(s));if(e.liveText&&!e.olderTranscript&&!e.messages.some(s=>s.id===e.liveMessage)){let s=a("article","message assistant");s.append(a("div","message-label","Assistant \xB7 streaming"),a("div","prose",e.liveText)),t.append(s)}if(e.diffs.length){let s=a("section","changes");s.append(U("WORKING TREE",`${e.diffs.length} changed file${e.diffs.length===1?"":"s"}`));for(let i of e.diffs){let n=a("details","diff"),o=a("summary");o.append(a("span","mono",i.file),a("span","diff-stats",`+${i.additions} \u2212${i.deletions}`)),n.append(o,y(i.patch,`Diff ${i.file}`)),s.append(n)}t.append(s)}else e.unavailable.has("Diffs")&&t.append(a("p","muted","Diffs are unavailable on this server."));return t}message(e){let t=a("article",`message ${e.kind}`),s=e.kind==="user"?"You":e.kind==="assistant"?`${e.agent??"Assistant"} \xB7 ${e.finished?"completed":"working"}`:"Session note";if(t.append(a("div","message-label",s)),e.kind==="user")for(let i of e.parts)t.append(this.part(i));else{let i=this.model.state.liveMessage===e.id?this.model.state.liveText:"";t.append(a("div","prose",i.startsWith(e.text)&&i.length>e.text.length?i:e.text))}for(let i of e.tools??[]){let n=a("details","tool"),o=a("summary","",`${i.name} \xB7 ${W(i.status)}`);n.append(o,y(i.arguments,"Tool arguments")),i.output&&n.append(y(i.output,"Tool output")),t.append(n)}return e.kind==="assistant"&&e.error&&t.append(a("p","tool-error","This step ended with an error. Review its tool output and the current Session state.")),t}part(e){return e.type==="text"?a("div","prose",e.text):e.type==="tool_result"?y(e.output,"Tool result"):e.type==="tool_call"?y({tool:e.name,input:e.input},"Tool call"):a("p","muted",["image","document","attachment"].includes(e.type)?"Attachment \xB7 display is outside this dashboard slice":"Reasoning content is not expanded here.")}composer(){let e=this.model.state,t=a("form","composer"),s=a("textarea");s.rows=3,s.placeholder="What should we work on next?",s.value=e.draft,s.disabled=!this.model.actionable||!!e.command,s.maxLength=128*1024,s.addEventListener("input",()=>this.model.setDraft(s.value)),t.append(C("Message",s,"composer"));let i=a("div","composer-bar"),n=a("select");for(let[d,h]of[["steer","Steer"],["queue","Queue"]]){let u=a("option","",h);u.value=d,n.append(u)}n.value=e.delivery,n.disabled=!this.model.actionable||!!e.command,n.addEventListener("change",()=>{e.delivery=n.value}),i.append(C("Delivery",n,"delivery"));let o=a("div","composer-actions");o.append(f("Interrupt",()=>{this.model.interrupt()},!this.model.actionable||!!e.command||e.session?.status!=="running","button quiet danger"));let c=a("button","button primary","Send message");return c.type="submit",c.disabled=!this.model.actionable||!!e.command,o.append(c),i.append(o),t.append(i,a("p","fine-print","Steer joins the current work; Queue waits for the next turn. Only a server acknowledgement confirms delivery.")),t.addEventListener("submit",d=>{d.preventDefault(),this.model.prompt()}),t}approvals(){let e=this.model.state,t=a("div","approvals"),s=a("div","inbox-intro");if(s.append(U("HUMAN JUDGEMENT","Approval inbox"),a("p","muted","Review the exact owner, origin and scope. A browser cannot revive a request already rejected while headless.")),t.append(s),!e.cards.length){let i=a("div","empty-state");i.append(a("span","empty-glyph","\u2713"),a("h2","","Nothing waiting for your decision"),a("p","","Requests appear here when the server asks. Your presence never grants permission by itself.")),t.append(i)}for(let i of e.cards)t.append(i.request.kind==="permission"?this.permission(i):this.question(i));return t}requestHeader(e){let t=e.request,s=a("header","request-header"),i=t.kind==="permission"?`Allow ${t.action}?`:"A question for you";s.append(T(t.kind==="permission"?"Permission":"Question","amber"),a("h3","",i));let n=a("dl","identity");for(let[o,c]of[["Owner Session",t.session_id],["Request",t.id],["Origin",t.origin?`${t.origin.title} \xB7 ${t.origin.agent}${t.origin.member?` \xB7 ${t.origin.member}`:""}`:"This Session"],["Location",t.origin?.directory??this.model.state.session?.directory??""]])n.append(a("dt","",o),a("dd","mono",c));return s.append(n),s}status(e){if(!e.decision)return;let t=a("div","notice",e.decision.status==="submitting"?"Waiting for server acknowledgement. No result is assumed.":"Answer result unknown. The original owner, request, displayed payload and idempotency key are retained.");return e.decision.status==="uncertain"&&t.append(f("Retry same answer",()=>{this.model.answer(e,e.decision.body,!0)},!this.model.actionable)),t}permission(e){let t=e.request;if(t.kind!=="permission")return a("div");let s=a("article","request-card");s.setAttribute("aria-label",`Permission request ${t.id}`),s.append(this.requestHeader(e)),s.append(a("h4","","Requested resources"),y(t.resources.join(` `),"Requested resources")),t.metadata!==void 0&&t.metadata!==null&&s.append(a("h4","","Command, diff and reason"),y(t.metadata,"Command or diff"));let i=a("div","request-actions"),n=!this.model.actionable||!!e.decision;i.append(f("Approve once",()=>{this.model.answer(e,{reply:"once"})},n,"button primary")),i.append(f("Review saved approval",()=>this.model.review(e),n||!t.always_patterns.length)),i.append(f("Reject and stop",()=>{this.model.answer(e,{reply:"reject"})},n,"button danger")),i.append(f("Reject with feedback",()=>this.model.feedback(e),n,"button quiet")),s.append(i);let o=this.status(e);return o&&s.append(o),s}question(e){let t=e.request;if(t.kind!=="question")return a("div");let s=a("article","request-card");s.setAttribute("aria-label",`Question request ${t.id}`),s.append(this.requestHeader(e));let i=this.model.state.answers.get(e.key)??t.questions.map(()=>[]),n=this.model.state.customs.get(e.key)??t.questions.map(()=>""),o=!this.model.actionable||!!e.decision;t.questions.forEach((u,p)=>{let b=a("fieldset","question");if(b.disabled=o,b.append(a("legend","",u.header),a("p","",u.question)),u.options.forEach((v,L)=>{let q=a("input");q.type=u.multi_select?"checkbox":"radio",q.name=`answer-${t.id}-${p}`,q.checked=i[p]?.includes(v.label)??!1,q.dataset.focus=`${e.key}-${p}-${L}`,q.addEventListener("change",()=>{let V=i[p]??[];i[p]=u.multi_select?q.checked?[...V,v.label]:V.filter(ae=>ae!==v.label):[v.label],u.multi_select||(n[p]=""),this.model.state.answers.set(e.key,i),this.model.state.customs.set(e.key,n),this.model.emit()});let K=a("label","choice"),B=a("span");B.append(a("strong","",v.label)),v.description&&B.append(a("span","muted",v.description)),K.append(q,B),b.append(K)}),u.allow_custom!==!1){let v=a("input");v.type="text",v.value=n[p]??"",v.maxLength=16*1024,v.addEventListener("input",()=>{n[p]=v.value,u.multi_select||(i[p]=[]),this.model.state.customs.set(e.key,n),this.model.state.answers.set(e.key,i),this.model.emit()}),b.append(C("Custom answer",v,`${e.key}-custom-${p}`))}s.append(b)});let c=i.map((u,p)=>[...u,...n[p]?.trim()?[n[p].trim()]:[]]),d=a("div","request-actions");d.append(f("Submit answers",()=>{this.model.answer(e,{answers:c})},o||c.some(u=>!u.length),"button primary"),f("Dismiss question",()=>{this.model.answer(e,{})},o,"button quiet danger")),s.append(d);let h=this.status(e);return h&&s.append(h),s}nativeStop(e){let t=F(this.model.computer);t&&e.prepend(t)}review(e){let t=a("dialog","dialog"),s=e.request;if(s.kind!=="permission")return t;t.setAttribute("aria-labelledby","saved-approval-heading");let i=a("h2","","Review saved approval");i.id="saved-approval-heading",t.append(i,a("p","","This saves approval for the exact patterns below. Review their breadth and the command before confirming."),this.requestHeader(e),y(s.metadata??s.resources,"Command under review"),a("h3","","Saved-approval scope"),y(s.always_patterns.join(` `),"Saved-approval scope"));let n=a("input");n.type="checkbox",n.dataset.focus="review-scope",n.checked=this.model.state.reviewed,n.addEventListener("change",()=>{this.model.state.reviewed=n.checked,this.model.emit()});let o=a("label","choice");o.append(n,a("span","","I reviewed this saved approval scope")),t.append(o);let c=a("div","request-actions");return c.append(f("Cancel",()=>{this.model.state.review=void 0,this.model.emit()}),f("Approve always",()=>{this.model.answer(e,{reply:"always"})},!this.model.state.reviewed||!this.model.actionable,"button primary")),t.append(c),this.nativeStop(t),t.addEventListener("cancel",d=>{d.preventDefault(),this.model.state.review=void 0,this.model.emit()}),t}feedback(e){let t=a("dialog","dialog");t.setAttribute("aria-labelledby","feedback-heading");let s=a("h2","","Reject with feedback");s.id="feedback-heading",t.append(s,a("p","","The model receives your feedback. Reject and stop, without feedback, is a separate action."),this.requestHeader(e));let i=a("textarea");i.rows=4,i.maxLength=16*1024,i.value=this.model.state.feedbackDraft,t.append(C("Rejection feedback",i,"rejection-feedback"));let n=f("Reject with feedback",()=>{i.value.trim()&&this.model.answer(e,{reply:"reject",message:i.value})},!0,"button danger");i.addEventListener("input",()=>{this.model.state.feedbackDraft=i.value,n.disabled=!i.value.trim()||!this.model.actionable});let o=a("div","request-actions");return o.append(f("Cancel",()=>{this.model.state.feedback=void 0,this.model.emit()}),n),t.append(o),this.nativeStop(t),t.addEventListener("cancel",c=>{c.preventDefault(),this.model.state.feedback=void 0,this.model.emit()}),t}reservations(){let e=this.model.state,t=a("section","run-section");t.append(a("h3","","Budget allocations")),e.reservationError?t.append(a("p","muted",e.reservationError)):e.reservations.length||t.append(a("p","empty-small","No request reservations for this Session. Soft budgets do not reserve capacity."));for(let s of e.reservations){let i=a("details","run-card");i.append(a("summary","",`${s.purpose} \xB7 ${s.state} \xB7 ${s.held_tokens.toLocaleString()} tokens held`),a("p","fine-print","Declared request bounds are scheduling estimates. Uncertain requests keep their remaining allocation until an owner audits the provider outcome."),y(s,"Reservation bounds, actual usage and uncertainty")),t.append(i)}return t}runs(){let e=this.model.state,t=a("div","runs"),s=!this.model.actionable||!!e.command;t.append(U("IN FLIGHT & RECENT","Work monitor"),a("p","fine-print","Costs are known priced subtotals. Unknown pricing and incomplete usage may add cost; zero does not establish free execution.")),t.append(this.reservations());let i=a("section","run-section");i.append(a("h3","","Workflow runs")),e.unavailable.has("Workflows")?i.append(a("p","muted","Workflow routes are unavailable on this server.")):e.runs.length||i.append(a("p","empty-small","No workflow runs for this Session."));for(let d of e.runs){let h=a("article","run-card");h.append(a("h4","",d.name),T(d.status),a("p","muted",`${d.phase??"No current phase"} \xB7 ${G(d.cost_usd)} \xB7 ${d.tokens.toLocaleString()} tokens`));let u=a("div","request-actions");["running","waiting_input","queued"].includes(d.status)&&u.append(f("Pause workflow",()=>{this.model.control(`/workflows/runs/${encodeURIComponent(d.id)}/pause`,"Pause workflow")},s)),d.status==="paused"&&u.append(f("Resume workflow",()=>{this.model.control(`/workflows/runs/${encodeURIComponent(d.id)}/resume`,"Resume workflow")},s)),["running","waiting_input","paused","queued"].includes(d.status)&&u.append(f("Stop workflow",()=>{this.model.control(`/workflows/runs/${encodeURIComponent(d.id)}/stop`,"Stop workflow")},s,"button danger")),u.append(f("View run evidence",()=>{this.model.runDetail(d.id)},!this.model.actionable,"button quiet")),h.append(u);let p=e.details.get(d.id);p&&h.append(this.evidence(p)),i.append(h)}t.append(i);let n=a("section","run-section");n.append(a("h3","","Background Jobs")),e.unavailable.has("Jobs")?n.append(a("p","muted","Job routes are unavailable on this server.")):e.jobs.length||n.append(a("p","empty-small","No background Jobs for this Session."));for(let d of e.jobs){let h=a("article","run-card");h.append(a("h4","",d.name||d.description),T(d.status),a("p","muted",`${d.kind} \xB7 ${G(d.cost)}`)),d.command&&h.append(y(d.command,"Job command"));let u=a("div","request-actions");d.status==="running"&&u.append(f("Stop Job",()=>{this.model.control(`/jobs/${encodeURIComponent(d.id)}/stop`,"Stop Job")},s,"button danger")),u.append(f("View Job output",()=>{this.model.jobOutput(d.id)},!this.model.actionable,"button quiet")),h.append(u),this.model.jobLog?.id===d.id&&h.append(y(this.model.jobLog.text,"Job output")),n.append(h)}t.append(n);let o=a("section","run-section");o.append(a("h3","","Goals")),e.unavailable.has("Goals")?o.append(a("p","muted","Goal routes are unavailable on this server.")):e.goals?.current||o.append(a("p","empty-small","No current Goal. Browser presence does not start one."));let c=e.goals?.current;if(c&&e.session){let d=a("article","run-card");d.append(a("h4","",c.condition),T(c.status)),c.reason&&d.append(a("p","prose",c.reason));let h=`/sessions/${encodeURIComponent(e.session.id)}/goals`,u=a("div","request-actions");c.status==="active"&&u.append(f("Pause current Goal",()=>{this.model.control(`${h}/pause`,"Pause Goal")},s)),c.status==="paused"&&u.append(f("Resume current Goal",()=>{this.model.control(`${h}/resume`,"Resume Goal")},s)),u.append(f("Clear Goal",()=>{this.model.control(`${h}/${encodeURIComponent(c.goal_id)}`,"Clear Goal","DELETE")},s,"button danger")),d.append(u),o.append(d)}return t.append(o),t}evidence(e){let t=a("section","evidence");t.append(a("h4","","Run evidence"),a("p","muted",`${e.status}${e.reason?` \xB7 ${e.reason}`:""}`));let s=e.solve_evidence;if(!s)return t.append(a("p","muted","No server-owned solve evidence attached. Workflow output is not a verification badge.")),t;let i=e.result&&typeof e.result=="object"?e.result:{},n=typeof i.recommended_candidate=="string"?i.recommended_candidate:void 0,o=s.candidates.find(c=>c.label===n||c.candidate_id===n);if(e.result===void 0&&e.result_file?(t.append(a("p","evidence-recommendation","Recommendation unavailable: report stored externally. This view is incomplete."),y(e.result_file,"External report path")),e.result_preview&&t.append(y(e.result_preview,"External report preview"))):t.append(a("p","evidence-recommendation",o?.host_verified&&i.verification_status==="recommended"?`Workflow recommendation: ${o.label??o.candidate_id} \xB7 review required`:"No candidate recommended. Review checks and incomplete outcomes.")),i.budget!==void 0){let c=a("details","tool");c.append(a("summary","","Budget observation and completeness"),y(i.budget,"Budget observation")),t.append(c)}i.limitations!==void 0&&t.append(y(i.limitations,"Report limitations")),typeof i.verification_status=="string"&&t.append(T(i.verification_status));for(let c of s.candidates){let d=a("div","candidate");d.append(a("h4","",c.label??c.candidate_id),T(c.host_verified?"Host-verified check evidence":"Unverified"),a("p","mono",c.branch??"No retained branch"),a("p","muted",`${c.model??"Model unavailable"} \xB7 ${G(c.cost_usd)}`),y(c.checks,"Candidate checks")),t.append(d)}return t.append(a("p","fine-print","Recommendations require human review. This dashboard does not apply, merge or publish patches.")),t}};var ie=document.getElementById("app");if(!ie)throw new Error("Dashboard root is unavailable");var R,fe=new M(window.location.origin,()=>{R?.state.authenticated&&R.authenticationRequired()});R=new D(fe);var ne=new j(ie,R);R.changed=()=>ne.render();window.addEventListener("online",()=>R.wake());window.addEventListener("offline",()=>{R.state.connection="offline",R.emit()});window.addEventListener("pagehide",()=>R.logout());document.addEventListener("visibilitychange",()=>{document.visibilityState==="visible"&&R.wake()});ne.render(); diff --git a/web/src/model.ts b/web/src/model.ts index 5ad7070f9..ee2796b51 100644 --- a/web/src/model.ts +++ b/web/src/model.ts @@ -446,6 +446,8 @@ export class Dashboard { if (event.durable?.aggregateID === session) this.#cursors.set(session, Math.max(this.#cursors.get(session) ?? -1, event.durable.seq)); const payload = event.data as Record | null; if (event.type === "session.text.delta" && payload?.session_id === session && typeof payload.text === "string" && typeof payload.message_id === "string") { this.textDelta(payload.message_id, payload.text); return; } + // `provider-catalog` → Retry policy: the partial answer was discarded and the request replays. + if (event.type === "session.retry" && payload?.session_id === session && payload.discarded === true && payload.message_id === this.state.liveMessage) { this.state.liveText = ""; this.emit(); return; } clearTimeout(this.#refreshTimer); this.#refreshTimer = setTimeout(() => { void this.refresh().catch((error) => this.showError(error)); }, 100); } diff --git a/web/test/model.test.ts b/web/test/model.test.ts index e90799dce..c04baf2e9 100644 --- a/web/test/model.test.ts +++ b/web/test/model.test.ts @@ -70,6 +70,20 @@ test("durable completed messages retire matching live text even before the Sessi deliver("msg_other", "Separate answer"); assert.equal(model.state.liveText, "Separate answer", "different message IDs do not concatenate"); }); +test("a retry that discarded a partial answer clears its live text before the replay streams", async (t) => { + const { model } = await ready(t); + const send = (type: string, data: Record) => (model as unknown as { event(event: EventEnvelope, session: string): void }).event({ id: "live", type, data: { session_id: "ses_one", ...data } } as EventEnvelope, "ses_one"); + send("session.text.delta", { message_id: "msg_live", text: "first half" }); + send("session.retry", { message_id: "msg_live", attempt: 1, delay_ms: 900, error: "transport", discarded: false }); + assert.equal(model.state.liveText, "first half", "a retry before output keeps the text"); + send("session.retry", { message_id: "msg_other", attempt: 1, delay_ms: 900, error: "transport", discarded: true }); + assert.equal(model.state.liveText, "first half", "another message's retry does not clear it"); + send("session.retry", { message_id: "msg_live", attempt: 2, delay_ms: 900, error: "transport", discarded: true }); + assert.equal(model.state.liveText, ""); + send("session.text.delta", { message_id: "msg_live", text: "Done." }); + assert.equal(model.state.liveText, "Done."); +}); + test("lost reply retries the exact owner/request/key/payload, never the selected parent or a fresh decision", async (t) => { const { model, api } = await ready(t); const card = model.state.cards[0]!; api.failReply = true; await model.answer(card, { reply: "once" });