Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -20,7 +20,7 @@ test-support surface.
| `inference_override` | Typed request matching and response precedence shared by all inference routes: endpoint + foreground/auxiliary classification, named expectation state, overlapping-duplicate fingerprint replay, per-expectation barriers, compatibility FIFO dispatch, auth rejection, and compatibility completion-gate policy. The module is crate-private; only `InferenceEndpoint`, `InferenceRequestMatcher`, and `InferenceExpectation` are re-exported. |
| `mock_server` | `MockInferenceServer``/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/models`, `/v1/settings`, `/v1/user` on `127.0.0.1:0`. `/v1/models` entries are `MockModelEntry` (re-exported as `MockModel` for PTY tests): `new(id)` / `with_agent_type(id, ty)` plus chainable `with_api_backend`, `with_supports_backend_search(bool)``supportsBackendSearch`, `with_supports_reasoning_effort(bool)``supportsReasoningEffort`, `with_reasoning_effort(&str)``reasoningEffort`, `with_reasoning_efforts(Vec<Value>)``reasoningEfforts` (raw option tables/bare strings), all emitted top-level as `parse_remote_model_value` reads them. Inference precedence is **matched expectation > compatibility FIFO > required-auth > echo/fixed mode**. Register a uniquely named response with `expect_response(name, InferenceRequestMatcher::{foreground,auxiliary}(InferenceEndpoint::{ChatCompletions,Responses,Messages}), ScriptedResponse)` or `expect_response_blocked`; duplicate names fail at registration and requests atomically claim one matching expectation. Overlapping duplicate requests replay by a deterministic fingerprint of endpoint, request kind, non-empty `x-grok-req-id`, and serialized request body; tool-result follow-ups reuse the turn id but change the body, so they claim the next expectation. Production exposes no explicit HTTP attempt/model-call identity, so completed sequential retries are intentionally not inferred from timing: after the active shared call settles, an identical request claims the next expectation. A foreground request normally carries a non-empty `x-grok-turn-idx`; a non-turn non-empty `x-grok-req-id` is auxiliary even if it uses tools, and empty headers fall through to the 2+-tool compatibility heuristic. The returned `InferenceExpectation` has watch-backed `wait_received`, `wait_blocked`, `release`, `wait_satisfied`, `is_satisfied`, and `assert_satisfied` lifecycle operations. `release` only opens the barrier; response-body/stream-owned RAII publishes `Satisfied` only when the primary crosses terminal and every active overlapping copy settles. Primary cancellation cleans up without satisfaction or replay retention, and dropping a handle safely releases blocked work. Echo (default) streams `Echo: <last user message>` and fixed mode via `set_response(text)` reconstructs bytes exactly. Constructors (`start`, `start_with_models`, `start_with_required_auth`) return `anyhow::Result`. Settings are 404-until-set (`set_settings(impl Serialize)`, `preset_allow_access()` for the `{"allow_access": true}` gate); scripted `/v1/settings` one-shots (`enqueue_response`) take precedence over the steady-state value (stale-snapshot tests). `/v1/user` serves a minimal `UserInfo` whose `subscriptionTier` is controlled by `set_user_subscription_tier(Option<&str>)` (`None` = free); its log entries keep the query string (e.g. `/v1/user?include=subscription`) so subscription-check cadence is countable. Request log: `requests()` (`LogEntry` with body, `authorization`, full POST headers + `header(name)` accessor), `request_bodies()`, `request_count()`, `has_chat_completion_request()` / `has_responses_request()` (exact, per endpoint), `messages_request_count()`, `last_system_prompt()`, `request_log_summary()`. **Storage:** `POST /v1/storage` with flippable 401 (`set_storage_unauthorized`); accepted uploads via `storage_uploads()``StorageUpload { path, size, body, authorization }` (`body` retained up to 256 KiB, empty above; `authorization` is the raw header). Runtime knobs: `set_models`, `set_messages_stop_reason`. Shuts down on drop. |
| `scripted` | Data-only response bodies (no axum types in the public surface): `SseEvent { event, data }` (`::data`, `::with_event`), `ScriptedBody::{Json, Sse, Raw}` (`Raw` = byte-controllable malformed SSE), `ScriptedResponse { status, headers, body }` (`::sse`, `::json`, `::text`). Prefer request-matched expectations for inference calls; `enqueue_response(path, response)` remains a compatibility FIFO per path and is still used for non-inference one-shots such as `/v1/settings`. Scripted SSE honors `set_chunk_delay`; matched JSON, raw, SSE, and even empty SSE bodies all honor per-expectation completion barriers. The compatibility `hold_agent_completions` gate also covers foreground scripted SSE on all three inference endpoints. Validation is eager — bad status/header panics at registration. |
| `sse` | The three wire formats as event-list builders: `chat_completion_events` / `responses_api_events` / `messages_api_events(text, model, stop_reason)` (echo-style, whitespace-collapsing) plus byte-exact variants `chat_completion_events_exact` / `responses_api_events_exact` (messages is single-delta, byte-exact by construction). The exact/echo split is load-bearing — see the in-module byte-exactness tests. Also the scripted-scenario builders returning `SseEvent`s (for `ScriptedResponse::sse`): `responses_api_reasoning_only_events(reasoning, model)` — reasoning summary deltas completing with a `reasoning` item but no message/output-text, so the shell collector classifies the turn `EmptyReason::ReasoningOnly` (the model-doomloop trigger); `responses_api_reasoning_and_text_events(reasoning, text, model)` — reasoning deltas then a normal text answer (the ordinary reasoning-model turn); `responses_api_reasoning_then_tool_call_events(reasoning, call_id, name, arguments, model)` + its Chat Completions twin `chat_completions_reasoning_then_tool_call_events(...)` — reasoning deltas then one tool call (the think-then-call turn whose tool call finishes the thought and keeps the turn non-empty); the doom-loop check trio: `responses_api_doom_loop_check_events(triggers, reasoning, model)` — a doomed reasoning-only turn with NAMED `response.doom_loop_check` frames re-sent per cumulative prefix of `triggers` plus the terminal `doom_loop_check.triggers` copy on `response.completed`, `responses_api_doom_loop_terminal_only_events(triggers, reasoning, text, model)` — a normal answer whose terminal response alone carries the field, and `responses_api_with_doom_loop_frame(check_frame_data, reasoning, text, model)` — splices one named check frame with a caller-supplied payload (byte-exact `xai_grok_sampling_types::doom_loop::SAMPLE_CHECK_EVENT_DATA{,_CUMULATIVE}` fixtures or malformed variants) into an ordinary turn. |
| `sse` | The three wire formats as event-list builders: `chat_completion_events` / `responses_api_events` / `messages_api_events(text, model, stop_reason)` (echo-style, whitespace-collapsing) plus byte-exact axum variants `chat_completion_events_exact` / `responses_api_events_exact` and matching public scripted variants `chat_completion_script_exact` / `responses_api_script_exact` (messages is single-delta, byte-exact by construction). The exact/echo split is load-bearing — see the in-module byte-exactness tests. Also the scripted-scenario builders returning `SseEvent`s (for `ScriptedResponse::sse`): `responses_api_reasoning_only_events(reasoning, model)` — reasoning summary deltas completing with a `reasoning` item but no message/output-text, so the shell collector classifies the turn `EmptyReason::ReasoningOnly` (the model-doomloop trigger); `responses_api_reasoning_and_text_events(reasoning, text, model)` — reasoning deltas then a normal text answer (the ordinary reasoning-model turn); `responses_api_reasoning_then_tool_call_events(reasoning, call_id, name, arguments, model)` + its Chat Completions twin `chat_completions_reasoning_then_tool_call_events(...)` — reasoning deltas then one tool call (the think-then-call turn whose tool call finishes the thought and keeps the turn non-empty); the doom-loop check trio: `responses_api_doom_loop_check_events(triggers, reasoning, model)` — a doomed reasoning-only turn with NAMED `response.doom_loop_check` frames re-sent per cumulative prefix of `triggers` plus the terminal `doom_loop_check.triggers` copy on `response.completed`, `responses_api_doom_loop_terminal_only_events(triggers, reasoning, text, model)` — a normal answer whose terminal response alone carries the field, and `responses_api_with_doom_loop_frame(check_frame_data, reasoning, text, model)` — splices one named check frame with a caller-supplied payload (byte-exact `xai_grok_sampling_types::doom_loop::SAMPLE_CHECK_EVENT_DATA{,_CUMULATIVE}` fixtures or malformed variants) into an ordinary turn. |
| `acp_client` | `GrokStdioClient` — drives `grok agent stdio` over real pipes through `agent-client-protocol`: spawn variants (`spawn`, `spawn_with_home`, `spawn_with_home_and_env`, `spawn_with_home_env_and_args`), initialize/authenticate, session create/load, prompt, `*_with_timeout` wrappers, captured text + stderr. `RawStdioClient` — raw-wire sibling for bytes the typed `ClientSideConnection` can never produce (escaped-slash methods `"session\/prompt"`, string UUID ids — the Xcode/Foundation shape): `send_line` writes a line verbatim; `response_for_id` matches the response by exact string id (the match IS the id-echo assertion), skips notifications, auto-refuses agent→client requests with `-32601`, and panics on timeout with skipped-traffic diagnostics (count + last lines; `0 other messages` = true silence). Both spawn through one hermetic `spawn_agent_process` (sandbox env + debug-log kill-list exists once) atop `process::spawn_piped_with_stderr_capture` (crate-internal `process` module: pipes, `kill_on_drop`, stderr drain — also used by `leader::LeaderStdioClient`). |
| `headless` | `run_headless(server, args, cwd)` / `run_headless_with_env(server, args, cwd, env)` (extra env applied after the defaults, so it overrides them) / `run_headless_with_cmd(cmd)``HeadlessResult { status, stdout, stderr, timed_out }` (60s cap), `assert_headless_success`, `assert_no_crashes` (panic/SIGSEGV/linker patterns), `stderr_tail`. |
| `env` | `grok_binary()` (`GROK_BINARY` env → `CARGO_BIN_EXE` → local debug build of `xai-grok-pager`), `git_workdir()` (temp git repo, forces full libgit2 init), `test_env_cmd_tokio(cmd, mock_url, home)` (sandboxed HOME **and GROK_HOME** — Windows resolves `~` via USERPROFILE, so HOME alone doesn't sandbox — + mock endpoints + telemetry kill-switches). |

View file

@ -10,6 +10,8 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use crate::scaled;
use agent_client_protocol::{self as acp, Agent as _};
use tempfile::TempDir;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
@ -306,16 +308,27 @@ impl GrokStdioClient {
self.home.as_ref().expect("test home already taken").path()
}
/// Timing breadcrumb for tuning CI timeout budgets (visible with --nocapture).
fn log_timing(what: &str, started: std::time::Instant) {
eprintln!("[harness-timing] {what}: {:?}", started.elapsed());
}
pub async fn initialize_with_timeout(&self) -> acp::InitializeResponse {
tokio::time::timeout(Duration::from_secs(20), self.initialize())
let started = std::time::Instant::now();
let r = tokio::time::timeout(scaled(Duration::from_secs(20)), self.initialize())
.await
.unwrap_or_else(|_| panic!("initialize timed out\nstderr:\n{}", self.stderr()))
.unwrap_or_else(|_| panic!("initialize timed out\nstderr:\n{}", self.stderr()));
Self::log_timing("initialize", started);
r
}
pub async fn create_session_with_timeout(&self, cwd: &Path) -> acp::SessionId {
tokio::time::timeout(Duration::from_secs(20), self.create_session(cwd))
let started = std::time::Instant::now();
let r = tokio::time::timeout(scaled(Duration::from_secs(20)), self.create_session(cwd))
.await
.unwrap_or_else(|_| panic!("session/new timed out\nstderr:\n{}", self.stderr()))
.unwrap_or_else(|_| panic!("session/new timed out\nstderr:\n{}", self.stderr()));
Self::log_timing("session/new", started);
r
}
pub async fn create_session_with_model_timeout(
@ -324,7 +337,7 @@ impl GrokStdioClient {
model_id: &str,
) -> acp::SessionId {
tokio::time::timeout(
Duration::from_secs(20),
scaled(Duration::from_secs(20)),
self.create_session_with_model(cwd, model_id),
)
.await
@ -342,7 +355,7 @@ impl GrokStdioClient {
model_id: &str,
) -> acp::Result<acp::SetSessionModelResponse> {
tokio::time::timeout(
Duration::from_secs(20),
scaled(Duration::from_secs(20)),
self.set_model(session_id, model_id),
)
.await
@ -359,9 +372,15 @@ impl GrokStdioClient {
session_id: &acp::SessionId,
text: &str,
) -> acp::Result<acp::PromptResponse> {
tokio::time::timeout(Duration::from_secs(30), self.prompt(session_id, text))
.await
.unwrap_or_else(|_| panic!("prompt timed out\nstderr:\n{}", self.stderr()))
let started = std::time::Instant::now();
let r = tokio::time::timeout(
scaled(Duration::from_secs(30)),
self.prompt(session_id, text),
)
.await
.unwrap_or_else(|_| panic!("prompt timed out\nstderr:\n{}", self.stderr()));
Self::log_timing("prompt", started);
r
}
pub async fn load_session_with_timeout(
@ -372,7 +391,7 @@ impl GrokStdioClient {
// 60s: session/load replays history and is slower under Rosetta
// (macos-x86_64 lifecycle CI). 20s flaked repeatedly there.
tokio::time::timeout(
Duration::from_secs(60),
scaled(Duration::from_secs(60)),
self.conn.load_session(
acp::LoadSessionRequest::new(session_id.clone(), cwd.to_path_buf())
.mcp_servers(vec![]),
@ -459,7 +478,7 @@ impl RawStdioClient {
) -> serde_json::Value {
use tokio::io::AsyncBufReadExt as _;
let deadline = tokio::time::Instant::now() + timeout;
let deadline = tokio::time::Instant::now() + scaled(timeout);
let mut line = String::new();
let mut skipped = 0_usize;
let mut skipped_tail: Vec<String> = Vec::new();

View file

@ -17,12 +17,19 @@ pub struct HeadlessResult {
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
/// Wall time of the grok invocation; logged so CI timeout budgets can be
/// tuned against observed durations.
pub elapsed: Duration,
}
const HEADLESS_TIMEOUT_SECS: u64 = 60;
/// Timeout for one headless grok invocation: 60 seconds, multiplied by
/// [`crate::scaled`]'s `GROK_TEST_TIMEOUT_SCALE`.
fn headless_timeout() -> Duration {
crate::scaled(Duration::from_secs(60))
}
/// Run `grok` with the given args against the mock server, with a 60s timeout.
/// Uses an isolated HOME and disables telemetry.
/// Run `grok` with the given args against the mock server, bounded by
/// [`headless_timeout_secs`]. Uses an isolated HOME and disables telemetry.
pub async fn run_headless(
server: &MockInferenceServer,
args: &[&str],
@ -55,6 +62,7 @@ pub async fn run_headless_with_env(
pub async fn run_headless_with_cmd(mut cmd: tokio::process::Command) -> HeadlessResult {
let binary = grok_binary();
let started = std::time::Instant::now();
let mut child = cmd
.spawn()
.unwrap_or_else(|e| panic!("failed to spawn grok binary at {}: {e}", binary.display()));
@ -75,12 +83,7 @@ pub async fn run_headless_with_cmd(mut cmd: tokio::process::Command) -> Headless
Ok::<Vec<u8>, std::io::Error>(stderr_buf)
});
let (status, timed_out) = match tokio::time::timeout(
Duration::from_secs(HEADLESS_TIMEOUT_SECS),
child.wait(),
)
.await
{
let (status, timed_out) = match tokio::time::timeout(headless_timeout(), child.wait()).await {
Ok(result) => (
result.unwrap_or_else(|e| {
panic!("failed to wait for grok binary {}: {e}", binary.display())
@ -110,11 +113,17 @@ pub async fn run_headless_with_cmd(mut cmd: tokio::process::Command) -> Headless
Err(err) => panic!("stderr task join failed for {}: {err}", binary.display()),
};
let elapsed = started.elapsed();
// Timing breadcrumb for tuning CI timeout budgets against observed
// durations (visible with --nocapture).
eprintln!("[harness-timing] headless grok run: {elapsed:?} (timed_out={timed_out})");
HeadlessResult {
status,
stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(),
stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
timed_out,
elapsed,
}
}
@ -140,7 +149,8 @@ pub fn assert_headless_success(
) {
assert!(
!result.timed_out,
"{label}: timed out after {HEADLESS_TIMEOUT_SECS}s\nstderr tail:\n{}",
"{label}: timed out after {:?}\nstderr tail:\n{}",
headless_timeout(),
stderr_tail(&result.stderr, 500)
);
assert!(

View file

@ -19,6 +19,17 @@
//! - [`grok_binary`] — Resolve the grok binary path (GROK_BINARY env or cargo_bin)
//! - [`spawn_counting_server`] — Connection-counting HTTP/1.1 server for wire/pooling tests
//! - [`uds_proxy::UdsProxy`] — Frame-aware fault-injection proxy for leader IPC sockets (unix)
/// Multiply a harness timeout by `GROK_TEST_TIMEOUT_SCALE` (positive integer,
/// default 1). CI lanes on shared runner pools raise it so pool load slows
/// tests instead of failing them (see the Grok Build merge CI workflow).
pub fn scaled(base: std::time::Duration) -> std::time::Duration {
let scale = std::env::var("GROK_TEST_TIMEOUT_SCALE")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.filter(|&v| v > 0)
.unwrap_or(1);
base * scale
}
pub mod acp_client;
pub mod counting_server;
pub mod env;

View file

@ -47,7 +47,10 @@ pub fn messages_api_events(text: &str, model: &str, stop_reason: &str) -> Vec<Ev
/// (whitespace-collapsing; use [`chat_completion_events_exact`] when the
/// receiver must reconstruct `text` byte-for-byte).
pub fn chat_completion_events(text: &str, model: &str) -> Vec<Event> {
chat_completion_events_from_deltas(&space_prefixed_deltas(text.split_whitespace()), model)
scripted_to_axum(chat_completion_script_from_deltas(
&space_prefixed_deltas(text.split_whitespace()),
model,
))
}
/// Like [`chat_completion_events`] but byte-exact: concatenating the deltas
@ -55,7 +58,12 @@ pub fn chat_completion_events(text: &str, model: &str) -> Vec<Event> {
/// Fenced code blocks (mermaid etc.) need their newlines to parse as a block,
/// which `split_whitespace` would destroy.
pub fn chat_completion_events_exact(text: &str, model: &str) -> Vec<Event> {
chat_completion_events_from_deltas(&chat_completion_deltas(text), model)
scripted_to_axum(chat_completion_script_exact(text, model))
}
/// Byte-exact Chat Completions events for a [`crate::ScriptedResponse`].
pub fn chat_completion_script_exact(text: &str, model: &str) -> Vec<SseEvent> {
chat_completion_script_from_deltas(&chat_completion_deltas(text), model)
}
/// Split `text` into deltas that reconstruct it byte-for-byte: the first
@ -80,7 +88,7 @@ fn space_prefixed_deltas<'a>(words: impl Iterator<Item = &'a str>) -> Vec<String
.collect()
}
fn chat_completion_events_from_deltas(deltas: &[String], model: &str) -> Vec<Event> {
fn chat_completion_script_from_deltas(deltas: &[String], model: &str) -> Vec<SseEvent> {
let n = deltas.len();
let mut events = Vec::new();
@ -116,28 +124,25 @@ fn chat_completion_events_from_deltas(deltas: &[String], model: &str) -> Vec<Eve
}]
})
};
events.push(Event::default().data(chunk.to_string()));
events.push(SseEvent::data(chunk.to_string()));
}
// Usage chunk
events.push(
Event::default().data(
json!({
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 1234567890,
"model": model,
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": n,
"total_tokens": 10 + n
}
})
.to_string(),
),
);
events.push(Event::default().data("[DONE]"));
events.push(SseEvent::data(
json!({
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 1234567890,
"model": model,
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": n,
"total_tokens": 10 + n
}
})
.to_string(),
));
events.push(SseEvent::data("[DONE]"));
events
}
@ -149,13 +154,18 @@ pub fn responses_api_events(text: &str, model: &str) -> Vec<Event> {
.split_whitespace()
.map(|word| format!("{word} "))
.collect();
responses_api_events_from_deltas(&deltas, text, model)
scripted_to_axum(responses_api_script_from_deltas(&deltas, text, model))
}
/// Like [`responses_api_events`] but byte-exact: concatenating the deltas
/// reproduces `text` byte-for-byte (newlines and whitespace runs preserved).
pub fn responses_api_events_exact(text: &str, model: &str) -> Vec<Event> {
responses_api_events_from_deltas(&responses_api_deltas(text), text, model)
scripted_to_axum(responses_api_script_exact(text, model))
}
/// Byte-exact Responses API events for a [`crate::ScriptedResponse`].
pub fn responses_api_script_exact(text: &str, model: &str) -> Vec<SseEvent> {
responses_api_script_from_deltas(&responses_api_deltas(text), text, model)
}
/// `split_inclusive(' ')` keeps each chunk's trailing space, so concatenating
@ -166,87 +176,91 @@ fn responses_api_deltas(text: &str) -> Vec<String> {
// `deltas` and `text` deliberately disagree in echo mode: collapsed deltas, uncollapsed
// `response.completed` text — inherited load-bearing shell behavior, do not unify.
fn responses_api_events_from_deltas(deltas: &[String], text: &str, model: &str) -> Vec<Event> {
fn responses_api_script_from_deltas(deltas: &[String], text: &str, model: &str) -> Vec<SseEvent> {
let mut events = Vec::new();
let mut seq = 0;
// response.created
events.push(
Event::default().data(
json!({
"type": "response.created",
"sequence_number": seq,
"response": {
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"model": model,
"status": "in_progress",
"output": []
}
})
.to_string(),
),
);
events.push(SseEvent::data(
json!({
"type": "response.created",
"sequence_number": seq,
"response": {
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"model": model,
"status": "in_progress",
"output": []
}
})
.to_string(),
));
seq += 1;
// Text deltas
for chunk in deltas {
events.push(
Event::default().data(
json!({
"type": "response.output_text.delta",
"sequence_number": seq,
"item_id": "item_test",
"output_index": 0,
"content_index": 0,
"delta": chunk
})
.to_string(),
),
);
events.push(SseEvent::data(
json!({
"type": "response.output_text.delta",
"sequence_number": seq,
"item_id": "item_test",
"output_index": 0,
"content_index": 0,
"delta": chunk
})
.to_string(),
));
seq += 1;
}
// response.completed
events.push(
Event::default().data(
json!({
"type": "response.completed",
"sequence_number": seq,
"response": {
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"model": model,
events.push(SseEvent::data(
json!({
"type": "response.completed",
"sequence_number": seq,
"response": {
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"model": model,
"status": "completed",
"output": [{
"type": "message",
"id": "msg_test",
"role": "assistant",
"status": "completed",
"output": [{
"type": "message",
"id": "msg_test",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": text,
"annotations": []
}]
}],
"usage": {
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
"input_tokens_details": { "cached_tokens": 0 },
"output_tokens_details": { "reasoning_tokens": 0 }
}
"content": [{
"type": "output_text",
"text": text,
"annotations": []
}]
}],
"usage": {
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
"input_tokens_details": { "cached_tokens": 0 },
"output_tokens_details": { "reasoning_tokens": 0 }
}
})
.to_string(),
),
);
events.push(Event::default().data("[DONE]"));
}
})
.to_string(),
));
events.push(SseEvent::data("[DONE]"));
events
}
fn scripted_to_axum(events: Vec<SseEvent>) -> Vec<Event> {
events
.into_iter()
.map(|scripted| {
let event = Event::default().data(scripted.data);
match scripted.event {
Some(name) => event.event(name),
None => event,
}
})
.collect()
}
/// Generate Responses API SSE events for a reasoning-only completion: the
/// model streams reasoning summary deltas and finishes with a `reasoning`
/// output item but NO message / output-text and no tool call. The shell's