Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402ae0d466 | ||
|
|
acb3c6d68b | ||
|
|
40ac83e632 | ||
|
|
58da395941 | ||
|
|
0e3ef94c9e | ||
|
|
1f68dfc2b5 | ||
|
|
84977a464c | ||
|
|
62ada5eaa4 | ||
|
|
f5ff0b7c13 | ||
|
|
3337cafcdf | ||
|
|
e87784118b | ||
|
|
40fada28ea | ||
|
|
58cc94d4b7 | ||
|
|
ccabea59c9 | ||
|
|
8cc1dc042d | ||
|
|
183c37446e | ||
|
|
651d64f34d | ||
|
|
b98d4b59f5 | ||
|
|
df6d99c07d | ||
|
|
9843510e1f | ||
|
|
83bda3dfb2 |
+557
-88
@@ -1,9 +1,10 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::{marker::PhantomData, sync::Arc, time::Instant};
|
use std::{future::Future, marker::PhantomData, pin::Pin, sync::Arc, time::Instant};
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::time::Instant as TokioInstant;
|
||||||
use tracing::{debug, info, trace, warn};
|
use tracing::{debug, info, trace, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -27,7 +28,8 @@ use crate::{
|
|||||||
timeline::{TextBlockCollector, ThinkingBlockCollector, Timeline, ToolCallCollector},
|
timeline::{TextBlockCollector, ThinkingBlockCollector, Timeline, ToolCallCollector},
|
||||||
tool::{
|
tool::{
|
||||||
ToolCall, ToolDefinition as EngineToolDefinition, ToolError, ToolExecutionContext,
|
ToolCall, ToolDefinition as EngineToolDefinition, ToolError, ToolExecutionContext,
|
||||||
ToolOutputLimits, ToolResult, truncate_content,
|
ToolExecutionHandle, ToolExecutionPolicy, ToolExecutionTerminal, ToolOutputLimits,
|
||||||
|
ToolResult, ToolResultDisposition, truncate_content,
|
||||||
},
|
},
|
||||||
tool_server::{ToolServer, ToolServerHandle},
|
tool_server::{ToolServer, ToolServerHandle},
|
||||||
};
|
};
|
||||||
@@ -47,12 +49,18 @@ pub enum EngineError {
|
|||||||
/// Cancelled by CancellationToken
|
/// Cancelled by CancellationToken
|
||||||
#[error("Cancelled")]
|
#[error("Cancelled")]
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
/// Paused by the caller at the next safe boundary.
|
||||||
|
#[error("Paused")]
|
||||||
|
PauseRequested,
|
||||||
/// Config warnings (unsupported options)
|
/// Config warnings (unsupported options)
|
||||||
#[error("Config warnings: {}", .0.iter().map(|w| w.to_string()).collect::<Vec<_>>().join(", "))]
|
#[error("Config warnings: {}", .0.iter().map(|w| w.to_string()).collect::<Vec<_>>().join(", "))]
|
||||||
ConfigWarnings(Vec<ConfigWarning>),
|
ConfigWarnings(Vec<ConfigWarning>),
|
||||||
/// A durable-history observer rejected an item before it entered history.
|
/// A durable-history observer rejected an item before it entered history.
|
||||||
#[error("History append failed: {0}")]
|
#[error("History append failed: {0}")]
|
||||||
HistoryAppend(String),
|
HistoryAppend(String),
|
||||||
|
/// Tool terminalization lost its execution-attempt compare-and-set fence.
|
||||||
|
#[error("Tool execution attempt fence failed: {0}")]
|
||||||
|
ToolAttemptFence(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tool registration error
|
/// Tool registration error
|
||||||
@@ -70,6 +78,59 @@ pub struct EngineConfig {
|
|||||||
_private: (),
|
_private: (),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Project terminal tool outputs into the assistant's original ToolCall order.
|
||||||
|
///
|
||||||
|
/// Runtime history intentionally retains completion order so every result can
|
||||||
|
/// be committed without waiting for slower siblings. The provider projection
|
||||||
|
/// is deterministic within each contiguous result batch and does not rewrite
|
||||||
|
/// the committed transcript.
|
||||||
|
struct ProviderHistoryProjection {
|
||||||
|
items: Vec<Item>,
|
||||||
|
original_to_projected_index: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materialize_provider_history(items: &[Item]) -> ProviderHistoryProjection {
|
||||||
|
let mut materialized: Vec<_> = items.iter().cloned().enumerate().collect();
|
||||||
|
let mut call_order = HashMap::<String, usize>::new();
|
||||||
|
let mut next_call_order = 0usize;
|
||||||
|
let mut index = 0usize;
|
||||||
|
|
||||||
|
while index < materialized.len() {
|
||||||
|
match &materialized[index].1 {
|
||||||
|
Item::ToolCall { call_id, .. } => {
|
||||||
|
call_order.insert(call_id.clone(), next_call_order);
|
||||||
|
next_call_order += 1;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
Item::ToolResult { .. } => {
|
||||||
|
let start = index;
|
||||||
|
while index < materialized.len()
|
||||||
|
&& matches!(materialized[index].1, Item::ToolResult { .. })
|
||||||
|
{
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
materialized[start..index].sort_by_key(|(_, item)| match item {
|
||||||
|
Item::ToolResult { call_id, .. } => {
|
||||||
|
call_order.get(call_id).copied().unwrap_or(usize::MAX)
|
||||||
|
}
|
||||||
|
_ => unreachable!("tool-result run contains only ToolResult items"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => index += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut original_to_projected_index = vec![0; materialized.len()];
|
||||||
|
for (projected_index, (original_index, _)) in materialized.iter().enumerate() {
|
||||||
|
original_to_projected_index[*original_index] = projected_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderHistoryProjection {
|
||||||
|
items: materialized.into_iter().map(|(_, item)| item).collect(),
|
||||||
|
original_to_projected_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Legacy serializable outcome used by the Worker session-log compatibility boundary.
|
/// Legacy serializable outcome used by the Worker session-log compatibility boundary.
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -109,6 +170,7 @@ impl From<Result<EngineResult, EngineError>> for EngineRunExit {
|
|||||||
Self::Interrupted(StopReason::ContextWindowExceeded)
|
Self::Interrupted(StopReason::ContextWindowExceeded)
|
||||||
}
|
}
|
||||||
Err(EngineError::Cancelled) => Self::Interrupted(StopReason::Cancelled),
|
Err(EngineError::Cancelled) => Self::Interrupted(StopReason::Cancelled),
|
||||||
|
Err(EngineError::PauseRequested) => Self::Paused,
|
||||||
Err(error) => Self::Interrupted(StopReason::Unexpected(error)),
|
Err(error) => Self::Interrupted(StopReason::Unexpected(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,10 +188,68 @@ pub struct EngineRunOutput<C: LlmClient, A = ()> {
|
|||||||
|
|
||||||
/// Internal: tool execution result
|
/// Internal: tool execution result
|
||||||
enum ToolExecutionResult {
|
enum ToolExecutionResult {
|
||||||
Completed(Vec<ToolResult>),
|
Completed,
|
||||||
Paused,
|
Paused,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ToolExecutionAttempt {
|
||||||
|
attempt_id: String,
|
||||||
|
terminal: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-batch compare-and-set fence for terminal ToolResult commits.
|
||||||
|
///
|
||||||
|
/// A completion may commit only when its attempt id still matches the active
|
||||||
|
/// execution for that call and no prior terminal output has won the fence.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct ToolExecutionAttemptFence {
|
||||||
|
attempts: HashMap<String, ToolExecutionAttempt>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolExecutionAttemptFence {
|
||||||
|
fn register(&mut self, call_id: String, attempt_id: String) {
|
||||||
|
self.attempts.insert(
|
||||||
|
call_id,
|
||||||
|
ToolExecutionAttempt {
|
||||||
|
attempt_id,
|
||||||
|
terminal: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_commit(&self, call_id: &str, attempt_id: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.attempts.get(call_id),
|
||||||
|
Some(attempt) if attempt.attempt_id == attempt_id && !attempt.terminal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_terminal(&mut self, call_id: &str, attempt_id: &str) -> bool {
|
||||||
|
let Some(attempt) = self.attempts.get_mut(call_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if attempt.attempt_id != attempt_id || attempt.terminal {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
attempt.terminal = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_terminal(&self, call_id: &str) -> bool {
|
||||||
|
self.attempts
|
||||||
|
.get(call_id)
|
||||||
|
.is_some_and(|attempt| attempt.terminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn attempt_id(&self, call_id: &str) -> Option<&str> {
|
||||||
|
self.attempts
|
||||||
|
.get(call_id)
|
||||||
|
.map(|attempt| attempt.attempt_id.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_STREAM_CONTINUATIONS: u32 = 3;
|
const MAX_STREAM_CONTINUATIONS: u32 = 3;
|
||||||
|
|
||||||
/// Central component for managing LLM interactions
|
/// Central component for managing LLM interactions
|
||||||
@@ -228,6 +348,8 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
|||||||
tool_execution_batch_count: usize,
|
tool_execution_batch_count: usize,
|
||||||
/// Maximum number of AgentTurns (None = unlimited)
|
/// Maximum number of AgentTurns (None = unlimited)
|
||||||
max_turns: Option<u32>,
|
max_turns: Option<u32>,
|
||||||
|
/// Caller-selected policy for interrupting started provider operations.
|
||||||
|
tool_execution_policy: ToolExecutionPolicy,
|
||||||
/// AgentTurn-start callbacks (1:1 with LlmCall today)
|
/// AgentTurn-start callbacks (1:1 with LlmCall today)
|
||||||
turn_start_cbs: Vec<Box<dyn Fn(usize) + Send + Sync>>,
|
turn_start_cbs: Vec<Box<dyn Fn(usize) + Send + Sync>>,
|
||||||
/// AgentTurn-end callbacks (1:1 with LlmCall today)
|
/// AgentTurn-end callbacks (1:1 with LlmCall today)
|
||||||
@@ -266,6 +388,10 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
|||||||
/// Cancel notification channel (for interrupting execution)
|
/// Cancel notification channel (for interrupting execution)
|
||||||
cancel_tx: mpsc::Sender<()>,
|
cancel_tx: mpsc::Sender<()>,
|
||||||
cancel_rx: mpsc::Receiver<()>,
|
cancel_rx: mpsc::Receiver<()>,
|
||||||
|
/// Pause notification channel. Unlike cancellation, this waits for already
|
||||||
|
/// started tools to reach provider-confirmed terminal results.
|
||||||
|
pause_tx: mpsc::Sender<()>,
|
||||||
|
pause_rx: mpsc::Receiver<()>,
|
||||||
/// Byte-size caps applied to tool `content` before it reaches history.
|
/// Byte-size caps applied to tool `content` before it reaches history.
|
||||||
/// `None` disables truncation (tests and minimal setups).
|
/// `None` disables truncation (tests and minimal setups).
|
||||||
tool_output_limits: Option<ToolOutputLimits>,
|
tool_output_limits: Option<ToolOutputLimits>,
|
||||||
@@ -303,7 +429,10 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn finish_logical_run(&mut self, result: &Result<EngineResult, EngineError>) {
|
fn finish_logical_run(&mut self, result: &Result<EngineResult, EngineError>) {
|
||||||
if !matches!(result, Ok(EngineResult::Paused) | Ok(EngineResult::Yielded)) {
|
if !matches!(
|
||||||
|
result,
|
||||||
|
Ok(EngineResult::Paused | EngineResult::Yielded) | Err(EngineError::PauseRequested)
|
||||||
|
) {
|
||||||
self.active_run_turn_count = None;
|
self.active_run_turn_count = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,14 +441,19 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
while self.cancel_rx.try_recv().is_ok() {}
|
while self.cancel_rx.try_recv().is_ok() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discard pending cancellation notifications while the engine is idle.
|
fn drain_pause_queue(&mut self) {
|
||||||
|
while self.pause_rx.try_recv().is_ok() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard pending interruption notifications while the engine is idle.
|
||||||
///
|
///
|
||||||
/// Cancellation is a running-turn control signal. Callers that own a higher
|
/// Cancellation and pause are running-turn control signals. Callers that own
|
||||||
/// level run state can use this before starting a new turn so an old idle
|
/// a higher level run state can use this before starting a new turn so an old
|
||||||
/// signal does not poison the next request, while cancellation queued after
|
/// idle signal does not poison the next request, while interruption queued
|
||||||
/// the run has been accepted remains observable by the turn loop.
|
/// after the run has been accepted remains observable by the turn loop.
|
||||||
pub fn clear_pending_cancel(&mut self) {
|
pub fn clear_pending_cancel(&mut self) {
|
||||||
self.drain_cancel_queue();
|
self.drain_cancel_queue();
|
||||||
|
self.drain_pause_queue();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_cancelled(&mut self) -> bool {
|
fn try_cancelled(&mut self) -> bool {
|
||||||
@@ -331,6 +465,14 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn try_paused(&mut self) -> bool {
|
||||||
|
use tokio::sync::mpsc::error::TryRecvError;
|
||||||
|
match self.pause_rx.try_recv() {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(TryRecvError::Empty | TryRecvError::Disconnected) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Register a text block observer with scoped callbacks.
|
/// Register a text block observer with scoped callbacks.
|
||||||
///
|
///
|
||||||
/// The setup closure is called once per text block. Inside it, register
|
/// The setup closure is called once per text block. Inside it, register
|
||||||
@@ -794,6 +936,22 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
self.cancel_tx.clone()
|
self.cancel_tx.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the safe-boundary pause notification sender.
|
||||||
|
pub fn pause_sender(&self) -> mpsc::Sender<()> {
|
||||||
|
self.pause_tx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the deadline policy applied to already-started provider operations.
|
||||||
|
/// Worker/controller layers own this lifecycle policy; Agen owns only the
|
||||||
|
/// mechanical terminalization of each call.
|
||||||
|
pub fn set_tool_execution_policy(&mut self, policy: ToolExecutionPolicy) {
|
||||||
|
self.tool_execution_policy = policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_execution_policy(&self) -> ToolExecutionPolicy {
|
||||||
|
self.tool_execution_policy
|
||||||
|
}
|
||||||
|
|
||||||
/// Set request configuration at once
|
/// Set request configuration at once
|
||||||
pub fn set_request_config(&mut self, config: RequestConfig) {
|
pub fn set_request_config(&mut self, config: RequestConfig) {
|
||||||
self.request_config = config;
|
self.request_config = config;
|
||||||
@@ -892,8 +1050,14 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
request = request.system(system);
|
request = request.system(system);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add items directly (Request now uses Items natively)
|
// History keeps terminal tool outputs in completion order so each
|
||||||
request = request.items(context.iter().cloned());
|
// result can be committed immediately. Providers, however, expect a
|
||||||
|
// deterministic projection matching the assistant's ToolCall order.
|
||||||
|
let projection = materialize_provider_history(context);
|
||||||
|
let projected_cache_anchor = self
|
||||||
|
.cache_anchor
|
||||||
|
.and_then(|anchor| projection.original_to_projected_index.get(anchor).copied());
|
||||||
|
request = request.items(projection.items);
|
||||||
|
|
||||||
// Add tool definitions
|
// Add tool definitions
|
||||||
for tool_def in tool_definitions {
|
for tool_def in tool_definitions {
|
||||||
@@ -906,7 +1070,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
// Attach the cache prefix anchor (may be narrower than `context`
|
// Attach the cache prefix anchor (may be narrower than `context`
|
||||||
// if the prune projection trimmed items from the head — keep it
|
// if the prune projection trimmed items from the head — keep it
|
||||||
// in range).
|
// in range).
|
||||||
request.cache_anchor = self.cache_anchor.filter(|&anchor| anchor < context.len());
|
request.cache_anchor = projected_cache_anchor;
|
||||||
request.cache_key = self.cache_key.clone();
|
request.cache_key = self.cache_key.clone();
|
||||||
|
|
||||||
request
|
request
|
||||||
@@ -978,9 +1142,17 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
/// executes approved tools in parallel and applies post_tool_call hooks to results.
|
/// executes approved tools in parallel and applies post_tool_call hooks to results.
|
||||||
async fn execute_tools(
|
async fn execute_tools(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
history: &mut History<A>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
tool_calls: Vec<ToolCall>,
|
tool_calls: Vec<ToolCall>,
|
||||||
) -> Result<ToolExecutionResult, EngineError> {
|
) -> Result<ToolExecutionResult, EngineError> {
|
||||||
use futures::future::join_all;
|
use futures::stream::{FuturesUnordered, StreamExt};
|
||||||
|
|
||||||
|
// A pause observed before provider ownership starts leaves every call
|
||||||
|
// NotStarted and therefore eligible for an explicit later retry.
|
||||||
|
if self.try_paused() {
|
||||||
|
return Ok(ToolExecutionResult::Paused);
|
||||||
|
}
|
||||||
|
|
||||||
// Map from tool call ID to (ToolCall, Meta, Tool, Context)
|
// Map from tool call ID to (ToolCall, Meta, Tool, Context)
|
||||||
// Retained because it's needed for PostToolCall hooks
|
// Retained because it's needed for PostToolCall hooks
|
||||||
@@ -1039,53 +1211,264 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
context.clone(),
|
context.clone(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
approved_calls.push((tool_call, context));
|
approved_calls.push((tool_call, context, Some(info.tool)));
|
||||||
} else {
|
} else {
|
||||||
// Unknown tools go into approved list as-is (will error at execution)
|
// Unknown tools go into approved list as-is (will error at execution)
|
||||||
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||||
approved_calls.push((tool_call, context));
|
approved_calls.push((tool_call, context, None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Execute approved tools in parallel (cancellable)
|
// Phase 2: Execute approved tools in parallel. FuturesUnordered yields
|
||||||
let futures: Vec<_> = approved_calls
|
// each terminal result as soon as that call completes instead of
|
||||||
.into_iter()
|
// holding fast siblings behind the slowest call in the batch.
|
||||||
.map(|(tool_call, context)| {
|
let started_calls: Vec<_> = approved_calls
|
||||||
let tool_server = self.tool_server.clone();
|
.iter()
|
||||||
async move {
|
.map(|(tool_call, context, _)| (tool_call.id.clone(), context.batch_id.clone()))
|
||||||
let input_json = serde_json::to_string(&tool_call.input).unwrap_or_default();
|
|
||||||
match tool_server
|
|
||||||
.call_tool(&tool_call.name, &input_json, context)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(output) => ToolResult::from_output(&tool_call.id, output),
|
|
||||||
Err(e) => ToolResult::error(&tool_call.id, e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
let mut attempt_fence = ToolExecutionAttemptFence::default();
|
||||||
// Make tool execution cancellable
|
for (call_id, attempt_id) in &started_calls {
|
||||||
let mut results = tokio::select! {
|
attempt_fence.register(call_id.clone(), attempt_id.clone());
|
||||||
results = join_all(futures) => results,
|
|
||||||
cancel = self.cancel_rx.recv() => {
|
|
||||||
if cancel.is_some() {
|
|
||||||
info!("Tool execution cancelled");
|
|
||||||
}
|
}
|
||||||
self.timeline.abort_current_block();
|
let futures: FuturesUnordered<Pin<Box<dyn Future<Output = (String, ToolResult)> + Send>>> =
|
||||||
return Err(EngineError::Cancelled);
|
FuturesUnordered::new();
|
||||||
|
let mut execution_handles = HashMap::new();
|
||||||
|
for (tool_call, context, tool) in approved_calls {
|
||||||
|
let attempt_id = context.batch_id.clone();
|
||||||
|
let input_json = serde_json::to_string(&tool_call.input).unwrap_or_default();
|
||||||
|
let call_id = tool_call.id.clone();
|
||||||
|
let future: Pin<Box<dyn Future<Output = (String, ToolResult)> + Send>> = match tool {
|
||||||
|
None => {
|
||||||
|
let result =
|
||||||
|
ToolResult::error(&call_id, format!("Tool not found: {}", tool_call.name));
|
||||||
|
Box::pin(async move { (attempt_id, result) })
|
||||||
|
}
|
||||||
|
Some(tool) => {
|
||||||
|
let (handle, terminal) = ToolExecutionHandle::start(tool, input_json, context);
|
||||||
|
execution_handles.insert(call_id.clone(), handle);
|
||||||
|
Box::pin(async move {
|
||||||
|
let result = match terminal.await {
|
||||||
|
ToolExecutionTerminal::Confirmed(Ok(output)) => {
|
||||||
|
ToolResult::from_output(&call_id, output)
|
||||||
|
}
|
||||||
|
ToolExecutionTerminal::Confirmed(Err(ToolError::Cancelled(output))) => {
|
||||||
|
ToolResult::from_output_with_disposition(
|
||||||
|
&call_id,
|
||||||
|
output,
|
||||||
|
ToolResultDisposition::Cancelled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ToolExecutionTerminal::Confirmed(Err(ToolError::Interrupted(
|
||||||
|
output,
|
||||||
|
))) => ToolResult::from_output_with_disposition(
|
||||||
|
&call_id,
|
||||||
|
output,
|
||||||
|
ToolResultDisposition::Interrupted,
|
||||||
|
),
|
||||||
|
ToolExecutionTerminal::Confirmed(Err(error)) => {
|
||||||
|
ToolResult::error(&call_id, error.to_string())
|
||||||
|
}
|
||||||
|
ToolExecutionTerminal::OutcomeUnknown => {
|
||||||
|
ToolResult::outcome_unknown(&call_id)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
results.extend(synthetic_results);
|
(attempt_id, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
futures.push(future);
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: Apply post_tool_call interceptor
|
// Synthetic results are already terminal and need no execution wait.
|
||||||
for tool_result in &mut results {
|
// Commit them before polling ordinary calls so they obey the same
|
||||||
if let Some((tool_call, meta, tool, context)) =
|
// commit-before-publish boundary.
|
||||||
call_info_map.get(&tool_result.tool_use_id)
|
let mut terminal_call_ids = HashSet::new();
|
||||||
{
|
let mut pause_requested = false;
|
||||||
|
let mut pause_deadline = None;
|
||||||
|
for result in synthetic_results {
|
||||||
|
self.finalize_and_commit_tool_result(
|
||||||
|
history,
|
||||||
|
annotate,
|
||||||
|
result,
|
||||||
|
None,
|
||||||
|
&call_info_map,
|
||||||
|
&mut attempt_fence,
|
||||||
|
&mut terminal_call_ids,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut futures = futures;
|
||||||
|
while !futures.is_empty() {
|
||||||
|
tokio::select! {
|
||||||
|
// If cancellation and a completed result are both ready, drain
|
||||||
|
// the completed result first. This preserves every terminal
|
||||||
|
// output observed before the cancellation boundary.
|
||||||
|
biased;
|
||||||
|
result = futures.next() => {
|
||||||
|
let (attempt_id, result) =
|
||||||
|
result.expect("non-empty FuturesUnordered returns a result");
|
||||||
|
self.finalize_and_commit_tool_result(
|
||||||
|
history,
|
||||||
|
annotate,
|
||||||
|
result,
|
||||||
|
Some(&attempt_id),
|
||||||
|
&call_info_map,
|
||||||
|
&mut attempt_fence,
|
||||||
|
&mut terminal_call_ids,
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
pause = self.pause_rx.recv(), if !pause_requested => {
|
||||||
|
if pause.is_some() {
|
||||||
|
// Pause first waits for already-started tools to reach a
|
||||||
|
// natural safe boundary. If they do not, Worker policy
|
||||||
|
// escalates to the same explicit cancel-and-confirm path.
|
||||||
|
pause_requested = true;
|
||||||
|
pause_deadline = Some(
|
||||||
|
TokioInstant::now()
|
||||||
|
+ self.tool_execution_policy.pause_safe_boundary_timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
|
||||||
|
pause_deadline = None;
|
||||||
|
let _ = self.cancel_tx.try_send(());
|
||||||
|
}
|
||||||
|
cancel = self.cancel_rx.recv() => {
|
||||||
|
if cancel.is_some() {
|
||||||
|
info!("Tool execution cancellation requested");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancellation_request_deadline = TokioInstant::now()
|
||||||
|
+ self.tool_execution_policy.cancellation_request_timeout;
|
||||||
|
let cancellation_requests = execution_handles
|
||||||
|
.iter()
|
||||||
|
.filter(|(call_id, _)| !terminal_call_ids.contains(*call_id))
|
||||||
|
.map(|(call_id, handle)| {
|
||||||
|
let call_id = call_id.clone();
|
||||||
|
let handle = handle.clone();
|
||||||
|
async move {
|
||||||
|
(
|
||||||
|
call_id,
|
||||||
|
handle.cancel_before(cancellation_request_deadline).await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let cancellation_requests: FuturesUnordered<_> =
|
||||||
|
cancellation_requests.collect();
|
||||||
|
for (call_id, result) in cancellation_requests.collect::<Vec<_>>().await {
|
||||||
|
if let Err(error) = result {
|
||||||
|
warn!(
|
||||||
|
%call_id,
|
||||||
|
error = %error,
|
||||||
|
"Tool cooperative cancellation request failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep polling the original execution handles to their
|
||||||
|
// provider-confirmed terminal result until the caller-selected
|
||||||
|
// deadline. The execution remains owned even if this polling
|
||||||
|
// future is later dropped.
|
||||||
|
let deadline = TokioInstant::now()
|
||||||
|
+ self.tool_execution_policy.terminal_confirmation_timeout;
|
||||||
|
while !futures.is_empty() {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
result = futures.next() => {
|
||||||
|
let (attempt_id, result) =
|
||||||
|
result.expect("non-empty FuturesUnordered returns a result");
|
||||||
|
self.finalize_and_commit_tool_result(
|
||||||
|
history,
|
||||||
|
annotate,
|
||||||
|
result,
|
||||||
|
Some(&attempt_id),
|
||||||
|
&call_info_map,
|
||||||
|
&mut attempt_fence,
|
||||||
|
&mut terminal_call_ids,
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep_until(deadline) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls that did not confirm a terminal outcome inside the
|
||||||
|
// grace period are durably closed as OutcomeUnknown before
|
||||||
|
// Engine/Worker final status becomes observable.
|
||||||
|
for (call_id, attempt_id) in &started_calls {
|
||||||
|
if !attempt_fence.is_terminal(call_id) {
|
||||||
|
if let Some(handle) = execution_handles.get(call_id) {
|
||||||
|
handle.force_close();
|
||||||
|
}
|
||||||
|
self.finalize_and_commit_tool_result(
|
||||||
|
history,
|
||||||
|
annotate,
|
||||||
|
ToolResult::outcome_unknown(call_id),
|
||||||
|
Some(attempt_id),
|
||||||
|
&call_info_map,
|
||||||
|
&mut attempt_fence,
|
||||||
|
&mut terminal_call_ids,
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.timeline.abort_current_block();
|
||||||
|
if pause_requested {
|
||||||
|
return Ok(ToolExecutionResult::Paused);
|
||||||
|
}
|
||||||
|
return Err(EngineError::Cancelled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(if pause_requested {
|
||||||
|
ToolExecutionResult::Paused
|
||||||
|
} else {
|
||||||
|
ToolExecutionResult::Completed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply post-execution policy, bound the model-visible payload, durably
|
||||||
|
/// append one terminal ToolResult, and only then publish it to observers.
|
||||||
|
async fn finalize_and_commit_tool_result(
|
||||||
|
&mut self,
|
||||||
|
history: &mut History<A>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
|
mut tool_result: ToolResult,
|
||||||
|
execution_attempt_id: Option<&str>,
|
||||||
|
call_info_map: &HashMap<
|
||||||
|
String,
|
||||||
|
(
|
||||||
|
ToolCall,
|
||||||
|
crate::tool::ToolMeta,
|
||||||
|
Arc<dyn crate::tool::Tool>,
|
||||||
|
ToolExecutionContext,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
attempt_fence: &mut ToolExecutionAttemptFence,
|
||||||
|
terminal_call_ids: &mut HashSet<String>,
|
||||||
|
) -> Result<bool, EngineError> {
|
||||||
|
let call_id = tool_result.tool_use_id.as_str();
|
||||||
|
let may_commit = match execution_attempt_id {
|
||||||
|
Some(attempt_id) => attempt_fence.can_commit(call_id, attempt_id),
|
||||||
|
None => !terminal_call_ids.contains(call_id),
|
||||||
|
};
|
||||||
|
if !may_commit {
|
||||||
|
warn!(
|
||||||
|
call_id,
|
||||||
|
execution_attempt_id,
|
||||||
|
disposition = ?tool_result.disposition,
|
||||||
|
"Ignoring stale or duplicate tool result after terminal output commit"
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let call_info = call_info_map.get(&tool_result.tool_use_id);
|
||||||
|
let mut abort_reason = None;
|
||||||
|
if let Some((tool_call, meta, tool, context)) = call_info {
|
||||||
let mut info = ToolResultInfo {
|
let mut info = ToolResultInfo {
|
||||||
call: tool_call.clone(),
|
call: tool_call.clone(),
|
||||||
result: tool_result.clone(),
|
result: tool_result,
|
||||||
meta: meta.clone(),
|
meta: meta.clone(),
|
||||||
tool: tool.clone(),
|
tool: tool.clone(),
|
||||||
context: context.clone(),
|
context: context.clone(),
|
||||||
@@ -1094,27 +1477,23 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
match self.interceptor.post_tool_call(&mut info).await {
|
match self.interceptor.post_tool_call(&mut info).await {
|
||||||
PostToolAction::Continue => {}
|
PostToolAction::Continue => {}
|
||||||
PostToolAction::Abort(reason) => {
|
PostToolAction::Abort(reason) => {
|
||||||
return Err(EngineError::Aborted(reason));
|
abort_reason = Some(reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reflect interceptor-modified results
|
tool_result = info.result;
|
||||||
*tool_result = info.result;
|
|
||||||
}
|
}
|
||||||
|
if tool_result.is_error && tool_result.disposition.is_success() {
|
||||||
|
tool_result.disposition = ToolResultDisposition::Error;
|
||||||
}
|
}
|
||||||
|
tool_result.is_error = !tool_result.disposition.is_success();
|
||||||
|
|
||||||
// Phase 4: Cap `content` byte-size before it enters history.
|
// Cap content only after post_tool_call so interceptors still observe
|
||||||
// Runs *after* post_tool_call so interceptors (audit, logging,
|
// the full payload and any content they inject is bounded too.
|
||||||
// classification) still observe the full content, and any
|
if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = (
|
||||||
// content they inject is also truncated — closing the last gap
|
self.tool_output_limits.as_ref(),
|
||||||
// before the data reaches the next LLM request.
|
call_info,
|
||||||
if let Some(limits) = self.tool_output_limits.as_ref() {
|
tool_result.content.as_mut(),
|
||||||
for tool_result in &mut results {
|
) {
|
||||||
let Some(content) = tool_result.content.as_mut() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some((tool_call, _, _, _)) = call_info_map.get(&tool_result.tool_use_id) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let limit = limits.limit_for(&tool_call.name);
|
let limit = limits.limit_for(&tool_call.name);
|
||||||
let before = content.len();
|
let before = content.len();
|
||||||
truncate_content(content, limit);
|
truncate_content(content, limit);
|
||||||
@@ -1135,14 +1514,37 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Emit per-result callbacks on the post-truncation payload.
|
let item = Item::tool_result_item_with_disposition_and_attachments(
|
||||||
for tool_result in &results {
|
&tool_result.tool_use_id,
|
||||||
self.emit_tool_result(tool_result);
|
&tool_result.summary,
|
||||||
|
tool_result.content.clone(),
|
||||||
|
tool_result.disposition,
|
||||||
|
tool_result.attachments.clone(),
|
||||||
|
);
|
||||||
|
self.append_history_items(history, std::iter::once(item), annotate)?;
|
||||||
|
if let Some(attempt_id) = execution_attempt_id
|
||||||
|
&& !attempt_fence.commit_terminal(&tool_result.tool_use_id, attempt_id)
|
||||||
|
{
|
||||||
|
return Err(EngineError::ToolAttemptFence(
|
||||||
|
"tool execution attempt fence changed during terminal commit".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
terminal_call_ids.insert(tool_result.tool_use_id.clone());
|
||||||
Ok(ToolExecutionResult::Completed(results))
|
debug!(
|
||||||
|
tool = call_info
|
||||||
|
.map(|(call, _, _, _)| call.name.as_str())
|
||||||
|
.unwrap_or("unknown"),
|
||||||
|
call_id = %tool_result.tool_use_id,
|
||||||
|
execution_attempt_id,
|
||||||
|
disposition = ?tool_result.disposition,
|
||||||
|
"Tool execution terminalized"
|
||||||
|
);
|
||||||
|
self.emit_tool_result(&tool_result);
|
||||||
|
if let Some(reason) = abort_reason {
|
||||||
|
return Err(EngineError::Aborted(reason));
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal turn execution logic
|
/// Internal turn execution logic
|
||||||
@@ -1438,6 +1840,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
let stream_started = Instant::now();
|
let stream_started = Instant::now();
|
||||||
let stream_result = tokio::select! {
|
let stream_result = tokio::select! {
|
||||||
stream_result = self.client.stream(request.clone()) => stream_result,
|
stream_result = self.client.stream(request.clone()) => stream_result,
|
||||||
|
pause = self.pause_rx.recv() => {
|
||||||
|
if pause.is_some() {
|
||||||
|
info!("Paused before stream started");
|
||||||
|
}
|
||||||
|
self.timeline.abort_current_block();
|
||||||
|
return Err(EngineError::PauseRequested);
|
||||||
|
}
|
||||||
cancel = self.cancel_rx.recv() => {
|
cancel = self.cancel_rx.recv() => {
|
||||||
if cancel.is_some() {
|
if cancel.is_some() {
|
||||||
info!("Cancelled before stream started");
|
info!("Cancelled before stream started");
|
||||||
@@ -1469,6 +1878,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
);
|
);
|
||||||
let first_event_result = tokio::select! {
|
let first_event_result = tokio::select! {
|
||||||
first_event = wait_for_first_stream_event(stream, DEFAULT_FIRST_STREAM_EVENT_TIMEOUT) => first_event,
|
first_event = wait_for_first_stream_event(stream, DEFAULT_FIRST_STREAM_EVENT_TIMEOUT) => first_event,
|
||||||
|
pause = self.pause_rx.recv() => {
|
||||||
|
if pause.is_some() {
|
||||||
|
info!("Paused before first stream event");
|
||||||
|
}
|
||||||
|
self.timeline.abort_current_block();
|
||||||
|
return Err(EngineError::PauseRequested);
|
||||||
|
}
|
||||||
cancel = self.cancel_rx.recv() => {
|
cancel = self.cancel_rx.recv() => {
|
||||||
if cancel.is_some() {
|
if cancel.is_some() {
|
||||||
info!("Cancelled before first stream event");
|
info!("Cancelled before first stream event");
|
||||||
@@ -1555,6 +1971,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = tokio::time::sleep(wait) => {}
|
_ = tokio::time::sleep(wait) => {}
|
||||||
|
pause = self.pause_rx.recv() => {
|
||||||
|
if pause.is_some() {
|
||||||
|
info!("Paused during LLM retry backoff");
|
||||||
|
}
|
||||||
|
self.timeline.abort_current_block();
|
||||||
|
return Err(EngineError::PauseRequested);
|
||||||
|
}
|
||||||
cancel = self.cancel_rx.recv() => {
|
cancel = self.cancel_rx.recv() => {
|
||||||
if cancel.is_some() {
|
if cancel.is_some() {
|
||||||
info!("Cancelled during LLM retry backoff");
|
info!("Cancelled during LLM retry backoff");
|
||||||
@@ -1633,6 +2056,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pause = self.pause_rx.recv() => {
|
||||||
|
if pause.is_some() {
|
||||||
|
info!("Paused during response stream");
|
||||||
|
}
|
||||||
|
self.timeline.abort_current_block();
|
||||||
|
return Err(EngineError::PauseRequested);
|
||||||
|
}
|
||||||
cancel = self.cancel_rx.recv() => {
|
cancel = self.cancel_rx.recv() => {
|
||||||
if cancel.is_some() {
|
if cancel.is_some() {
|
||||||
info!("Stream cancelled");
|
info!("Stream cancelled");
|
||||||
@@ -1658,23 +2088,9 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
tool_calls: Vec<ToolCall>,
|
tool_calls: Vec<ToolCall>,
|
||||||
) -> Result<Option<EngineResult>, EngineError> {
|
) -> Result<Option<EngineResult>, EngineError> {
|
||||||
match self.execute_tools(tool_calls).await {
|
match self.execute_tools(history, annotate, tool_calls).await {
|
||||||
Ok(ToolExecutionResult::Paused) => Ok(Some(EngineResult::Paused)),
|
Ok(ToolExecutionResult::Paused) => Ok(Some(EngineResult::Paused)),
|
||||||
Ok(ToolExecutionResult::Completed(results)) => {
|
Ok(ToolExecutionResult::Completed) => Ok(None),
|
||||||
// Route per-result pushes through the callback path so
|
|
||||||
// observers see each tool result as it lands.
|
|
||||||
let items = results.into_iter().map(|result| {
|
|
||||||
Item::tool_result_item_with_attachments(
|
|
||||||
&result.tool_use_id,
|
|
||||||
&result.summary,
|
|
||||||
result.content,
|
|
||||||
result.is_error,
|
|
||||||
result.attachments,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
self.append_history_items(history, items, annotate)?;
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1688,6 +2104,7 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
|||||||
let thinking_block_collector = ThinkingBlockCollector::new();
|
let thinking_block_collector = ThinkingBlockCollector::new();
|
||||||
let mut timeline = Timeline::new();
|
let mut timeline = Timeline::new();
|
||||||
let (cancel_tx, cancel_rx) = mpsc::channel(1);
|
let (cancel_tx, cancel_rx) = mpsc::channel(1);
|
||||||
|
let (pause_tx, pause_rx) = mpsc::channel(1);
|
||||||
|
|
||||||
// Register collectors with Timeline
|
// Register collectors with Timeline
|
||||||
timeline.on_text_block(text_block_collector.clone());
|
timeline.on_text_block(text_block_collector.clone());
|
||||||
@@ -1710,6 +2127,7 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
|||||||
llm_call_count: 0,
|
llm_call_count: 0,
|
||||||
tool_execution_batch_count: 0,
|
tool_execution_batch_count: 0,
|
||||||
max_turns: None,
|
max_turns: None,
|
||||||
|
tool_execution_policy: ToolExecutionPolicy::default(),
|
||||||
turn_start_cbs: Vec::new(),
|
turn_start_cbs: Vec::new(),
|
||||||
turn_end_cbs: Vec::new(),
|
turn_end_cbs: Vec::new(),
|
||||||
llm_call_start_cbs: Vec::new(),
|
llm_call_start_cbs: Vec::new(),
|
||||||
@@ -1724,6 +2142,8 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
|||||||
request_config: RequestConfig::default(),
|
request_config: RequestConfig::default(),
|
||||||
cancel_tx,
|
cancel_tx,
|
||||||
cancel_rx,
|
cancel_rx,
|
||||||
|
pause_tx,
|
||||||
|
pause_rx,
|
||||||
tool_output_limits: None,
|
tool_output_limits: None,
|
||||||
prune_config: None,
|
prune_config: None,
|
||||||
token_estimator: None,
|
token_estimator: None,
|
||||||
@@ -1982,6 +2402,7 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
|||||||
llm_call_count: self.llm_call_count,
|
llm_call_count: self.llm_call_count,
|
||||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||||
max_turns: self.max_turns,
|
max_turns: self.max_turns,
|
||||||
|
tool_execution_policy: self.tool_execution_policy,
|
||||||
turn_start_cbs: self.turn_start_cbs,
|
turn_start_cbs: self.turn_start_cbs,
|
||||||
turn_end_cbs: self.turn_end_cbs,
|
turn_end_cbs: self.turn_end_cbs,
|
||||||
llm_call_start_cbs: self.llm_call_start_cbs,
|
llm_call_start_cbs: self.llm_call_start_cbs,
|
||||||
@@ -1997,6 +2418,8 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
|||||||
|
|
||||||
cancel_tx: self.cancel_tx,
|
cancel_tx: self.cancel_tx,
|
||||||
cancel_rx: self.cancel_rx,
|
cancel_rx: self.cancel_rx,
|
||||||
|
pause_tx: self.pause_tx,
|
||||||
|
pause_rx: self.pause_rx,
|
||||||
tool_output_limits: self.tool_output_limits,
|
tool_output_limits: self.tool_output_limits,
|
||||||
prune_config: self.prune_config,
|
prune_config: self.prune_config,
|
||||||
token_estimator: self.token_estimator,
|
token_estimator: self.token_estimator,
|
||||||
@@ -2091,7 +2514,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
self.append_history_items(history, extras, annotate)?;
|
self.append_history_items(history, extras, annotate)?;
|
||||||
}
|
}
|
||||||
self.start_logical_run();
|
self.start_logical_run();
|
||||||
let result = self.run_turn_loop(history, annotate).await;
|
let result = match self.run_turn_loop(history, annotate).await {
|
||||||
|
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
let result = self.finalize_interruption(result).await;
|
let result = self.finalize_interruption(result).await;
|
||||||
self.finish_logical_run(&result);
|
self.finish_logical_run(&result);
|
||||||
result
|
result
|
||||||
@@ -2114,7 +2540,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
) -> Result<EngineResult, EngineError> {
|
) -> Result<EngineResult, EngineError> {
|
||||||
self.ensure_logical_run();
|
self.ensure_logical_run();
|
||||||
let result = self.run_turn_loop(history, annotate).await;
|
let result = match self.run_turn_loop(history, annotate).await {
|
||||||
|
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
let result = self.finalize_interruption(result).await;
|
let result = self.finalize_interruption(result).await;
|
||||||
self.finish_logical_run(&result);
|
self.finish_logical_run(&result);
|
||||||
result
|
result
|
||||||
@@ -2146,6 +2575,7 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
llm_call_count: self.llm_call_count,
|
llm_call_count: self.llm_call_count,
|
||||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||||
max_turns: self.max_turns,
|
max_turns: self.max_turns,
|
||||||
|
tool_execution_policy: self.tool_execution_policy,
|
||||||
turn_start_cbs: self.turn_start_cbs,
|
turn_start_cbs: self.turn_start_cbs,
|
||||||
turn_end_cbs: self.turn_end_cbs,
|
turn_end_cbs: self.turn_end_cbs,
|
||||||
llm_call_start_cbs: self.llm_call_start_cbs,
|
llm_call_start_cbs: self.llm_call_start_cbs,
|
||||||
@@ -2161,6 +2591,8 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
|
|
||||||
cancel_tx: self.cancel_tx,
|
cancel_tx: self.cancel_tx,
|
||||||
cancel_rx: self.cancel_rx,
|
cancel_rx: self.cancel_rx,
|
||||||
|
pause_tx: self.pause_tx,
|
||||||
|
pause_rx: self.pause_rx,
|
||||||
tool_output_limits: self.tool_output_limits,
|
tool_output_limits: self.tool_output_limits,
|
||||||
prune_config: self.prune_config,
|
prune_config: self.prune_config,
|
||||||
token_estimator: self.token_estimator,
|
token_estimator: self.token_estimator,
|
||||||
@@ -2296,6 +2728,43 @@ mod tests {
|
|||||||
use crate::tool::{Attachment, ImageAttachment};
|
use crate::tool::{Attachment, ImageAttachment};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_execution_attempt_fence_rejects_duplicate_and_stale_results() {
|
||||||
|
let mut fence = ToolExecutionAttemptFence::default();
|
||||||
|
fence.register("call".to_string(), "attempt-1".to_string());
|
||||||
|
assert!(fence.can_commit("call", "attempt-1"));
|
||||||
|
assert!(fence.commit_terminal("call", "attempt-1"));
|
||||||
|
assert!(!fence.commit_terminal("call", "attempt-1"));
|
||||||
|
|
||||||
|
fence.register("call".to_string(), "attempt-2".to_string());
|
||||||
|
assert_eq!(fence.attempt_id("call"), Some("attempt-2"));
|
||||||
|
assert!(!fence.can_commit("call", "attempt-1"));
|
||||||
|
assert!(!fence.commit_terminal("call", "attempt-1"));
|
||||||
|
assert!(fence.commit_terminal("call", "attempt-2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_projection_reorders_results_and_remaps_cache_anchor() {
|
||||||
|
let items = vec![
|
||||||
|
Item::tool_call_json("call_slow", "slow", serde_json::json!({})),
|
||||||
|
Item::tool_call_json("call_fast", "fast", serde_json::json!({})),
|
||||||
|
Item::tool_result_item("call_fast", "fast result", None, false),
|
||||||
|
Item::tool_result_item("call_slow", "slow result", None, false),
|
||||||
|
];
|
||||||
|
|
||||||
|
let projection = materialize_provider_history(&items);
|
||||||
|
let result_order: Vec<_> = projection
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| match item {
|
||||||
|
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(result_order, ["call_slow", "call_fast"]);
|
||||||
|
assert_eq!(projection.original_to_projected_index, [0, 1, 3, 2]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_attachment_round_trips_through_durable_history_json() {
|
fn tool_attachment_round_trips_through_durable_history_json() {
|
||||||
let body: Arc<[u8]> = Arc::from(&b"image-body"[..]);
|
let body: Arc<[u8]> = Arc::from(&b"image-body"[..]);
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ pub use handler::ToolUseBlockStart;
|
|||||||
pub use history::{History, HistoryEntry};
|
pub use history::{History, HistoryEntry};
|
||||||
pub use interceptor::Interceptor;
|
pub use interceptor::Interceptor;
|
||||||
pub use message::{ContentPart, Item, Message, Role};
|
pub use message::{ContentPart, Item, Message, Role};
|
||||||
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
|
pub use tool::{
|
||||||
|
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
|
||||||
|
ToolExecutionTerminal, ToolExecutionTerminalFuture, ToolOutputLimits, ToolResult,
|
||||||
|
ToolResultDisposition,
|
||||||
|
};
|
||||||
pub use usage_record::UsageRecord;
|
pub use usage_record::UsageRecord;
|
||||||
|
|
||||||
/// Implementation dependencies used by code generated from `agen` macros.
|
/// Implementation dependencies used by code generated from `agen` macros.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
use std::{fmt, sync::Arc};
|
use std::{fmt, sync::Arc};
|
||||||
|
|
||||||
use crate::tool::Attachment;
|
use crate::tool::{Attachment, ToolResultDisposition};
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -121,6 +121,9 @@ pub enum Item {
|
|||||||
/// Detailed output (removed by pruning when old enough)
|
/// Detailed output (removed by pruning when old enough)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
|
/// Typed terminal state used for replay and recovery.
|
||||||
|
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||||
|
disposition: ToolResultDisposition,
|
||||||
/// Whether the tool result represents an execution error.
|
/// Whether the tool result represents an execution error.
|
||||||
#[serde(default, skip_serializing_if = "is_false")]
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
@@ -261,7 +264,17 @@ impl Item {
|
|||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
|
Self::tool_result_item_with_disposition_and_attachments(
|
||||||
|
call_id,
|
||||||
|
summary,
|
||||||
|
content,
|
||||||
|
if is_error {
|
||||||
|
ToolResultDisposition::Error
|
||||||
|
} else {
|
||||||
|
ToolResultDisposition::Success
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a tool result item with durable, prunable structured attachments.
|
/// Create a tool result item with durable, prunable structured attachments.
|
||||||
@@ -272,11 +285,33 @@ impl Item {
|
|||||||
is_error: bool,
|
is_error: bool,
|
||||||
attachments: Vec<Attachment>,
|
attachments: Vec<Attachment>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
Self::tool_result_item_with_disposition_and_attachments(
|
||||||
|
call_id,
|
||||||
|
summary,
|
||||||
|
content,
|
||||||
|
if is_error {
|
||||||
|
ToolResultDisposition::Error
|
||||||
|
} else {
|
||||||
|
ToolResultDisposition::Success
|
||||||
|
},
|
||||||
|
attachments,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_result_item_with_disposition_and_attachments(
|
||||||
|
call_id: impl Into<String>,
|
||||||
|
summary: impl Into<String>,
|
||||||
|
content: Option<String>,
|
||||||
|
disposition: ToolResultDisposition,
|
||||||
|
attachments: Vec<Attachment>,
|
||||||
|
) -> Self {
|
||||||
|
let is_error = !disposition.is_success();
|
||||||
Self::ToolResult {
|
Self::ToolResult {
|
||||||
id: None,
|
id: None,
|
||||||
call_id: call_id.into(),
|
call_id: call_id.into(),
|
||||||
summary: summary.into(),
|
summary: summary.into(),
|
||||||
content,
|
content,
|
||||||
|
disposition,
|
||||||
is_error,
|
is_error,
|
||||||
attachments,
|
attachments,
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-2
@@ -3,7 +3,14 @@
|
|||||||
//! Traits for defining tools callable by LLM.
|
//! Traits for defining tools callable by LLM.
|
||||||
//! Usually auto-implemented using the `#[tool]` macro.
|
//! Usually auto-implemented using the `#[tool]` macro.
|
||||||
|
|
||||||
use std::{collections::HashMap, fmt, sync::Arc};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fmt,
|
||||||
|
future::Future,
|
||||||
|
pin::Pin,
|
||||||
|
sync::Arc,
|
||||||
|
task::{Context, Poll},
|
||||||
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
@@ -23,6 +30,12 @@ pub enum ToolError {
|
|||||||
/// Internal error
|
/// Internal error
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
|
/// Cooperative cancellation completed with bounded terminal output.
|
||||||
|
#[error("Tool execution cancelled")]
|
||||||
|
Cancelled(ToolOutput),
|
||||||
|
/// Execution was interrupted with a confirmed bounded terminal output.
|
||||||
|
#[error("Tool execution interrupted")]
|
||||||
|
Interrupted(ToolOutput),
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -158,6 +171,28 @@ pub enum Attachment {
|
|||||||
Image(ImageAttachment),
|
Image(ImageAttachment),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Terminal disposition of one started tool call.
|
||||||
|
///
|
||||||
|
/// `Cancelled` means the tool confirmed cancellation. `OutcomeUnknown` means
|
||||||
|
/// execution stopped without confirmation, so neither completion nor side
|
||||||
|
/// effects may be inferred.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ToolResultDisposition {
|
||||||
|
#[default]
|
||||||
|
Success,
|
||||||
|
Error,
|
||||||
|
Interrupted,
|
||||||
|
Cancelled,
|
||||||
|
OutcomeUnknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolResultDisposition {
|
||||||
|
pub const fn is_success(&self) -> bool {
|
||||||
|
matches!(self, Self::Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tool execution result.
|
/// Tool execution result.
|
||||||
///
|
///
|
||||||
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
||||||
@@ -322,6 +357,12 @@ impl ToolExecutionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Identifies one live execution attempt without making the batch id a durable
|
||||||
|
/// replay or idempotency authority.
|
||||||
|
pub fn execution_id(&self) -> String {
|
||||||
|
format!("{}:{}", self.batch_id, self.call_id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Context for direct, non-engine calls in unit tests and low-level callers.
|
/// Context for direct, non-engine calls in unit tests and low-level callers.
|
||||||
pub fn direct() -> Self {
|
pub fn direct() -> Self {
|
||||||
Self::new("direct", "direct", 0)
|
Self::new("direct", "direct", 0)
|
||||||
@@ -334,6 +375,142 @@ impl Default for ToolExecutionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The provider-confirmed terminal result of one started tool execution.
|
||||||
|
///
|
||||||
|
/// `OutcomeUnknown` is reserved for an execution task that had to be force-closed
|
||||||
|
/// or failed before the provider could confirm its terminal result.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ToolExecutionTerminal {
|
||||||
|
Confirmed(Result<ToolOutput, ToolError>),
|
||||||
|
OutcomeUnknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The completion future paired with a [`ToolExecutionHandle`]. Dropping this
|
||||||
|
/// future does not drop the provider execution: the spawned execution remains
|
||||||
|
/// owned by its handle until it completes or is explicitly force-closed.
|
||||||
|
pub struct ToolExecutionTerminalFuture {
|
||||||
|
task: tokio::task::JoinHandle<Result<ToolOutput, ToolError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Future for ToolExecutionTerminalFuture {
|
||||||
|
type Output = ToolExecutionTerminal;
|
||||||
|
|
||||||
|
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||||
|
match Pin::new(&mut self.task).poll(cx) {
|
||||||
|
Poll::Ready(Ok(result)) => Poll::Ready(ToolExecutionTerminal::Confirmed(result)),
|
||||||
|
Poll::Ready(Err(_)) => Poll::Ready(ToolExecutionTerminal::OutcomeUnknown),
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live ownership and control for one started tool execution.
|
||||||
|
///
|
||||||
|
/// Execution, cancellation, and terminal confirmation remain provider-owned:
|
||||||
|
/// this handle starts `Tool::execute`, delegates cooperative cancellation to
|
||||||
|
/// `Tool::cancel_execution`, and treats execution-future completion as the
|
||||||
|
/// provider's terminal confirmation. Agen may force-close only after its caller's
|
||||||
|
/// deadline expires, at which point the outcome is necessarily unknown.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ToolExecutionHandle {
|
||||||
|
inner: Arc<ToolExecutionHandleInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToolExecutionHandleInner {
|
||||||
|
tool: Arc<dyn Tool>,
|
||||||
|
context: ToolExecutionContext,
|
||||||
|
abort: tokio::task::AbortHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ToolExecutionHandleInner {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Losing the final live owner is an explicit forced close, never a
|
||||||
|
// best-effort detached provider future.
|
||||||
|
self.abort.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ToolExecutionHandle {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("ToolExecutionHandle")
|
||||||
|
.field("call_id", &self.inner.context.call_id)
|
||||||
|
.field("batch_id", &self.inner.context.batch_id)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolExecutionHandle {
|
||||||
|
pub fn start(
|
||||||
|
tool: Arc<dyn Tool>,
|
||||||
|
input_json: String,
|
||||||
|
context: ToolExecutionContext,
|
||||||
|
) -> (Self, ToolExecutionTerminalFuture) {
|
||||||
|
let execution_tool = Arc::clone(&tool);
|
||||||
|
let execution_context = context.clone();
|
||||||
|
let task =
|
||||||
|
tokio::spawn(
|
||||||
|
async move { execution_tool.execute(&input_json, execution_context).await },
|
||||||
|
);
|
||||||
|
let abort = task.abort_handle();
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(ToolExecutionHandleInner {
|
||||||
|
tool,
|
||||||
|
context,
|
||||||
|
abort,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolExecutionTerminalFuture { task },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn context(&self) -> &ToolExecutionContext {
|
||||||
|
&self.inner.context
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_before(&self, deadline: tokio::time::Instant) -> Result<(), ToolError> {
|
||||||
|
match tokio::time::timeout_at(
|
||||||
|
deadline,
|
||||||
|
self.inner.tool.cancel_execution(&self.inner.context),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(ToolError::Internal(format!(
|
||||||
|
"tool cancellation request exceeded its deadline for call {}",
|
||||||
|
self.inner.context.call_id
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn force_close(&self) {
|
||||||
|
self.inner.abort.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ToolExecutionPolicy {
|
||||||
|
/// Time a pause waits for already-started providers to reach a natural safe
|
||||||
|
/// boundary before escalating to explicit cooperative cancellation.
|
||||||
|
pub pause_safe_boundary_timeout: std::time::Duration,
|
||||||
|
/// Maximum time allowed for a provider to accept one cooperative
|
||||||
|
/// cancellation request.
|
||||||
|
pub cancellation_request_timeout: std::time::Duration,
|
||||||
|
/// Maximum time allowed for all providers to confirm terminal results after
|
||||||
|
/// cancellation has been requested.
|
||||||
|
pub terminal_confirmation_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ToolExecutionPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
pause_safe_boundary_timeout: std::time::Duration::from_millis(100),
|
||||||
|
cancellation_request_timeout: std::time::Duration::from_millis(100),
|
||||||
|
terminal_confirmation_timeout: std::time::Duration::from_millis(500),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tool trait
|
// Tool trait
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -402,6 +579,26 @@ pub trait Tool: Send + Sync {
|
|||||||
input_json: &str,
|
input_json: &str,
|
||||||
ctx: ToolExecutionContext,
|
ctx: ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError>;
|
) -> Result<ToolOutput, ToolError>;
|
||||||
|
|
||||||
|
/// Request cooperative cancellation for one started call.
|
||||||
|
///
|
||||||
|
/// Implementations that own cancellable provider operations should signal
|
||||||
|
/// every live execution identified by `call_id`, then let `execute` return
|
||||||
|
/// the confirmed bounded terminal output. Direct callers may use this
|
||||||
|
/// compatibility surface; Agen uses [`Tool::cancel_execution`] so providers
|
||||||
|
/// can bind cancellation to one exact live attempt.
|
||||||
|
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request cooperative cancellation for one exact started execution.
|
||||||
|
///
|
||||||
|
/// The default preserves existing tools by delegating to `cancel(call_id)`.
|
||||||
|
/// Providers with their own execution registry should override this method
|
||||||
|
/// and key cancellation by [`ToolExecutionContext::execution_id`].
|
||||||
|
async fn cancel_execution(&self, ctx: &ToolExecutionContext) -> Result<(), ToolError> {
|
||||||
|
self.cancel(&ctx.call_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -429,6 +626,9 @@ pub struct ToolCall {
|
|||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
/// Corresponding tool call ID
|
/// Corresponding tool call ID
|
||||||
pub tool_use_id: String,
|
pub tool_use_id: String,
|
||||||
|
/// Typed terminal state.
|
||||||
|
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||||
|
pub disposition: ToolResultDisposition,
|
||||||
/// Short summary (always kept in history)
|
/// Short summary (always kept in history)
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
/// Detailed output (prunable)
|
/// Detailed output (prunable)
|
||||||
@@ -445,11 +645,20 @@ pub struct ToolResult {
|
|||||||
impl ToolResult {
|
impl ToolResult {
|
||||||
/// Create a success result from a [`ToolOutput`].
|
/// Create a success result from a [`ToolOutput`].
|
||||||
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
||||||
|
Self::from_output_with_disposition(tool_use_id, output, ToolResultDisposition::Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_output_with_disposition(
|
||||||
|
tool_use_id: impl Into<String>,
|
||||||
|
output: ToolOutput,
|
||||||
|
disposition: ToolResultDisposition,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tool_use_id: tool_use_id.into(),
|
tool_use_id: tool_use_id.into(),
|
||||||
|
disposition,
|
||||||
summary: output.summary,
|
summary: output.summary,
|
||||||
content: output.content,
|
content: output.content,
|
||||||
is_error: false,
|
is_error: !disposition.is_success(),
|
||||||
attachments: output.attachments,
|
attachments: output.attachments,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,12 +667,28 @@ impl ToolResult {
|
|||||||
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tool_use_id: tool_use_id.into(),
|
tool_use_id: tool_use_id.into(),
|
||||||
|
disposition: ToolResultDisposition::Error,
|
||||||
summary: message.into(),
|
summary: message.into(),
|
||||||
content: None,
|
content: None,
|
||||||
is_error: true,
|
is_error: true,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Close an execution whose completion and side effects cannot be confirmed.
|
||||||
|
pub fn outcome_unknown(tool_use_id: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
tool_use_id: tool_use_id.into(),
|
||||||
|
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||||
|
summary: "Tool execution outcome unknown".to_string(),
|
||||||
|
content: Some(
|
||||||
|
"Execution was interrupted before completion could be confirmed. Completion and side effects are unknown."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
is_error: true,
|
||||||
|
attachments: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
pub struct MockLlmClient {
|
pub struct MockLlmClient {
|
||||||
responses: Arc<Vec<Vec<Event>>>,
|
responses: Arc<Vec<Vec<Event>>>,
|
||||||
call_count: Arc<AtomicUsize>,
|
call_count: Arc<AtomicUsize>,
|
||||||
|
requests: Arc<Mutex<Vec<Request>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockLlmClient {
|
impl MockLlmClient {
|
||||||
@@ -30,6 +31,7 @@ impl MockLlmClient {
|
|||||||
Self {
|
Self {
|
||||||
responses: Arc::new(responses),
|
responses: Arc::new(responses),
|
||||||
call_count: Arc::new(AtomicUsize::new(0)),
|
call_count: Arc::new(AtomicUsize::new(0)),
|
||||||
|
requests: Arc::new(Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +43,10 @@ impl MockLlmClient {
|
|||||||
pub fn event_count(&self) -> usize {
|
pub fn event_count(&self) -> usize {
|
||||||
self.responses.iter().map(|v| v.len()).sum()
|
self.responses.iter().map(|v| v.len()).sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn requests(&self) -> Vec<Request> {
|
||||||
|
self.requests.lock().unwrap().clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -51,8 +57,9 @@ impl LlmClient for MockLlmClient {
|
|||||||
|
|
||||||
async fn stream(
|
async fn stream(
|
||||||
&self,
|
&self,
|
||||||
_request: Request,
|
request: Request,
|
||||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||||
|
self.requests.lock().unwrap().push(request);
|
||||||
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||||
if count >= self.responses.len() {
|
if count >= self.responses.len() {
|
||||||
return Err(ClientError::Api {
|
return Err(ClientError::Api {
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo
|
|||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||||
use agen::tool::{
|
use agen::tool::{
|
||||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
||||||
|
ToolResultDisposition,
|
||||||
};
|
};
|
||||||
use agen::{Engine, History};
|
use agen::{Engine, History, Item, ToolExecutionPolicy};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
@@ -70,6 +71,144 @@ impl Tool for SlowTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FirstAttemptHangsTool {
|
||||||
|
calls: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FirstAttemptHangsTool {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Arc::new(AtomicUsize::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn definition(&self) -> ToolDefinition {
|
||||||
|
let tool = self.clone();
|
||||||
|
Arc::new(move || {
|
||||||
|
let meta = ToolMeta::new("hang_once")
|
||||||
|
.description("Hangs on the first execution attempt")
|
||||||
|
.input_schema(serde_json::json!({"type": "object"}));
|
||||||
|
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_count(&self) -> usize {
|
||||||
|
self.calls.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for FirstAttemptHangsTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_input_json: &str,
|
||||||
|
_ctx: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let attempt = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if attempt == 0 {
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
}
|
||||||
|
Ok("completed on retry".to_string().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CooperativeCancelTool {
|
||||||
|
calls: Arc<AtomicUsize>,
|
||||||
|
cancelled: Arc<tokio::sync::Notify>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CooperativeCancelTool {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Arc::new(AtomicUsize::new(0)),
|
||||||
|
cancelled: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn definition(&self) -> ToolDefinition {
|
||||||
|
let tool = self.clone();
|
||||||
|
Arc::new(move || {
|
||||||
|
let meta = ToolMeta::new("cooperative")
|
||||||
|
.description("Returns bounded progress after cancellation")
|
||||||
|
.input_schema(serde_json::json!({"type": "object"}));
|
||||||
|
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for CooperativeCancelTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_input_json: &str,
|
||||||
|
_ctx: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.cancelled.notified().await;
|
||||||
|
Err(ToolError::Cancelled(ToolOutput {
|
||||||
|
summary: "cooperative command cancelled".to_string(),
|
||||||
|
content: Some("stdout before cancellation\nstderr before cancellation".to_string()),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||||
|
self.cancelled.notify_one();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SafePauseTool {
|
||||||
|
calls: Arc<AtomicUsize>,
|
||||||
|
cancellations: Arc<AtomicUsize>,
|
||||||
|
release: Arc<tokio::sync::Notify>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SafePauseTool {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Arc::new(AtomicUsize::new(0)),
|
||||||
|
cancellations: Arc::new(AtomicUsize::new(0)),
|
||||||
|
release: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn definition(&self) -> ToolDefinition {
|
||||||
|
let tool = self.clone();
|
||||||
|
Arc::new(move || {
|
||||||
|
let meta = ToolMeta::new("safe_pause")
|
||||||
|
.description("Waits for a safe-boundary release")
|
||||||
|
.input_schema(serde_json::json!({"type": "object"}));
|
||||||
|
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for SafePauseTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_input_json: &str,
|
||||||
|
_ctx: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.release.notified().await;
|
||||||
|
Ok(ToolOutput {
|
||||||
|
summary: "safe-boundary complete".to_string(),
|
||||||
|
content: Some("safe-boundary complete".to_string()),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||||
|
self.cancellations.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ContextRecordingTool {
|
struct ContextRecordingTool {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -179,6 +318,450 @@ async fn test_parallel_tool_execution() {
|
|||||||
println!("Parallel execution completed in {:?}", elapsed);
|
println!("Parallel execution completed in {:?}", elapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn completed_results_commit_before_publish_without_waiting_for_siblings() {
|
||||||
|
let client = MockLlmClient::with_responses(vec![
|
||||||
|
vec![
|
||||||
|
Event::tool_use_start(0, "call_slow", "slow_first"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::tool_use_start(1, "call_fast", "fast_second"),
|
||||||
|
Event::tool_input_delta(1, r#"{}"#),
|
||||||
|
Event::tool_use_stop(1),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
Event::text_block_start(0),
|
||||||
|
Event::text_delta(0, "Done"),
|
||||||
|
Event::text_block_stop(0, None),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
let client_probe = client.clone();
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
engine.register_tool(SlowTool::new("slow_first", 100).definition());
|
||||||
|
engine.register_tool(SlowTool::new("fast_second", 5).definition());
|
||||||
|
|
||||||
|
let observed = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
|
let published = observed.clone();
|
||||||
|
engine.on_tool_result(move |result| {
|
||||||
|
published
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(format!("publish:{}", result.tool_use_id));
|
||||||
|
});
|
||||||
|
|
||||||
|
let committed = observed.clone();
|
||||||
|
let mut annotate = move |item: &Item| {
|
||||||
|
if let Item::ToolResult { call_id, .. } = item {
|
||||||
|
committed.lock().unwrap().push(format!("commit:{call_id}"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
let mut history = History::new();
|
||||||
|
let _ = engine
|
||||||
|
.run_with_annotation(&mut history, "run both", &mut annotate)
|
||||||
|
.await;
|
||||||
|
observed.lock().unwrap().push("run-returned".to_string());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
observed.lock().unwrap().as_slice(),
|
||||||
|
[
|
||||||
|
"commit:call_fast",
|
||||||
|
"publish:call_fast",
|
||||||
|
"commit:call_slow",
|
||||||
|
"publish:call_slow",
|
||||||
|
"run-returned",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let committed_order: Vec<_> = history
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| match &entry.item {
|
||||||
|
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(committed_order, ["call_fast", "call_slow"]);
|
||||||
|
|
||||||
|
let requests = client_probe.requests();
|
||||||
|
let projected_order: Vec<_> = requests[1]
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| match item {
|
||||||
|
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(projected_order, ["call_slow", "call_fast"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancellation_preserves_completed_results_and_resume_skips_them() {
|
||||||
|
let client = MockLlmClient::with_responses(vec![
|
||||||
|
vec![
|
||||||
|
Event::tool_use_start(0, "call_hang", "hang_once"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::tool_use_start(1, "call_fast_a", "fast_a"),
|
||||||
|
Event::tool_input_delta(1, r#"{}"#),
|
||||||
|
Event::tool_use_stop(1),
|
||||||
|
Event::tool_use_start(2, "call_fast_b", "fast_b"),
|
||||||
|
Event::tool_input_delta(2, r#"{}"#),
|
||||||
|
Event::tool_use_stop(2),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
Event::text_block_start(0),
|
||||||
|
Event::text_delta(0, "Recovered"),
|
||||||
|
Event::text_block_stop(0, None),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
let hanging = FirstAttemptHangsTool::new();
|
||||||
|
let fast_a = SlowTool::new("fast_a", 1);
|
||||||
|
let fast_b = SlowTool::new("fast_b", 2);
|
||||||
|
engine.register_tool(hanging.definition());
|
||||||
|
engine.register_tool(fast_a.definition());
|
||||||
|
engine.register_tool(fast_b.definition());
|
||||||
|
|
||||||
|
let cancel = engine.cancel_sender();
|
||||||
|
let cancel_task = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
cancel.send(()).await.unwrap();
|
||||||
|
});
|
||||||
|
let mut history = History::new();
|
||||||
|
let output = engine.run(&mut history, "start").await;
|
||||||
|
let mut engine = output.engine;
|
||||||
|
cancel_task.await.unwrap();
|
||||||
|
|
||||||
|
let completed_before_resume = history
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
matches!(
|
||||||
|
&entry.item,
|
||||||
|
Item::ToolResult { call_id, .. }
|
||||||
|
if call_id == "call_fast_a" || call_id == "call_fast_b"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let unknown_before_resume = history
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
matches!(
|
||||||
|
&entry.item,
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||||
|
..
|
||||||
|
} if call_id == "call_hang"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(completed_before_resume, 2);
|
||||||
|
assert_eq!(unknown_before_resume, 1);
|
||||||
|
assert_eq!(fast_a.call_count(), 1);
|
||||||
|
assert_eq!(fast_b.call_count(), 1);
|
||||||
|
assert_eq!(hanging.call_count(), 1);
|
||||||
|
|
||||||
|
let _ = engine.resume(&mut history).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fast_a.call_count(),
|
||||||
|
1,
|
||||||
|
"completed call must not be re-executed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fast_b.call_count(),
|
||||||
|
1,
|
||||||
|
"completed call must not be re-executed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hanging.call_count(),
|
||||||
|
1,
|
||||||
|
"OutcomeUnknown is terminal and must not be re-executed"
|
||||||
|
);
|
||||||
|
let completed_after_resume = history
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
matches!(
|
||||||
|
&entry.item,
|
||||||
|
Item::ToolResult { call_id, .. }
|
||||||
|
if call_id == "call_fast_a" || call_id == "call_fast_b"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(completed_after_resume, 2);
|
||||||
|
assert_eq!(
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
matches!(
|
||||||
|
&entry.item,
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||||
|
..
|
||||||
|
} if call_id == "call_hang"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cooperative_cancellation_commits_bounded_terminal_output() {
|
||||||
|
let client = MockLlmClient::with_responses(vec![vec![
|
||||||
|
Event::tool_use_start(0, "call_cooperative", "cooperative"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]]);
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
let tool = CooperativeCancelTool::new();
|
||||||
|
engine.register_tool(tool.definition());
|
||||||
|
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||||
|
let published = observed.clone();
|
||||||
|
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
|
||||||
|
let committed = observed.clone();
|
||||||
|
let mut annotate = move |item: &Item| {
|
||||||
|
if matches!(item, Item::ToolResult { .. }) {
|
||||||
|
committed.lock().unwrap().push("committed");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
let cancel = engine.cancel_sender();
|
||||||
|
let cancel_task = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
cancel.send(()).await.unwrap();
|
||||||
|
});
|
||||||
|
let mut history = History::new();
|
||||||
|
let output = engine
|
||||||
|
.run_with_annotation(&mut history, "start", &mut annotate)
|
||||||
|
.await;
|
||||||
|
observed.lock().unwrap().push("run-returned");
|
||||||
|
cancel_task.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
observed.lock().unwrap().as_slice(),
|
||||||
|
["committed", "published", "run-returned"]
|
||||||
|
);
|
||||||
|
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||||
|
let terminal: Vec<_> = history
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| match &entry.item {
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition,
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
} if call_id == "call_cooperative" => Some((*disposition, content.as_deref())),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(terminal.len(), 1);
|
||||||
|
assert_eq!(terminal[0].0, ToolResultDisposition::Cancelled);
|
||||||
|
assert_eq!(
|
||||||
|
terminal[0].1,
|
||||||
|
Some("stdout before cancellation\nstderr before cancellation")
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
output.result,
|
||||||
|
agen::EngineRunExit::Interrupted(agen::StopReason::Cancelled)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pause_waits_for_started_tool_terminal_without_cancelling_provider() {
|
||||||
|
let client = MockLlmClient::with_responses(vec![vec![
|
||||||
|
Event::tool_use_start(0, "call_safe_pause", "safe_pause"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]]);
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
let tool = SafePauseTool::new();
|
||||||
|
engine.register_tool(tool.definition());
|
||||||
|
|
||||||
|
let pause = engine.pause_sender();
|
||||||
|
let calls = Arc::clone(&tool.calls);
|
||||||
|
let release = Arc::clone(&tool.release);
|
||||||
|
let control = tokio::spawn(async move {
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while calls.load(Ordering::SeqCst) == 0 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("tool execution starts");
|
||||||
|
pause.send(()).await.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
release.notify_one();
|
||||||
|
});
|
||||||
|
|
||||||
|
let started_at = std::time::Instant::now();
|
||||||
|
let mut history = History::new();
|
||||||
|
let output = engine.run(&mut history, "pause safely").await;
|
||||||
|
control.await.unwrap();
|
||||||
|
|
||||||
|
assert!(started_at.elapsed() >= Duration::from_millis(50));
|
||||||
|
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0);
|
||||||
|
assert!(matches!(output.result, agen::EngineRunExit::Paused));
|
||||||
|
assert!(history.iter().any(|entry| matches!(
|
||||||
|
&entry.item,
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: ToolResultDisposition::Success,
|
||||||
|
..
|
||||||
|
} if call_id == "call_safe_pause"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pause_escalates_to_explicit_cancel_and_confirm_after_safe_boundary_deadline() {
|
||||||
|
let client = MockLlmClient::with_responses(vec![vec![
|
||||||
|
Event::tool_use_start(0, "call_pause_cancel", "cooperative"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]]);
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||||
|
pause_safe_boundary_timeout: Duration::from_millis(20),
|
||||||
|
cancellation_request_timeout: Duration::from_millis(50),
|
||||||
|
terminal_confirmation_timeout: Duration::from_millis(100),
|
||||||
|
});
|
||||||
|
let tool = CooperativeCancelTool::new();
|
||||||
|
engine.register_tool(tool.definition());
|
||||||
|
|
||||||
|
let pause = engine.pause_sender();
|
||||||
|
let calls = Arc::clone(&tool.calls);
|
||||||
|
let control = tokio::spawn(async move {
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while calls.load(Ordering::SeqCst) == 0 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("tool execution starts");
|
||||||
|
pause.send(()).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut history = History::new();
|
||||||
|
let output = engine.run(&mut history, "pause with escalation").await;
|
||||||
|
control.await.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(output.result, agen::EngineRunExit::Paused));
|
||||||
|
assert!(history.iter().any(|entry| matches!(
|
||||||
|
&entry.item,
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: ToolResultDisposition::Cancelled,
|
||||||
|
..
|
||||||
|
} if call_id == "call_pause_cancel"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancellation_completion_race_commits_one_terminal_output() {
|
||||||
|
for iteration in 0..24u64 {
|
||||||
|
let client = MockLlmClient::with_responses(vec![
|
||||||
|
vec![
|
||||||
|
Event::tool_use_start(0, "call_racy", "racy"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
vec![Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
})],
|
||||||
|
]);
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
let delay = 2 + iteration % 3;
|
||||||
|
let tool = SlowTool::new("racy", delay);
|
||||||
|
engine.register_tool(tool.definition());
|
||||||
|
let cancel = engine.cancel_sender();
|
||||||
|
let cancel_task = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(delay)).await;
|
||||||
|
let _ = cancel.send(()).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut history = History::new();
|
||||||
|
let _ = engine.run(&mut history, "race").await;
|
||||||
|
cancel_task.await.unwrap();
|
||||||
|
let terminal_count = history
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
matches!(
|
||||||
|
&entry.item,
|
||||||
|
Item::ToolResult { call_id, .. } if call_id == "call_racy"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(terminal_count, 1, "iteration {iteration}");
|
||||||
|
assert_eq!(tool.call_count(), 1, "iteration {iteration}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_result_commit_failure_prevents_publication() {
|
||||||
|
let client = MockLlmClient::with_responses(vec![vec![
|
||||||
|
Event::tool_use_start(0, "call_fast", "fast"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]]);
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
engine.register_tool(SlowTool::new("fast", 1).definition());
|
||||||
|
|
||||||
|
let published = Arc::new(AtomicUsize::new(0));
|
||||||
|
let published_probe = published.clone();
|
||||||
|
engine.on_tool_result(move |_| {
|
||||||
|
published_probe.fetch_add(1, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut history = History::new();
|
||||||
|
let mut reject_tool_result = |item: &Item| {
|
||||||
|
if matches!(item, Item::ToolResult { .. }) {
|
||||||
|
Err("session log unavailable".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = engine
|
||||||
|
.run_with_annotation(&mut history, "start", &mut reject_tool_result)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(published.load(Ordering::SeqCst), 0);
|
||||||
|
assert!(
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.all(|entry| !matches!(entry.item, Item::ToolResult { .. }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_tool_execution_context_order_and_batch_id() {
|
async fn test_tool_execution_context_order_and_batch_id() {
|
||||||
let client = MockLlmClient::with_responses(vec![
|
let client = MockLlmClient::with_responses(vec![
|
||||||
@@ -583,3 +1166,76 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
|||||||
} if call_id == "call_1" && summary == "permission denied"
|
} if call_id == "call_1" && summary == "permission denied"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
||||||
|
let client = MockLlmClient::new(vec![
|
||||||
|
Event::tool_use_start(0, "call_confirmed", "confirmed"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
let tool = SlowTool::new("confirmed", 1);
|
||||||
|
engine.register_tool(tool.definition());
|
||||||
|
|
||||||
|
struct AbortAfterResult;
|
||||||
|
#[async_trait]
|
||||||
|
impl Interceptor for AbortAfterResult {
|
||||||
|
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||||
|
PostToolAction::Abort("policy stopped the run".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engine.set_interceptor(AbortAfterResult);
|
||||||
|
|
||||||
|
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||||
|
let published = observed.clone();
|
||||||
|
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
|
||||||
|
let committed = observed.clone();
|
||||||
|
let mut annotate = move |item: &Item| {
|
||||||
|
if matches!(item, Item::ToolResult { .. }) {
|
||||||
|
committed.lock().unwrap().push("committed");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut history = History::new();
|
||||||
|
let output = engine
|
||||||
|
.run_with_annotation(&mut history, "run confirmed tool", &mut annotate)
|
||||||
|
.await;
|
||||||
|
observed.lock().unwrap().push("run-returned");
|
||||||
|
|
||||||
|
assert_eq!(tool.call_count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
observed.lock().unwrap().as_slice(),
|
||||||
|
["committed", "published", "run-returned"]
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
output.result,
|
||||||
|
agen::EngineRunExit::Interrupted(agen::StopReason::Unexpected(
|
||||||
|
agen::EngineError::Aborted(ref reason)
|
||||||
|
)) if reason == "policy stopped the run"
|
||||||
|
));
|
||||||
|
let terminal: Vec<_> = history
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| match &entry.item {
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition,
|
||||||
|
..
|
||||||
|
} if call_id == "call_confirmed" => Some(*disposition),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(terminal, [ToolResultDisposition::Success]);
|
||||||
|
assert!(!history.iter().any(|entry| matches!(
|
||||||
|
&entry.item,
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||||
|
..
|
||||||
|
} if call_id == "call_confirmed"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -279,4 +279,58 @@ mod tests {
|
|||||||
assert_eq!(grep.matched_files, 2);
|
assert_eq!(grep.matched_files, 2);
|
||||||
assert!(!grep.output.contains("c.txt"));
|
assert!(!grep.output.contains("c.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grep_content_groups_lines_by_file_and_marks_matches() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
temp.path().join("first.txt"),
|
||||||
|
"before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap();
|
||||||
|
let root = temp.path().canonicalize().unwrap();
|
||||||
|
let readable = RootAccess(root.clone());
|
||||||
|
|
||||||
|
let grep = run_grep(
|
||||||
|
&root,
|
||||||
|
root.clone(),
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "needle".to_string(),
|
||||||
|
path: FsPath::root(),
|
||||||
|
glob: Some("*.txt".to_string()),
|
||||||
|
output_mode: GrepOutputMode::Content,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 1,
|
||||||
|
after_context: 1,
|
||||||
|
multiline: false,
|
||||||
|
file_type: None,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
&readable,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(grep.match_count, 3);
|
||||||
|
assert_eq!(grep.matched_files, 2);
|
||||||
|
assert_eq!(
|
||||||
|
grep.output,
|
||||||
|
concat!(
|
||||||
|
"first.txt\n",
|
||||||
|
" 1 │ before\n",
|
||||||
|
" > 2 │ needle one\n",
|
||||||
|
" 3 │ after\n",
|
||||||
|
" …\n",
|
||||||
|
" 6 │ before distant\n",
|
||||||
|
" > 7 │ needle distant\n",
|
||||||
|
" 8 │ after distant\n",
|
||||||
|
"\n",
|
||||||
|
"second.txt\n",
|
||||||
|
" > 1 │ needle two\n",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(grep.output.matches("first.txt").count(), 1);
|
||||||
|
assert_eq!(grep.output.matches("second.txt").count(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::FsAccessPolicy;
|
use crate::FsAccessPolicy;
|
||||||
@@ -57,20 +59,11 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepOutputMode::Content => {
|
GrepOutputMode::Content => {
|
||||||
for line in &self.lines {
|
output.push_str(&render_content_lines(
|
||||||
let separator = if line.is_match { ':' } else { '-' };
|
root,
|
||||||
let path = logical_display(root, &line.path);
|
&self.lines,
|
||||||
if self.show_line_numbers
|
self.show_line_numbers,
|
||||||
&& let Some(number) = line.line_number
|
|
||||||
{
|
|
||||||
output.push_str(&format!(
|
|
||||||
"{path}{separator}{number}{separator}{}\n",
|
|
||||||
line.text
|
|
||||||
));
|
));
|
||||||
} else {
|
|
||||||
output.push_str(&format!("{path}{separator}{}\n", line.text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepResult {
|
GrepResult {
|
||||||
@@ -82,6 +75,48 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String {
|
||||||
|
let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new();
|
||||||
|
for line in lines {
|
||||||
|
grouped.entry(&line.path).or_default().push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() {
|
||||||
|
if file_index > 0 {
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
let _ = writeln!(output, "{}", logical_display(root, path));
|
||||||
|
|
||||||
|
let number_width = file_lines
|
||||||
|
.iter()
|
||||||
|
.filter_map(|line| line.line_number)
|
||||||
|
.map(|number| number.to_string().len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1);
|
||||||
|
let mut previous_line_end = None;
|
||||||
|
for line in file_lines {
|
||||||
|
if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number)
|
||||||
|
&& number > previous_end
|
||||||
|
{
|
||||||
|
let _ = writeln!(output, " …");
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker = if line.is_match { '>' } else { ' ' };
|
||||||
|
if show_line_numbers && let Some(number) = line.line_number {
|
||||||
|
let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text);
|
||||||
|
} else {
|
||||||
|
let _ = writeln!(output, " {marker} │ {}", line.text);
|
||||||
|
}
|
||||||
|
previous_line_end = line
|
||||||
|
.line_number
|
||||||
|
.map(|number| number + line.text.split('\n').count() as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
fn logical_display(root: &Path, path: &Path) -> String {
|
fn logical_display(root: &Path, path: &Path) -> String {
|
||||||
path.strip_prefix(root)
|
path.strip_prefix(root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
|
|||||||
@@ -946,7 +946,7 @@ fn apply_role_profile(
|
|||||||
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
||||||
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
||||||
value["feature"]["worker"] = serde_json::json!({
|
value["feature"]["worker"] = serde_json::json!({
|
||||||
"enabled": matches!(slug, "companion" | "orchestrator"),
|
"enabled": slug == "orchestrator",
|
||||||
"direct_spawn": slug != "orchestrator"
|
"direct_spawn": slug != "orchestrator"
|
||||||
});
|
});
|
||||||
value["feature"]["manage_workdir"] = serde_json::json!({
|
value["feature"]["manage_workdir"] = serde_json::json!({
|
||||||
@@ -1408,7 +1408,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_companion_can_manage_workdirs() {
|
fn builtin_coder_uses_sub_worker_control_without_worker_control() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let resolved = ProfileResolver::new()
|
||||||
|
.with_workspace_base(tmp.path())
|
||||||
|
.resolve(
|
||||||
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
|
||||||
|
ProfileResolveOptions::with_worker_name("coder-worker"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!resolved.manifest.feature.worker.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_companion_uses_sub_worker_control_without_worker_control() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let resolved = ProfileResolver::new()
|
let resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
@@ -1419,6 +1434,8 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
||||||
|
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!resolved.manifest.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -352,6 +352,18 @@ pub struct InternalWorkerSnapshot {
|
|||||||
pub internal_workers: Vec<InternalWorkerSnapshot>,
|
pub internal_workers: Vec<InternalWorkerSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ToolResultDisposition {
|
||||||
|
#[default]
|
||||||
|
Success,
|
||||||
|
Error,
|
||||||
|
Interrupted,
|
||||||
|
Cancelled,
|
||||||
|
OutcomeUnknown,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||||
@@ -501,6 +513,8 @@ pub enum Event {
|
|||||||
/// summary-only, or when the result was pruned.
|
/// summary-only, or when the result was pruned.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
output: Option<String>,
|
output: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
disposition: Option<ToolResultDisposition>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
},
|
},
|
||||||
@@ -1839,6 +1853,7 @@ mod tests {
|
|||||||
id: "call_1".into(),
|
id: "call_1".into(),
|
||||||
summary: "Read 128 bytes".into(),
|
summary: "Read 128 bytes".into(),
|
||||||
output: Some("hello world".into()),
|
output: Some("hello world".into()),
|
||||||
|
disposition: Some(ToolResultDisposition::Success),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
@@ -1855,11 +1870,13 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
summary,
|
summary,
|
||||||
output,
|
output,
|
||||||
|
disposition,
|
||||||
is_error,
|
is_error,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(id, "call_1");
|
assert_eq!(id, "call_1");
|
||||||
assert_eq!(summary, "Read 128 bytes");
|
assert_eq!(summary, "Read 128 bytes");
|
||||||
assert_eq!(output.as_deref(), Some("hello world"));
|
assert_eq!(output.as_deref(), Some("hello world"));
|
||||||
|
assert_eq!(disposition, Some(ToolResultDisposition::Success));
|
||||||
assert!(!is_error);
|
assert!(!is_error);
|
||||||
}
|
}
|
||||||
other => panic!("expected ToolResult, got {other:?}"),
|
other => panic!("expected ToolResult, got {other:?}"),
|
||||||
@@ -1872,6 +1889,7 @@ mod tests {
|
|||||||
id: "call_2".into(),
|
id: "call_2".into(),
|
||||||
summary: "ok".into(),
|
summary: "ok".into(),
|
||||||
output: None,
|
output: None,
|
||||||
|
disposition: Some(ToolResultDisposition::Success),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
@@ -1887,6 +1905,7 @@ mod tests {
|
|||||||
id: "call_3".into(),
|
id: "call_3".into(),
|
||||||
summary: "invalid argument".into(),
|
summary: "invalid argument".into(),
|
||||||
output: None,
|
output: None,
|
||||||
|
disposition: Some(ToolResultDisposition::Error),
|
||||||
is_error: true,
|
is_error: true,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||||
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
||||||
RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, WorkerStatus,
|
RunResult, ScopeRule, Segment, ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
|
||||||
subscription::{
|
subscription::{
|
||||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||||
@@ -45,6 +45,7 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<TurnResult>(&cfg, &mut output);
|
push_decl::<TurnResult>(&cfg, &mut output);
|
||||||
push_decl::<InvokeKind>(&cfg, &mut output);
|
push_decl::<InvokeKind>(&cfg, &mut output);
|
||||||
push_decl::<RunResult>(&cfg, &mut output);
|
push_decl::<RunResult>(&cfg, &mut output);
|
||||||
|
push_decl::<ToolResultDisposition>(&cfg, &mut output);
|
||||||
push_decl::<ErrorCode>(&cfg, &mut output);
|
push_decl::<ErrorCode>(&cfg, &mut output);
|
||||||
push_decl::<Permission>(&cfg, &mut output);
|
push_decl::<Permission>(&cfg, &mut output);
|
||||||
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
use agen::{
|
use agen::{
|
||||||
llm_client::types::{ContentPart, Item, Role},
|
llm_client::types::{ContentPart, Item, Role},
|
||||||
tool::{Attachment, ImageAttachment},
|
tool::{Attachment, ImageAttachment, ToolResultDisposition},
|
||||||
};
|
};
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||||
@@ -61,6 +61,8 @@ pub enum LoggedItem {
|
|||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
attachments: Vec<LoggedAttachment>,
|
attachments: Vec<LoggedAttachment>,
|
||||||
|
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||||
|
disposition: ToolResultDisposition,
|
||||||
#[serde(default, skip_serializing_if = "is_false")]
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
},
|
},
|
||||||
@@ -128,6 +130,7 @@ impl From<&Item> for LoggedItem {
|
|||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
|
disposition,
|
||||||
is_error,
|
is_error,
|
||||||
..
|
..
|
||||||
} => Self::ToolResult {
|
} => Self::ToolResult {
|
||||||
@@ -135,6 +138,7 @@ impl From<&Item> for LoggedItem {
|
|||||||
summary: summary.clone(),
|
summary: summary.clone(),
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
attachments: attachments.iter().map(LoggedAttachment::from).collect(),
|
attachments: attachments.iter().map(LoggedAttachment::from).collect(),
|
||||||
|
disposition: *disposition,
|
||||||
is_error: *is_error,
|
is_error: *is_error,
|
||||||
},
|
},
|
||||||
Item::Reasoning {
|
Item::Reasoning {
|
||||||
@@ -184,15 +188,24 @@ impl From<LoggedItem> for Item {
|
|||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
|
disposition,
|
||||||
is_error,
|
is_error,
|
||||||
} => Item::ToolResult {
|
} => {
|
||||||
|
let disposition = if is_error && disposition.is_success() {
|
||||||
|
ToolResultDisposition::Error
|
||||||
|
} else {
|
||||||
|
disposition
|
||||||
|
};
|
||||||
|
Item::ToolResult {
|
||||||
id: None,
|
id: None,
|
||||||
call_id,
|
call_id,
|
||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
|
disposition,
|
||||||
is_error,
|
is_error,
|
||||||
attachments: attachments.into_iter().map(Attachment::from).collect(),
|
attachments: attachments.into_iter().map(Attachment::from).collect(),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
LoggedItem::Reasoning {
|
LoggedItem::Reasoning {
|
||||||
text,
|
text,
|
||||||
summary,
|
summary,
|
||||||
@@ -430,6 +443,42 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outcome_unknown_tool_result_round_trips_as_terminal() {
|
||||||
|
let original = Item::tool_result_item_with_disposition_and_attachments(
|
||||||
|
"call_unknown",
|
||||||
|
"outcome unknown",
|
||||||
|
Some("bounded progress".to_string()),
|
||||||
|
ToolResultDisposition::OutcomeUnknown,
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
let logged: LoggedItem = (&original).into();
|
||||||
|
let json = serde_json::to_string(&logged).unwrap();
|
||||||
|
assert!(json.contains(r#""disposition":"outcome_unknown""#));
|
||||||
|
match Item::from(serde_json::from_str::<LoggedItem>(&json).unwrap()) {
|
||||||
|
Item::ToolResult {
|
||||||
|
disposition,
|
||||||
|
is_error,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(disposition, ToolResultDisposition::OutcomeUnknown);
|
||||||
|
assert!(is_error);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected variant: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_error_tool_result_infers_error_disposition() {
|
||||||
|
let legacy = r#"{"kind":"tool_result","call_id":"call_old","summary":"failed","content":null,"is_error":true}"#;
|
||||||
|
match Item::from(serde_json::from_str::<LoggedItem>(legacy).unwrap()) {
|
||||||
|
Item::ToolResult { disposition, .. } => {
|
||||||
|
assert_eq!(disposition, ToolResultDisposition::Error)
|
||||||
|
}
|
||||||
|
other => panic!("unexpected variant: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_result_persistence_round_trips_binary_attachments() {
|
fn tool_result_persistence_round_trips_binary_attachments() {
|
||||||
let original = Item::tool_result_item_with_attachments(
|
let original = Item::tool_result_item_with_attachments(
|
||||||
|
|||||||
@@ -1546,7 +1546,7 @@ fn model_ticket_reference(
|
|||||||
match ticket.meta.resource_key {
|
match ticket.meta.resource_key {
|
||||||
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
||||||
Some(_) => Err(ToolError::ExecutionFailed(format!(
|
Some(_) => Err(ToolError::ExecutionFailed(format!(
|
||||||
"{tool_name} failed: required Ticket human key is unavailable"
|
"{tool_name} failed: required Ticket key is unavailable"
|
||||||
))),
|
))),
|
||||||
None => Ok(ticket.meta.id),
|
None => Ok(ticket.meta.id),
|
||||||
}
|
}
|
||||||
|
|||||||
+156
-11
@@ -1,5 +1,6 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -20,21 +21,65 @@ struct BashParams {
|
|||||||
|
|
||||||
pub(crate) struct BashTool {
|
pub(crate) struct BashTool {
|
||||||
session: WorkdirSessionHandle,
|
session: WorkdirSessionHandle,
|
||||||
|
state: Arc<Mutex<BashExecutionState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ActiveCommand {
|
||||||
|
call_id: String,
|
||||||
|
execution_nonce: u64,
|
||||||
|
handle: CommandHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BashExecutionState {
|
||||||
|
active: HashMap<String, ActiveCommand>,
|
||||||
|
cancellation_requested: HashSet<String>,
|
||||||
|
legacy_cancellation_requested: HashSet<String>,
|
||||||
|
next_execution_nonce: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CommandGuard {
|
struct CommandGuard {
|
||||||
session: WorkdirSessionHandle,
|
session: WorkdirSessionHandle,
|
||||||
|
state: Arc<Mutex<BashExecutionState>>,
|
||||||
|
execution_id: String,
|
||||||
|
execution_nonce: u64,
|
||||||
handle: Option<CommandHandle>,
|
handle: Option<CommandHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for CommandGuard {
|
impl Drop for CommandGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(handle) = self.handle.take() {
|
let Some(handle) = self.handle.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let workdir = self.session.clone();
|
let workdir = self.session.clone();
|
||||||
|
let state = Arc::clone(&self.state);
|
||||||
|
let execution_id = self.execution_id.clone();
|
||||||
|
let execution_nonce = self.execution_nonce;
|
||||||
|
// A dropped provider future is not terminal confirmation. Keep the live
|
||||||
|
// execution registered until cleanup has both requested cancellation and
|
||||||
|
// observed terminal command output, so cancellation/session teardown
|
||||||
|
// cannot race with an apparently empty registry.
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = workdir.cancel_command(handle).await;
|
let _ = workdir.cancel_command(handle.clone()).await;
|
||||||
});
|
let _ = workdir
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: INLINE_BYTE_BUDGET,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
if state
|
||||||
|
.active
|
||||||
|
.get(&execution_id)
|
||||||
|
.is_some_and(|active| active.execution_nonce == execution_nonce)
|
||||||
|
{
|
||||||
|
state.active.remove(&execution_id);
|
||||||
|
state.cancellation_requested.remove(&execution_id);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,20 +97,50 @@ impl Tool for BashTool {
|
|||||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||||
.clamp(1, MAX_TIMEOUT_SECS);
|
.clamp(1, MAX_TIMEOUT_SECS);
|
||||||
let cmd_summary = truncate_for_summary(¶ms.command);
|
let cmd_summary = truncate_for_summary(¶ms.command);
|
||||||
|
let execution_id = ctx.execution_id();
|
||||||
|
let call_id = ctx.call_id;
|
||||||
|
let execution_nonce = {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.next_execution_nonce = state.next_execution_nonce.wrapping_add(1);
|
||||||
|
state.next_execution_nonce
|
||||||
|
};
|
||||||
|
let mut guard = CommandGuard {
|
||||||
|
session: self.session.clone(),
|
||||||
|
state: self.state.clone(),
|
||||||
|
execution_id: execution_id.clone(),
|
||||||
|
execution_nonce,
|
||||||
|
handle: None,
|
||||||
|
};
|
||||||
let handle = self
|
let handle = self
|
||||||
.session
|
.session
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: params.command,
|
command: params.command,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
output_limit: INLINE_BYTE_BUDGET,
|
output_limit: INLINE_BYTE_BUDGET,
|
||||||
tool_call_id: Some(ctx.call_id),
|
tool_call_id: Some(call_id.clone()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(crate::ToolsError::from)?;
|
.map_err(crate::ToolsError::from)?;
|
||||||
let mut guard = CommandGuard {
|
let cancel_after_start = {
|
||||||
session: self.session.clone(),
|
let mut state = self.state.lock().unwrap();
|
||||||
handle: Some(handle.clone()),
|
state.active.insert(
|
||||||
|
execution_id.clone(),
|
||||||
|
ActiveCommand {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
execution_nonce,
|
||||||
|
handle: handle.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.cancellation_requested.contains(&execution_id)
|
||||||
|
|| state.legacy_cancellation_requested.contains(&call_id)
|
||||||
};
|
};
|
||||||
|
guard.handle = Some(handle.clone());
|
||||||
|
if cancel_after_start {
|
||||||
|
self.session
|
||||||
|
.cancel_command(handle.clone())
|
||||||
|
.await
|
||||||
|
.map_err(crate::ToolsError::from)?;
|
||||||
|
}
|
||||||
let output = self
|
let output = self
|
||||||
.session
|
.session
|
||||||
.command_output(CommandOutputRequest {
|
.command_output(CommandOutputRequest {
|
||||||
@@ -76,9 +151,27 @@ impl Tool for BashTool {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(crate::ToolsError::from)?;
|
.map_err(crate::ToolsError::from)?;
|
||||||
|
let cancellation_requested = {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let owns_registration = state
|
||||||
|
.active
|
||||||
|
.get(&execution_id)
|
||||||
|
.is_some_and(|active| active.execution_nonce == execution_nonce);
|
||||||
|
let exact = if owns_registration {
|
||||||
|
state.active.remove(&execution_id);
|
||||||
|
state.cancellation_requested.remove(&execution_id)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
let legacy = state.legacy_cancellation_requested.remove(&call_id);
|
||||||
|
exact || legacy
|
||||||
|
};
|
||||||
guard.handle = None;
|
guard.handle = None;
|
||||||
|
|
||||||
let summary = if output.timed_out {
|
let timed_out = output.timed_out;
|
||||||
|
let summary = if cancellation_requested {
|
||||||
|
format!("$ {cmd_summary} (cancelled)")
|
||||||
|
} else if output.timed_out {
|
||||||
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
|
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
|
||||||
} else {
|
} else {
|
||||||
match output.exit_code {
|
match output.exit_code {
|
||||||
@@ -97,11 +190,62 @@ impl Tool for BashTool {
|
|||||||
} else {
|
} else {
|
||||||
Some(output.content)
|
Some(output.content)
|
||||||
};
|
};
|
||||||
Ok(ToolOutput {
|
let output = ToolOutput {
|
||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
})
|
};
|
||||||
|
if cancellation_requested {
|
||||||
|
Err(ToolError::Cancelled(output))
|
||||||
|
} else if timed_out {
|
||||||
|
Err(ToolError::Interrupted(output))
|
||||||
|
} else {
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel(&self, call_id: &str) -> Result<(), ToolError> {
|
||||||
|
let handles = {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state
|
||||||
|
.legacy_cancellation_requested
|
||||||
|
.insert(call_id.to_string());
|
||||||
|
state
|
||||||
|
.active
|
||||||
|
.values()
|
||||||
|
.filter(|active| active.call_id == call_id)
|
||||||
|
.map(|active| active.handle.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for handle in handles {
|
||||||
|
self.session
|
||||||
|
.cancel_command(handle)
|
||||||
|
.await
|
||||||
|
.map_err(crate::ToolsError::from)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel_execution(
|
||||||
|
&self,
|
||||||
|
ctx: &agen::tool::ToolExecutionContext,
|
||||||
|
) -> Result<(), ToolError> {
|
||||||
|
let execution_id = ctx.execution_id();
|
||||||
|
let handle = {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.cancellation_requested.insert(execution_id.clone());
|
||||||
|
state
|
||||||
|
.active
|
||||||
|
.get(&execution_id)
|
||||||
|
.map(|active| active.handle.clone())
|
||||||
|
};
|
||||||
|
if let Some(handle) = handle {
|
||||||
|
self.session
|
||||||
|
.cancel_command(handle)
|
||||||
|
.await
|
||||||
|
.map_err(crate::ToolsError::from)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +267,7 @@ pub fn bash_tool(session: WorkdirSessionHandle, _output_dir: PathBuf) -> ToolDef
|
|||||||
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
||||||
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
|
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
||||||
});
|
});
|
||||||
(meta, tool)
|
(meta, tool)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
|||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(GrepParams);
|
let schema = schemars::schema_for!(GrepParams);
|
||||||
let meta = ToolMeta::new("Grep")
|
let meta = ToolMeta::new("Grep")
|
||||||
.description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
.description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
||||||
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
||||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolMeta};
|
use agen::tool::{
|
||||||
|
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle,
|
||||||
|
ToolExecutionTerminal, ToolMeta,
|
||||||
|
};
|
||||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
@@ -401,5 +404,84 @@ async fn bash_provider_output_does_not_expose_internal_paths() {
|
|||||||
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
|
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
|
||||||
|
let (dir, _spill, reg) = setup();
|
||||||
|
let marker = dir.path().join("must-not-run-after-cancel");
|
||||||
|
let command = format!(
|
||||||
|
"printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'",
|
||||||
|
marker.display()
|
||||||
|
);
|
||||||
|
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
|
||||||
|
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
|
||||||
|
let bash = reg.get("Bash");
|
||||||
|
let executing = bash.clone();
|
||||||
|
let execution_context = context.clone();
|
||||||
|
let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await });
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
bash.cancel_execution(&context)
|
||||||
|
.await
|
||||||
|
.expect("signal exact execution cancellation");
|
||||||
|
let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution)
|
||||||
|
.await
|
||||||
|
.expect("cancelled Bash should terminate inside the Engine grace budget")
|
||||||
|
.expect("Bash task join");
|
||||||
|
|
||||||
|
let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else {
|
||||||
|
panic!("expected typed cancellation result");
|
||||||
|
};
|
||||||
|
let content = output.content.expect("bounded progress output");
|
||||||
|
assert!(
|
||||||
|
content.contains("before"),
|
||||||
|
"missing pre-cancel stdout: {content}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains("err-before"),
|
||||||
|
"missing pre-cancel stderr: {content}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!content.contains("after"),
|
||||||
|
"post-cancel output leaked: {content}"
|
||||||
|
);
|
||||||
|
assert!(content.len() <= 16 * 1024, "output must remain bounded");
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||||
|
assert!(
|
||||||
|
!marker.exists(),
|
||||||
|
"the cancelled command continued executing after terminal confirmation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
|
||||||
|
let (dir, _spill, reg) = setup();
|
||||||
|
let marker = dir.path().join("must-not-survive-force-close");
|
||||||
|
let command = format!("sleep 1; touch {}", marker.display());
|
||||||
|
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
|
||||||
|
let bash = reg.get("Bash");
|
||||||
|
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
|
||||||
|
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context);
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
handle.force_close();
|
||||||
|
assert!(matches!(
|
||||||
|
terminal.await,
|
||||||
|
ToolExecutionTerminal::OutcomeUnknown
|
||||||
|
));
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||||
|
assert!(
|
||||||
|
!marker.exists(),
|
||||||
|
"CommandGuard cleanup allowed a force-closed command to continue"
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = bash
|
||||||
|
.execute(r#"{"command":"printf 'reused'"}"#, Default::default())
|
||||||
|
.await
|
||||||
|
.expect("workdir session remains reusable after cleanup");
|
||||||
|
assert_eq!(output.content.as_deref(), Some("reused"));
|
||||||
|
}
|
||||||
|
|
||||||
// Sanity: unused Path import guard
|
// Sanity: unused Path import guard
|
||||||
const _: fn() -> &'static Path = || Path::new("/");
|
const _: fn() -> &'static Path = || Path::new("/");
|
||||||
|
|||||||
@@ -1244,6 +1244,7 @@ impl App {
|
|||||||
id,
|
id,
|
||||||
summary,
|
summary,
|
||||||
output,
|
output,
|
||||||
|
disposition: _,
|
||||||
is_error,
|
is_error,
|
||||||
} => {
|
} => {
|
||||||
self.latest_llm_wait_event = None;
|
self.latest_llm_wait_event = None;
|
||||||
|
|||||||
+145
-14
@@ -485,6 +485,7 @@ impl WorkerController {
|
|||||||
// into the controller task so the in-flight turn can be reached
|
// into the controller task so the in-flight turn can be reached
|
||||||
// via these handles while worker itself is borrowed by drive_turn.
|
// via these handles while worker itself is borrowed by drive_turn.
|
||||||
let cancel_tx = worker.engine_mut().cancel_sender();
|
let cancel_tx = worker.engine_mut().cancel_sender();
|
||||||
|
let pause_tx = worker.engine_mut().pause_sender();
|
||||||
let notify_buffer = worker.notify_buffer_handle();
|
let notify_buffer = worker.notify_buffer_handle();
|
||||||
|
|
||||||
tokio::spawn(controller_loop(
|
tokio::spawn(controller_loop(
|
||||||
@@ -494,6 +495,7 @@ impl WorkerController {
|
|||||||
shared_state,
|
shared_state,
|
||||||
runtime_dir,
|
runtime_dir,
|
||||||
cancel_tx,
|
cancel_tx,
|
||||||
|
pause_tx,
|
||||||
notify_buffer,
|
notify_buffer,
|
||||||
self_parent_socket,
|
self_parent_socket,
|
||||||
spawner_name,
|
spawner_name,
|
||||||
@@ -763,6 +765,19 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
|||||||
id: result.tool_use_id.clone(),
|
id: result.tool_use_id.clone(),
|
||||||
summary: result.summary.clone(),
|
summary: result.summary.clone(),
|
||||||
output: result.content.clone(),
|
output: result.content.clone(),
|
||||||
|
disposition: Some(match result.disposition {
|
||||||
|
agen::ToolResultDisposition::Success => protocol::ToolResultDisposition::Success,
|
||||||
|
agen::ToolResultDisposition::Error => protocol::ToolResultDisposition::Error,
|
||||||
|
agen::ToolResultDisposition::Interrupted => {
|
||||||
|
protocol::ToolResultDisposition::Interrupted
|
||||||
|
}
|
||||||
|
agen::ToolResultDisposition::Cancelled => {
|
||||||
|
protocol::ToolResultDisposition::Cancelled
|
||||||
|
}
|
||||||
|
agen::ToolResultDisposition::OutcomeUnknown => {
|
||||||
|
protocol::ToolResultDisposition::OutcomeUnknown
|
||||||
|
}
|
||||||
|
}),
|
||||||
is_error: result.is_error,
|
is_error: result.is_error,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -983,14 +998,6 @@ where
|
|||||||
|
|
||||||
if feature_config.sub_worker.enabled {
|
if feature_config.sub_worker.enabled {
|
||||||
worker.register_worker_orchestration_instruction();
|
worker.register_worker_orchestration_instruction();
|
||||||
if !feature_config.worker.enabled {
|
|
||||||
feature_registry.add_module(
|
|
||||||
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
|
||||||
worker.workspace_client_handle(),
|
|
||||||
spawned_registry.clone(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||||
@@ -1123,6 +1130,7 @@ async fn controller_loop<C, St>(
|
|||||||
shared_state: Arc<WorkerSharedState>,
|
shared_state: Arc<WorkerSharedState>,
|
||||||
runtime_dir: Arc<RuntimeDir>,
|
runtime_dir: Arc<RuntimeDir>,
|
||||||
cancel_tx: mpsc::Sender<()>,
|
cancel_tx: mpsc::Sender<()>,
|
||||||
|
pause_tx: mpsc::Sender<()>,
|
||||||
notify_buffer: NotifyBuffer,
|
notify_buffer: NotifyBuffer,
|
||||||
self_parent_socket: Option<PathBuf>,
|
self_parent_socket: Option<PathBuf>,
|
||||||
spawner_name: String,
|
spawner_name: String,
|
||||||
@@ -1169,6 +1177,9 @@ async fn controller_loop<C, St>(
|
|||||||
// clear at run start prevents stale partial output left by an older
|
// clear at run start prevents stale partial output left by an older
|
||||||
// interrupted/error turn from being carried into the next snapshot.
|
// interrupted/error turn from being carried into the next snapshot.
|
||||||
worker.clear_in_flight_events();
|
worker.clear_in_flight_events();
|
||||||
|
let parent_originated = run.is_parent_originated();
|
||||||
|
let user_input_run = matches!(&run, PendingRun::Run(_) | PendingRun::RunTracked { .. });
|
||||||
|
if !user_input_run {
|
||||||
set_controller_status(
|
set_controller_status(
|
||||||
&shared_state,
|
&shared_state,
|
||||||
&runtime_dir,
|
&runtime_dir,
|
||||||
@@ -1176,15 +1187,25 @@ async fn controller_loop<C, St>(
|
|||||||
WorkerStatus::Running,
|
WorkerStatus::Running,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let parent_originated = run.is_parent_originated();
|
}
|
||||||
let (mut new_status, shutdown) = match run {
|
let (mut new_status, shutdown) = match run {
|
||||||
PendingRun::Run(input) => {
|
PendingRun::Run(input) => {
|
||||||
|
let (input_commit_tx, input_commit_rx) = oneshot::channel();
|
||||||
drive_turn(
|
drive_turn(
|
||||||
worker.run(input),
|
worker.run_with_input_extensions_and_commit_hook(
|
||||||
|
input,
|
||||||
|
Vec::new(),
|
||||||
|
move || {
|
||||||
|
let _ = input_commit_tx.send(());
|
||||||
|
},
|
||||||
|
),
|
||||||
&mut method_rx,
|
&mut method_rx,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
&cancel_tx,
|
&cancel_tx,
|
||||||
|
&pause_tx,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
|
&runtime_dir,
|
||||||
|
Some(input_commit_rx),
|
||||||
¬ify_buffer,
|
¬ify_buffer,
|
||||||
self_parent_socket.as_ref(),
|
self_parent_socket.as_ref(),
|
||||||
&spawner_name,
|
&spawner_name,
|
||||||
@@ -1194,12 +1215,22 @@ async fn controller_loop<C, St>(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
PendingRun::RunTracked { input, extension } => {
|
PendingRun::RunTracked { input, extension } => {
|
||||||
|
let (input_commit_tx, input_commit_rx) = oneshot::channel();
|
||||||
drive_turn(
|
drive_turn(
|
||||||
worker.run_with_input_extensions(input, vec![extension]),
|
worker.run_with_input_extensions_and_commit_hook(
|
||||||
|
input,
|
||||||
|
vec![extension],
|
||||||
|
move || {
|
||||||
|
let _ = input_commit_tx.send(());
|
||||||
|
},
|
||||||
|
),
|
||||||
&mut method_rx,
|
&mut method_rx,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
&cancel_tx,
|
&cancel_tx,
|
||||||
|
&pause_tx,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
|
&runtime_dir,
|
||||||
|
Some(input_commit_rx),
|
||||||
¬ify_buffer,
|
¬ify_buffer,
|
||||||
self_parent_socket.as_ref(),
|
self_parent_socket.as_ref(),
|
||||||
&spawner_name,
|
&spawner_name,
|
||||||
@@ -1214,7 +1245,10 @@ async fn controller_loop<C, St>(
|
|||||||
&mut method_rx,
|
&mut method_rx,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
&cancel_tx,
|
&cancel_tx,
|
||||||
|
&pause_tx,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
|
&runtime_dir,
|
||||||
|
None,
|
||||||
¬ify_buffer,
|
¬ify_buffer,
|
||||||
self_parent_socket.as_ref(),
|
self_parent_socket.as_ref(),
|
||||||
&spawner_name,
|
&spawner_name,
|
||||||
@@ -1229,7 +1263,10 @@ async fn controller_loop<C, St>(
|
|||||||
&mut method_rx,
|
&mut method_rx,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
&cancel_tx,
|
&cancel_tx,
|
||||||
|
&pause_tx,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
|
&runtime_dir,
|
||||||
|
None,
|
||||||
¬ify_buffer,
|
¬ify_buffer,
|
||||||
self_parent_socket.as_ref(),
|
self_parent_socket.as_ref(),
|
||||||
&spawner_name,
|
&spawner_name,
|
||||||
@@ -1626,7 +1663,10 @@ async fn drive_turn<F>(
|
|||||||
method_rx: &mut mpsc::Receiver<Method>,
|
method_rx: &mut mpsc::Receiver<Method>,
|
||||||
event_tx: &broadcast::Sender<Event>,
|
event_tx: &broadcast::Sender<Event>,
|
||||||
cancel_tx: &mpsc::Sender<()>,
|
cancel_tx: &mpsc::Sender<()>,
|
||||||
|
pause_tx: &mpsc::Sender<()>,
|
||||||
shared_state: &Arc<WorkerSharedState>,
|
shared_state: &Arc<WorkerSharedState>,
|
||||||
|
runtime_dir: &RuntimeDir,
|
||||||
|
mut input_commit_rx: Option<oneshot::Receiver<()>>,
|
||||||
notify_buffer: &NotifyBuffer,
|
notify_buffer: &NotifyBuffer,
|
||||||
parent_socket: Option<&PathBuf>,
|
parent_socket: Option<&PathBuf>,
|
||||||
self_name: &str,
|
self_name: &str,
|
||||||
@@ -1642,10 +1682,34 @@ where
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
// If input commit and provider completion become ready together, expose
|
||||||
|
// Running only after processing the commit fence. This makes the
|
||||||
|
// Running snapshot contract deterministic even for immediate clients.
|
||||||
|
biased;
|
||||||
|
committed = async {
|
||||||
|
input_commit_rx
|
||||||
|
.as_mut()
|
||||||
|
.expect("input commit receiver guarded by select condition")
|
||||||
|
.await
|
||||||
|
}, if input_commit_rx.is_some() => {
|
||||||
|
input_commit_rx = None;
|
||||||
|
if committed.is_ok() {
|
||||||
|
set_controller_status(
|
||||||
|
shared_state,
|
||||||
|
runtime_dir,
|
||||||
|
event_tx,
|
||||||
|
WorkerStatus::Running,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
result = &mut worker_future => {
|
result = &mut worker_future => {
|
||||||
return match result {
|
return match result {
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
let (status, run_result) = match r {
|
let (status, run_result) = match r {
|
||||||
|
WorkerRunResult::Finished if pause_requested => {
|
||||||
|
(WorkerStatus::Paused, RunResult::Paused)
|
||||||
|
}
|
||||||
WorkerRunResult::Finished => (WorkerStatus::Idle, RunResult::Finished),
|
WorkerRunResult::Finished => (WorkerStatus::Idle, RunResult::Finished),
|
||||||
WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused),
|
WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused),
|
||||||
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
|
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
|
||||||
@@ -1718,7 +1782,7 @@ where
|
|||||||
}
|
}
|
||||||
Some(Method::Pause) => {
|
Some(Method::Pause) => {
|
||||||
pause_requested = true;
|
pause_requested = true;
|
||||||
let _ = cancel_tx.try_send(());
|
let _ = pause_tx.try_send(());
|
||||||
}
|
}
|
||||||
Some(Method::Shutdown) => {
|
Some(Method::Shutdown) => {
|
||||||
shutdown_requested = true;
|
shutdown_requested = true;
|
||||||
@@ -1970,11 +2034,13 @@ mod tests {
|
|||||||
event_tx: broadcast::Sender<Event>,
|
event_tx: broadcast::Sender<Event>,
|
||||||
cancel_tx: mpsc::Sender<()>,
|
cancel_tx: mpsc::Sender<()>,
|
||||||
_cancel_rx: mpsc::Receiver<()>,
|
_cancel_rx: mpsc::Receiver<()>,
|
||||||
|
pause_tx: mpsc::Sender<()>,
|
||||||
|
_pause_rx: mpsc::Receiver<()>,
|
||||||
shared_state: Arc<WorkerSharedState>,
|
shared_state: Arc<WorkerSharedState>,
|
||||||
notify_buffer: NotifyBuffer,
|
notify_buffer: NotifyBuffer,
|
||||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
parent_socket_path: PathBuf,
|
parent_socket_path: PathBuf,
|
||||||
_runtime_dir: Arc<RuntimeDir>,
|
runtime_dir: Arc<RuntimeDir>,
|
||||||
_temp: TempDir,
|
_temp: TempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1988,6 +2054,7 @@ mod tests {
|
|||||||
let (method_tx, method_rx) = mpsc::channel::<Method>(16);
|
let (method_tx, method_rx) = mpsc::channel::<Method>(16);
|
||||||
let (event_tx, _) = broadcast::channel::<Event>(16);
|
let (event_tx, _) = broadcast::channel::<Event>(16);
|
||||||
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
||||||
|
let (pause_tx, pause_rx) = mpsc::channel::<()>(1);
|
||||||
let shared_state = Arc::new(WorkerSharedState::new(
|
let shared_state = Arc::new(WorkerSharedState::new(
|
||||||
"child-worker".to_string(),
|
"child-worker".to_string(),
|
||||||
session_store::new_segment_id(),
|
session_store::new_segment_id(),
|
||||||
@@ -2013,11 +2080,13 @@ mod tests {
|
|||||||
event_tx,
|
event_tx,
|
||||||
cancel_tx,
|
cancel_tx,
|
||||||
_cancel_rx: cancel_rx,
|
_cancel_rx: cancel_rx,
|
||||||
|
pause_tx,
|
||||||
|
_pause_rx: pause_rx,
|
||||||
shared_state,
|
shared_state,
|
||||||
notify_buffer,
|
notify_buffer,
|
||||||
spawned_registry,
|
spawned_registry,
|
||||||
parent_socket_path,
|
parent_socket_path,
|
||||||
_runtime_dir: runtime_dir,
|
runtime_dir,
|
||||||
_temp: temp,
|
_temp: temp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2070,7 +2139,10 @@ mod tests {
|
|||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
&env.notify_buffer,
|
&env.notify_buffer,
|
||||||
Some(&env.parent_socket_path),
|
Some(&env.parent_socket_path),
|
||||||
"child-worker",
|
"child-worker",
|
||||||
@@ -2091,6 +2163,44 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pause_waits_for_run_boundary_and_uses_safe_pause_channel() {
|
||||||
|
let mut env = make_env().await;
|
||||||
|
let method_tx = env._method_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
method_tx.send(Method::Pause).await.expect("send pause");
|
||||||
|
});
|
||||||
|
|
||||||
|
let worker_future = async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
Ok::<_, WorkerError>(WorkerRunResult::Finished)
|
||||||
|
};
|
||||||
|
let started_at = std::time::Instant::now();
|
||||||
|
let (status, shutdown) = drive_turn(
|
||||||
|
worker_future,
|
||||||
|
&mut env.method_rx,
|
||||||
|
&env.event_tx,
|
||||||
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
|
&env.notify_buffer,
|
||||||
|
None,
|
||||||
|
"child-worker",
|
||||||
|
&env.spawned_registry,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, WorkerStatus::Paused);
|
||||||
|
assert!(!shutdown);
|
||||||
|
assert!(started_at.elapsed() >= Duration::from_millis(100));
|
||||||
|
assert!(env._pause_rx.try_recv().is_ok());
|
||||||
|
assert!(env._cancel_rx.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn non_parent_originated_finished_stays_silent() {
|
async fn non_parent_originated_finished_stays_silent() {
|
||||||
let mut env = make_env().await;
|
let mut env = make_env().await;
|
||||||
@@ -2102,7 +2212,10 @@ mod tests {
|
|||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
&env.notify_buffer,
|
&env.notify_buffer,
|
||||||
Some(&env.parent_socket_path),
|
Some(&env.parent_socket_path),
|
||||||
"child-worker",
|
"child-worker",
|
||||||
@@ -2137,7 +2250,10 @@ mod tests {
|
|||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
&env.notify_buffer,
|
&env.notify_buffer,
|
||||||
Some(&env.parent_socket_path),
|
Some(&env.parent_socket_path),
|
||||||
"child-worker",
|
"child-worker",
|
||||||
@@ -2178,7 +2294,10 @@ mod tests {
|
|||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
&env.notify_buffer,
|
&env.notify_buffer,
|
||||||
Some(&env.parent_socket_path),
|
Some(&env.parent_socket_path),
|
||||||
"child-worker",
|
"child-worker",
|
||||||
@@ -2217,7 +2336,10 @@ mod tests {
|
|||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
&env.notify_buffer,
|
&env.notify_buffer,
|
||||||
Some(&env.parent_socket_path),
|
Some(&env.parent_socket_path),
|
||||||
"parent",
|
"parent",
|
||||||
@@ -2253,7 +2375,10 @@ mod tests {
|
|||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
&env.notify_buffer,
|
&env.notify_buffer,
|
||||||
Some(&env.parent_socket_path),
|
Some(&env.parent_socket_path),
|
||||||
"parent",
|
"parent",
|
||||||
@@ -2287,7 +2412,10 @@ mod tests {
|
|||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
&env.notify_buffer,
|
&env.notify_buffer,
|
||||||
Some(&env.parent_socket_path),
|
Some(&env.parent_socket_path),
|
||||||
"parent",
|
"parent",
|
||||||
@@ -2320,7 +2448,10 @@ mod tests {
|
|||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
|
&env.runtime_dir,
|
||||||
|
None,
|
||||||
&env.notify_buffer,
|
&env.notify_buffer,
|
||||||
Some(&env.parent_socket_path),
|
Some(&env.parent_socket_path),
|
||||||
"child-worker",
|
"child-worker",
|
||||||
|
|||||||
@@ -209,9 +209,7 @@ impl WorkspaceHttpObjectiveBackend {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.filter(|key| is_canonical_resource_key(key, "T-"))
|
.filter(|key| is_canonical_resource_key(key, "T-"))
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| ToolError::ExecutionFailed("required T- key is unavailable".to_string()))
|
||||||
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn objective_url(&self, id: &str) -> String {
|
fn objective_url(&self, id: &str) -> String {
|
||||||
@@ -295,7 +293,7 @@ fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
|
|||||||
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
||||||
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
||||||
return Err(ToolError::ExecutionFailed(
|
return Err(ToolError::ExecutionFailed(
|
||||||
"required O- human key is unavailable".to_string(),
|
"required O- key is unavailable".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let projected = serde_json::json!({
|
let projected = serde_json::json!({
|
||||||
@@ -712,7 +710,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn objective_show_summary_uses_projected_human_key() {
|
async fn objective_show_summary_uses_projected_key() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
let server = thread::spawn(move || {
|
let server = thread::spawn(move || {
|
||||||
@@ -764,7 +762,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() {
|
async fn objective_link_summaries_resolve_internal_ticket_ids_to_keys() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
let server = thread::spawn(move || {
|
let server = thread::spawn(move || {
|
||||||
@@ -833,7 +831,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn objective_output_rejects_noncanonical_human_keys() {
|
fn objective_output_rejects_noncanonical_keys() {
|
||||||
let response = ObjectiveDetail {
|
let response = ObjectiveDetail {
|
||||||
resource_key: "O-internal".to_string(),
|
resource_key: "O-internal".to_string(),
|
||||||
title: "Objective".to_string(),
|
title: "Objective".to_string(),
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryRespo
|
|||||||
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
|
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
|
||||||
let item = object(value, "Ticket query item")?;
|
let item = object(value, "Ticket query item")?;
|
||||||
Ok(ModelTicketQueryItem {
|
Ok(ModelTicketQueryItem {
|
||||||
ticket: human_ref(item, "resource_key", "T-")?,
|
ticket: resource_ref(item, "resource_key", "T-")?,
|
||||||
title: string_field(item, "title")?,
|
title: string_field(item, "title")?,
|
||||||
state: string_field(item, "state")?,
|
state: string_field(item, "state")?,
|
||||||
readiness: optional_string(item, "readiness")?,
|
readiness: optional_string(item, "readiness")?,
|
||||||
@@ -245,7 +245,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
|||||||
.transpose()?,
|
.transpose()?,
|
||||||
linked_objectives: string_array(item, "linked_objective_keys")?
|
linked_objectives: string_array(item, "linked_objective_keys")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|key| validate_human_ref(key, "O-"))
|
.map(|key| validate_resource_ref(key, "O-"))
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
relation_count: usize_field(item, "relation_count")?,
|
relation_count: usize_field(item, "relation_count")?,
|
||||||
blocker_count: usize_field(item, "blocker_count")?,
|
blocker_count: usize_field(item, "blocker_count")?,
|
||||||
@@ -273,7 +273,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
|||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
Ok(ModelTicketDetail {
|
Ok(ModelTicketDetail {
|
||||||
ticket: human_ref(root, "resource_key", "T-")?,
|
ticket: resource_ref(root, "resource_key", "T-")?,
|
||||||
title: string_field(root, "title")?,
|
title: string_field(root, "title")?,
|
||||||
body: string_field(root, "body")?,
|
body: string_field(root, "body")?,
|
||||||
state: string_field(root, "state")?,
|
state: string_field(root, "state")?,
|
||||||
@@ -332,10 +332,10 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
|
|||||||
let item = object(value, "Objective query item")?;
|
let item = object(value, "Objective query item")?;
|
||||||
let linked_tickets = string_array(item, "linked_ticket_keys")?
|
let linked_tickets = string_array(item, "linked_ticket_keys")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|key| validate_human_ref(key, "T-"))
|
.map(|key| validate_resource_ref(key, "T-"))
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
Ok(ModelObjectiveQueryItem {
|
Ok(ModelObjectiveQueryItem {
|
||||||
objective: human_ref(item, "resource_key", "O-")?,
|
objective: resource_ref(item, "resource_key", "O-")?,
|
||||||
title: string_field(item, "title")?,
|
title: string_field(item, "title")?,
|
||||||
summary: optional_string(item, "snippet")?,
|
summary: optional_string(item, "snippet")?,
|
||||||
state: string_field(item, "state")?,
|
state: string_field(item, "state")?,
|
||||||
@@ -349,7 +349,7 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
|
|||||||
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
|
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
|
||||||
let root = object(&value, "Objective detail response")?;
|
let root = object(&value, "Objective detail response")?;
|
||||||
Ok(ModelObjectiveDetail {
|
Ok(ModelObjectiveDetail {
|
||||||
objective: human_ref(root, "resource_key", "O-")?,
|
objective: resource_ref(root, "resource_key", "O-")?,
|
||||||
title: string_field(root, "title")?,
|
title: string_field(root, "title")?,
|
||||||
body: string_field(root, "body")?,
|
body: string_field(root, "body")?,
|
||||||
state: string_field(root, "state")?,
|
state: string_field(root, "state")?,
|
||||||
@@ -373,7 +373,7 @@ pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDet
|
|||||||
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
||||||
let worker = object(value, "Worker summary")?;
|
let worker = object(value, "Worker summary")?;
|
||||||
Ok(ModelWorkerSummary {
|
Ok(ModelWorkerSummary {
|
||||||
worker: human_ref(worker, "worker_resource_key", "W-")?,
|
worker: resource_ref(worker, "worker_resource_key", "W-")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ fn project_relation(
|
|||||||
None => optional_string(relation, "at")?,
|
None => optional_string(relation, "at")?,
|
||||||
};
|
};
|
||||||
Ok(ModelRelation {
|
Ok(ModelRelation {
|
||||||
ticket: human_ref(relation, ticket_key, "T-")?,
|
ticket: resource_ref(relation, ticket_key, "T-")?,
|
||||||
kind,
|
kind,
|
||||||
note,
|
note,
|
||||||
created_at,
|
created_at,
|
||||||
@@ -449,7 +449,7 @@ fn project_relation(
|
|||||||
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
|
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
|
||||||
let blocker = object(value, "Ticket blocker")?;
|
let blocker = object(value, "Ticket blocker")?;
|
||||||
Ok(ModelBlocker {
|
Ok(ModelBlocker {
|
||||||
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
|
ticket: resource_ref(blocker, "blocking_resource_key", "T-")?,
|
||||||
kind: string_field(blocker, "relation_kind")?,
|
kind: string_field(blocker, "relation_kind")?,
|
||||||
state: optional_string(blocker, "blocking_state")?,
|
state: optional_string(blocker, "blocking_state")?,
|
||||||
resolved: bool_field(blocker, "resolved")?,
|
resolved: bool_field(blocker, "resolved")?,
|
||||||
@@ -466,7 +466,7 @@ fn project_notice(value: &Value) -> Result<ModelNotice, String> {
|
|||||||
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
|
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
|
||||||
let summary = object(value, "Objective summary")?;
|
let summary = object(value, "Objective summary")?;
|
||||||
Ok(ModelObjectiveSummary {
|
Ok(ModelObjectiveSummary {
|
||||||
objective: human_ref(summary, "resource_key", "O-")?,
|
objective: resource_ref(summary, "resource_key", "O-")?,
|
||||||
title: string_field(summary, "title")?,
|
title: string_field(summary, "title")?,
|
||||||
state: string_field(summary, "state")?,
|
state: string_field(summary, "state")?,
|
||||||
})
|
})
|
||||||
@@ -475,7 +475,7 @@ fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, Str
|
|||||||
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
||||||
let summary = object(value, "Ticket summary")?;
|
let summary = object(value, "Ticket summary")?;
|
||||||
Ok(ModelTicketSummary {
|
Ok(ModelTicketSummary {
|
||||||
ticket: human_ref(summary, "resource_key", "T-")?,
|
ticket: resource_ref(summary, "resource_key", "T-")?,
|
||||||
title: string_field(summary, "title")?,
|
title: string_field(summary, "title")?,
|
||||||
state: string_field(summary, "state")?,
|
state: string_field(summary, "state")?,
|
||||||
})
|
})
|
||||||
@@ -491,9 +491,7 @@ fn project_assignment(
|
|||||||
let principal = match kind.as_str() {
|
let principal = match kind.as_str() {
|
||||||
"worker" => current_coder
|
"worker" => current_coder
|
||||||
.map(|coder| coder.worker.clone())
|
.map(|coder| coder.worker.clone())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
|
||||||
"Worker assignment is missing a Workspace human key projection".to_string()
|
|
||||||
})?,
|
|
||||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||||
"user" => "user".to_string(),
|
"user" => "user".to_string(),
|
||||||
other => format!("source:{other}"),
|
other => format!("source:{other}"),
|
||||||
@@ -645,23 +643,23 @@ fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, S
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
|
fn resource_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
|
||||||
let value = object
|
let value = object
|
||||||
.get(key)
|
.get(key)
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
|
.ok_or_else(|| format!("required {prefix} key is unavailable"))?;
|
||||||
validate_human_ref(value, prefix)
|
validate_resource_ref(value, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> {
|
fn validate_resource_ref(value: String, prefix: &str) -> Result<String, String> {
|
||||||
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
|
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
|
||||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
});
|
});
|
||||||
if valid {
|
if valid {
|
||||||
Ok(value)
|
Ok(value)
|
||||||
} else {
|
} else {
|
||||||
Err(format!("required {prefix} human key is unavailable"))
|
Err(format!("required {prefix} key is unavailable"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,7 +669,7 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn objective_projection_exposes_only_human_resource_references() {
|
fn objective_projection_exposes_only_resource_references() {
|
||||||
let projected = project_objective_detail(json!({
|
let projected = project_objective_detail(json!({
|
||||||
"id": "00001M10HW6BV",
|
"id": "00001M10HW6BV",
|
||||||
"resource_key": "O-543",
|
"resource_key": "O-543",
|
||||||
@@ -779,9 +777,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn human_resource_projection_rejects_noncanonical_keys() {
|
fn resource_projection_rejects_noncanonical_keys() {
|
||||||
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
||||||
assert!(validate_human_ref(key.to_string(), prefix).is_err());
|
assert!(validate_resource_ref(key.to_string(), prefix).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,9 +306,11 @@ struct BackendTicketService {
|
|||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketService for BackendTicketService {
|
struct WorkspaceTicketService {
|
||||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
backend: WorkspaceHttpTicketBackend,
|
||||||
let ticket = self.backend.show(ticket_ref.into())?;
|
}
|
||||||
|
|
||||||
|
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
|
||||||
let resource_key = ticket
|
let resource_key = ticket
|
||||||
.meta
|
.meta
|
||||||
.resource_key
|
.resource_key
|
||||||
@@ -319,6 +321,17 @@ impl TicketService for BackendTicketService {
|
|||||||
resource_key,
|
resource_key,
|
||||||
workflow_state: ticket.meta.workflow_state,
|
workflow_state: ticket.meta.workflow_state,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TicketService for BackendTicketService {
|
||||||
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
|
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TicketService for WorkspaceTicketService {
|
||||||
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
|
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,9 +653,14 @@ impl FeatureModule for TicketFeature {
|
|||||||
let Some(backend) = self.tool_backend(context) else {
|
let Some(backend) = self.tool_backend(context) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
|
let ticket_service: Arc<dyn TicketService> = match &self.backend {
|
||||||
|
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
|
||||||
|
backend: WorkspaceHttpTicketBackend::new(client.clone()),
|
||||||
|
}),
|
||||||
|
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
|
||||||
backend: backend.clone(),
|
backend: backend.clone(),
|
||||||
});
|
}),
|
||||||
|
};
|
||||||
context.services().provide(
|
context.services().provide(
|
||||||
ServiceDeclaration::new(
|
ServiceDeclaration::new(
|
||||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||||
@@ -714,6 +732,26 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
Self::invoke_client(client, workspace_id, operation)
|
Self::invoke_client(client, workspace_id, operation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn show_unprojected(&self, ticket_ref: &str) -> TicketResult<Ticket> {
|
||||||
|
let client = self.client.clone();
|
||||||
|
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
|
||||||
|
let ticket_path = Self::ticket_path(&TicketIdOrSlug::from(ticket_ref));
|
||||||
|
let request = move || {
|
||||||
|
Self::request_unprojected(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("/api/w/{workspace_id}/tickets/{ticket_path}/record"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
|
return std::thread::spawn(request).join().map_err(|_| {
|
||||||
|
TicketError::Conflict("ticket REST request thread panicked".to_string())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
request()
|
||||||
|
}
|
||||||
|
|
||||||
fn ticket_path(id: &TicketIdOrSlug) -> String {
|
fn ticket_path(id: &TicketIdOrSlug) -> String {
|
||||||
let value = match id {
|
let value = match id {
|
||||||
TicketIdOrSlug::Id(value)
|
TicketIdOrSlug::Id(value)
|
||||||
@@ -738,6 +776,29 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
endpoint: String,
|
endpoint: String,
|
||||||
body: Option<serde_json::Value>,
|
body: Option<serde_json::Value>,
|
||||||
) -> TicketResult<T> {
|
) -> TicketResult<T> {
|
||||||
|
let mut value = Self::request_value(client, method, endpoint, body)?;
|
||||||
|
Self::canonicalize_ticket_references(&mut value);
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_unprojected<T: serde::de::DeserializeOwned>(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
method: WorkspaceRequestMethod,
|
||||||
|
endpoint: String,
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> TicketResult<T> {
|
||||||
|
let value = Self::request_value(client, method, endpoint, body)?;
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_value(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
method: WorkspaceRequestMethod,
|
||||||
|
endpoint: String,
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> TicketResult<Value> {
|
||||||
let request = match body {
|
let request = match body {
|
||||||
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
||||||
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
||||||
@@ -756,11 +817,7 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
response.status
|
response.status
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
serde_json::from_str(&response.body)
|
||||||
TicketError::Conflict(format!("decode ticket REST response: {error}"))
|
|
||||||
})?;
|
|
||||||
Self::canonicalize_ticket_references(&mut value);
|
|
||||||
serde_json::from_value(value)
|
|
||||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,9 +867,7 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| TicketError::Conflict("required Ticket key is unavailable".to_string()))
|
||||||
TicketError::Conflict("required Ticket human key is unavailable".to_string())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_unit(
|
fn request_unit(
|
||||||
@@ -889,7 +944,7 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
.is_some_and(is_canonical_ticket_resource_key)
|
.is_some_and(is_canonical_ticket_resource_key)
|
||||||
{
|
{
|
||||||
return Err(TicketError::Conflict(
|
return Err(TicketError::Conflict(
|
||||||
"required Ticket human key is unavailable".to_string(),
|
"required Ticket key is unavailable".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||||
@@ -1861,7 +1916,7 @@ provider = "github"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_http_backend_records_relation_with_authoritative_human_keys() {
|
fn workspace_http_backend_records_relation_with_authoritative_keys() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let server = thread::spawn(move || {
|
let server = thread::spawn(move || {
|
||||||
@@ -2000,6 +2055,46 @@ provider = "github"
|
|||||||
assert_eq!(removed.target, "T-2");
|
assert_eq!(removed.target, "T-2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let local = LocalTicketBackend::new(temp.path().join("tickets"));
|
||||||
|
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
|
||||||
|
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
|
||||||
|
ticket.meta.resource_key = Some("T-548".to_string());
|
||||||
|
ticket.meta.workflow_state = TicketWorkflowState::Queued;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let response_body = serde_json::to_string(&ticket).unwrap();
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0_u8; 8192];
|
||||||
|
let len = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||||
|
assert!(request.starts_with("GET /api/w/workspace-a/tickets/T-548/record HTTP/1.1"));
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
response_body.len(),
|
||||||
|
response_body
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let service = WorkspaceTicketService {
|
||||||
|
backend: WorkspaceHttpTicketBackend::new(Arc::new(
|
||||||
|
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let handoff = service.ticket_handoff("T-548").unwrap();
|
||||||
|
|
||||||
|
server.join().unwrap();
|
||||||
|
assert_eq!(handoff.id, created.id);
|
||||||
|
assert_eq!(handoff.resource_key, "T-548");
|
||||||
|
assert_eq!(handoff.workflow_state, TicketWorkflowState::Queued);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
|
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
|
||||||
assert!(is_canonical_ticket_resource_key("T-482"));
|
assert!(is_canonical_ticket_resource_key("T-482"));
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::prompt::catalog::PromptCatalog;
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
use agen::Item;
|
use agen::{Item, ToolResultDisposition};
|
||||||
|
|
||||||
/// Build synthetic `Item::ToolResult` items for every unanswered
|
/// Build synthetic `Item::ToolResult` items for every unanswered
|
||||||
/// `Item::ToolCall` in `history`, preserving order.
|
/// `Item::ToolCall` in `history`, preserving order.
|
||||||
@@ -28,7 +28,16 @@ pub(crate) fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Ve
|
|||||||
for item in history {
|
for item in history {
|
||||||
if let Item::ToolCall { call_id, .. } = item {
|
if let Item::ToolCall { call_id, .. } = item {
|
||||||
if !answered.contains(call_id.as_str()) {
|
if !answered.contains(call_id.as_str()) {
|
||||||
out.push(Item::tool_result(call_id.clone(), summary));
|
out.push(Item::tool_result_item_with_disposition_and_attachments(
|
||||||
|
call_id.clone(),
|
||||||
|
summary,
|
||||||
|
Some(
|
||||||
|
"Execution ended before completion could be confirmed. Completion and side effects are unknown."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
ToolResultDisposition::OutcomeUnknown,
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,10 +86,12 @@ mod tests {
|
|||||||
Item::ToolResult {
|
Item::ToolResult {
|
||||||
call_id,
|
call_id,
|
||||||
summary: got,
|
summary: got,
|
||||||
|
disposition,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(call_id, "c1");
|
assert_eq!(call_id, "c1");
|
||||||
assert_eq!(got, &summary);
|
assert_eq!(got, &summary);
|
||||||
|
assert_eq!(*disposition, ToolResultDisposition::OutcomeUnknown);
|
||||||
}
|
}
|
||||||
other => panic!("expected ToolResult, got {other:?}"),
|
other => panic!("expected ToolResult, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
+343
-19
@@ -11,7 +11,7 @@ use agen::llm_client::types::Role;
|
|||||||
use agen::state::Mutable;
|
use agen::state::Mutable;
|
||||||
use agen::{
|
use agen::{
|
||||||
Engine, EngineError, EngineResult, EngineRunExit, History, HistoryEntry, Item, StopReason,
|
Engine, EngineError, EngineResult, EngineRunExit, History, HistoryEntry, Item, StopReason,
|
||||||
ToolOutputLimits, UsageRecord,
|
ToolExecutionPolicy, ToolOutputLimits, UsageRecord,
|
||||||
};
|
};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use session_store::{
|
use session_store::{
|
||||||
@@ -2581,7 +2581,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
result: &EngineRunExit,
|
result: &EngineRunExit,
|
||||||
snapshot: &EmptyTurnRollbackSnapshot,
|
snapshot: &EmptyTurnRollbackSnapshot,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if !matches!(result, EngineRunExit::Interrupted(StopReason::Cancelled)) {
|
if !matches!(
|
||||||
|
result,
|
||||||
|
EngineRunExit::Paused | EngineRunExit::Interrupted(StopReason::Cancelled)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if self.ai_activity_counter.load(Ordering::SeqCst) != snapshot.ai_activity_count {
|
if self.ai_activity_counter.load(Ordering::SeqCst) != snapshot.ai_activity_count {
|
||||||
@@ -2752,10 +2755,28 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
pub(crate) async fn run_with_input_extensions(
|
pub(crate) async fn run_with_input_extensions(
|
||||||
&mut self,
|
&mut self,
|
||||||
input: Vec<Segment>,
|
input: Vec<Segment>,
|
||||||
mut input_extensions: Vec<SessionExtension>,
|
input_extensions: Vec<SessionExtension>,
|
||||||
) -> Result<WorkerRunResult, WorkerError>
|
) -> Result<WorkerRunResult, WorkerError>
|
||||||
where
|
where
|
||||||
St: Clone + 'static,
|
St: Clone + 'static,
|
||||||
|
{
|
||||||
|
self.run_with_input_extensions_and_commit_hook(input, input_extensions, || {})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run user input and invoke `on_input_committed` only after the annotated
|
||||||
|
/// input has crossed both the durable Store and live SegmentLogSink commit
|
||||||
|
/// boundaries. The Controller uses this fence before exposing `Running`, so
|
||||||
|
/// every in-flight snapshot for a user turn includes its committed input.
|
||||||
|
pub(crate) async fn run_with_input_extensions_and_commit_hook<F>(
|
||||||
|
&mut self,
|
||||||
|
input: Vec<Segment>,
|
||||||
|
mut input_extensions: Vec<SessionExtension>,
|
||||||
|
on_input_committed: F,
|
||||||
|
) -> Result<WorkerRunResult, WorkerError>
|
||||||
|
where
|
||||||
|
St: Clone + 'static,
|
||||||
|
F: FnOnce(),
|
||||||
{
|
{
|
||||||
let (input, pending_flow_state, flow_projection) = self.prepare_flow_input(input)?;
|
let (input, pending_flow_state, flow_projection) = self.prepare_flow_input(input)?;
|
||||||
if let Some(state) = pending_flow_state.as_ref() {
|
if let Some(state) = pending_flow_state.as_ref() {
|
||||||
@@ -2810,6 +2831,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.expect("flow_runtime_state poisoned") = Some(state);
|
.expect("flow_runtime_state poisoned") = Some(state);
|
||||||
}
|
}
|
||||||
self.user_segments.push(input.clone());
|
self.user_segments.push(input.clone());
|
||||||
|
on_input_committed();
|
||||||
|
|
||||||
// Resolve `@<path>` file refs to system messages stashed for the
|
// Resolve `@<path>` file refs to system messages stashed for the
|
||||||
// WorkerInterceptor to attach right after the user message. Resolution
|
// WorkerInterceptor to attach right after the user message. Resolution
|
||||||
@@ -2934,31 +2956,23 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage the post-interruption cleanup at the front of worker
|
/// Durably close every unanswered ToolCall before the interrupted run's
|
||||||
/// history: close every unanswered `Item::ToolCall` with a synthetic
|
/// final lifecycle record/status is published.
|
||||||
/// `Item::ToolResult` (Anthropic wire-validity), then append a
|
fn terminalize_orphan_tool_calls(&mut self) -> Result<(), WorkerError> {
|
||||||
/// system note so the LLM understands the prior turn was cut
|
|
||||||
/// short. Called from `Worker::run` when the worker's
|
|
||||||
/// `last_run_interrupted` flag is set (i.e. the Worker just transitioned
|
|
||||||
/// out of Paused via a new user input).
|
|
||||||
fn apply_interrupt_prep(&mut self) -> Result<(), WorkerError> {
|
|
||||||
let tool_result_summary = self
|
let tool_result_summary = self
|
||||||
.prompts()
|
.prompts()
|
||||||
.load_full()
|
.load_full()
|
||||||
.interrupt_tool_result_summary()
|
.interrupt_tool_result_summary()
|
||||||
.map_err(WorkerError::from)?;
|
.map_err(WorkerError::from)?;
|
||||||
let system_note = self
|
|
||||||
.prompts()
|
|
||||||
.load_full()
|
|
||||||
.interrupt_system_note()
|
|
||||||
.map_err(WorkerError::from)?;
|
|
||||||
|
|
||||||
let history_items = self.history();
|
let history_items = self.history();
|
||||||
let closures = crate::interrupt_prep::orphan_tool_result_closures(
|
let closures = crate::interrupt_prep::orphan_tool_result_closures(
|
||||||
&history_items,
|
&history_items,
|
||||||
&tool_result_summary,
|
&tool_result_summary,
|
||||||
);
|
);
|
||||||
if !closures.is_empty() {
|
if closures.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let subject = worker_subject(self.session.session_id());
|
let subject = worker_subject(self.session.session_id());
|
||||||
for item in closures {
|
for item in closures {
|
||||||
let entry = HistoryEntry::new(
|
let entry = HistoryEntry::new(
|
||||||
@@ -2977,7 +2991,17 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
self.session.history_mut().push_entry(entry);
|
self.session.history_mut().push_entry(entry);
|
||||||
self.session.note_mutation();
|
self.session.note_mutation();
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_interrupt_prep(&mut self) -> Result<(), WorkerError> {
|
||||||
|
self.terminalize_orphan_tool_calls()?;
|
||||||
|
let system_note = self
|
||||||
|
.prompts()
|
||||||
|
.load_full()
|
||||||
|
.interrupt_system_note()
|
||||||
|
.map_err(WorkerError::from)?;
|
||||||
|
|
||||||
let interrupt_prompt_provenance =
|
let interrupt_prompt_provenance =
|
||||||
self.prompt_render_provenance("internal.interrupt_system_note");
|
self.prompt_render_provenance("internal.interrupt_system_note");
|
||||||
let interrupt_metadata = new_history_metadata(
|
let interrupt_metadata = new_history_metadata(
|
||||||
@@ -3243,6 +3267,9 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
where
|
where
|
||||||
St: Clone + 'static,
|
St: Clone + 'static,
|
||||||
{
|
{
|
||||||
|
if matches!(&result, EngineRunExit::Interrupted(_)) {
|
||||||
|
self.terminalize_orphan_tool_calls()?;
|
||||||
|
}
|
||||||
self.persist_turn(history_before, &result).await?;
|
self.persist_turn(history_before, &result).await?;
|
||||||
|
|
||||||
if matches!(result, EngineRunExit::Yielded) {
|
if matches!(result, EngineRunExit::Yielded) {
|
||||||
@@ -5793,6 +5820,15 @@ pub fn apply_worker_manifest<C: LlmClient + 'static, A>(
|
|||||||
) {
|
) {
|
||||||
worker.set_request_config(request_config_from_engine_manifest(wm));
|
worker.set_request_config(request_config_from_engine_manifest(wm));
|
||||||
worker.set_max_turns(wm.max_turns.map(|n| n.get()));
|
worker.set_max_turns(wm.max_turns.map(|n| n.get()));
|
||||||
|
// Worker owns the lifecycle strategy for already-started tool operations.
|
||||||
|
// The provider must first accept cooperative cancellation, then confirm a
|
||||||
|
// terminal result before this bounded deadline; Agen handles only the
|
||||||
|
// mechanical per-call terminalization.
|
||||||
|
worker.set_tool_execution_policy(ToolExecutionPolicy {
|
||||||
|
pause_safe_boundary_timeout: Duration::from_millis(100),
|
||||||
|
cancellation_request_timeout: Duration::from_millis(250),
|
||||||
|
terminal_confirmation_timeout: Duration::from_millis(500),
|
||||||
|
});
|
||||||
worker.set_tool_output_limits(Some(ToolOutputLimits {
|
worker.set_tool_output_limits(Some(ToolOutputLimits {
|
||||||
default_max_bytes: wm.tool_output.default_max_bytes,
|
default_max_bytes: wm.tool_output.default_max_bytes,
|
||||||
per_tool: wm.tool_output.per_tool.clone(),
|
per_tool: wm.tool_output.per_tool.clone(),
|
||||||
@@ -5908,8 +5944,10 @@ fn stop_reason_error_code(reason: &StopReason) -> ErrorCode {
|
|||||||
| StopReason::Unexpected(
|
| StopReason::Unexpected(
|
||||||
EngineError::Aborted(_)
|
EngineError::Aborted(_)
|
||||||
| EngineError::Cancelled
|
| EngineError::Cancelled
|
||||||
|
| EngineError::PauseRequested
|
||||||
| EngineError::ConfigWarnings(_)
|
| EngineError::ConfigWarnings(_)
|
||||||
| EngineError::HistoryAppend(_),
|
| EngineError::HistoryAppend(_)
|
||||||
|
| EngineError::ToolAttemptFence(_),
|
||||||
) => ErrorCode::Internal,
|
) => ErrorCode::Internal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7423,6 +7461,118 @@ mod build_summary_prompt_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PauseResumeClient {
|
||||||
|
calls: Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PauseResumeClient {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl agen::llm_client::LlmClient for PauseResumeClient {
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
_request: agen::llm_client::Request,
|
||||||
|
) -> Result<
|
||||||
|
std::pin::Pin<
|
||||||
|
Box<
|
||||||
|
dyn futures::Stream<
|
||||||
|
Item = Result<agen::llm_client::Event, agen::llm_client::ClientError>,
|
||||||
|
> + Send,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
|
agen::llm_client::ClientError,
|
||||||
|
> {
|
||||||
|
use agen::llm_client::{Event, ResponseStatus, StatusEvent};
|
||||||
|
let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let events = if call == 0 {
|
||||||
|
vec![
|
||||||
|
Event::tool_use_start(0, "call_pending", "pending_once"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
Event::text_block_start(0),
|
||||||
|
Event::text_delta(0, "done"),
|
||||||
|
Event::text_block_stop(0, None),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||||
|
Box::new(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CountingPendingTool {
|
||||||
|
calls: Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl agen::tool::Tool for CountingPendingTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_input_json: &str,
|
||||||
|
_ctx: agen::ToolExecutionContext,
|
||||||
|
) -> Result<agen::tool::ToolOutput, agen::tool::ToolError> {
|
||||||
|
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
Ok("executed once".to_string().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn counting_pending_tool(
|
||||||
|
calls: Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
) -> agen::tool::ToolDefinition {
|
||||||
|
Arc::new(move || {
|
||||||
|
let meta = agen::tool::ToolMeta::new("pending_once")
|
||||||
|
.description("Counts resumable pending execution")
|
||||||
|
.input_schema(serde_json::json!({"type": "object"}));
|
||||||
|
(
|
||||||
|
meta,
|
||||||
|
Arc::new(CountingPendingTool {
|
||||||
|
calls: calls.clone(),
|
||||||
|
}) as Arc<dyn agen::tool::Tool>,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PauseOnceHook {
|
||||||
|
should_pause: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl crate::hook::Hook<crate::hook::PreToolCall> for PauseOnceHook {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_input: &crate::hook::ToolCallSummary,
|
||||||
|
) -> crate::hook::HookPreToolAction {
|
||||||
|
if self
|
||||||
|
.should_pause
|
||||||
|
.swap(false, std::sync::atomic::Ordering::SeqCst)
|
||||||
|
{
|
||||||
|
crate::hook::HookPreToolAction::Pause
|
||||||
|
} else {
|
||||||
|
crate::hook::HookPreToolAction::Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct NoopClient;
|
struct NoopClient;
|
||||||
|
|
||||||
@@ -7843,6 +7993,7 @@ mod build_summary_prompt_tests {
|
|||||||
summary: "wrote a file".into(),
|
summary: "wrote a file".into(),
|
||||||
content: None,
|
content: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
disposition: Default::default(),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -7885,6 +8036,7 @@ mod build_summary_prompt_tests {
|
|||||||
summary: "wrote a file".into(),
|
summary: "wrote a file".into(),
|
||||||
content: None,
|
content: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
disposition: Default::default(),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -7929,6 +8081,7 @@ mod build_summary_prompt_tests {
|
|||||||
summary: "side effect".into(),
|
summary: "side effect".into(),
|
||||||
content: None,
|
content: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
disposition: Default::default(),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
},
|
},
|
||||||
metadata: new_history_metadata(
|
metadata: new_history_metadata(
|
||||||
@@ -8028,6 +8181,177 @@ mod build_summary_prompt_tests {
|
|||||||
assert!(err.contains("session head changed"));
|
assert!(err.contains("session head changed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hook_paused_pending_tool_resumes_and_executes_once() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
|
||||||
|
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let mut engine =
|
||||||
|
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(PauseResumeClient::new());
|
||||||
|
engine.register_tool(counting_pending_tool(calls.clone()));
|
||||||
|
let mut worker = Worker::new(
|
||||||
|
minimal_manifest(),
|
||||||
|
engine,
|
||||||
|
store,
|
||||||
|
WorkerWorkspaceContext::no_workspace(),
|
||||||
|
WorkerFilesystemAuthority::None,
|
||||||
|
Scope::empty(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let should_pause = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||||
|
worker.add_pre_tool_call_hook(PauseOnceHook { should_pause });
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
worker.run_text("start").await.unwrap(),
|
||||||
|
WorkerRunResult::Paused
|
||||||
|
);
|
||||||
|
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||||
|
assert!(worker.history().iter().any(|item| matches!(
|
||||||
|
item,
|
||||||
|
Item::ToolCall { call_id, .. } if call_id == "call_pending"
|
||||||
|
)));
|
||||||
|
assert!(!worker.history().iter().any(|item| matches!(
|
||||||
|
item,
|
||||||
|
Item::ToolResult { call_id, .. } if call_id == "call_pending"
|
||||||
|
)));
|
||||||
|
|
||||||
|
assert_eq!(worker.resume().await.unwrap(), WorkerRunResult::Finished);
|
||||||
|
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(
|
||||||
|
worker
|
||||||
|
.history()
|
||||||
|
.iter()
|
||||||
|
.filter(|item| matches!(
|
||||||
|
item,
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: agen::ToolResultDisposition::Success,
|
||||||
|
..
|
||||||
|
} if call_id == "call_pending"
|
||||||
|
))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interrupted_result_terminalizes_orphan_before_run_completed() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = minimal_manifest();
|
||||||
|
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
|
||||||
|
let cwd = dir.path().join("workspace");
|
||||||
|
std::fs::create_dir_all(&cwd).unwrap();
|
||||||
|
let scope = Scope::writable(&cwd).unwrap();
|
||||||
|
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||||
|
let mut worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
|
||||||
|
store,
|
||||||
|
WorkerWorkspaceContext::local_filesystem(None),
|
||||||
|
authority,
|
||||||
|
scope,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
worker.ensure_segment_head().unwrap();
|
||||||
|
worker.wire_history_persistence();
|
||||||
|
worker.set_history_for_test(vec![
|
||||||
|
Item::tool_call("call-known", "Read", "{}"),
|
||||||
|
Item::tool_result_item_with_disposition_and_attachments(
|
||||||
|
"call-known",
|
||||||
|
"known result",
|
||||||
|
Some("confirmed output".to_string()),
|
||||||
|
agen::ToolResultDisposition::Success,
|
||||||
|
Vec::new(),
|
||||||
|
),
|
||||||
|
Item::tool_call("call-orphan", "Bash", "{}"),
|
||||||
|
]);
|
||||||
|
let _ = worker
|
||||||
|
.handle_worker_result(
|
||||||
|
EngineRunExit::Interrupted(StopReason::Cancelled),
|
||||||
|
worker.history().len(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let history = worker.history();
|
||||||
|
assert_eq!(
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.filter(|item| matches!(
|
||||||
|
item,
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: agen::ToolResultDisposition::Success,
|
||||||
|
..
|
||||||
|
} if call_id == "call-known"
|
||||||
|
))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(!history.iter().any(|item| matches!(
|
||||||
|
item,
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: agen::ToolResultDisposition::OutcomeUnknown,
|
||||||
|
..
|
||||||
|
} if call_id == "call-known"
|
||||||
|
)));
|
||||||
|
assert_eq!(
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.filter(|item| matches!(
|
||||||
|
item,
|
||||||
|
Item::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: agen::ToolResultDisposition::OutcomeUnknown,
|
||||||
|
..
|
||||||
|
} if call_id == "call-orphan"
|
||||||
|
))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
let entries = worker
|
||||||
|
.store
|
||||||
|
.read_all(
|
||||||
|
worker.segment_state.session_id(),
|
||||||
|
worker.segment_state.segment_id(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let terminal_index = entries
|
||||||
|
.iter()
|
||||||
|
.position(|entry| {
|
||||||
|
matches!(
|
||||||
|
entry,
|
||||||
|
LogEntry::AnnotatedToolResult {
|
||||||
|
entry: session_store::LoggedHistoryEntry {
|
||||||
|
item: session_store::LoggedItem::ToolResult {
|
||||||
|
call_id,
|
||||||
|
disposition: agen::ToolResultDisposition::OutcomeUnknown,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} if call_id == "call-orphan"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.expect("durable OutcomeUnknown closure");
|
||||||
|
let final_index = entries
|
||||||
|
.iter()
|
||||||
|
.position(|entry| {
|
||||||
|
matches!(
|
||||||
|
entry,
|
||||||
|
LogEntry::RunCompleted { .. } | LogEntry::RunErrored { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.expect("durable final run status");
|
||||||
|
assert!(terminal_index < final_index);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apply_interrupt_prep_appends_via_callback_and_logs_independent_entries() {
|
async fn apply_interrupt_prep_appends_via_callback_and_logs_independent_entries() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -806,13 +806,30 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
|
|||||||
let client = MockClient::sequential(vec![MockResponse::Hang(simple_text_events())]);
|
let client = MockClient::sequential(vec![MockResponse::Hang(simple_text_events())]);
|
||||||
let worker = make_worker(client).await;
|
let worker = make_worker(client).await;
|
||||||
let handle = spawn_controller(worker).await;
|
let handle = spawn_controller(worker).await;
|
||||||
|
let mut events = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.send(Method::run_text("hello in-flight"))
|
.send(Method::run_text("hello in-flight"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
wait_for_status(&handle, WorkerStatus::Running).await;
|
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if matches!(
|
||||||
|
events.recv().await,
|
||||||
|
Ok(Event::Status {
|
||||||
|
status: WorkerStatus::Running,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("running status event");
|
||||||
|
|
||||||
|
// The Running event is the in-flight visibility fence: the committed
|
||||||
|
// annotated input must already be available to an immediately attaching
|
||||||
|
// subscriber rather than racing behind this status transition.
|
||||||
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path())
|
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2152,9 +2169,13 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
|
|||||||
for item in items {
|
for item in items {
|
||||||
match item {
|
match item {
|
||||||
agen::Item::ToolResult {
|
agen::Item::ToolResult {
|
||||||
call_id, summary, ..
|
call_id,
|
||||||
|
summary,
|
||||||
|
disposition,
|
||||||
|
..
|
||||||
} if call_id == "call_orphan" => {
|
} if call_id == "call_orphan" => {
|
||||||
assert_eq!(summary, "[Interrupted by user]");
|
assert_eq!(summary, "Tool execution outcome unknown");
|
||||||
|
assert_eq!(*disposition, agen::ToolResultDisposition::OutcomeUnknown);
|
||||||
saw_synthetic_tool_result = true;
|
saw_synthetic_tool_result = true;
|
||||||
}
|
}
|
||||||
agen::Item::Message { role, content, .. } if *role == agen::Role::System => {
|
agen::Item::Message { role, content, .. } if *role == agen::Role::System => {
|
||||||
@@ -2345,8 +2366,11 @@ async fn paused_cancel_abandons_resume_and_next_input_is_fresh_run() {
|
|||||||
assert!(
|
assert!(
|
||||||
items.iter().any(|item| matches!(
|
items.iter().any(|item| matches!(
|
||||||
item,
|
item,
|
||||||
agen::Item::ToolResult { call_id, summary, .. }
|
agen::Item::ToolResult {
|
||||||
if call_id == "call_cancelled" && summary == "[Interrupted by user]"
|
call_id,
|
||||||
|
disposition: agen::ToolResultDisposition::OutcomeUnknown,
|
||||||
|
..
|
||||||
|
} if call_id == "call_cancelled"
|
||||||
)),
|
)),
|
||||||
"paused cancel should close orphan tool_use before future requests: {items:?}"
|
"paused cancel should close orphan tool_use before future requests: {items:?}"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4449,6 +4449,13 @@ mod tests {
|
|||||||
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
|
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(companion.feature.manage_workdir.enabled);
|
assert!(companion.feature.manage_workdir.enabled);
|
||||||
|
assert!(companion.feature.sub_worker.enabled);
|
||||||
|
assert!(!companion.feature.worker.enabled);
|
||||||
|
let coder = archive
|
||||||
|
.resolve_profile("builtin:coder", root.path(), "embedded-test-coder")
|
||||||
|
.unwrap();
|
||||||
|
assert!(coder.feature.sub_worker.enabled);
|
||||||
|
assert!(!coder.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4475,6 +4482,8 @@ mod tests {
|
|||||||
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
|
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(manifest.worker.name, "remote-test-worker");
|
assert_eq!(manifest.worker.name, "remote-test-worker");
|
||||||
|
assert!(manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!manifest.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5458,6 +5458,36 @@ fn resolve_workspace_ticket_reference(
|
|||||||
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
|
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_workspace_ticket_identity(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workspace_id: &str,
|
||||||
|
reference: &str,
|
||||||
|
) -> ApiResult<String> {
|
||||||
|
let ticket_id = resolve_workspace_ticket_reference(api, workspace_id, reference)?;
|
||||||
|
let ticket = browser_ticket_backend(api)?
|
||||||
|
.show(ticket_id.clone().into())
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
if ticket.meta.id != ticket_id {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"resolved Ticket identity does not match Ticket authority".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(ticket_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_workspace_worker_ticket_assignment(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workspace_id: &str,
|
||||||
|
assignment: &mut Option<CreateWorkspaceWorkerTicketAssignmentRequest>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
if let Some(assignment) = assignment {
|
||||||
|
assignment.ticket_id =
|
||||||
|
resolve_workspace_ticket_identity(api, workspace_id, &assignment.ticket_id)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct MergeRequestListHttpQuery {
|
struct MergeRequestListHttpQuery {
|
||||||
state: Option<String>,
|
state: Option<String>,
|
||||||
@@ -8217,6 +8247,11 @@ async fn spawn_known_worker(
|
|||||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
|
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
|
||||||
|
resolve_workspace_worker_ticket_assignment(
|
||||||
|
&api,
|
||||||
|
&path.workspace_id,
|
||||||
|
&mut request.ticket_assignment,
|
||||||
|
)?;
|
||||||
let relation = if request.ticket_assignment.is_some() {
|
let relation = if request.ticket_assignment.is_some() {
|
||||||
"assigned"
|
"assigned"
|
||||||
} else {
|
} else {
|
||||||
@@ -9261,9 +9296,8 @@ fn cleanup_working_directory_for_runtime(
|
|||||||
result.diagnostics,
|
result.diagnostics,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
|
|
||||||
api.store.upsert_workdir_registry(&record)?;
|
|
||||||
let mut summary = working_directory.summary;
|
let mut summary = working_directory.summary;
|
||||||
|
persist_workdir_cleanup_observation(&api, runtime_id, &summary)?;
|
||||||
apply_workdir_occupancy_projection(&api, &mut summary)?;
|
apply_workdir_occupancy_projection(&api, &mut summary)?;
|
||||||
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
@@ -14154,11 +14188,7 @@ fn sync_runtime_workdir_observations(
|
|||||||
api.store.upsert_workdir_registry(&updated)?;
|
api.store.upsert_workdir_registry(&updated)?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
record.materialization_status =
|
persist_workdir_runtime_miss(api, record, result.diagnostics.as_slice())?;
|
||||||
workdir_status_from_runtime_miss(result.diagnostics.as_slice()).to_string();
|
|
||||||
record.cleanliness = "unknown".to_string();
|
|
||||||
record.updated_at = now_registry_timestamp();
|
|
||||||
api.store.upsert_workdir_registry(&record)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -14172,17 +14202,54 @@ fn sync_runtime_workdir_observations(
|
|||||||
Ok(response.diagnostics)
|
Ok(response.diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
|
fn persist_workdir_cleanup_observation(
|
||||||
if diagnostics
|
api: &WorkspaceApi,
|
||||||
|
runtime_id: &str,
|
||||||
|
summary: &WorkingDirectorySummary,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
if summary.status == WorkingDirectoryStatusKind::NotFound {
|
||||||
|
api.store.delete_workdir_registry(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
summary.working_directory_id.as_str(),
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
let record = workdir_record_from_summary(api, runtime_id, summary);
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workdir_runtime_miss_is_not_found(diagnostics: &[RuntimeDiagnostic]) -> bool {
|
||||||
|
diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.any(|diagnostic| diagnostic.code == "working_directory_not_found")
|
.any(|diagnostic| diagnostic.code == "working_directory_not_found")
|
||||||
{
|
}
|
||||||
|
|
||||||
|
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
|
||||||
|
if workdir_runtime_miss_is_not_found(diagnostics) {
|
||||||
"not_found"
|
"not_found"
|
||||||
} else {
|
} else {
|
||||||
"unknown"
|
"unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_workdir_runtime_miss(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
mut record: WorkdirRegistryRecord,
|
||||||
|
diagnostics: &[RuntimeDiagnostic],
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
if workdir_runtime_miss_is_not_found(diagnostics) {
|
||||||
|
api.store
|
||||||
|
.delete_workdir_registry(&api.config.workspace_id, record.workdir_id.as_str())?;
|
||||||
|
} else {
|
||||||
|
record.materialization_status = "unknown".to_string();
|
||||||
|
record.cleanliness = "unknown".to_string();
|
||||||
|
record.updated_at = now_registry_timestamp();
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
|
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
||||||
@@ -16980,22 +17047,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workdir_runtime_miss_uses_exact_typed_code() {
|
fn workdir_runtime_miss_uses_exact_typed_code() {
|
||||||
assert_eq!(
|
let typed_not_found = [RuntimeDiagnostic {
|
||||||
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
|
|
||||||
code: "working_directory_not_found".to_string(),
|
code: "working_directory_not_found".to_string(),
|
||||||
severity: DiagnosticSeverity::Warning,
|
severity: DiagnosticSeverity::Warning,
|
||||||
message: "missing".to_string(),
|
message: "missing".to_string(),
|
||||||
}]),
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
workdir_status_from_runtime_miss(&typed_not_found),
|
||||||
"not_found"
|
"not_found"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert!(workdir_runtime_miss_is_not_found(&typed_not_found));
|
||||||
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
|
|
||||||
|
let unrelated = [RuntimeDiagnostic {
|
||||||
code: "some_other_not_found".to_string(),
|
code: "some_other_not_found".to_string(),
|
||||||
severity: DiagnosticSeverity::Warning,
|
severity: DiagnosticSeverity::Warning,
|
||||||
message: "not a typed workdir miss".to_string(),
|
message: "not a typed workdir miss".to_string(),
|
||||||
}]),
|
}];
|
||||||
"unknown"
|
assert_eq!(workdir_status_from_runtime_miss(&unrelated), "unknown");
|
||||||
);
|
assert!(!workdir_runtime_miss_is_not_found(&unrelated));
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DeterministicExecutionBackend {
|
struct DeterministicExecutionBackend {
|
||||||
@@ -20186,6 +20255,25 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
ticket_id
|
ticket_id
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_resource_key)
|
||||||
|
.unwrap(),
|
||||||
|
ticket_id
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_id).unwrap(),
|
||||||
|
ticket_id
|
||||||
|
);
|
||||||
|
let mut assignment = Some(CreateWorkspaceWorkerTicketAssignmentRequest {
|
||||||
|
ticket_id: ticket_resource_key.clone(),
|
||||||
|
operation_id: "ticket-key-assignment".to_string(),
|
||||||
|
});
|
||||||
|
resolve_workspace_worker_ticket_assignment(&api, TEST_WORKSPACE_ID, &mut assignment)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(assignment.unwrap().ticket_id, ticket_id);
|
||||||
|
let missing =
|
||||||
|
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, "T-999999").unwrap_err();
|
||||||
|
assert_eq!(missing.into_response().status(), StatusCode::NOT_FOUND);
|
||||||
let path = || ScopedRecordPath {
|
let path = || ScopedRecordPath {
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
id: ticket_id.clone(),
|
id: ticket_id.clone(),
|
||||||
@@ -21308,6 +21396,87 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn confirmed_runtime_miss_removes_registry_record_but_unknown_is_retained() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(workspace.path());
|
||||||
|
let api = test_api(workspace.path()).await;
|
||||||
|
seed_cleanup_workdir(&api, "deleted-workdir", "present", "clean");
|
||||||
|
let deleted = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
persist_workdir_runtime_miss(
|
||||||
|
&api,
|
||||||
|
deleted,
|
||||||
|
&[RuntimeDiagnostic {
|
||||||
|
code: "working_directory_not_found".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Warning,
|
||||||
|
message: "missing".to_string(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
seed_cleanup_workdir(&api, "unknown-workdir", "present", "clean");
|
||||||
|
let unknown = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
persist_workdir_runtime_miss(
|
||||||
|
&api,
|
||||||
|
unknown,
|
||||||
|
&[RuntimeDiagnostic {
|
||||||
|
code: "runtime_unavailable".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Warning,
|
||||||
|
message: "temporarily unavailable".to_string(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
api.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.materialization_status,
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cleanup_not_found_observation_removes_registry_record() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(workspace.path());
|
||||||
|
let api = test_api(workspace.path()).await;
|
||||||
|
let working_directory_id = "cleanup-existing";
|
||||||
|
seed_cleanup_workdir(&api, working_directory_id, "present", "clean");
|
||||||
|
let record = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let mut summary = workdir_summary_from_record(&record);
|
||||||
|
summary.status = WorkingDirectoryStatusKind::NotFound;
|
||||||
|
|
||||||
|
persist_workdir_cleanup_observation(&api, "runtime-test", &summary).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn seed_cleanup_link(api: &WorkspaceApi, runtime_worker_id: &str, workdir_id: &str) {
|
fn seed_cleanup_link(api: &WorkspaceApi, runtime_worker_id: &str, workdir_id: &str) {
|
||||||
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
|
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
|
||||||
api.store
|
api.store
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ The Workspace Server owns one control-plane SQLite database. Schema changes are
|
|||||||
|
|
||||||
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
|
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
|
||||||
|
|
||||||
- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity;
|
- rebuilds Ticket, Objective, assignment, Artifact, and resource-key tables with Workspace-scoped composite identity;
|
||||||
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
|
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
|
||||||
- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error;
|
- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error;
|
||||||
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
|
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import "./base.dcdl" // {
|
|||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
flow = { enabled = true; };
|
flow = { enabled = true; };
|
||||||
worker = { enabled = true; };
|
|
||||||
ticket = { enabled = true; thread = true; };
|
ticket = { enabled = true; thread = true; };
|
||||||
merge_request = {
|
merge_request = {
|
||||||
show = true;
|
show = true;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import "./base.dcdl" // {
|
|||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
worker = { enabled = true; };
|
|
||||||
manage_workdir = { enabled = true; };
|
manage_workdir = { enabled = true; };
|
||||||
ticket = { enabled = true; authoring = true; thread = true; };
|
ticket = { enabled = true; authoring = true; thread = true; };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+22
-12
@@ -9,9 +9,9 @@
|
|||||||
"npm:@codemirror/view@6.43.8": "6.43.8",
|
"npm:@codemirror/view@6.43.8": "6.43.8",
|
||||||
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
||||||
"npm:@lezer/highlight@1.2.3": "1.2.3",
|
"npm:@lezer/highlight@1.2.3": "1.2.3",
|
||||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
|
||||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||||
"npm:clsx@2.1.1": "2.1.1",
|
"npm:clsx@2.1.1": "2.1.1",
|
||||||
"npm:cookie@0.6.0": "0.6.0",
|
"npm:cookie@0.6.0": "0.6.0",
|
||||||
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
|
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
|
||||||
"npm:svelte@5.45.6": "5.45.6",
|
"npm:svelte@5.45.6": "5.45.6",
|
||||||
"npm:typescript@5.9.3": "5.9.3",
|
"npm:typescript@5.9.3": "5.9.3",
|
||||||
"npm:vite@7.2.7": "7.2.7"
|
"npm:vite@7.2.7": "7.2.7_yaml@2.9.0",
|
||||||
|
"npm:yaml@2.9.0": "2.9.0"
|
||||||
},
|
},
|
||||||
"jsr": {
|
"jsr": {
|
||||||
"@std/assert@1.0.19": {
|
"@std/assert@1.0.19": {
|
||||||
@@ -433,13 +434,13 @@
|
|||||||
"acorn"
|
"acorn"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
|
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
|
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/kit"
|
"@sveltejs/kit"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
|
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
|
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@standard-schema/spec",
|
"@standard-schema/spec",
|
||||||
@@ -465,7 +466,7 @@
|
|||||||
],
|
],
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_vite@7.2.7": {
|
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
|
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/vite-plugin-svelte",
|
"@sveltejs/vite-plugin-svelte",
|
||||||
@@ -474,7 +475,7 @@
|
|||||||
"vite"
|
"vite"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7": {
|
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/vite-plugin-svelte-inspector",
|
"@sveltejs/vite-plugin-svelte-inspector",
|
||||||
@@ -966,7 +967,7 @@
|
|||||||
"vfile-message"
|
"vfile-message"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"vite@7.2.7": {
|
"vite@7.2.7_yaml@2.9.0": {
|
||||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
@@ -974,14 +975,18 @@
|
|||||||
"picomatch",
|
"picomatch",
|
||||||
"postcss",
|
"postcss",
|
||||||
"rollup",
|
"rollup",
|
||||||
"tinyglobby"
|
"tinyglobby",
|
||||||
|
"yaml"
|
||||||
],
|
],
|
||||||
"optionalDependencies": [
|
"optionalDependencies": [
|
||||||
"fsevents"
|
"fsevents"
|
||||||
],
|
],
|
||||||
|
"optionalPeers": [
|
||||||
|
"yaml"
|
||||||
|
],
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
"vitefu@1.1.2_vite@7.2.7": {
|
"vitefu@1.1.2_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
|
"integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"vite"
|
"vite"
|
||||||
@@ -993,6 +998,10 @@
|
|||||||
"w3c-keyname@2.2.8": {
|
"w3c-keyname@2.2.8": {
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
||||||
},
|
},
|
||||||
|
"yaml@2.9.0": {
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"bin": true
|
||||||
|
},
|
||||||
"zimmerframe@1.1.4": {
|
"zimmerframe@1.1.4": {
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
||||||
},
|
},
|
||||||
@@ -1024,7 +1033,8 @@
|
|||||||
"packageJson": {
|
"packageJson": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"npm:@fontsource/ibm-plex-mono@5.3.0",
|
"npm:@fontsource/ibm-plex-mono@5.3.0",
|
||||||
"npm:gen-interface-jp@0.8.0"
|
"npm:gen-interface-jp@0.8.0",
|
||||||
|
"npm:yaml@2.9.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||||
"gen-interface-jp": "0.8.0"
|
"gen-interface-jp": "0.8.0",
|
||||||
|
"yaml": "2.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type InvokeKind = "user_send" | "notify" | "worker_event" | "system_remin
|
|||||||
|
|
||||||
export type RunResult = "finished" | "paused" | "limit_reached" | "rolled_back";
|
export type RunResult = "finished" | "paused" | "limit_reached" | "rolled_back";
|
||||||
|
|
||||||
|
export type ToolResultDisposition = "success" | "error" | "interrupted" | "cancelled" | "outcome_unknown";
|
||||||
|
|
||||||
export type ErrorCode = "already_running" | "not_running" | "not_paused" | "provider_error" | "tool_error" | "invalid_request" | "internal";
|
export type ErrorCode = "already_running" | "not_running" | "not_paused" | "provider_error" | "tool_error" | "invalid_request" | "internal";
|
||||||
|
|
||||||
export type Permission = "read" | "write";
|
export type Permission = "read" | "write";
|
||||||
@@ -191,7 +193,7 @@ summary: string,
|
|||||||
* Full tool output. Absent when the tool chose to return
|
* Full tool output. Absent when the tool chose to return
|
||||||
* summary-only, or when the result was pruned.
|
* summary-only, or when the result was pruned.
|
||||||
*/
|
*/
|
||||||
output?: string | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { entries: Array<unknown>, greeting: Greeting, status: WorkerStatus,
|
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { entries: Array<unknown>, greeting: Greeting, status: WorkerStatus,
|
||||||
/**
|
/**
|
||||||
* Unfinished model output that has already streamed in the current
|
* Unfinished model output that has already streamed in the current
|
||||||
* run but is not yet represented by committed snapshot entries.
|
* run but is not yet represented by committed snapshot entries.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let { item }: Props = $props();
|
let { item }: Props = $props();
|
||||||
|
let detailOpen = $state(false);
|
||||||
let nowMs = $state(Date.now());
|
let nowMs = $state(Date.now());
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -55,22 +56,20 @@
|
|||||||
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
function toolLabel(line: ConsoleLine): string {
|
||||||
const [firstLine = '', ...rest] = line.body.split('\n');
|
return line.toolCallLabel ?? line.toolCall?.name ?? line.title;
|
||||||
const [label, suffix = ''] = firstLine.split(' — ', 2);
|
}
|
||||||
return {
|
|
||||||
label,
|
function toolStatus(line: ConsoleLine): string {
|
||||||
suffix,
|
return line.toolStatus ?? line.toolCall?.state ?? '';
|
||||||
rest: rest.join('\n')
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRenderMarkdown(line: ConsoleLine): boolean {
|
function shouldRenderMarkdown(line: ConsoleLine): boolean {
|
||||||
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
|
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
function bodyTextAfterToolSummary(line: ConsoleLine): string {
|
function toolBodyText(line: ConsoleLine): string {
|
||||||
return toolSummary(line).rest;
|
return detailOpen ? (line.expandedBody ?? line.body) : line.body;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -107,20 +106,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if item.kind === 'tool'}
|
{:else if item.kind === 'tool'}
|
||||||
<div class="tool-summary">
|
<div class="tool-summary">
|
||||||
<span class="tool-label">{toolSummary(item).label}</span>
|
<span class="tool-label">{toolLabel(item)}</span>
|
||||||
<span class="tool-separator"> — </span>
|
<span class={`tool-status ${item.toolCall?.state ?? ''}`}>{toolStatus(item)}</span>
|
||||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
{#if item.detail}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-detail-button"
|
||||||
|
aria-expanded={detailOpen}
|
||||||
|
onclick={() => (detailOpen = !detailOpen)}
|
||||||
|
>detail</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.compaction}
|
{#if item.compaction}
|
||||||
<!-- rendered as one lifecycle item above -->
|
<!-- rendered as one lifecycle item above -->
|
||||||
{:else if item.kind === 'tool'}
|
{:else if item.kind === 'tool'}
|
||||||
{#if bodyTextAfterToolSummary(item)}
|
{#if toolBodyText(item)}
|
||||||
<p class="console-plain-text">
|
<p class="console-plain-text">
|
||||||
{#if isBashTool(item)}
|
{#if isBashTool(item)}
|
||||||
<AnsiText text={bodyTextAfterToolSummary(item)} />
|
<AnsiText text={toolBodyText(item)} />
|
||||||
{:else}
|
{:else}
|
||||||
{bodyTextAfterToolSummary(item)}
|
{toolBodyText(item)}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -147,11 +153,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.detail}
|
{#if item.detail && detailOpen}
|
||||||
<details class="message-detail">
|
<div class="message-detail" role="region" aria-label={`${toolLabel(item)} detail`}>
|
||||||
<summary>detail</summary>
|
|
||||||
<p>{item.detail}</p>
|
<p>{item.detail}</p>
|
||||||
</details>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -306,48 +311,46 @@
|
|||||||
.tool-summary {
|
.tool-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0;
|
gap: 0.5rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-label {
|
.tool-label {
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--tui-cyan);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-separator {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-suffix {
|
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow: hidden;
|
||||||
|
color: var(--tui-cyan);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-separator,
|
.tool-status {
|
||||||
.tool-suffix {
|
flex: 0 0 auto;
|
||||||
color: var(--tui-dark-gray);
|
color: var(--tui-dark-gray);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-state-error .tool-suffix {
|
.tool-state-error .tool-status {
|
||||||
color: var(--tui-red);
|
color: var(--tui-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-state-running .tool-suffix,
|
.tool-state-running .tool-status,
|
||||||
.tool-state-streaming_args .tool-suffix,
|
.tool-state-streaming_args .tool-status,
|
||||||
.tool-state-pending .tool-suffix {
|
.tool-state-pending .tool-status {
|
||||||
color: var(--tui-yellow);
|
color: var(--tui-yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-state-done .tool-suffix {
|
.tool-state-done .tool-status {
|
||||||
color: var(--tui-dark-gray);
|
color: var(--tui-dark-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.console-line.error-line .tool-status {
|
||||||
|
color: var(--tui-red);
|
||||||
|
}
|
||||||
|
|
||||||
.message-heading {
|
.message-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -412,13 +415,47 @@
|
|||||||
color: var(--code);
|
color: var(--code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-detail-button {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.08rem 0.35rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 750;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-line:hover .tool-detail-button,
|
||||||
|
.tool-detail-button:focus-visible,
|
||||||
|
.tool-detail-button[aria-expanded='true'] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.message-detail {
|
.message-detail {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
padding-left: 0.6rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-detail summary {
|
.message-detail p {
|
||||||
cursor: pointer;
|
margin: 0;
|
||||||
font-weight: 800;
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.tool-detail-button {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -165,6 +165,108 @@ Deno.test("segment rotation retains a live error beside the real SegmentStart hi
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("reload snapshot projects provenance-annotated history entries", () => {
|
||||||
|
const metadata = {
|
||||||
|
entry_id: "history-entry-1",
|
||||||
|
origin: {
|
||||||
|
kind: "model_output",
|
||||||
|
worker: {
|
||||||
|
workspace_id: "workspace-secret",
|
||||||
|
runtime_id: "runtime-secret",
|
||||||
|
worker_id: "worker-secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const annotated = (item: unknown) => ({ item, metadata });
|
||||||
|
const projection = projectConsole([{
|
||||||
|
eventId: "annotated-reload",
|
||||||
|
event: snapshotEvent("/repo", [
|
||||||
|
{
|
||||||
|
kind: "annotated_segment_start",
|
||||||
|
ts: 1,
|
||||||
|
session_id: "session-1",
|
||||||
|
system_prompt: null,
|
||||||
|
config: {},
|
||||||
|
history: [annotated({
|
||||||
|
kind: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ kind: "text", text: "older committed reply" }],
|
||||||
|
})],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "annotated_user_input",
|
||||||
|
ts: 2,
|
||||||
|
segments: [{ kind: "text", content: "latest user message" }],
|
||||||
|
history: [annotated({
|
||||||
|
kind: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ kind: "text", text: "latest user message" }],
|
||||||
|
})],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "annotated_assistant_item",
|
||||||
|
ts: 3,
|
||||||
|
entry: annotated({
|
||||||
|
kind: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ kind: "text", text: "latest committed reply" }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "annotated_assistant_item",
|
||||||
|
ts: 4,
|
||||||
|
entry: annotated({
|
||||||
|
kind: "tool_call",
|
||||||
|
call_id: "annotated-call",
|
||||||
|
name: "Read",
|
||||||
|
arguments: '{"file_path":"/repo/a.md"}',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "annotated_tool_result",
|
||||||
|
ts: 5,
|
||||||
|
entry: annotated({
|
||||||
|
kind: "tool_result",
|
||||||
|
call_id: "annotated-call",
|
||||||
|
summary: "Read 1 line from /repo/a.md",
|
||||||
|
content: "1→content",
|
||||||
|
is_error: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "annotated_system_item",
|
||||||
|
ts: 6,
|
||||||
|
entry: annotated({
|
||||||
|
kind: "notification",
|
||||||
|
message: "Worker completed",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
projection.lines.map((line) =>
|
||||||
|
`${line.kind}:${line.toolCallLabel ?? line.body}`
|
||||||
|
),
|
||||||
|
[
|
||||||
|
"assistant:older committed reply",
|
||||||
|
"user:latest user message",
|
||||||
|
"assistant:latest committed reply",
|
||||||
|
"tool:Read(1 file)",
|
||||||
|
"system:Worker completed",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const visible = JSON.stringify(projection.lines);
|
||||||
|
assert(
|
||||||
|
!visible.includes("workspace-secret"),
|
||||||
|
"history metadata must not enter Console rows",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!visible.includes("runtime-secret"),
|
||||||
|
"history origin must remain non-visible metadata",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
|
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
|
||||||
assert(
|
assert(
|
||||||
workerConsoleHref({
|
workerConsoleHref({
|
||||||
@@ -313,26 +415,25 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
|||||||
!toolLines[0].streaming,
|
!toolLines[0].streaming,
|
||||||
"completed tool call should not remain streaming",
|
"completed tool call should not remain streaming",
|
||||||
);
|
);
|
||||||
assert(
|
assertEquals(toolLines[0].toolCallLabel, "Bash($ pwd)");
|
||||||
toolLines[0].body.includes("$ pwd"),
|
assertEquals(toolLines[0].toolStatus, "done");
|
||||||
"Bash command should be summarized",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/repo"),
|
toolLines[0].body.includes("/repo"),
|
||||||
"tool result should be folded into the Call block",
|
"tool result should be folded into the Call block",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("line9"),
|
toolLines[0].body.includes("line9"),
|
||||||
"Bash result preview should include the ninth output line",
|
"Bash preview should include the ninth output line",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!toolLines[0].body.includes("line10") &&
|
!toolLines[0].body.includes("line10") &&
|
||||||
!toolLines[0].body.includes("line12"),
|
toolLines[0].body.includes("… +3 more lines"),
|
||||||
"Bash result preview should be capped at ten display lines",
|
"Bash preview should retain its line cap",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("… +3 more lines"),
|
toolLines[0].expandedBody?.includes("line12") === true &&
|
||||||
"Bash result preview should show omitted output count",
|
!toolLines[0].expandedBody?.includes("more lines"),
|
||||||
|
"Bash detail should show every returned output line",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].detail?.includes("id: call-1"),
|
toolLines[0].detail?.includes("id: call-1"),
|
||||||
@@ -421,7 +522,8 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
|
assertEquals(line.toolCallLabel, "Bash($ long-command)");
|
||||||
|
assertEquals(line.toolStatus, "failed (exit 7)");
|
||||||
assert(!line.body.includes("elapsed"), line.body);
|
assert(!line.body.includes("elapsed"), line.body);
|
||||||
assert(!line.body.includes("stdout:"), line.body);
|
assert(!line.body.includes("stdout:"), line.body);
|
||||||
assert(line.body.includes("ready\n"), line.body);
|
assert(line.body.includes("ready\n"), line.body);
|
||||||
@@ -463,7 +565,8 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
|||||||
|
|
||||||
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assert(line.body.includes("Bash — running…"), line.body);
|
assertEquals(line.toolCallLabel, "Bash($ slow)");
|
||||||
|
assertEquals(line.toolStatus, "running…");
|
||||||
assert(!line.body.includes("elapsed"), line.body);
|
assert(!line.body.includes("elapsed"), line.body);
|
||||||
assert(!line.body.includes("stdout:"), line.body);
|
assert(!line.body.includes("stdout:"), line.body);
|
||||||
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
|
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
|
||||||
@@ -474,7 +577,7 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
|||||||
assertEquals(line.streaming, true);
|
assertEquals(line.streaming, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole caps default tool request and result previews", () => {
|
Deno.test("projectConsole caps default preview but keeps complete detail body", () => {
|
||||||
const projection = projectConsole([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
eventId: "70",
|
eventId: "70",
|
||||||
@@ -508,19 +611,100 @@ Deno.test("projectConsole caps default tool request and result previews", () =>
|
|||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · CustomTool");
|
assertEquals(line.title, "Call · CustomTool");
|
||||||
assertEquals(line.body.split("\n").length, 7);
|
assertEquals(line.toolCallLabel, 'CustomTool("first":"one","second":"two","third":"three","fourth":"four")');
|
||||||
assert(line.body.includes("CustomTool — done"), "tool state should be shown");
|
assertEquals(line.toolStatus, "done");
|
||||||
|
assertEquals(line.body.split("\n").length, 3);
|
||||||
assert(
|
assert(
|
||||||
line.body.includes('"first": "one"'),
|
line.body.includes("out1") && line.body.includes("… +3 more lines"),
|
||||||
"request preview should be shown",
|
"normal display should retain the capped response preview",
|
||||||
|
);
|
||||||
|
assert(!line.body.includes("first"), "request arguments should stay in the Call signature and detail");
|
||||||
|
assert(
|
||||||
|
line.detail?.includes("arguments:\nfirst: one") === true &&
|
||||||
|
line.detail?.includes("fourth: four") === true,
|
||||||
|
"detail metadata should render complete request arguments as YAML",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
line.expandedBody?.includes("out5") === true &&
|
||||||
|
!line.expandedBody?.includes("more lines"),
|
||||||
|
"detail body should contain the complete result",
|
||||||
);
|
);
|
||||||
assert(line.body.includes("out1"), "result preview should be shown");
|
|
||||||
assert(!line.body.includes("third"), "request preview should be capped");
|
|
||||||
assert(!line.body.includes("out3"), "result preview should be capped");
|
|
||||||
assert(line.body.includes("… +"), "overflow marker should be shown");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => {
|
Deno.test("projectConsole renders JSON tool responses as YAML", () => {
|
||||||
|
const projection = projectConsole([
|
||||||
|
{
|
||||||
|
eventId: "json-call",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "json-tool",
|
||||||
|
name: "CustomTool",
|
||||||
|
arguments: "{}",
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "json-result",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "json-tool",
|
||||||
|
summary: "json completed",
|
||||||
|
output: JSON.stringify({
|
||||||
|
status: "ok",
|
||||||
|
items: [{ id: 1 }, { id: 2 }],
|
||||||
|
}),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "invalid-json-call",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "invalid-json-tool",
|
||||||
|
name: "CustomTool",
|
||||||
|
arguments: "{}",
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "invalid-json-result",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "invalid-json-tool",
|
||||||
|
summary: "invalid json",
|
||||||
|
output: '{"status": broken}',
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
|
const jsonLine = toolLines.find((line) => line.id.includes("json-tool"));
|
||||||
|
const invalidLine = toolLines.find((line) => line.id.includes("invalid-json-tool"));
|
||||||
|
assert(jsonLine, "JSON tool line should be projected");
|
||||||
|
assert(invalidLine, "invalid JSON tool line should be projected");
|
||||||
|
assert(
|
||||||
|
jsonLine.expandedBody?.includes("status: ok") === true &&
|
||||||
|
jsonLine.expandedBody?.includes(" - id: 2") === true,
|
||||||
|
"detail body should serialize parsed JSON as YAML",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
jsonLine.body.includes("more lines"),
|
||||||
|
"normal preview should cap the pretty-printed JSON",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
invalidLine.expandedBody?.includes('{"status": broken}') === true,
|
||||||
|
"invalid JSON-looking output should remain unchanged",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole caps Grep preview but keeps complete detail body", () => {
|
||||||
const projection = projectConsole([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
eventId: "72",
|
eventId: "72",
|
||||||
@@ -549,17 +733,19 @@ Deno.test("projectConsole shows Grep query and caps result preview to five entri
|
|||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · Grep");
|
assertEquals(line.title, "Call · Grep");
|
||||||
assert(
|
assertEquals(line.toolCallLabel, "Grep(needle)");
|
||||||
line.body.includes("Grep — 6 matches"),
|
assertEquals(line.toolStatus, "done");
|
||||||
"Grep summary should be shown",
|
|
||||||
);
|
|
||||||
assert(line.body.includes("query: needle"), "Grep query should be shown");
|
|
||||||
assert(line.body.includes("hit1"), "first result should be shown");
|
assert(line.body.includes("hit1"), "first result should be shown");
|
||||||
assert(line.body.includes("hit5"), "fifth result should be shown");
|
assert(line.body.includes("hit5"), "fifth result should be shown");
|
||||||
assert(!line.body.includes("hit6"), "sixth result should be capped");
|
assert(!line.body.includes("hit6"), "normal preview should retain its result cap");
|
||||||
assert(
|
assert(
|
||||||
line.body.includes("… +1 more results"),
|
line.body.includes("… +1 more results"),
|
||||||
"overflow marker should be shown",
|
"preview should show the omitted result count",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
line.expandedBody?.includes("hit6") === true &&
|
||||||
|
!line.expandedBody?.includes("more results"),
|
||||||
|
"detail body should show every Grep result",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -594,17 +780,15 @@ Deno.test("projectConsole keeps Grep error detail in the body", () => {
|
|||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · Grep");
|
assertEquals(line.title, "Call · Grep");
|
||||||
assert(
|
assertEquals(line.toolCallLabel, "Grep(needle)");
|
||||||
line.body.includes("Grep — Failed"),
|
assertEquals(line.toolStatus, "error");
|
||||||
"error suffix should stay short",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
line.body.includes(message),
|
line.body.includes(message),
|
||||||
"error detail should remain visible in the body",
|
"error detail should remain visible in the body",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!line.body.includes(`Grep — ${message}`),
|
!line.toolCallLabel?.includes(message),
|
||||||
"error detail should not be repeated in the suffix",
|
"error detail should not be repeated in the Call signature",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -879,9 +1063,10 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo
|
|||||||
assertEquals(toolLines.length, 1);
|
assertEquals(toolLines.length, 1);
|
||||||
assertEquals(toolLines[0].title, "Call · Read");
|
assertEquals(toolLines[0].title, "Call · Read");
|
||||||
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
|
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
|
||||||
|
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
|
||||||
|
assertEquals(toolLines[0].toolStatus, "reading…");
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/tmp/a.md") &&
|
toolLines[0].body.includes("/tmp/a.md"),
|
||||||
toolLines[0].body.includes("Read — reading"),
|
|
||||||
"Read call should render aggregate progress and path without content",
|
"Read call should render aggregate progress and path without content",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1018,10 +1203,8 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
|
|||||||
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(toolLines.length, 1);
|
assertEquals(toolLines.length, 1);
|
||||||
assertEquals(toolLines[0].title, "Call · Read");
|
assertEquals(toolLines[0].title, "Call · Read");
|
||||||
assert(
|
assertEquals(toolLines[0].toolCallLabel, "Read(2 files)");
|
||||||
toolLines[0].body.includes("Read — 2 files read"),
|
assertEquals(toolLines[0].toolStatus, "done");
|
||||||
"aggregate count should be shown",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/tmp/a.md"),
|
toolLines[0].body.includes("/tmp/a.md"),
|
||||||
"first path should be listed",
|
"first path should be listed",
|
||||||
@@ -1073,7 +1256,9 @@ Deno.test("projectConsole renders Edit calls with structured diff lines", () =>
|
|||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · Edit");
|
assertEquals(line.title, "Call · Edit");
|
||||||
assert(line.body.includes("diff: -1 +2"), "diff summary should be shown");
|
assertEquals(line.toolCallLabel, "Edit(/tmp/a.md)");
|
||||||
|
assertEquals(line.toolStatus, "done");
|
||||||
|
assertEquals(line.body, "ok");
|
||||||
assertEquals(line.diff?.map((row) => row.kind), [
|
assertEquals(line.diff?.map((row) => row.kind), [
|
||||||
"context",
|
"context",
|
||||||
"remove",
|
"remove",
|
||||||
@@ -1242,13 +1427,13 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
|||||||
assertEquals(projection.status, "running");
|
assertEquals(projection.status, "running");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
projection.lines.map((line) =>
|
projection.lines.map((line) =>
|
||||||
`${line.kind}:${line.body}:${line.streaming}`
|
`${line.kind}:${line.toolCallLabel ? `${line.toolCallLabel}\n${line.body}` : line.body}:${line.streaming}`
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
"user:seed user:false",
|
"user:seed user:false",
|
||||||
"user:new user:false",
|
"user:new user:false",
|
||||||
"assistant:assistant reply:false",
|
"assistant:assistant reply:false",
|
||||||
"tool:Read — 1 file read\n /tmp/a.md:false",
|
"tool:Read(1 file)\n /tmp/a.md:false",
|
||||||
"status:Compacting…:true",
|
"status:Compacting…:true",
|
||||||
"in_flight:partial:true",
|
"in_flight:partial:true",
|
||||||
],
|
],
|
||||||
@@ -1476,25 +1661,26 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bodies = projection.lines.filter((line) => line.kind === "tool").map((
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
line,
|
const bodies = toolLines.map((line) => line.body);
|
||||||
) => line.body);
|
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
|
||||||
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
|
assertEquals(bodies[0], " src/main.rs");
|
||||||
assert(
|
assert(
|
||||||
projection.lines[0].detail?.includes("from src/main.rs"),
|
projection.lines[0].detail?.includes("from src/main.rs"),
|
||||||
"Read summary detail path should be relative",
|
"Read summary detail path should be relative",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
bodies.some((body) =>
|
toolLines.some((line) =>
|
||||||
body.includes("Write — out.txt") && body.includes("Wrote out.txt")
|
line.toolCallLabel === "Write(out.txt)" && line.body.includes("Wrote out.txt")
|
||||||
),
|
),
|
||||||
"Write header and known result path should be relative",
|
"Write signature and known result path should be relative",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
bodies.some((body) =>
|
toolLines.some((line) =>
|
||||||
body.includes("Edit — src/main.rs") && body.includes("Edited src/main.rs")
|
line.toolCallLabel === "Edit(src/main.rs)" &&
|
||||||
|
line.body.includes("Edited src/main.rs")
|
||||||
),
|
),
|
||||||
"Edit header and known result path should be relative",
|
"Edit signature and known result path should be relative",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
bodies.some((body) =>
|
bodies.some((body) =>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
InternalWorkerSnapshot,
|
InternalWorkerSnapshot,
|
||||||
Segment,
|
Segment,
|
||||||
} from "$lib/generated/protocol";
|
} from "$lib/generated/protocol";
|
||||||
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
import { workspaceRoute } from "$lib/workspace/api/http";
|
||||||
import {
|
import {
|
||||||
applyRunActivityEvent,
|
applyRunActivityEvent,
|
||||||
@@ -86,6 +87,9 @@ export type ConsoleLine = {
|
|||||||
kind: ConsoleLineKind;
|
kind: ConsoleLineKind;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
expandedBody?: string;
|
||||||
|
toolCallLabel?: string;
|
||||||
|
toolStatus?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
compaction?: ConsoleCompaction;
|
compaction?: ConsoleCompaction;
|
||||||
diff?: ConsoleDiffLine[];
|
diff?: ConsoleDiffLine[];
|
||||||
@@ -1376,7 +1380,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
|||||||
title: item.title.startsWith("Call · Tool result")
|
title: item.title.startsWith("Call · Tool result")
|
||||||
? item.title
|
? item.title
|
||||||
: `Call · ${toolCall.name}`,
|
: `Call · ${toolCall.name}`,
|
||||||
body: renderToolCall(toolCall),
|
body: renderToolResponse(toolCall),
|
||||||
|
expandedBody: renderToolResponse(toolCall, true),
|
||||||
|
toolCallLabel: toolCallSignature(toolCall),
|
||||||
|
toolStatus: toolCallStatus(toolCall),
|
||||||
detail: toolCallDetail(toolCall),
|
detail: toolCallDetail(toolCall),
|
||||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||||
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
||||||
@@ -1384,7 +1391,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToolCall(toolCall: ToolCallView): string {
|
function renderToolResponse(toolCall: ToolCallView, expanded = false): string {
|
||||||
switch (toolCall.name) {
|
switch (toolCall.name) {
|
||||||
case "Read":
|
case "Read":
|
||||||
return renderReadTool(toolCall);
|
return renderReadTool(toolCall);
|
||||||
@@ -1395,14 +1402,54 @@ function renderToolCall(toolCall: ToolCallView): string {
|
|||||||
case "Glob":
|
case "Glob":
|
||||||
return renderSearchTool(toolCall);
|
return renderSearchTool(toolCall);
|
||||||
case "Grep":
|
case "Grep":
|
||||||
return renderGrepTool(toolCall);
|
return renderGrepTool(toolCall, expanded);
|
||||||
case "Bash":
|
case "Bash":
|
||||||
return renderBashTool(toolCall);
|
return renderBashTool(toolCall, expanded);
|
||||||
default:
|
default:
|
||||||
return renderDefaultTool(toolCall);
|
return renderDefaultTool(toolCall, expanded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolCallSignature(toolCall: ToolCallView): string {
|
||||||
|
const args = parsedArgs(toolCall);
|
||||||
|
switch (toolCall.name) {
|
||||||
|
case "Read":
|
||||||
|
return `Read(${readPath(toolCall)})`;
|
||||||
|
case "Write":
|
||||||
|
case "Edit": {
|
||||||
|
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
||||||
|
return `${toolCall.name}(${path})`;
|
||||||
|
}
|
||||||
|
case "Glob":
|
||||||
|
return `Glob(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
|
||||||
|
case "Grep":
|
||||||
|
return `Grep(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
|
||||||
|
case "Bash": {
|
||||||
|
const command = stringField(args, "command");
|
||||||
|
return `Bash(${command ? `$ ${singleLine(command)}` : genericCallArguments(toolCall)})`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `${toolCall.name}(${genericCallArguments(toolCall)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericCallArguments(toolCall: ToolCallView): string {
|
||||||
|
const raw = toolCall.arguments ?? toolCall.argsStream;
|
||||||
|
if (!raw.trim()) return "";
|
||||||
|
const parsed = parseJson(raw);
|
||||||
|
if (parsed === undefined) return singleLine(raw);
|
||||||
|
const serialized = JSON.stringify(parsed) ?? "null";
|
||||||
|
return isRecord(parsed) ? serialized.slice(1, -1) : serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleLine(value: string): string {
|
||||||
|
return value.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallStatus(toolCall: ToolCallView): string {
|
||||||
|
return toolCall.name === "Bash" ? commandStateSuffix(toolCall) : stateSuffix(toolCall.state);
|
||||||
|
}
|
||||||
|
|
||||||
function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
|
function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
|
||||||
const result: ConsoleLine[] = [];
|
const result: ConsoleLine[] = [];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -1434,9 +1481,6 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
const paths = calls.map(readPath);
|
const paths = calls.map(readPath);
|
||||||
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
||||||
const body = compactLines([
|
const body = compactLines([
|
||||||
inProgress
|
|
||||||
? `Read — reading (${count} file${plural(count)}…)`
|
|
||||||
: `Read — ${count} file${plural(count)} read`,
|
|
||||||
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
||||||
inProgress && paths.length > visiblePaths.length
|
inProgress && paths.length > visiblePaths.length
|
||||||
? ` … (${paths.length - visiblePaths.length} earlier)`
|
? ` … (${paths.length - visiblePaths.length} earlier)`
|
||||||
@@ -1447,6 +1491,8 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
kind: "tool",
|
kind: "tool",
|
||||||
title: "Call · Read",
|
title: "Call · Read",
|
||||||
body,
|
body,
|
||||||
|
toolCallLabel: `Read(${count} file${plural(count)})`,
|
||||||
|
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
|
||||||
detail: calls.map(readDetail).join("\n\n"),
|
detail: calls.map(readDetail).join("\n\n"),
|
||||||
eventId: group.at(-1)?.eventId,
|
eventId: group.at(-1)?.eventId,
|
||||||
source: "event",
|
source: "event",
|
||||||
@@ -1483,32 +1529,16 @@ function readDetail(toolCall: ToolCallView): string {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReadTool(toolCall: ToolCallView): string {
|
function renderReadTool(_toolCall: ToolCallView): string {
|
||||||
return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`;
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWriteTool(toolCall: ToolCallView): string {
|
function renderWriteTool(toolCall: ToolCallView): string {
|
||||||
const args = parsedArgs(toolCall);
|
return knownToolResultText(toolCall) ?? "";
|
||||||
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
|
||||||
const content = stringField(args, "content");
|
|
||||||
return compactLines([
|
|
||||||
`Write — ${path} (${stateSuffix(toolCall.state)})`,
|
|
||||||
cappedSection(content, 5),
|
|
||||||
knownToolResultText(toolCall),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditTool(toolCall: ToolCallView): string {
|
function renderEditTool(toolCall: ToolCallView): string {
|
||||||
const args = parsedArgs(toolCall);
|
return knownToolResultText(toolCall) ?? "";
|
||||||
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
|
||||||
const diff = editDiff(toolCall) ?? [];
|
|
||||||
const removes = diff.filter((line) => line.kind === "remove").length;
|
|
||||||
const adds = diff.filter((line) => line.kind === "add").length;
|
|
||||||
return compactLines([
|
|
||||||
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
|
|
||||||
diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
|
|
||||||
knownToolResultText(toolCall),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
|
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
|
||||||
@@ -1591,52 +1621,20 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSearchTool(toolCall: ToolCallView): string {
|
function renderSearchTool(toolCall: ToolCallView): string {
|
||||||
const summary = toolCall.summary?.trim();
|
return knownToolResultText(toolCall) ?? "";
|
||||||
return compactLines([
|
|
||||||
`${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`,
|
|
||||||
knownToolResultText(toolCall),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGrepTool(toolCall: ToolCallView): string {
|
function renderGrepTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||||
const summary = toolCall.summary?.trim();
|
const result = knownToolResultText(toolCall);
|
||||||
return compactLines([
|
return expanded ? result ?? "" : cappedResultSection(result, 5) ?? "";
|
||||||
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
|
|
||||||
grepQueryText(toolCall),
|
|
||||||
cappedResultSection(knownToolResultText(toolCall), 5),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolHeaderSuffix(
|
function renderBashTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||||
toolCall: ToolCallView,
|
if (["done", "error"].includes(toolCall.state)) {
|
||||||
summary?: string,
|
const result = resultText(toolCall);
|
||||||
): string {
|
return expanded ? result ?? "" : cappedDisplaySection(result, 10) ?? "";
|
||||||
if (toolCall.state === "error") {
|
|
||||||
return "Failed";
|
|
||||||
}
|
}
|
||||||
return summary ? firstLine(summary) : stateSuffix(toolCall.state);
|
return renderLiveCommandOutput(toolCall.command) ?? "";
|
||||||
}
|
|
||||||
|
|
||||||
function grepQueryText(toolCall: ToolCallView): string | undefined {
|
|
||||||
const args = parsedArgs(toolCall);
|
|
||||||
const pattern = stringField(args, "pattern");
|
|
||||||
if (pattern) {
|
|
||||||
return `query: ${pattern}`;
|
|
||||||
}
|
|
||||||
const renderedArgs = argsText(toolCall);
|
|
||||||
return renderedArgs ? `query:\n${renderedArgs}` : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBashTool(toolCall: ToolCallView): string {
|
|
||||||
const args = parsedArgs(toolCall);
|
|
||||||
const command = stringField(args, "command");
|
|
||||||
return compactLines([
|
|
||||||
`Bash — ${commandStateSuffix(toolCall)}`,
|
|
||||||
command ? `$ ${command}` : argsText(toolCall),
|
|
||||||
["done", "error"].includes(toolCall.state)
|
|
||||||
? cappedDisplaySection(resultText(toolCall), 10)
|
|
||||||
: renderLiveCommandOutput(toolCall.command),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandStateSuffix(toolCall: ToolCallView): string {
|
function commandStateSuffix(toolCall: ToolCallView): string {
|
||||||
@@ -1690,12 +1688,9 @@ function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDefaultTool(toolCall: ToolCallView): string {
|
function renderDefaultTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||||
return compactLines([
|
const result = resultText(toolCall);
|
||||||
`${toolCall.name} — ${stateSuffix(toolCall.state)}`,
|
return expanded ? result ?? "" : cappedDisplaySection(result, 3) ?? "";
|
||||||
cappedDisplaySection(argsText(toolCall), 3),
|
|
||||||
cappedDisplaySection(resultText(toolCall), 3),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolCallDetail(toolCall: ToolCallView): string {
|
function toolCallDetail(toolCall: ToolCallView): string {
|
||||||
@@ -1713,10 +1708,30 @@ function toolCallDetail(toolCall: ToolCallView): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resultText(toolCall: ToolCallView): string | undefined {
|
function resultText(toolCall: ToolCallView): string | undefined {
|
||||||
if (toolCall.output) {
|
const text = toolCall.output || toolCall.summary;
|
||||||
return toolCall.output;
|
return text ? formatJsonResponseAsYaml(text) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJsonResponseAsYaml(text: string): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||||
|
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(trimmed);
|
||||||
|
if (parsed === null || typeof parsed !== "object") {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return stringifyYaml(parsed).trimEnd();
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
return toolCall.summary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function knownToolResultText(toolCall: ToolCallView): string | undefined {
|
function knownToolResultText(toolCall: ToolCallView): string | undefined {
|
||||||
@@ -1803,7 +1818,7 @@ function argsText(toolCall: ToolCallView): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parsed = parseJson(raw);
|
const parsed = parseJson(raw);
|
||||||
return parsed === undefined ? raw : jsonPreview(parsed);
|
return parsed === undefined ? raw : stringifyYaml(parsed).trimEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsedArgs(
|
function parsedArgs(
|
||||||
@@ -1830,21 +1845,6 @@ function compactLines(lines: Array<string | undefined | null | false>): string {
|
|||||||
return lines.filter((line): line is string => Boolean(line)).join("\n");
|
return lines.filter((line): line is string => Boolean(line)).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function cappedSection(
|
|
||||||
value: string | undefined,
|
|
||||||
cap: number,
|
|
||||||
): string | undefined {
|
|
||||||
if (!value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const lines = value.split(/\r?\n/);
|
|
||||||
const shown = lines.slice(0, cap);
|
|
||||||
if (lines.length > cap) {
|
|
||||||
shown.push(`… +${lines.length - cap} more lines`);
|
|
||||||
}
|
|
||||||
return shown.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function cappedDisplaySection(
|
function cappedDisplaySection(
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
maxLines: number,
|
maxLines: number,
|
||||||
@@ -1960,24 +1960,34 @@ function applyLogEntry(
|
|||||||
applyLoggedItem(projection, `${eventId}-history-${index}`, item)
|
applyLoggedItem(projection, `${eventId}-history-${index}`, item)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "user_input":
|
case "annotated_segment_start":
|
||||||
projection.lines.push(
|
arrayField(entry, "history").forEach((historyEntry, index) =>
|
||||||
line(
|
applyLoggedHistoryEntry(
|
||||||
eventId,
|
projection,
|
||||||
"user",
|
`${eventId}-history-${index}`,
|
||||||
"User",
|
historyEntry,
|
||||||
segmentsToText(arrayField(entry, "segments") as Segment[]),
|
)
|
||||||
),
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "user_input":
|
||||||
|
case "annotated_user_input":
|
||||||
|
applyLoggedUserInput(projection, eventId, entry);
|
||||||
|
break;
|
||||||
case "system_item":
|
case "system_item":
|
||||||
projection.lines.push(systemItemLine(eventId, entry["item"]));
|
projection.lines.push(systemItemLine(eventId, entry["item"]));
|
||||||
applyTaskSystemItem(projection, entry["item"]);
|
applyTaskSystemItem(projection, entry["item"]);
|
||||||
break;
|
break;
|
||||||
|
case "annotated_system_item":
|
||||||
|
applyLoggedSystemEntry(projection, eventId, entry["entry"]);
|
||||||
|
break;
|
||||||
case "assistant_item":
|
case "assistant_item":
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
applyLoggedItem(projection, eventId, entry["item"]);
|
applyLoggedItem(projection, eventId, entry["item"]);
|
||||||
break;
|
break;
|
||||||
|
case "annotated_assistant_item":
|
||||||
|
case "annotated_tool_result":
|
||||||
|
applyLoggedHistoryEntry(projection, eventId, entry["entry"]);
|
||||||
|
break;
|
||||||
case "run_errored":
|
case "run_errored":
|
||||||
projection.lines.push(
|
projection.lines.push(
|
||||||
line(
|
line(
|
||||||
@@ -2056,6 +2066,52 @@ function compactMessageForState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyLoggedUserInput(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
eventId: string,
|
||||||
|
entry: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
let body = segmentsToText(arrayField(entry, "segments") as Segment[]);
|
||||||
|
if (!body && stringField(entry, "kind") === "annotated_user_input") {
|
||||||
|
body = loggedUserText(arrayField(entry, "history"));
|
||||||
|
}
|
||||||
|
projection.lines.push(line(eventId, "user", "User", body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loggedUserText(history: unknown[]): string {
|
||||||
|
for (const historyEntry of history) {
|
||||||
|
if (!isRecord(historyEntry) || !isRecord(historyEntry["item"])) continue;
|
||||||
|
const item = historyEntry["item"];
|
||||||
|
if (
|
||||||
|
stringField(item, "kind") !== "message" ||
|
||||||
|
stringField(item, "role") !== "user"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return loggedContentText(arrayField(item, "content"));
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLoggedHistoryEntry(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
eventId: string,
|
||||||
|
historyEntry: unknown,
|
||||||
|
): void {
|
||||||
|
if (!isRecord(historyEntry)) return;
|
||||||
|
applyLoggedItem(projection, eventId, historyEntry["item"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLoggedSystemEntry(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
eventId: string,
|
||||||
|
historyEntry: unknown,
|
||||||
|
): void {
|
||||||
|
if (!isRecord(historyEntry)) return;
|
||||||
|
projection.lines.push(systemItemLine(eventId, historyEntry["item"]));
|
||||||
|
applyTaskSystemItem(projection, historyEntry["item"]);
|
||||||
|
}
|
||||||
|
|
||||||
function applyLoggedItem(
|
function applyLoggedItem(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
|
|||||||
@@ -251,7 +251,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
ticketDetailLoad.includes("/repositories") &&
|
ticketDetailLoad.includes("/repositories") &&
|
||||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
ticketDetailPage.includes("async function queueTicket") &&
|
ticketDetailPage.includes("async function queueTicket") &&
|
||||||
ticketDetailPage.includes("`${ticketPath}/queue`") &&
|
ticketDetailPage.includes("const path = ticketPath") &&
|
||||||
|
ticketDetailPage.includes("`${path}/queue`") &&
|
||||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||||
ticketDetailPage.includes("mergeRequest.review_status") &&
|
ticketDetailPage.includes("mergeRequest.review_status") &&
|
||||||
@@ -402,7 +403,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
|||||||
consoleLine.includes("item.kind === 'tool'") &&
|
consoleLine.includes("item.kind === 'tool'") &&
|
||||||
consoleLine.includes("{#if isBashTool(item)}") &&
|
consoleLine.includes("{#if isBashTool(item)}") &&
|
||||||
consoleLine.includes(
|
consoleLine.includes(
|
||||||
"<AnsiText text={bodyTextAfterToolSummary(item)} />",
|
"<AnsiText text={toolBodyText(item)} />",
|
||||||
) &&
|
) &&
|
||||||
consoleLine.includes(
|
consoleLine.includes(
|
||||||
".console-line.tool-bash .console-plain-text",
|
".console-line.tool-bash .console-plain-text",
|
||||||
@@ -419,6 +420,30 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker Console expands uncapped tool body from the hover detail action", async () => {
|
||||||
|
const consoleLine = await Deno.readTextFile(
|
||||||
|
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
consoleLine.includes(
|
||||||
|
"return detailOpen ? (line.expandedBody ?? line.body) : line.body",
|
||||||
|
) &&
|
||||||
|
consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
|
||||||
|
consoleLine.includes('class={`tool-status') &&
|
||||||
|
consoleLine.includes('class="tool-detail-button"') &&
|
||||||
|
consoleLine.includes("aria-expanded={detailOpen}") &&
|
||||||
|
consoleLine.includes("detailOpen = !detailOpen") &&
|
||||||
|
consoleLine.includes("item.detail && detailOpen") &&
|
||||||
|
consoleLine.includes('role="region"') &&
|
||||||
|
consoleLine.includes(".console-line:hover .tool-detail-button") &&
|
||||||
|
consoleLine.includes(".tool-detail-button:focus-visible") &&
|
||||||
|
consoleLine.includes("@media (hover: none)") &&
|
||||||
|
!consoleLine.includes('<details class="message-detail">'),
|
||||||
|
"Normal tool display should keep its preview while detail reveals the uncapped body and existing metadata",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
|
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
|
||||||
const consoleLine = await Deno.readTextFile(
|
const consoleLine = await Deno.readTextFile(
|
||||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
const initialData = untrack(() => data);
|
const initialData = untrack(() => data);
|
||||||
const loadedTicket = initialData.ticket.data;
|
const loadedTicket = initialData.ticket.data;
|
||||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||||
const loadedRepositories = initialData.repositories.data;
|
const loadedRepositories = $derived(data.repositories.data);
|
||||||
|
|
||||||
type QueueOutcome = {
|
type QueueOutcome = {
|
||||||
requested_ticket: string;
|
requested_ticket: string;
|
||||||
@@ -62,6 +62,8 @@
|
|||||||
let manualRuntimeId = $state("");
|
let manualRuntimeId = $state("");
|
||||||
let manualWorkerId = $state("");
|
let manualWorkerId = $state("");
|
||||||
let cancellationReason = $state("");
|
let cancellationReason = $state("");
|
||||||
|
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
|
||||||
|
let routeGeneration = 0;
|
||||||
const coderAssignment = $derived(
|
const coderAssignment = $derived(
|
||||||
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
||||||
);
|
);
|
||||||
@@ -95,13 +97,43 @@
|
|||||||
|
|
||||||
function applyTicket(updatedTicket: TicketDetail): void {
|
function applyTicket(updatedTicket: TicketDetail): void {
|
||||||
ticket = updatedTicket;
|
ticket = updatedTicket;
|
||||||
editTitle = ticket.title;
|
editTitle = updatedTicket.title;
|
||||||
editBody = ticket.body;
|
editBody = updatedTicket.body;
|
||||||
repositoryId = ticket.repository_id ?? "";
|
repositoryId = updatedTicket.repository_id ?? "";
|
||||||
refSelector = ticket.ref_selector ?? "";
|
refSelector = updatedTicket.ref_selector ?? "";
|
||||||
nextState = ticket.state;
|
nextState = updatedTicket.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetTicketView(updatedTicket: TicketDetail): void {
|
||||||
|
applyTicket(updatedTicket);
|
||||||
|
editing = false;
|
||||||
|
transitionReason = "";
|
||||||
|
threadRole = "comment";
|
||||||
|
threadBody = "";
|
||||||
|
resolution = "";
|
||||||
|
busy = null;
|
||||||
|
errorMessage = null;
|
||||||
|
queueMessage = null;
|
||||||
|
readyOperationKey = null;
|
||||||
|
manualRuntimeId = "";
|
||||||
|
manualWorkerId = "";
|
||||||
|
cancellationReason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const incomingTicketId = data.ticketId;
|
||||||
|
const incomingTicket = data.ticket.data;
|
||||||
|
if (!incomingTicket) return;
|
||||||
|
const incomingSnapshot = `${incomingTicketId}:${incomingTicket.item_revision}`;
|
||||||
|
|
||||||
|
untrack(() => {
|
||||||
|
if (incomingSnapshot === routeTicketSnapshot) return;
|
||||||
|
routeTicketSnapshot = incomingSnapshot;
|
||||||
|
routeGeneration += 1;
|
||||||
|
resetTicketView(incomingTicket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function mutate(
|
async function mutate(
|
||||||
action: string,
|
action: string,
|
||||||
suffix: string,
|
suffix: string,
|
||||||
@@ -109,40 +141,51 @@
|
|||||||
method = "POST",
|
method = "POST",
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (busy) return false;
|
if (busy) return false;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = `${ticketPath}${suffix}`;
|
||||||
busy = action;
|
busy = action;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
const path = `${ticketPath}${suffix}`;
|
|
||||||
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
||||||
method,
|
method,
|
||||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
});
|
});
|
||||||
|
if (generation !== routeGeneration) return false;
|
||||||
applyTicket(response);
|
applyTicket(response);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation === routeGeneration) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueTicket(): Promise<void> {
|
async function queueTicket(): Promise<void> {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = ticketPath;
|
||||||
busy = "queue";
|
busy = "queue";
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
queueMessage = null;
|
queueMessage = null;
|
||||||
try {
|
try {
|
||||||
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
||||||
`${ticketPath}/queue`,
|
`${path}/queue`,
|
||||||
{ method: "POST", body: JSON.stringify({}) },
|
{ method: "POST", body: JSON.stringify({}) },
|
||||||
);
|
);
|
||||||
|
if (generation !== routeGeneration) return;
|
||||||
|
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
|
||||||
|
if (generation !== routeGeneration) return;
|
||||||
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
||||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
applyTicket(updatedTicket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation === routeGeneration) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,11 +195,13 @@
|
|||||||
principal: Record<string, string>,
|
principal: Record<string, string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = ticketPath;
|
||||||
busy = action;
|
busy = action;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
await workspaceApiJsonWithBody(
|
await workspaceApiJsonWithBody(
|
||||||
`${ticketPath}/assignments/${role}`,
|
`${path}/assignments/${role}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -166,11 +211,16 @@
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
if (generation !== routeGeneration) return;
|
||||||
|
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
|
||||||
|
if (generation !== routeGeneration) return;
|
||||||
|
applyTicket(updatedTicket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation === routeGeneration) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { assert, assertStringIncludes } from "jsr:@std/assert";
|
||||||
|
|
||||||
|
const pageSource = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test("ticket detail synchronizes reused route data", () => {
|
||||||
|
const effectStart = pageSource.indexOf("$effect(() => {");
|
||||||
|
assert(effectStart >= 0, "ticket detail must react to reused route props");
|
||||||
|
|
||||||
|
const effectSource = pageSource.slice(effectStart);
|
||||||
|
for (
|
||||||
|
const token of [
|
||||||
|
"data.ticketId",
|
||||||
|
"data.ticket.data",
|
||||||
|
"incomingTicket.item_revision",
|
||||||
|
"routeGeneration += 1",
|
||||||
|
"resetTicketView(incomingTicket)",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assertStringIncludes(effectSource, token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("ticket detail fences stale mutation responses", () => {
|
||||||
|
for (
|
||||||
|
const operation of [
|
||||||
|
"async function mutate(",
|
||||||
|
"async function queueTicket(",
|
||||||
|
"async function mutateAssignment(",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
const operationStart = pageSource.indexOf(operation);
|
||||||
|
assert(operationStart >= 0, `missing ${operation}`);
|
||||||
|
const nextOperation = pageSource.indexOf(
|
||||||
|
"\n async function ",
|
||||||
|
operationStart + 1,
|
||||||
|
);
|
||||||
|
const operationSource = pageSource.slice(
|
||||||
|
operationStart,
|
||||||
|
nextOperation === -1 ? undefined : nextOperation,
|
||||||
|
);
|
||||||
|
assertStringIncludes(operationSource, "const generation = routeGeneration");
|
||||||
|
assertStringIncludes(operationSource, "generation !== routeGeneration");
|
||||||
|
assertStringIncludes(operationSource, "generation === routeGeneration");
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user