Synced from monorepo
Synced from monorepo Changes: - Temporarily disable session share link creation in the TUI - Do not approve plan on empty Enter from the revise prompt - Expose chat product Skills via ACP available_commands_update - Return immediately from a blocking wait on an already-completed ACP task - Split headless pager module for clearer structure - Stop git worktree prune from removing user registrations on resume - Use compaction sampler tokenizer for item token counts - Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE - Cancel all session subagents when the user stops - Let the session persistence actor exit when its session ends - Make fullscreen terminal resize much cheaper on long sessions - Report honestly from kill_task when an ACP task does not exist - Hide /usage for external-auth deployments - Forward the history-load trailer’s computer_reason to the client - Remove ineffective no-op tool reminder - Declare slash-command screen-mode support in one place - Keep settings enum picker on the committed value until Enter - Reap a PTY’s full process tree - Stream tool calls from headless mode over ACP - Bridge gateway task lifecycle to ACP for chat session background tasks - Don’t warn about truncated history on a suppressed replay - Fit full-replace summarizer input and recover on context-length errors - Stop dropping agents over an unrecognized frontmatter color - Add /undo as a slash alias for /rewind - Harden sleep/wake token-refresh paths against forced re-login - Add session/list ACP method - Give each sampling backend its own conversion module - Treat an unenrolled child process as a lint error - Suppress the cancelled marker on send-now wake turns - Stop tearing down Roslyn on every edit, and read C# diagnostics Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
parent
500129c714
commit
dd04f397b1
367 changed files with 29489 additions and 10051 deletions
|
|
@ -292,6 +292,9 @@ pub fn stream_chat_completions<'a>(
|
|||
message_chunks_emitted: message_chunk_count,
|
||||
doom_loop_signals: Vec::new(),
|
||||
stop_message: None,
|
||||
message_id: None,
|
||||
raw_stop_reason: None,
|
||||
stop_sequence: None,
|
||||
};
|
||||
|
||||
yield SamplingEvent::Completed {
|
||||
|
|
|
|||
|
|
@ -171,6 +171,9 @@ mod tests {
|
|||
message_chunks_emitted: 1,
|
||||
doom_loop_signals: Vec::new(),
|
||||
stop_message: None,
|
||||
message_id: None,
|
||||
raw_stop_reason: None,
|
||||
stop_sequence: None,
|
||||
}),
|
||||
metrics: InferenceLatencyStats::default(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,22 @@ use crate::events::{SamplingChannel, SamplingErrorInfo, SamplingEvent};
|
|||
use crate::metrics::InferenceLatencyStats;
|
||||
use crate::types::RequestId;
|
||||
|
||||
/// The verbatim wire string for a Messages API stop reason, before it collapses
|
||||
/// into the internal [`StopReason`]. Uses the enum's serde `snake_case`
|
||||
/// renaming so it cannot drift from the wire contract.
|
||||
fn messages_stop_reason_wire(sr: &messages::StopReason) -> String {
|
||||
match serde_json::to_value(sr) {
|
||||
Ok(serde_json::Value::String(s)) => s,
|
||||
other => {
|
||||
debug_assert!(
|
||||
false,
|
||||
"StopReason must serialize to a string, got {other:?}"
|
||||
);
|
||||
"end_turn".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a Messages API event reflects real model progress
|
||||
/// rather than a liveness-only heartbeat (Ping).
|
||||
pub(crate) fn messages_event_has_meaningful_content(event: &MessageStreamEvent) -> bool {
|
||||
|
|
@ -99,6 +115,12 @@ pub fn stream_messages<'a>(
|
|||
let mut final_output_tokens: u32 = 0;
|
||||
let mut final_stop_reason: Option<StopReason> = None;
|
||||
let mut final_stop_message: Option<String> = None;
|
||||
let mut final_message_id: Option<String> = None;
|
||||
let mut final_raw_stop_reason: Option<String> = None;
|
||||
// The provider's matched stop sequence (Messages `message_delta.stop_sequence`),
|
||||
// set only on a `stop_sequence`-terminated turn; carried through so the
|
||||
// headless `streaming-messages-json` consumer can echo it.
|
||||
let mut final_stop_sequence: Option<String> = None;
|
||||
|
||||
// Assistant-response accumulators (built up as ContentBlockStop
|
||||
// events fire). Reasoning is collected into a synthesized
|
||||
|
|
@ -152,10 +174,26 @@ pub fn stream_messages<'a>(
|
|||
|
||||
match event {
|
||||
MessageStreamEvent::MessageStart { message } => {
|
||||
final_message_id = Some(message.id.clone());
|
||||
final_model = Some(message.model.clone());
|
||||
final_input_tokens = message.usage.input_tokens;
|
||||
final_cache_read_input_tokens = message.usage.cache_read_input_tokens;
|
||||
final_cache_creation_input_tokens = message.usage.cache_creation_input_tokens;
|
||||
// Surface the real id/model/input-usage in order, before any
|
||||
// content, so partial-mode framing emits them on the real
|
||||
// `message_start` instead of a synthesized placeholder.
|
||||
yield SamplingEvent::ResponseStarted {
|
||||
request_id: request_id.clone(),
|
||||
message_id: message.id,
|
||||
model: message.model,
|
||||
input_tokens: u64::from(message.usage.input_tokens),
|
||||
cache_read_input_tokens: u64::from(
|
||||
message.usage.cache_read_input_tokens,
|
||||
),
|
||||
cache_creation_input_tokens: u64::from(
|
||||
message.usage.cache_creation_input_tokens,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
MessageStreamEvent::ContentBlockStart {
|
||||
|
|
@ -237,7 +275,19 @@ pub fn stream_messages<'a>(
|
|||
arguments_delta: None,
|
||||
};
|
||||
}
|
||||
_ => {} // Image / ToolResult are not expected in assistant streams.
|
||||
// Encrypted reasoning the model chose to redact. Deliberately
|
||||
// parse-only: the `RedactedThinking` wire variant exists so a
|
||||
// stream that includes one deserializes instead of failing the
|
||||
// whole event parse and discarding an already-streamed
|
||||
// response, but its opaque `data` blob is not surfaced as a
|
||||
// `SamplingEvent` — forwarding it to the headless reducer's
|
||||
// `redacted_thinking` block would need a new event threaded
|
||||
// through the deferred sampler→shell→reducer hop and handled by
|
||||
// every `SamplingEvent` consumer (TUI included), so it is not
|
||||
// wired. No consumer claims redacted_thinking support.
|
||||
ContentBlock::RedactedThinking { .. } => {}
|
||||
// Image / ToolResult are not expected in assistant streams.
|
||||
_ => {}
|
||||
},
|
||||
|
||||
MessageStreamEvent::ContentBlockDelta { index, delta } => {
|
||||
|
|
@ -312,6 +362,15 @@ pub fn stream_messages<'a>(
|
|||
}
|
||||
}
|
||||
BlockType::Thinking => {
|
||||
// Surface the encrypted signature in order (at the
|
||||
// thinking block's stop) so partial-mode framing can
|
||||
// emit `signature_delta` before its `content_block_stop`.
|
||||
if !state.signature.is_empty() {
|
||||
yield SamplingEvent::ReasoningCompleted {
|
||||
request_id: request_id.clone(),
|
||||
signature: state.signature.clone(),
|
||||
};
|
||||
}
|
||||
if !state.thinking_acc.is_empty() || !state.signature.is_empty() {
|
||||
// Anthropic Messages API `Thinking` blocks uniquely
|
||||
// carry an encrypted `signature` distinct
|
||||
|
|
@ -359,6 +418,14 @@ pub fn stream_messages<'a>(
|
|||
if let Some(details) = delta.stop_details {
|
||||
final_stop_message = details.explanation;
|
||||
}
|
||||
// Keep the exact wire string so consumers can echo it.
|
||||
final_raw_stop_reason =
|
||||
delta.stop_reason.as_ref().map(messages_stop_reason_wire);
|
||||
// The matched stop sequence rides the same terminal delta
|
||||
// (present only on a `stop_sequence` stop); carry it verbatim.
|
||||
if delta.stop_sequence.is_some() {
|
||||
final_stop_sequence = delta.stop_sequence.clone();
|
||||
}
|
||||
final_stop_reason = delta.stop_reason.map(|sr| match sr {
|
||||
messages::StopReason::EndTurn => StopReason::Stop,
|
||||
messages::StopReason::MaxTokens => StopReason::Length,
|
||||
|
|
@ -470,6 +537,7 @@ pub fn stream_messages<'a>(
|
|||
total_tokens: total_prompt_tokens.saturating_add(final_output_tokens),
|
||||
reasoning_tokens: 0,
|
||||
cached_prompt_tokens: final_cache_read_input_tokens,
|
||||
cache_creation_prompt_tokens: final_cache_creation_input_tokens,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
|
@ -511,6 +579,9 @@ pub fn stream_messages<'a>(
|
|||
message_chunks_emitted: message_chunk_count,
|
||||
doom_loop_signals: Vec::new(),
|
||||
stop_message: final_stop_message,
|
||||
message_id: final_message_id,
|
||||
raw_stop_reason: final_raw_stop_reason,
|
||||
stop_sequence: final_stop_sequence,
|
||||
};
|
||||
|
||||
yield SamplingEvent::Completed {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ fn message_delta_with_stop(stop: messages::StopReason) -> MessageStreamEvent {
|
|||
MessageStreamEvent::MessageDelta {
|
||||
delta: MessageDeltaBody {
|
||||
stop_reason: Some(stop),
|
||||
stop_sequence: None,
|
||||
stop_details: None,
|
||||
},
|
||||
usage: MessageDeltaUsage {
|
||||
|
|
@ -75,6 +76,7 @@ fn message_delta_refusal_with_explanation(explanation: &str) -> MessageStreamEve
|
|||
MessageStreamEvent::MessageDelta {
|
||||
delta: MessageDeltaBody {
|
||||
stop_reason: Some(messages::StopReason::Refusal),
|
||||
stop_sequence: None,
|
||||
stop_details: Some(messages::StopDetails {
|
||||
r#type: Some("refusal".to_string()),
|
||||
category: Some("frontier_llm".to_string()),
|
||||
|
|
@ -141,6 +143,10 @@ async fn text_block_assembles_into_completed_response() {
|
|||
assert_eq!(a.content.as_ref(), "Hello, world!");
|
||||
assert_eq!(a.model_id.as_deref(), Some("messages-compatible-model"));
|
||||
assert_eq!(response.stop_reason, Some(StopReason::Stop));
|
||||
// Provider message id and the verbatim wire stop reason survive
|
||||
// onto the response (collapsed `stop_reason` loses the string).
|
||||
assert_eq!(response.message_id.as_deref(), Some("msg_1"));
|
||||
assert_eq!(response.raw_stop_reason.as_deref(), Some("end_turn"));
|
||||
let u = response.usage.as_ref().expect("usage extracted");
|
||||
assert_eq!(u.prompt_tokens, 10);
|
||||
assert_eq!(u.completion_tokens, 5);
|
||||
|
|
@ -208,6 +214,61 @@ async fn thinking_block_emits_reasoning_channel_and_preserved_in_response() {
|
|||
}
|
||||
}
|
||||
|
||||
/// `thinking(sig1) → text → thinking(sig2)` must surface each thinking block's
|
||||
/// OWN signature, in order, on its own `ReasoningCompleted` (emitted at that
|
||||
/// block's stop) — so the per-index signature reaches the headless reducer and
|
||||
/// each block keeps its own signature rather than collapsing to one.
|
||||
#[tokio::test]
|
||||
async fn multiple_thinking_blocks_emit_per_block_signatures_in_order() {
|
||||
let thinking_block = |index: u32, text: &str, sig: &str| {
|
||||
vec![
|
||||
Ok(MessageStreamEvent::ContentBlockStart {
|
||||
index,
|
||||
content_block: ContentBlock::Thinking {
|
||||
thinking: String::new(),
|
||||
signature: String::new(),
|
||||
},
|
||||
}),
|
||||
Ok(MessageStreamEvent::ContentBlockDelta {
|
||||
index,
|
||||
delta: StreamDelta::ThinkingDelta {
|
||||
thinking: text.into(),
|
||||
},
|
||||
}),
|
||||
Ok(MessageStreamEvent::ContentBlockDelta {
|
||||
index,
|
||||
delta: StreamDelta::SignatureDelta {
|
||||
signature: sig.into(),
|
||||
},
|
||||
}),
|
||||
Ok(block_stop(index)),
|
||||
]
|
||||
};
|
||||
let mut events: Vec<Result<MessageStreamEvent, SamplingError>> = vec![Ok(message_start())];
|
||||
events.extend(thinking_block(0, "first", "sig-1"));
|
||||
events.push(Ok(text_block_start(1)));
|
||||
events.push(Ok(text_delta(1, "interlude")));
|
||||
events.push(Ok(block_stop(1)));
|
||||
events.extend(thinking_block(2, "second", "sig-2"));
|
||||
events.push(Ok(MessageStreamEvent::MessageStop));
|
||||
|
||||
let raw = stream::iter(events).boxed();
|
||||
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
|
||||
|
||||
let sigs: Vec<&str> = evs
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
SamplingEvent::ReasoningCompleted { signature, .. } => Some(signature.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sigs,
|
||||
vec!["sig-1", "sig-2"],
|
||||
"each thinking block emits its own signature in order"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_use_block_assembles_into_tool_call() {
|
||||
let tool_start = MessageStreamEvent::ContentBlockStart {
|
||||
|
|
@ -574,6 +635,7 @@ fn message_delta_with_cache(
|
|||
MessageStreamEvent::MessageDelta {
|
||||
delta: MessageDeltaBody {
|
||||
stop_reason: Some(messages::StopReason::EndTurn),
|
||||
stop_sequence: None,
|
||||
stop_details: None,
|
||||
},
|
||||
usage: MessageDeltaUsage {
|
||||
|
|
@ -621,6 +683,7 @@ async fn prompt_tokens_sums_all_three_anthropic_buckets() {
|
|||
|
||||
assert_eq!(usage.prompt_tokens, 100 + 5000 + 200);
|
||||
assert_eq!(usage.cached_prompt_tokens, 5000);
|
||||
assert_eq!(usage.cache_creation_prompt_tokens, 200);
|
||||
assert_eq!(usage.completion_tokens, 7);
|
||||
assert_eq!(usage.total_tokens, 100 + 5000 + 200 + 7);
|
||||
}
|
||||
|
|
@ -638,6 +701,7 @@ async fn message_delta_cache_fields_override_message_start() {
|
|||
|
||||
assert_eq!(usage.prompt_tokens, 10 + 900 + 50);
|
||||
assert_eq!(usage.cached_prompt_tokens, 900);
|
||||
assert_eq!(usage.cache_creation_prompt_tokens, 50);
|
||||
assert_eq!(usage.completion_tokens, 4);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -381,6 +381,25 @@ pub(crate) fn stream_responses_tracked<'a>(
|
|||
ResponseStreamEvent::ResponseWebSearchCallCompleted(_)
|
||||
| ResponseStreamEvent::ResponseWebSearchCallSearching(_) => {}
|
||||
|
||||
// Code interpreter (server-side, like web/x search). Surface it
|
||||
// the same way x_search is: a generic backend tool call that the
|
||||
// shell renders as a client `tool_use` + `user` `tool_result`
|
||||
// split (grok has no HostedTool::CodeInterpreter, so these events
|
||||
// are latent under the current hosted-tool set). The started
|
||||
// event fires on InProgress; the full payload (code + outputs)
|
||||
// rides ResponseOutputItemDone(CodeInterpreterCall) below.
|
||||
ResponseStreamEvent::ResponseCodeInterpreterCallInProgress(ev) => {
|
||||
yield SamplingEvent::BackendToolCallStarted {
|
||||
request_id: request_id.clone(),
|
||||
call_id: ev.item_id.clone(),
|
||||
name: "code_interpreter".to_string(),
|
||||
};
|
||||
}
|
||||
// Interpreting/Completed carry no payload — the result arrives
|
||||
// via ResponseOutputItemDone(CodeInterpreterCall) below.
|
||||
ResponseStreamEvent::ResponseCodeInterpreterCallInterpreting(_)
|
||||
| ResponseStreamEvent::ResponseCodeInterpreterCallCompleted(_) => {}
|
||||
|
||||
// OutputItemDone carries the full result for backend tools.
|
||||
// For WebSearchCall this includes the query and source URLs.
|
||||
// For CustomToolCall this includes x_search results.
|
||||
|
|
@ -409,6 +428,19 @@ pub(crate) fn stream_responses_tracked<'a>(
|
|||
result,
|
||||
};
|
||||
}
|
||||
// Code interpreter: the full call (code + outputs) rides
|
||||
// the done item. Surfaced under the shared "code_interpreter"
|
||||
// name (matching the Started event); the shell renders it via
|
||||
// the client `tool_use` + `user` `tool_result` split.
|
||||
rs::OutputItem::CodeInterpreterCall(ci) => {
|
||||
let result = serde_json::to_value(ci).ok();
|
||||
yield SamplingEvent::BackendToolCallCompleted {
|
||||
request_id: request_id.clone(),
|
||||
call_id: ci.id.clone(),
|
||||
name: "code_interpreter".to_string(),
|
||||
result,
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -485,6 +517,7 @@ pub(crate) fn stream_responses_tracked<'a>(
|
|||
total_tokens: u.total_tokens,
|
||||
reasoning_tokens: u.output_tokens_details.reasoning_tokens,
|
||||
cached_prompt_tokens: u.input_tokens_details.cached_tokens,
|
||||
cache_creation_prompt_tokens: 0,
|
||||
});
|
||||
|
||||
let cost_usd_ticks = response
|
||||
|
|
@ -543,6 +576,9 @@ pub(crate) fn stream_responses_tracked<'a>(
|
|||
message_chunks_emitted: message_chunk_count,
|
||||
doom_loop_signals,
|
||||
stop_message: None, // not reported on the Responses API
|
||||
message_id: None, // no provider message id on the Responses API
|
||||
raw_stop_reason: None,
|
||||
stop_sequence: None,
|
||||
};
|
||||
|
||||
yield SamplingEvent::Completed {
|
||||
|
|
@ -874,6 +910,67 @@ mod tests {
|
|||
assert!(output_observed.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
/// A server-side code-interpreter run surfaces as a generic backend tool
|
||||
/// call (started on InProgress, completed on OutputItemDone) named
|
||||
/// "code_interpreter" — the same shape as x_search — so it is no longer
|
||||
/// silently dropped from the event stream.
|
||||
#[tokio::test]
|
||||
async fn code_interpreter_forwards_backend_tool_call() {
|
||||
let in_progress = rs::ResponseStreamEvent::ResponseCodeInterpreterCallInProgress(
|
||||
rs_types::ResponseCodeInterpreterCallInProgressEvent {
|
||||
sequence_number: 0,
|
||||
output_index: 0,
|
||||
item_id: "ci-1".into(),
|
||||
},
|
||||
);
|
||||
let done = rs::ResponseStreamEvent::ResponseOutputItemDone(
|
||||
rs_types::ResponseOutputItemDoneEvent {
|
||||
sequence_number: 1,
|
||||
output_index: 0,
|
||||
item: rs_types::OutputItem::CodeInterpreterCall(
|
||||
rs_types::CodeInterpreterToolCall {
|
||||
code: Some("print(1)".into()),
|
||||
container_id: "cont-1".into(),
|
||||
id: "ci-1".into(),
|
||||
outputs: None,
|
||||
status: rs_types::CodeInterpreterToolCallStatus::Completed,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
let raw = stream::iter(vec![Ok(in_progress), Ok(done), Ok(completed_event())]).boxed();
|
||||
let events = collect(stream_responses(
|
||||
raw,
|
||||
None,
|
||||
rid(),
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
events.iter().any(|e| matches!(
|
||||
e,
|
||||
SamplingEvent::BackendToolCallStarted { call_id, name, .. }
|
||||
if call_id == "ci-1" && name == "code_interpreter"
|
||||
)),
|
||||
"expected a code_interpreter BackendToolCallStarted, got {events:?}"
|
||||
);
|
||||
let completed = events.iter().find_map(|e| match e {
|
||||
SamplingEvent::BackendToolCallCompleted {
|
||||
call_id,
|
||||
name,
|
||||
result,
|
||||
..
|
||||
} if name == "code_interpreter" => Some((call_id.clone(), result.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let (call_id, result) = completed.expect("a code_interpreter BackendToolCallCompleted");
|
||||
assert_eq!(call_id, "ci-1");
|
||||
let result = result.expect("serialized code-interpreter payload");
|
||||
assert_eq!(result["code"], "print(1)");
|
||||
}
|
||||
|
||||
fn function_call_added_event(
|
||||
output_index: u32,
|
||||
call_id: &str,
|
||||
|
|
|
|||
Loading…
Reference in a new issue