fix(tui): interactive mid-stream network resume, paste dedup, and fleet type/model wiring for 0.9.4
- Preserve partial assistant output on interactive network/timeout stream
drops, append a runtime continuation message, and re-issue the request
bounded by MAX_STREAM_RETRIES.
- Stop sending large pasted text to the model both inline and as a backup
.md file; submit only the file @-mention.
- Promote `agent { type: "builder", model: "..." }` to a matching fleet
roster profile when the explicit model matches the profile's pinned
route; reject with a clearer message when it does not.
- Update CHANGELOG and sync crates/tui/CHANGELOG.
Targeted tests and clippy pass.
Generated with Devin (https://devin.ai)
This commit is contained in:
@@ -424,6 +424,13 @@ File edits, terminal width, and Windows installation.
|
||||
`retrieve_tool_result` ref for the elided middle instead of telling the
|
||||
model the bytes are unrecoverable; write failures degrade to the honest
|
||||
no-ref footer.
|
||||
- An interactive mid-stream network drop after partial output no longer fails
|
||||
the turn: the partial reply is preserved as a committed assistant message,
|
||||
a runtime continuation message is appended, and the request is re-issued
|
||||
bounded by the stream-retry budget.
|
||||
- Large pasted input is no longer sent to the model twice as inline text and
|
||||
as a backup `.md` paste file; the submitted message now carries only the
|
||||
`@`-mention so the model reads the file once.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -424,6 +424,13 @@ File edits, terminal width, and Windows installation.
|
||||
`retrieve_tool_result` ref for the elided middle instead of telling the
|
||||
model the bytes are unrecoverable; write failures degrade to the honest
|
||||
no-ref footer.
|
||||
- An interactive mid-stream network drop after partial output no longer fails
|
||||
the turn: the partial reply is preserved as a committed assistant message,
|
||||
a runtime continuation message is appended, and the request is re-issued
|
||||
bounded by the stream-retry budget.
|
||||
- Large pasted input is no longer sent to the model twice as inline text and
|
||||
as a backup `.md` paste file; the submitted message now carries only the
|
||||
`@`-mention so the model reads the file once.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -6160,7 +6160,8 @@ use self::streaming::{
|
||||
MAX_TRANSPARENT_STREAM_RETRIES, STREAM_MAX_CONTENT_BYTES, STREAM_MAX_DURATION_SECS,
|
||||
ToolCallDeltaFilterState, ToolUseState, contains_fake_tool_wrapper,
|
||||
filter_tool_call_delta_with_state, flush_tool_call_delta_state,
|
||||
should_resume_after_network_drop, should_resume_after_sleep, should_transparently_retry_stream,
|
||||
should_resume_after_network_drop, should_resume_after_sleep,
|
||||
should_resume_interactive_after_network_drop, should_transparently_retry_stream,
|
||||
sleep_gap_detected, stream_read_error_user_message,
|
||||
};
|
||||
use self::tool_catalog::{
|
||||
|
||||
@@ -128,6 +128,32 @@ pub(super) fn should_resume_after_network_drop(
|
||||
headless_host && network_class_error && retry_attempts < MAX_STREAM_RETRIES && !cancelled
|
||||
}
|
||||
|
||||
/// Decide whether an interactive TUI stream should be re-issued after a
|
||||
/// mid-stream network drop, preserving the partial reply and appending a
|
||||
/// runtime continuation message.
|
||||
///
|
||||
/// Unlike the headless resume, this keeps the partial fragment: the user has
|
||||
/// already seen the deltas, so the assistant message is committed and the next
|
||||
/// request is asked to continue where it left off. Tool calls are never resumed
|
||||
/// because an incomplete tool call could be re-issued and duplicate side
|
||||
/// effects. Bounded by `MAX_STREAM_RETRIES` and gated on a network/timeout-class
|
||||
/// error so model/parse/auth failures still surface normally.
|
||||
pub(super) fn should_resume_interactive_after_network_drop(
|
||||
terminal_chrome_enabled: bool,
|
||||
network_class_error: bool,
|
||||
any_content_received: bool,
|
||||
tool_uses_empty: bool,
|
||||
retry_attempts: u32,
|
||||
cancelled: bool,
|
||||
) -> bool {
|
||||
terminal_chrome_enabled
|
||||
&& network_class_error
|
||||
&& any_content_received
|
||||
&& tool_uses_empty
|
||||
&& retry_attempts < MAX_STREAM_RETRIES
|
||||
&& !cancelled
|
||||
}
|
||||
|
||||
/// Convert low-level reqwest/hyper stream read errors into an operator-facing
|
||||
/// message. The raw provider error remains attached, but the lead sentence
|
||||
/// explains why Codewhale may retry before any output and why it must surface
|
||||
|
||||
@@ -14572,6 +14572,76 @@ fn network_drop_resume_respects_budget_and_cancellation() {
|
||||
);
|
||||
}
|
||||
|
||||
// === interactive mid-stream network-drop resume (0.9.4) ======================
|
||||
//
|
||||
// The interactive TUI used to fail the turn when a provider stream dropped
|
||||
// after partial output because the #103 policy treated any post-content error
|
||||
// as terminal. The model now preserves the partial reply, commits it as an
|
||||
// assistant message, appends a runtime continuation user message, and re-issues
|
||||
// the request.
|
||||
|
||||
#[test]
|
||||
fn interactive_network_drop_resume_only_fires_for_interactive_hosts() {
|
||||
assert!(
|
||||
super::should_resume_interactive_after_network_drop(true, true, true, true, 0, false),
|
||||
"interactive TUI + partial text + no tools + budget must resume"
|
||||
);
|
||||
assert!(
|
||||
!super::should_resume_interactive_after_network_drop(false, true, true, true, 0, false),
|
||||
"headless hosts must use the headless resume path, not this one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_network_drop_resume_requires_partial_content_and_no_tools() {
|
||||
assert!(
|
||||
!super::should_resume_interactive_after_network_drop(true, true, false, true, 0, false),
|
||||
"no streamed content → transparent retry or nothing-streamed path"
|
||||
);
|
||||
assert!(
|
||||
!super::should_resume_interactive_after_network_drop(true, true, true, false, 0, false),
|
||||
"in-flight tool calls must never be resumed (side-effect duplication)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_network_drop_resume_requires_network_class_error() {
|
||||
assert!(
|
||||
!super::should_resume_interactive_after_network_drop(true, false, true, true, 0, false),
|
||||
"non-network failures must surface normally"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_network_drop_resume_respects_budget_and_cancellation() {
|
||||
assert!(
|
||||
super::should_resume_interactive_after_network_drop(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
super::MAX_STREAM_RETRIES - 1,
|
||||
false
|
||||
),
|
||||
"one short of the budget should still resume"
|
||||
);
|
||||
assert!(
|
||||
!super::should_resume_interactive_after_network_drop(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
false
|
||||
),
|
||||
"budget exhausted → surface the failure"
|
||||
);
|
||||
assert!(
|
||||
!super::should_resume_interactive_after_network_drop(true, true, true, true, 0, true),
|
||||
"cancelled turn must not resume"
|
||||
);
|
||||
}
|
||||
|
||||
/// Model client whose first `failures` streams emit partial content and then
|
||||
/// die with the network-class read error reqwest reports for a dropped
|
||||
/// chunked-transfer body; later streams complete a normal text turn.
|
||||
@@ -14764,6 +14834,138 @@ async fn headless_turn_retries_mid_stream_network_drop_and_recovers() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive one interactive (`terminal_chrome_enabled = true`) turn against the
|
||||
/// flaky client and collect every event through the terminal TurnComplete.
|
||||
async fn run_interactive_turn_with_flaky_network(
|
||||
failures: usize,
|
||||
) -> (std::sync::Arc<FlakyNetworkDropModelClient>, Vec<Event>) {
|
||||
let model = std::sync::Arc::new(FlakyNetworkDropModelClient {
|
||||
calls: std::sync::atomic::AtomicUsize::new(0),
|
||||
failures,
|
||||
});
|
||||
let client: crate::core::model_client::SharedModelClient = model.clone();
|
||||
let config = Config::default();
|
||||
let engine_config = EngineConfig {
|
||||
max_steps: 1,
|
||||
snapshots_enabled: false,
|
||||
subagents_enabled: false,
|
||||
terminal_chrome_enabled: true,
|
||||
..EngineConfig::default()
|
||||
};
|
||||
let (engine, handle) = Engine::new_with_model_client(engine_config, &config, client);
|
||||
let run_task = tokio::spawn(engine.run());
|
||||
|
||||
handle
|
||||
.send(Op::SendMessage {
|
||||
content: "solve the task".to_string(),
|
||||
mode: AppMode::Agent,
|
||||
route: resolved_route_for_test(&config, crate::config::DEFAULT_TEXT_MODEL),
|
||||
compaction: Box::new(CompactionConfig::default()),
|
||||
goal_objective: None,
|
||||
goal_token_budget: None,
|
||||
goal_status: crate::tools::goal::GoalStatus::Active,
|
||||
reasoning_effort: None,
|
||||
reasoning_effort_auto: false,
|
||||
auto_model: false,
|
||||
allow_shell: false,
|
||||
trust_mode: false,
|
||||
auto_approve: false,
|
||||
approval_mode: crate::tui::approval::ApprovalMode::Suggest,
|
||||
translation_enabled: false,
|
||||
allowed_tools: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
hook_executor: None,
|
||||
verbosity: None,
|
||||
provenance: UserInputProvenance::ExternalUser,
|
||||
})
|
||||
.await
|
||||
.expect("send interactive flaky-network turn");
|
||||
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let event = tokio::time::timeout(model_turn_event_timeout(), async {
|
||||
handle.rx_event.write().await.recv().await
|
||||
})
|
||||
.await
|
||||
.expect("interactive flaky-network event timeout")
|
||||
.expect("interactive flaky-network event");
|
||||
let terminal = matches!(event, Event::TurnComplete { .. });
|
||||
events.push(event);
|
||||
if terminal {
|
||||
break;
|
||||
}
|
||||
}
|
||||
handle.send(Op::Shutdown).await.expect("shutdown engine");
|
||||
run_task.await.expect("engine task");
|
||||
(model, events)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interactive_turn_preserves_partial_reply_and_recoveries_after_network_drop() {
|
||||
let (model, events) = run_interactive_turn_with_flaky_network(1).await;
|
||||
|
||||
assert_eq!(
|
||||
model.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||
2,
|
||||
"the dropped stream must be re-issued exactly once"
|
||||
);
|
||||
let (status, error) = events
|
||||
.iter()
|
||||
.find_map(|event| match event {
|
||||
Event::TurnComplete { status, error, .. } => Some((status, error)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("terminal TurnComplete");
|
||||
assert_eq!(
|
||||
*status,
|
||||
TurnOutcomeStatus::Completed,
|
||||
"a recovered retry must complete the turn: {error:?}"
|
||||
);
|
||||
assert!(error.is_none(), "recovered turn must not report an error");
|
||||
assert!(
|
||||
events.iter().any(|event| matches!(
|
||||
event,
|
||||
Event::Status { message } if message.contains("preserving partial reply and retrying (1/")
|
||||
)),
|
||||
"the interactive retry must be announced on the status channel: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|event| matches!(event, Event::Error { .. })),
|
||||
"a transient drop that the retry recovers must not surface an error event: {events:?}"
|
||||
);
|
||||
|
||||
// The partial reply must survive in the transcript, followed by a runtime
|
||||
// continuation user message and the retried assistant content.
|
||||
let transcript_text = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::SessionUpdated { messages, .. } => Some(messages),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.flat_map(|message| message.content.iter())
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
transcript_text.contains("partial answer that must be discarded"),
|
||||
"the partial reply must be preserved in the session: {transcript_text}"
|
||||
);
|
||||
assert!(
|
||||
transcript_text.contains("recovered after retry"),
|
||||
"retried content must be committed: {transcript_text}"
|
||||
);
|
||||
assert!(
|
||||
transcript_text.contains("provider stream dropped mid-response"),
|
||||
"the runtime continuation message must be appended: {transcript_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_turn_fails_with_real_error_after_network_drop_budget_exhausted() {
|
||||
let (model, events) =
|
||||
|
||||
@@ -977,6 +977,10 @@ impl Engine {
|
||||
// discards the fragment and re-issues the request instead of
|
||||
// forfeiting the whole exec session.
|
||||
let mut headless_stream_resume_pending = false;
|
||||
// Interactive mid-stream network-drop resume (0.9.4): preserve the
|
||||
// partial reply as a committed assistant message, append a runtime
|
||||
// user continuation message, and re-issue the request.
|
||||
let mut interactive_stream_resume_pending = false;
|
||||
let mut stream_content_bytes: usize = 0;
|
||||
let (chunk_timeout_secs, chunk_timeout) = stream_chunk_timeout_budget(&self.config);
|
||||
let max_duration = Duration::from_secs(STREAM_MAX_DURATION_SECS);
|
||||
@@ -1179,6 +1183,30 @@ impl Engine {
|
||||
headless_stream_resume_pending = true;
|
||||
break;
|
||||
}
|
||||
// Interactive TUI: a network/timeout-class stream drop
|
||||
// after partial text (but before any tool call) should
|
||||
// preserve the partial reply and re-issue the request
|
||||
// with a runtime continuation message, bounded by
|
||||
// MAX_STREAM_RETRIES. This keeps the turn alive instead
|
||||
// of failing with a terminal-looking error.
|
||||
if should_resume_interactive_after_network_drop(
|
||||
self.config.terminal_chrome_enabled,
|
||||
network_class_error,
|
||||
any_content_received,
|
||||
tool_uses.is_empty(),
|
||||
stream_retry_attempts,
|
||||
self.cancel_token.is_cancelled(),
|
||||
) {
|
||||
crate::logging::warn(format!(
|
||||
"Interactive stream resume: network drop after partial content; preserving fragment and scheduling request retry: {message}"
|
||||
));
|
||||
turn_error.get_or_insert(stream_read_error_user_message(
|
||||
&message,
|
||||
any_content_received,
|
||||
));
|
||||
interactive_stream_resume_pending = true;
|
||||
break;
|
||||
}
|
||||
let user_message =
|
||||
stream_read_error_user_message(&message, any_content_received);
|
||||
turn_error.get_or_insert(user_message.clone());
|
||||
@@ -1480,7 +1508,11 @@ impl Engine {
|
||||
&& current_text_visible.trim().is_empty()
|
||||
&& current_thinking.trim().is_empty()
|
||||
&& !pending_message_complete;
|
||||
if stream_died_with_nothing || sleep_resume_pending || headless_stream_resume_pending {
|
||||
if stream_died_with_nothing
|
||||
|| sleep_resume_pending
|
||||
|| headless_stream_resume_pending
|
||||
|| interactive_stream_resume_pending
|
||||
{
|
||||
if stream_retry_attempts < MAX_STREAM_RETRIES {
|
||||
stream_retry_attempts = stream_retry_attempts.saturating_add(1);
|
||||
if sleep_resume_pending {
|
||||
@@ -1510,6 +1542,66 @@ impl Engine {
|
||||
"Connection interrupted; retrying ({stream_retry_attempts}/{MAX_STREAM_RETRIES})"
|
||||
)))
|
||||
.await;
|
||||
} else if interactive_stream_resume_pending {
|
||||
crate::logging::warn(format!(
|
||||
"Resuming interactive turn after mid-stream network drop (attempt {stream_retry_attempts}/{MAX_STREAM_RETRIES}); preserving partial reply and retrying request"
|
||||
));
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
"Connection interrupted; preserving partial reply and retrying ({stream_retry_attempts}/{MAX_STREAM_RETRIES})"
|
||||
)))
|
||||
.await;
|
||||
// Finalize the partial text cell so the UI stops
|
||||
// streaming and the retried content lands in a fresh
|
||||
// cell instead of appending to an unfinished one.
|
||||
if let Some(index) = last_text_index {
|
||||
let _ = self.tx_event.send(Event::MessageComplete { index }).await;
|
||||
}
|
||||
// Commit the partial assistant message to the
|
||||
// conversation so the retried request sees the prefix
|
||||
// as already delivered. Build the blocks inline; the
|
||||
// outer `content_blocks` variable is still empty at
|
||||
// this point and will be rebuilt on the next round.
|
||||
let mut resume_blocks: Vec<ContentBlock> = Vec::new();
|
||||
if !current_thinking.is_empty() {
|
||||
resume_blocks.push(ContentBlock::Thinking {
|
||||
thinking: current_thinking.clone(),
|
||||
signature: current_thinking_signature.clone(),
|
||||
});
|
||||
}
|
||||
if !current_text_visible.is_empty() {
|
||||
resume_blocks.push(ContentBlock::Text {
|
||||
text: current_text_visible.clone(),
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
for tool in &tool_uses {
|
||||
resume_blocks.push(ContentBlock::ToolUse {
|
||||
id: tool.id.clone(),
|
||||
name: tool.name.clone(),
|
||||
input: tool.input.clone(),
|
||||
caller: tool.caller.clone(),
|
||||
});
|
||||
}
|
||||
let has_sendable_assistant_content = resume_blocks.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::Text { .. } | ContentBlock::ToolUse { .. }
|
||||
)
|
||||
});
|
||||
if has_sendable_assistant_content {
|
||||
self.add_session_message(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: resume_blocks,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
self.add_session_message(self.runtime_text_message_with_turn_metadata(
|
||||
"[runtime] The provider stream dropped mid-response. The partial reply above is preserved verbatim. Continue where you left off; do not repeat content already delivered.".to_string(),
|
||||
UserInputProvenance::Runtime,
|
||||
))
|
||||
.await;
|
||||
} else {
|
||||
crate::logging::warn(format!(
|
||||
"Stream died with no content (attempt {stream_retry_attempts}/{MAX_STREAM_RETRIES}); retrying request"
|
||||
|
||||
@@ -11465,7 +11465,23 @@ fn apply_spawn_profile(
|
||||
request: &mut SpawnRequest,
|
||||
roster: &crate::fleet::roster::FleetRoster,
|
||||
) -> Result<Option<crate::fleet::profile::AgentProfile>, ToolError> {
|
||||
let Some(profile_id) = request.profile.as_deref() else {
|
||||
// If the caller used a legacy `type`/`role` alias (e.g. `builder`) and it
|
||||
// resolves to a saved fleet roster member, treat it as a profile so the
|
||||
// child gets the member's pinned provider/model instead of colliding with
|
||||
// the session provider (#4177 keeps type aliases from being promoted when
|
||||
// they do *not* resolve to a member).
|
||||
let mut resolved_from_role = false;
|
||||
let profile_id = request.profile.as_deref().or_else(|| {
|
||||
if !request.agent_type_named || request.agent_type == FleetRole::Worker {
|
||||
return None;
|
||||
}
|
||||
let role = request.assignment.role.as_deref()?;
|
||||
resolve_roster_member(roster, role).map(|member| {
|
||||
resolved_from_role = true;
|
||||
member.id.as_str()
|
||||
})
|
||||
});
|
||||
let Some(profile_id) = profile_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(member) = resolve_roster_member(roster, profile_id) else {
|
||||
@@ -11492,28 +11508,43 @@ fn apply_spawn_profile(
|
||||
}
|
||||
|
||||
// Named fleet profiles bind 1:1 to their configured route (#5046).
|
||||
// The dispatching model cannot vary the model or model_strength for a named
|
||||
// profile — only 'general' exposes those options. This prevents the model
|
||||
// from composing invalid states (e.g. cloning the operator's model five
|
||||
// times, or binding the wrong wire protocol for a profile's model).
|
||||
// The dispatching model cannot vary the model_strength for a named
|
||||
// profile — only 'general' exposes that option. An explicit `model` that
|
||||
// *matches* the profile's pinned model is accepted as redundant and
|
||||
// ignored, so a caller that used `type: "builder"` with the same model the
|
||||
// profile already pins is helped through instead of being rejected.
|
||||
let is_general_slot = matches!(member.profile.slot, codewhale_config::FleetSlot::General);
|
||||
if !is_general_slot {
|
||||
if request.model.is_some() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"fleet profile '{}' binds a pre-configured route; 'model' may not be set for \
|
||||
named fleet roles. Named agents use exactly their configured model, route, and \
|
||||
posture — the dispatching model cannot override them. Remove 'model', or dispatch \
|
||||
without a profile to use 'general' (the only role with model options).",
|
||||
member.id
|
||||
)));
|
||||
if let Some(requested) = request.model.as_deref() {
|
||||
if let Some(pinned) = member.profile.model.as_deref() {
|
||||
if requested.trim().eq_ignore_ascii_case(pinned.trim()) {
|
||||
// Redundant; let the profile route win.
|
||||
request.model = None;
|
||||
} else {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"fleet profile '{}' pins model '{}', but the caller requested '{}'. \
|
||||
Named agents use exactly their configured model, route, and posture. \
|
||||
Remove 'model' to use the profile pin, or dispatch without a profile \
|
||||
(type: 'worker'/'general') to use 'model'.",
|
||||
member.id, pinned, requested
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"fleet profile '{}' binds a pre-configured route; 'model' may not be set for \
|
||||
named fleet roles. Named agents use exactly their configured model, route, and \
|
||||
posture — the dispatching model cannot override them. Remove 'model', or dispatch \
|
||||
with type: 'worker'/'general' (the only role with model options).",
|
||||
member.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
if request.model_strength_explicit {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"fleet profile '{}' binds a pre-configured route; 'model_strength' may not be \
|
||||
set for named fleet roles. Named agents use exactly their configured model, \
|
||||
route, and posture — the dispatching model cannot override them. Remove \
|
||||
'model_strength', or dispatch without a profile to use 'general' (the only role \
|
||||
with model options).",
|
||||
'model_strength', or dispatch with type: 'worker'/'general' (the only role with model options).",
|
||||
member.id
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -3342,6 +3342,23 @@ fn fleet_roster_with(id: &str, profile: codewhale_config::FleetProfile) -> Fleet
|
||||
FleetRoster::load(&config, tmp.path())
|
||||
}
|
||||
|
||||
/// A roster with a single explicit member and no personal/workspace profiles.
|
||||
/// Used for tests that resolve by role name (e.g. `type: "builder"`) and must
|
||||
/// not be shadowed by the operator's personal `~/.codewhale/agents/*.toml`.
|
||||
fn isolated_fleet_roster_with(id: &str, mut profile: codewhale_config::FleetProfile) -> FleetRoster {
|
||||
if profile.role.name.trim().is_empty() {
|
||||
profile.role.name = id.to_string();
|
||||
}
|
||||
FleetRoster::from_members(vec![crate::fleet::profile::AgentProfile {
|
||||
id: id.to_string(),
|
||||
display_name: Some(id.to_string()),
|
||||
description: None,
|
||||
profile,
|
||||
source: std::path::PathBuf::from("test"),
|
||||
origin: crate::fleet::roster::ProfileOrigin::Config,
|
||||
}])
|
||||
}
|
||||
|
||||
fn custom_fleet_profile(role: &str) -> codewhale_config::FleetProfile {
|
||||
codewhale_config::FleetProfile {
|
||||
slot: codewhale_config::FleetSlot::from_name(role),
|
||||
@@ -3987,6 +4004,59 @@ fn custom_fleet_profile_also_rejects_model_override() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A type alias that matches a saved fleet roster member is promoted to that
|
||||
/// profile so the child gets the member's provider/model pin. An explicit
|
||||
/// `model` that matches the profile's pinned model is treated as redundant and
|
||||
/// ignored, which is the common case when a model reads the profile and repeats
|
||||
/// the model id.
|
||||
#[test]
|
||||
fn apply_spawn_profile_promotes_type_alias_to_matching_member_and_ignores_matching_model() {
|
||||
let mut profile = custom_fleet_profile("builder");
|
||||
profile.provider = Some("deepseek".to_string());
|
||||
profile.model = Some("deepseek-v4-flash".to_string());
|
||||
let roster = isolated_fleet_roster_with("builder", profile);
|
||||
|
||||
let mut request = parse_spawn_request(&json!({
|
||||
"prompt": "implement a feature",
|
||||
"type": "builder",
|
||||
"model": "deepseek-v4-flash",
|
||||
"write_roots": ["."]
|
||||
}))
|
||||
.expect("parse should succeed");
|
||||
let member = apply_spawn_profile(&mut request, &roster)
|
||||
.expect("type alias matching a member should resolve")
|
||||
.expect("member resolved");
|
||||
assert_eq!(member.id, "builder");
|
||||
assert_eq!(request.agent_type, FleetRole::Builder);
|
||||
assert_eq!(request.profile.as_deref(), Some("builder"));
|
||||
assert!(
|
||||
request.model.is_none(),
|
||||
"redundant matching model should be dropped in favor of the profile pin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_spawn_profile_promoted_alias_rejects_model_mismatch() {
|
||||
let mut profile = custom_fleet_profile("builder");
|
||||
profile.provider = Some("deepseek".to_string());
|
||||
profile.model = Some("deepseek-v4-pro".to_string());
|
||||
let roster = isolated_fleet_roster_with("builder", profile);
|
||||
|
||||
let mut request = parse_spawn_request(&json!({
|
||||
"prompt": "implement a feature",
|
||||
"type": "builder",
|
||||
"model": "deepseek-v4-flash",
|
||||
"write_roots": ["."]
|
||||
}))
|
||||
.expect("parse should succeed");
|
||||
let err = apply_spawn_profile(&mut request, &roster)
|
||||
.expect_err("mismatched model on promoted profile must fail");
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("builder"), "error must name the member: {message}");
|
||||
assert!(message.contains("deepseek-v4-pro"), "error must name the pinned model: {message}");
|
||||
assert!(message.contains("deepseek-v4-flash"), "error must name the requested model: {message}");
|
||||
}
|
||||
|
||||
/// A Fleet worker subprocess launches as `--model <exact> --reasoning-effort
|
||||
/// auto`. That is a FIXED model with Auto reasoning: the raw `"auto"` sentinel
|
||||
/// must resolve, not travel to a provider that has no such tier.
|
||||
|
||||
@@ -1579,18 +1579,18 @@ impl App {
|
||||
// the consolidation in `insert_paste_text` first, so the user
|
||||
// sees the @mention in the composer before submission.
|
||||
self.consolidate_large_input_if_oversized();
|
||||
// If consolidation created a paste file, restore the full text and
|
||||
// append the @mention so the model can read the complete content
|
||||
// while the composer stays editable (#3263).
|
||||
let mut input = self
|
||||
.oversized_paste_full_text
|
||||
.take()
|
||||
.unwrap_or_else(|| self.input.clone());
|
||||
// If consolidation created a paste file, submit only the @-mention so
|
||||
// the model reads the full content from the paste file. Sending both
|
||||
// the inline text and the file mention duplicates the content in the
|
||||
// request and confuses the model.
|
||||
let mut input = self.input.clone();
|
||||
if let Some(reference) = self.pending_paste_reference.take() {
|
||||
if !input.is_empty() && !input.ends_with('\n') {
|
||||
input.push('\n');
|
||||
}
|
||||
input.push_str(&reference);
|
||||
// Drop the oversized inline copy; the paste file is now the
|
||||
// single source of truth for this content.
|
||||
self.oversized_paste_full_text = None;
|
||||
input = reference;
|
||||
} else if let Some(full) = self.oversized_paste_full_text.take() {
|
||||
input = full;
|
||||
}
|
||||
if !looks_like_slash_command_input(&input) {
|
||||
self.input_history.push(input.clone());
|
||||
@@ -1750,8 +1750,9 @@ impl App {
|
||||
}
|
||||
|
||||
// Keep a truncated preview in the composer so the user can still
|
||||
// select, copy, and edit it, while the full text is stored for
|
||||
// model submission. The @mention is appended at submit time (#3263).
|
||||
// select, copy, and edit it. The full text is written to the paste
|
||||
// file; at submit time the inline text is replaced by the @mention
|
||||
// so the model reads the file instead of receiving the content twice.
|
||||
self.pending_paste_reference = Some(format!("@{rel_path}"));
|
||||
self.oversized_paste_full_text = Some(full_input.clone());
|
||||
let display_chars = char_count(&full_input).min(MAX_COMPOSER_DISPLAY_CHARS);
|
||||
|
||||
@@ -2443,8 +2443,9 @@ fn cached_skills_reject_codewhale_only_workspace_symlink_escape() {
|
||||
#[test]
|
||||
fn paste_defers_oversized_text_consolidation_until_submit() {
|
||||
// (#3263): a large paste stays inline so the user can still edit it.
|
||||
// At submit time, the full text is sent to the model with the @mention
|
||||
// appended so the model can also read the paste file backup.
|
||||
// At submit time, the inline text is replaced by the @mention so the
|
||||
// model reads the full content from the paste file instead of receiving
|
||||
// it twice.
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir");
|
||||
let mut opts = test_options(false);
|
||||
opts.workspace = tmp.path().to_path_buf();
|
||||
@@ -2468,21 +2469,13 @@ fn paste_defers_oversized_text_consolidation_until_submit() {
|
||||
);
|
||||
|
||||
let submitted = app.submit_input().expect("expected submitted input");
|
||||
// The submitted text should contain the original content with the
|
||||
// @mention appended at the end (#3263).
|
||||
assert!(
|
||||
submitted.starts_with(&full_content),
|
||||
"submitted should contain full content, got: {}",
|
||||
submitted.starts_with("@.codewhale/pastes/paste-"),
|
||||
"submitted should be the @mention only, got: {}",
|
||||
&submitted[..submitted.len().min(80)]
|
||||
);
|
||||
let mention_start = full_content.len();
|
||||
assert!(
|
||||
submitted[mention_start..].starts_with("\n@.codewhale/pastes/paste-"),
|
||||
"expected @mention suffix, got: {}",
|
||||
&submitted[mention_start..]
|
||||
);
|
||||
assert!(submitted.ends_with(".md"), "expected .md extension");
|
||||
let mention = &submitted[mention_start + 2..]; // strip '\n@'
|
||||
let mention = &submitted[1..]; // strip leading '@'
|
||||
let abs = tmp.path().join(mention);
|
||||
assert!(abs.is_file(), "paste file must exist at {abs:?}");
|
||||
let written = std::fs::read_to_string(&abs).expect("read");
|
||||
@@ -2569,27 +2562,21 @@ fn submit_input_consolidates_oversized_input_into_paste_file() {
|
||||
|
||||
let submitted = app.submit_input().expect("expected submitted input");
|
||||
|
||||
// The submitted text should still contain the original content, with
|
||||
// the @mention appended at the end so the model can read the file
|
||||
// while the composer stays editable for the user (#3263).
|
||||
// The submitted text should be the @mention only so the model reads the
|
||||
// full content from the paste file instead of receiving it twice inline
|
||||
// and as a mention (#3263).
|
||||
assert!(
|
||||
submitted.starts_with(&full_content),
|
||||
"submitted text should contain original content, got: {}",
|
||||
submitted.starts_with("@.codewhale/pastes/paste-"),
|
||||
"submitted text should be the @mention, got: {}",
|
||||
&submitted[..submitted.len().min(80)]
|
||||
);
|
||||
let mention_start = full_content.len();
|
||||
assert!(
|
||||
submitted[mention_start..].starts_with("\n@.codewhale/pastes/paste-"),
|
||||
"submitted text should end with @mention, got suffix: {}",
|
||||
&submitted[mention_start..]
|
||||
);
|
||||
assert!(
|
||||
submitted.ends_with(".md"),
|
||||
"expected .md extension, got: {submitted}"
|
||||
);
|
||||
|
||||
// The paste file must exist on disk with the full original content.
|
||||
let mention = &submitted[mention_start + 2..]; // strip leading '\n@'
|
||||
let mention = &submitted[1..]; // strip leading '@'
|
||||
let abs_path = tmp.path().join(mention);
|
||||
assert!(abs_path.is_file(), "paste file must exist at {abs_path:?}");
|
||||
let written = std::fs::read_to_string(&abs_path).expect("read paste file");
|
||||
|
||||
Reference in New Issue
Block a user