Show and persist model reasoning
This commit is contained in:
@@ -34,7 +34,8 @@ User preferences live at `~/.cass/config.json`:
|
||||
```json
|
||||
{
|
||||
"default_model": "accounts/fireworks/models/qwen3p7-plus",
|
||||
"default_access_mode": "read-only"
|
||||
"default_access_mode": "read-only",
|
||||
"show_reasoning": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,6 +71,7 @@ cass check
|
||||
- `Ctrl-J`: insert newline
|
||||
- `Shift-Tab`: toggle read-only/full-access mode while idle
|
||||
- `Ctrl-O`: toggle compact/full tool output display
|
||||
- `Ctrl-Shift-R`: toggle reasoning display
|
||||
- `Up`/`Down` or mouse wheel: scroll transcript when no autocomplete menu is open
|
||||
- `PageUp`/`PageDown`: transcript scroll
|
||||
- `Ctrl-C` twice within 1.5 seconds: exit
|
||||
@@ -92,6 +94,8 @@ Resume this chat with: cass --resume <id>
|
||||
|
||||
Tool calls are shown compactly by default; press `Ctrl-O` to expand full tool output.
|
||||
|
||||
Reasoning is hidden by default unless `show_reasoning` is enabled; press `Ctrl-Shift-R` to toggle it. When providers stream reasoning fields, Cass persists that reasoning and sends it back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`.
|
||||
|
||||
Read-only mode allows `ls`, `read`, and `grep` within the launch cwd/`--cwd` and the bundled docs directory at `~/.cass/docs`.
|
||||
|
||||
Full-access mode additionally allows `write` and `edit`. Mutating tools use atomic writes where practical: Cass writes to a temporary file first, then renames it into place after validation/write success. `write` and `edit` are always blocked under `~/.cass/docs`.
|
||||
|
||||
@@ -20,7 +20,8 @@ Example:
|
||||
"default_access_mode": "read-only",
|
||||
"context_message_limit": 80,
|
||||
"model_tool_result_limit": 24000,
|
||||
"ui_tool_result_limit": 4000
|
||||
"ui_tool_result_limit": 4000,
|
||||
"show_reasoning": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -32,6 +33,7 @@ Fields:
|
||||
- `context_message_limit`: optional number of recent non-system messages sent to the model.
|
||||
- `model_tool_result_limit`: optional max bytes of tool output sent back to the model.
|
||||
- `ui_tool_result_limit`: optional max bytes of tool output shown in the UI unless full output is toggled.
|
||||
- `show_reasoning`: optional boolean, defaults to `false`. Shows provider-streamed reasoning in the transcript. Reasoning is persisted and sent back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`.
|
||||
|
||||
Deprecated compatibility fields from older Cass versions are still accepted: `provider`, `model`, `base_url`, and `api_key_env`. Prefer moving provider connection details to `providers.json`.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use tokio::sync::mpsc;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AgentEvent {
|
||||
AssistantChunk(String),
|
||||
ReasoningChunk(String),
|
||||
ToolCallStarted {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -131,6 +132,8 @@ pub async fn run_turn(
|
||||
|
||||
conversation.append(Record::Assistant {
|
||||
content: completion.content,
|
||||
reasoning: completion.reasoning,
|
||||
reasoning_field: completion.reasoning_field,
|
||||
tool_calls: tool_calls.clone(),
|
||||
ts: now_ts(),
|
||||
})?;
|
||||
@@ -172,6 +175,8 @@ fn append_visible_assistant(
|
||||
let _ = tx.send(AgentEvent::AssistantChunk(content.clone()));
|
||||
conversation.append(Record::Assistant {
|
||||
content,
|
||||
reasoning: String::new(),
|
||||
reasoning_field: None,
|
||||
tool_calls: Vec::new(),
|
||||
ts: now_ts(),
|
||||
})
|
||||
@@ -186,10 +191,14 @@ fn build_messages(records: &[Record], system: String, limit: usize) -> Vec<Model
|
||||
}),
|
||||
Record::Assistant {
|
||||
content,
|
||||
reasoning,
|
||||
reasoning_field,
|
||||
tool_calls,
|
||||
..
|
||||
} => non_system.push(ModelMessage::Assistant {
|
||||
content: content.clone(),
|
||||
reasoning: reasoning.clone(),
|
||||
reasoning_field: reasoning_field.clone(),
|
||||
tool_calls: tool_calls.clone(),
|
||||
}),
|
||||
Record::Tool {
|
||||
|
||||
+178
-12
@@ -95,10 +95,12 @@ async fn run_tui(
|
||||
let mut mode = config.default_access_mode;
|
||||
let mut status = String::new();
|
||||
let mut show_full_tools = false;
|
||||
let mut show_reasoning = config.show_reasoning;
|
||||
let mut scroll: u16 = 0;
|
||||
let mut last_ctrl_c: Option<Instant> = None;
|
||||
let mut handle: Option<JoinHandle<Result<Conversation>>> = None;
|
||||
let mut active_assistant: Option<usize> = None;
|
||||
let mut active_reasoning: Option<usize> = None;
|
||||
let mut stick_to_bottom = true;
|
||||
let mut chat_id = conversation.id.clone();
|
||||
let mut autofill_selected = 0usize;
|
||||
@@ -111,9 +113,11 @@ async fn run_tui(
|
||||
input: &input,
|
||||
transcript: &mut transcript,
|
||||
active_assistant: &mut active_assistant,
|
||||
active_reasoning: &mut active_reasoning,
|
||||
status: &mut status,
|
||||
stick_to_bottom,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
scroll: &mut scroll,
|
||||
},
|
||||
)?;
|
||||
@@ -128,9 +132,11 @@ async fn run_tui(
|
||||
input: &input,
|
||||
transcript: &mut transcript,
|
||||
active_assistant: &mut active_assistant,
|
||||
active_reasoning: &mut active_reasoning,
|
||||
status: &mut status,
|
||||
stick_to_bottom,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
scroll: &mut scroll,
|
||||
},
|
||||
)?;
|
||||
@@ -151,15 +157,29 @@ async fn run_tui(
|
||||
}),
|
||||
}
|
||||
active_assistant = None;
|
||||
active_reasoning = None;
|
||||
status = "idle".into();
|
||||
}
|
||||
|
||||
let autofill = build_autofill(&input, autofill_selected, &config, &cwd)?;
|
||||
autofill_selected = autofill.as_ref().map(|m| m.selected).unwrap_or(0);
|
||||
scroll = if stick_to_bottom {
|
||||
bottom_scroll(&terminal, &input, &transcript, show_full_tools)?
|
||||
bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?
|
||||
} else {
|
||||
clamp_scroll(&terminal, &input, &transcript, show_full_tools, scroll)?
|
||||
clamp_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
scroll,
|
||||
)?
|
||||
};
|
||||
|
||||
terminal.draw(|f| {
|
||||
@@ -176,6 +196,7 @@ async fn run_tui(
|
||||
status: &status,
|
||||
busy: handle.is_some(),
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
scroll,
|
||||
autofill: autofill.as_ref(),
|
||||
},
|
||||
@@ -221,13 +242,20 @@ async fn run_tui(
|
||||
(KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
show_full_tools = !show_full_tools;
|
||||
scroll = if stick_to_bottom {
|
||||
bottom_scroll(&terminal, &input, &transcript, show_full_tools)?
|
||||
bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?
|
||||
} else {
|
||||
clamp_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
scroll,
|
||||
)?
|
||||
};
|
||||
@@ -238,6 +266,63 @@ async fn run_tui(
|
||||
}
|
||||
.into();
|
||||
}
|
||||
(KeyCode::Char('R'), m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
show_reasoning = !show_reasoning;
|
||||
scroll = if stick_to_bottom {
|
||||
bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?
|
||||
} else {
|
||||
clamp_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
scroll,
|
||||
)?
|
||||
};
|
||||
status = if show_reasoning {
|
||||
"showing reasoning"
|
||||
} else {
|
||||
"hiding reasoning"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
(KeyCode::Char('r'), m)
|
||||
if m.contains(KeyModifiers::CONTROL)
|
||||
&& m.contains(KeyModifiers::SHIFT) =>
|
||||
{
|
||||
show_reasoning = !show_reasoning;
|
||||
scroll = if stick_to_bottom {
|
||||
bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?
|
||||
} else {
|
||||
clamp_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
scroll,
|
||||
)?
|
||||
};
|
||||
status = if show_reasoning {
|
||||
"showing reasoning"
|
||||
} else {
|
||||
"hiding reasoning"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
(KeyCode::Char('j'), m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
input.push('\n');
|
||||
autofill_selected = 0;
|
||||
@@ -280,6 +365,7 @@ async fn run_tui(
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -302,6 +388,7 @@ async fn run_tui(
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -318,6 +405,7 @@ async fn run_tui(
|
||||
input.clear();
|
||||
autofill_selected = 0;
|
||||
active_assistant = None;
|
||||
active_reasoning = None;
|
||||
status = format!("new chat {chat_id}");
|
||||
stick_to_bottom = true;
|
||||
scroll = bottom_scroll(
|
||||
@@ -325,6 +413,7 @@ async fn run_tui(
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -340,6 +429,7 @@ async fn run_tui(
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -364,6 +454,7 @@ async fn run_tui(
|
||||
input.clear();
|
||||
autofill_selected = 0;
|
||||
active_assistant = None;
|
||||
active_reasoning = None;
|
||||
status = format!("resumed chat {chat_id}");
|
||||
stick_to_bottom = true;
|
||||
scroll = bottom_scroll(
|
||||
@@ -371,6 +462,7 @@ async fn run_tui(
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -386,6 +478,7 @@ async fn run_tui(
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -408,6 +501,7 @@ async fn run_tui(
|
||||
content: msg.clone(),
|
||||
});
|
||||
active_assistant = None;
|
||||
active_reasoning = None;
|
||||
status = "running".into();
|
||||
let settings = AgentSettings {
|
||||
config: config.clone(),
|
||||
@@ -420,8 +514,13 @@ async fn run_tui(
|
||||
agent::run_turn(convo, msg, settings, tx2).await
|
||||
}));
|
||||
stick_to_bottom = true;
|
||||
scroll =
|
||||
bottom_scroll(&terminal, &input, &transcript, show_full_tools)?;
|
||||
scroll = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
(KeyCode::Backspace, _) => {
|
||||
@@ -440,8 +539,13 @@ async fn run_tui(
|
||||
if let Some(menu) = &autofill {
|
||||
autofill_selected = menu.next_index();
|
||||
} else {
|
||||
let max =
|
||||
bottom_scroll(&terminal, &input, &transcript, show_full_tools)?;
|
||||
let max = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
scroll = scroll.saturating_add(1).min(max);
|
||||
stick_to_bottom = scroll >= max;
|
||||
}
|
||||
@@ -451,8 +555,13 @@ async fn run_tui(
|
||||
stick_to_bottom = false;
|
||||
}
|
||||
(KeyCode::PageDown, _) => {
|
||||
let max =
|
||||
bottom_scroll(&terminal, &input, &transcript, show_full_tools)?;
|
||||
let max = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
scroll = scroll.saturating_add(10).min(max);
|
||||
stick_to_bottom = scroll >= max;
|
||||
}
|
||||
@@ -472,7 +581,13 @@ async fn run_tui(
|
||||
stick_to_bottom = false;
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
let max = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?;
|
||||
let max = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
scroll = scroll.saturating_add(3).min(max);
|
||||
stick_to_bottom = scroll >= max;
|
||||
}
|
||||
@@ -496,9 +611,11 @@ struct AgentEventContext<'a> {
|
||||
input: &'a str,
|
||||
transcript: &'a mut Vec<TranscriptBlock>,
|
||||
active_assistant: &'a mut Option<usize>,
|
||||
active_reasoning: &'a mut Option<usize>,
|
||||
status: &'a mut String,
|
||||
stick_to_bottom: bool,
|
||||
show_full_tools: bool,
|
||||
show_reasoning: bool,
|
||||
scroll: &'a mut u16,
|
||||
}
|
||||
|
||||
@@ -534,12 +651,33 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu
|
||||
ctx.transcript[idx].content.push_str(&s);
|
||||
update_bottom_scroll(ctx)?;
|
||||
}
|
||||
AgentEvent::ReasoningChunk(s) => {
|
||||
if ctx.active_reasoning.is_none() && s.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let idx = match *ctx.active_reasoning {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
ctx.transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Reasoning,
|
||||
title: "reasoning".into(),
|
||||
content: String::new(),
|
||||
});
|
||||
let i = ctx.transcript.len() - 1;
|
||||
*ctx.active_reasoning = Some(i);
|
||||
i
|
||||
}
|
||||
};
|
||||
ctx.transcript[idx].content.push_str(&s);
|
||||
update_bottom_scroll(ctx)?;
|
||||
}
|
||||
AgentEvent::ToolCallStarted {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
*ctx.active_assistant = None;
|
||||
*ctx.active_reasoning = None;
|
||||
ctx.transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Tool,
|
||||
title: format!("{name} … ({})", short_call_id(&id)),
|
||||
@@ -579,6 +717,7 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu
|
||||
}
|
||||
AgentEvent::TurnFinished => {
|
||||
*ctx.active_assistant = None;
|
||||
*ctx.active_reasoning = None;
|
||||
*ctx.status = "turn finished".into();
|
||||
}
|
||||
}
|
||||
@@ -587,7 +726,13 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu
|
||||
|
||||
fn update_bottom_scroll(ctx: &mut AgentEventContext<'_>) -> Result<()> {
|
||||
if ctx.stick_to_bottom {
|
||||
*ctx.scroll = bottom_scroll(ctx.terminal, ctx.input, ctx.transcript, ctx.show_full_tools)?;
|
||||
*ctx.scroll = bottom_scroll(
|
||||
ctx.terminal,
|
||||
ctx.input,
|
||||
ctx.transcript,
|
||||
ctx.show_full_tools,
|
||||
ctx.show_reasoning,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -963,11 +1108,13 @@ fn bottom_scroll(
|
||||
input: &str,
|
||||
transcript: &[TranscriptBlock],
|
||||
show_full_tools: bool,
|
||||
show_reasoning: bool,
|
||||
) -> Result<u16> {
|
||||
let area = render::transcript_area(terminal_area(terminal)?, input);
|
||||
Ok(render::max_transcript_scroll(
|
||||
transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
area,
|
||||
))
|
||||
}
|
||||
@@ -977,9 +1124,16 @@ fn clamp_scroll(
|
||||
input: &str,
|
||||
transcript: &[TranscriptBlock],
|
||||
show_full_tools: bool,
|
||||
show_reasoning: bool,
|
||||
scroll: u16,
|
||||
) -> Result<u16> {
|
||||
Ok(scroll.min(bottom_scroll(terminal, input, transcript, show_full_tools)?))
|
||||
Ok(scroll.min(bottom_scroll(
|
||||
terminal,
|
||||
input,
|
||||
transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?))
|
||||
}
|
||||
|
||||
fn blocks_from_conversation(conversation: &Conversation) -> Vec<TranscriptBlock> {
|
||||
@@ -993,9 +1147,17 @@ fn blocks_from_conversation(conversation: &Conversation) -> Vec<TranscriptBlock>
|
||||
}),
|
||||
conversation::Record::Assistant {
|
||||
content,
|
||||
reasoning,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
if !reasoning.trim().is_empty() {
|
||||
blocks.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Reasoning,
|
||||
title: "reasoning".into(),
|
||||
content: reasoning.clone(),
|
||||
});
|
||||
}
|
||||
if !content.trim().is_empty() {
|
||||
blocks.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Assistant,
|
||||
@@ -1133,6 +1295,8 @@ mod tests {
|
||||
path: PathBuf::new(),
|
||||
records: vec![conversation::Record::Assistant {
|
||||
content: "Done.".into(),
|
||||
reasoning: String::new(),
|
||||
reasoning_field: None,
|
||||
tool_calls: Vec::new(),
|
||||
ts: "now".into(),
|
||||
}],
|
||||
@@ -1164,6 +1328,8 @@ mod tests {
|
||||
path: PathBuf::new(),
|
||||
records: vec![conversation::Record::Assistant {
|
||||
content: "\nDone.".into(),
|
||||
reasoning: String::new(),
|
||||
reasoning_field: None,
|
||||
tool_calls: Vec::new(),
|
||||
ts: "now".into(),
|
||||
}],
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct ConfigFile {
|
||||
pub model_tool_result_limit: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui_tool_result_limit: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub show_reasoning: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -107,6 +109,7 @@ pub struct Config {
|
||||
pub context_message_limit: usize,
|
||||
pub model_tool_result_limit: usize,
|
||||
pub ui_tool_result_limit: usize,
|
||||
pub show_reasoning: bool,
|
||||
pub root: PathBuf,
|
||||
pub docs_dir: PathBuf,
|
||||
}
|
||||
@@ -131,6 +134,7 @@ impl Default for Config {
|
||||
context_message_limit: 80,
|
||||
model_tool_result_limit: 24_000,
|
||||
ui_tool_result_limit: 4_000,
|
||||
show_reasoning: false,
|
||||
root,
|
||||
docs_dir,
|
||||
}
|
||||
@@ -192,6 +196,9 @@ impl Config {
|
||||
if let Some(v) = file.ui_tool_result_limit {
|
||||
cfg.ui_tool_result_limit = v;
|
||||
}
|
||||
if let Some(v) = file.show_reasoning {
|
||||
cfg.show_reasoning = v;
|
||||
}
|
||||
}
|
||||
|
||||
if cli.readonly {
|
||||
|
||||
@@ -24,6 +24,10 @@ pub enum Record {
|
||||
},
|
||||
Assistant {
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
reasoning: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reasoning_field: Option<String>,
|
||||
tool_calls: Vec<StoredToolCall>,
|
||||
ts: String,
|
||||
},
|
||||
|
||||
@@ -68,6 +68,8 @@ impl OpenAiCompatibleProvider {
|
||||
}
|
||||
|
||||
let mut content = String::new();
|
||||
let mut reasoning = String::new();
|
||||
let mut reasoning_field: Option<String> = None;
|
||||
let mut partials: BTreeMap<usize, PartialToolCall> = BTreeMap::new();
|
||||
let mut buf = String::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
@@ -78,11 +80,25 @@ impl OpenAiCompatibleProvider {
|
||||
while let Some(pos) = buf.find("\n\n") {
|
||||
let frame = buf[..pos].to_string();
|
||||
buf = buf[pos + 2..].to_string();
|
||||
process_frame(&frame, &mut content, &mut partials, tx)?;
|
||||
process_frame(
|
||||
&frame,
|
||||
&mut content,
|
||||
&mut reasoning,
|
||||
&mut reasoning_field,
|
||||
&mut partials,
|
||||
tx,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if !buf.trim().is_empty() {
|
||||
process_frame(&buf, &mut content, &mut partials, tx)?;
|
||||
process_frame(
|
||||
&buf,
|
||||
&mut content,
|
||||
&mut reasoning,
|
||||
&mut reasoning_field,
|
||||
&mut partials,
|
||||
tx,
|
||||
)?;
|
||||
}
|
||||
|
||||
let tool_calls = partials
|
||||
@@ -101,6 +117,8 @@ impl OpenAiCompatibleProvider {
|
||||
.collect();
|
||||
Ok(CompletionResult {
|
||||
content,
|
||||
reasoning,
|
||||
reasoning_field,
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
@@ -109,6 +127,8 @@ impl OpenAiCompatibleProvider {
|
||||
fn process_frame(
|
||||
frame: &str,
|
||||
content: &mut String,
|
||||
reasoning: &mut String,
|
||||
reasoning_field: &mut Option<String>,
|
||||
partials: &mut BTreeMap<usize, PartialToolCall>,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<()> {
|
||||
@@ -121,7 +141,7 @@ fn process_frame(
|
||||
if data == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
handle_chunk(data, content, partials, tx)?;
|
||||
handle_chunk(data, content, reasoning, reasoning_field, partials, tx)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -129,6 +149,8 @@ fn process_frame(
|
||||
fn handle_chunk(
|
||||
data: &str,
|
||||
content: &mut String,
|
||||
reasoning: &mut String,
|
||||
reasoning_field: &mut Option<String>,
|
||||
partials: &mut BTreeMap<usize, PartialToolCall>,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<()> {
|
||||
@@ -141,6 +163,11 @@ fn handle_chunk(
|
||||
.or_else(|| choice.get("message"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
if let Some((field, s)) = reasoning_delta(&delta) {
|
||||
reasoning_field.get_or_insert_with(|| field.to_string());
|
||||
reasoning.push_str(s);
|
||||
let _ = tx.send(AgentEvent::ReasoningChunk(s.to_string()));
|
||||
}
|
||||
if let Some(s) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
content.push_str(s);
|
||||
let _ = tx.send(AgentEvent::AssistantChunk(s.to_string()));
|
||||
@@ -165,24 +192,72 @@ fn handle_chunk(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn messages_to_openai(messages: Vec<ModelMessage>) -> Vec<Value> {
|
||||
messages.into_iter().map(|m| match m {
|
||||
ModelMessage::System { content } => json!({"role":"system", "content":content}),
|
||||
ModelMessage::User { content } => json!({"role":"user", "content":content}),
|
||||
ModelMessage::Assistant { content, tool_calls } => {
|
||||
if tool_calls.is_empty() {
|
||||
json!({"role":"assistant", "content":content})
|
||||
} else {
|
||||
let calls: Vec<_> = tool_calls.into_iter().map(|tc| json!({
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {"name": tc.name, "arguments": tc.arguments.to_string()}
|
||||
})).collect();
|
||||
json!({"role":"assistant", "content": if content.is_empty() { Value::Null } else { Value::String(content) }, "tool_calls": calls})
|
||||
}
|
||||
fn reasoning_delta(delta: &Value) -> Option<(&'static str, &str)> {
|
||||
["reasoning_content", "reasoning", "thinking", "thought"]
|
||||
.into_iter()
|
||||
.find_map(|field| {
|
||||
delta
|
||||
.get(field)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| (field, s))
|
||||
})
|
||||
}
|
||||
|
||||
fn assistant_message_to_openai(
|
||||
content: String,
|
||||
reasoning: String,
|
||||
reasoning_field: Option<String>,
|
||||
tool_calls: Vec<StoredToolCall>,
|
||||
) -> Value {
|
||||
let mut message = json!({"role":"assistant", "content":content});
|
||||
if let Value::Object(ref mut obj) = message {
|
||||
if !reasoning.trim().is_empty() {
|
||||
let field = reasoning_field.unwrap_or_else(|| "reasoning_content".to_string());
|
||||
obj.insert(field, Value::String(reasoning));
|
||||
}
|
||||
ModelMessage::Tool { tool_call_id, name: _, content } => json!({"role":"tool", "tool_call_id":tool_call_id, "content":content}),
|
||||
}).collect()
|
||||
if !tool_calls.is_empty() {
|
||||
let calls: Vec<_> = tool_calls
|
||||
.into_iter()
|
||||
.map(|tc| {
|
||||
json!({
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {"name": tc.name, "arguments": tc.arguments.to_string()}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if obj
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|s| s.is_empty())
|
||||
{
|
||||
obj.insert("content".to_string(), Value::Null);
|
||||
}
|
||||
obj.insert("tool_calls".to_string(), Value::Array(calls));
|
||||
}
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
fn messages_to_openai(messages: Vec<ModelMessage>) -> Vec<Value> {
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|m| match m {
|
||||
ModelMessage::System { content } => json!({"role":"system", "content":content}),
|
||||
ModelMessage::User { content } => json!({"role":"user", "content":content}),
|
||||
ModelMessage::Assistant {
|
||||
content,
|
||||
reasoning,
|
||||
reasoning_field,
|
||||
tool_calls,
|
||||
} => assistant_message_to_openai(content, reasoning, reasoning_field, tool_calls),
|
||||
ModelMessage::Tool {
|
||||
tool_call_id,
|
||||
name: _,
|
||||
content,
|
||||
} => json!({"role":"tool", "tool_call_id":tool_call_id, "content":content}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tools_to_openai(tools: Vec<ToolSpec>) -> Vec<Value> {
|
||||
|
||||
@@ -12,6 +12,10 @@ pub enum ModelMessage {
|
||||
},
|
||||
Assistant {
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
reasoning: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reasoning_field: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
tool_calls: Vec<StoredToolCall>,
|
||||
},
|
||||
@@ -25,5 +29,7 @@ pub enum ModelMessage {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CompletionResult {
|
||||
pub content: String,
|
||||
pub reasoning: String,
|
||||
pub reasoning_field: Option<String>,
|
||||
pub tool_calls: Vec<StoredToolCall>,
|
||||
}
|
||||
|
||||
+26
-7
@@ -11,6 +11,7 @@ use unicode_width::UnicodeWidthChar;
|
||||
pub enum TranscriptKind {
|
||||
User,
|
||||
Assistant,
|
||||
Reasoning,
|
||||
Tool,
|
||||
Status,
|
||||
Error,
|
||||
@@ -35,6 +36,7 @@ pub struct RenderState<'a> {
|
||||
pub status: &'a str,
|
||||
pub busy: bool,
|
||||
pub show_full_tools: bool,
|
||||
pub show_reasoning: bool,
|
||||
pub scroll: u16,
|
||||
pub autofill: Option<&'a AutoFillMenu>,
|
||||
}
|
||||
@@ -43,10 +45,15 @@ pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) {
|
||||
let menu_height = autofill_height(state.autofill);
|
||||
let chunks = main_layout(f.area(), state.input, menu_height);
|
||||
|
||||
let lines = transcript_lines_from(state.transcript, state.show_full_tools);
|
||||
let lines = transcript_lines_from(
|
||||
state.transcript,
|
||||
state.show_full_tools,
|
||||
state.show_reasoning,
|
||||
);
|
||||
let effective_scroll = state.scroll.min(max_transcript_scroll(
|
||||
state.transcript,
|
||||
state.show_full_tools,
|
||||
state.show_reasoning,
|
||||
chunks[0],
|
||||
));
|
||||
let transcript = Paragraph::new(Text::from(lines))
|
||||
@@ -73,13 +80,14 @@ pub fn transcript_area(area: Rect, input: &str) -> Rect {
|
||||
pub fn max_transcript_scroll(
|
||||
transcript: &[TranscriptBlock],
|
||||
show_full_tools: bool,
|
||||
show_reasoning: bool,
|
||||
area: Rect,
|
||||
) -> u16 {
|
||||
// Paragraph::scroll is measured in rendered rows, not logical lines. Long
|
||||
// tool output and assistant messages wrap, so count rows with the same
|
||||
// word-wrapping behavior ratatui uses for Paragraph::wrap(trim: false).
|
||||
let content_width = area.width.max(1) as usize;
|
||||
let row_count = transcript_lines_from(transcript, show_full_tools)
|
||||
let row_count = transcript_lines_from(transcript, show_full_tools, show_reasoning)
|
||||
.iter()
|
||||
.map(|line| ratatui_wrapped_row_count(&line.to_string(), content_width))
|
||||
.sum::<usize>();
|
||||
@@ -143,17 +151,21 @@ fn render_autofill_menu(f: &mut Frame<'_>, area: Rect, menu: &AutoFillMenu) {
|
||||
fn transcript_lines_from(
|
||||
transcript: &[TranscriptBlock],
|
||||
show_full_tools: bool,
|
||||
show_reasoning: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
for block in transcript {
|
||||
if matches!(block.kind, TranscriptKind::Assistant) && block.content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if matches!(block.kind, TranscriptKind::Reasoning) && !show_reasoning {
|
||||
continue;
|
||||
}
|
||||
|
||||
let style = style_for(&block.kind);
|
||||
lines.push(Line::styled(heading_for(block), style));
|
||||
|
||||
let content = display_content(block, show_full_tools);
|
||||
let content = display_content(block, show_full_tools, show_reasoning);
|
||||
if !content.trim().is_empty() {
|
||||
for line in content.lines() {
|
||||
lines.push(Line::raw(format!(" {}", sanitize_line(line))));
|
||||
@@ -291,6 +303,7 @@ fn style_for(kind: &TranscriptKind) -> Style {
|
||||
match kind {
|
||||
TranscriptKind::User => theme::user(),
|
||||
TranscriptKind::Assistant => theme::assistant(),
|
||||
TranscriptKind::Reasoning => theme::reasoning(),
|
||||
TranscriptKind::Tool => theme::tool(),
|
||||
TranscriptKind::Status => Style::default().fg(Color::DarkGray),
|
||||
TranscriptKind::Error => theme::error(),
|
||||
@@ -301,6 +314,7 @@ fn heading_for(block: &TranscriptBlock) -> String {
|
||||
match block.kind {
|
||||
TranscriptKind::User => "› you".into(),
|
||||
TranscriptKind::Assistant => "cass".into(),
|
||||
TranscriptKind::Reasoning => "· reasoning".into(),
|
||||
TranscriptKind::Tool => format!("· {}", block.title),
|
||||
TranscriptKind::Status => {
|
||||
if block.title.trim().is_empty() || block.title == "status" {
|
||||
@@ -313,9 +327,11 @@ fn heading_for(block: &TranscriptBlock) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn display_content(block: &TranscriptBlock, show_full_tools: bool) -> String {
|
||||
fn display_content(block: &TranscriptBlock, show_full_tools: bool, show_reasoning: bool) -> String {
|
||||
if matches!(block.kind, TranscriptKind::Tool) && !show_full_tools {
|
||||
String::new()
|
||||
} else if matches!(block.kind, TranscriptKind::Reasoning) && !show_reasoning {
|
||||
String::new()
|
||||
} else {
|
||||
block.content.clone()
|
||||
}
|
||||
@@ -338,6 +354,9 @@ fn footer_text(state: &RenderState<'_>) -> String {
|
||||
if state.show_full_tools {
|
||||
parts.push("tools:full".into());
|
||||
}
|
||||
if state.show_reasoning {
|
||||
parts.push("reasoning:on".into());
|
||||
}
|
||||
if !state.status.trim().is_empty() {
|
||||
parts.push(state.status.trim().to_string());
|
||||
}
|
||||
@@ -438,7 +457,7 @@ mod tests {
|
||||
}];
|
||||
let area = Rect::new(0, 0, 12, 3);
|
||||
|
||||
assert!(max_transcript_scroll(&transcript, false, area) > 0);
|
||||
assert!(max_transcript_scroll(&transcript, false, false, area) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -450,7 +469,7 @@ mod tests {
|
||||
}];
|
||||
let area = Rect::new(0, 0, 80, 10);
|
||||
|
||||
assert_eq!(max_transcript_scroll(&transcript, false, area), 0);
|
||||
assert_eq!(max_transcript_scroll(&transcript, false, false, area), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -472,7 +491,7 @@ mod tests {
|
||||
},
|
||||
];
|
||||
let area = Rect::new(0, 0, 80, 5);
|
||||
let max = max_transcript_scroll(&transcript, true, area);
|
||||
let max = max_transcript_scroll(&transcript, true, false, area);
|
||||
|
||||
assert!(max > 50, "max scroll was {max}");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ pub fn assistant() -> Style {
|
||||
Style::default().fg(Color::White)
|
||||
}
|
||||
|
||||
pub fn reasoning() -> Style {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
}
|
||||
|
||||
pub fn tool() -> Style {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,105 @@ fn sse(body: &str) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200).set_body_raw(body.as_bytes().to_vec(), "text/event-stream")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reasoning_is_streamed_persisted_and_sent_back() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let reasoning_token = "internal-cass-reasoning-token";
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.and(body_string_contains(&format!(
|
||||
"\"reasoning_content\":\"{reasoning_token}\""
|
||||
)))
|
||||
.respond_with(sse(
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Second.\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n",
|
||||
))
|
||||
.with_priority(1)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(sse(
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"internal-cass-reasoning-token\",\"content\":\"First.\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n",
|
||||
))
|
||||
.with_priority(10)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let root = tempdir().unwrap();
|
||||
let cwd = tempdir().unwrap();
|
||||
let docs = tempdir().unwrap();
|
||||
let config = Config {
|
||||
root: root.path().to_path_buf(),
|
||||
docs_dir: docs.path().to_path_buf(),
|
||||
model: "test-model".into(),
|
||||
active_provider: cassady::config::ResolvedProviderConfig {
|
||||
base_url: server.uri(),
|
||||
api_key: "test-key".into(),
|
||||
..Config::default().active_provider
|
||||
},
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
let conversation = Conversation::create(
|
||||
&config.conversations_dir(),
|
||||
&config.model,
|
||||
cwd.path(),
|
||||
"base prompt".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
|
||||
|
||||
let updated = run_turn(
|
||||
conversation,
|
||||
"first".into(),
|
||||
AgentSettings {
|
||||
config: config.clone(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
mode: AccessMode::ReadOnly,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut streamed_reasoning = String::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AgentEvent::ReasoningChunk(chunk) = event {
|
||||
streamed_reasoning.push_str(&chunk);
|
||||
}
|
||||
}
|
||||
assert_eq!(streamed_reasoning, reasoning_token);
|
||||
assert!(matches!(
|
||||
updated.records.last().unwrap(),
|
||||
Record::Assistant {
|
||||
content,
|
||||
reasoning,
|
||||
reasoning_field,
|
||||
..
|
||||
} if content == "First."
|
||||
&& reasoning == reasoning_token
|
||||
&& reasoning_field.as_deref() == Some("reasoning_content")
|
||||
));
|
||||
|
||||
let (tx, _rx) = mpsc::unbounded_channel::<AgentEvent>();
|
||||
run_turn(
|
||||
updated,
|
||||
"second".into(),
|
||||
AgentSettings {
|
||||
config,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
mode: AccessMode::ReadOnly,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_final_response_is_reprompted_and_persisted() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Reference in New Issue
Block a user