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:
CodeWhale Bot
2026-08-07 03:49:08 -07:00
parent 21ed173cf1
commit efcf47a1d1
10 changed files with 479 additions and 55 deletions
+7
View File
@@ -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
+7
View File
@@ -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
+2 -1
View File
@@ -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::{
+26
View File
@@ -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
+202
View File
@@ -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) =
+93 -1
View File
@@ -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"
+46 -15
View File
@@ -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
)));
}
+70
View File
@@ -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.
+14 -13
View File
@@ -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);
+12 -25
View File
@@ -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");