Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 71 additions & 41 deletions crates/oxide-code/src/agent/compaction.rs
Original file line number Diff line number Diff line change
@@ -1,56 +1,49 @@
//! `/compact` driver: streams a one-shot summarization request through the live [`Client`] and
//! returns the trimmed summary text. The driver itself does not touch session state — that is
//! the caller's job (see `apply_compact` in the agent-loop dispatch).
//! `/compact` summarization request builder and stream collector.
//!
//! Wire shape: an empty tool list and a dedicated minimal system prompt so the model cannot
//! attempt a tool call mid-summary. The transcript is stripped to text-only content blocks
//! before sending — tool-use, tool-result, and thinking blocks are dropped. The rubric (and
//! optional user instructions) ride as a final user message after the stripped transcript.
//! Compaction sends text-only transcript messages, a dedicated summarization system prompt, and
//! no tool definitions. Session mutation happens in the agent loop after the summary succeeds.

use anyhow::{Result, bail};
use indoc::{formatdoc, indoc};

use crate::client::anthropic::Client;
use crate::client::anthropic::wire::{Delta, StreamEvent};
use crate::client::anthropic::wire::{ContentBlockInfo, Delta, StreamEvent};
use crate::message::{ContentBlock, Message};

/// Minimum messages required for compaction to be worthwhile. Below this, the summary is
/// usually longer than the transcript itself.
const MIN_MESSAGES_FOR_COMPACT: usize = 4;

/// System prompt for the summarization request. Deliberately narrow — the surrounding
/// `SYSTEM_PROMPT_PREFIX` ("You are Claude Code...") is added by the client; this section
/// reframes the model's job for the compaction turn.
pub(crate) const SUMMARIZATION_SYSTEM: &str = indoc! {r"
/// System prompt for the summarization request. The client still adds the regular Claude Code
/// prefix, so this only reframes the compaction turn.
const SUMMARIZATION_SYSTEM: &str = indoc! {r"
You are summarizing a conversation between a software engineer and an AI coding assistant.

Output ONLY the summary text. Do not call any tools. Do not ask clarifying questions. Do not
address the engineer directly. Write in plain prose; markdown bullets are fine where they
aid readability.
Output ONLY the summary text. Do not call any tools. Do not ask clarifying questions. Do
not address the engineer directly. Write in plain prose. Markdown bullets are fine where
they aid readability.
"};

/// User-message rubric. Five short asks; the model converges on the right shape without the
/// numbered-section ceremony Claude Code uses.
pub(crate) const SUMMARIZATION_USER_RUBRIC: &str = indoc! {r"
Summarize the conversation above so another instance of yourself can pick up where this one
left off. Capture, in this order:
/// User-message rubric. Five short asks keep the summary compact without named sections.
const SUMMARIZATION_USER_RUBRIC: &str = indoc! {r"
Summarize the conversation above so another instance of yourself can pick up where this one left
off. Capture, in this order:

1. The engineer's overall intent and any constraints they stated.
2. Key technical decisions made and why.
3. Files, functions, and code paths touched (full paths when known).
4. Current state: what is done, what is in progress, what is blocked.
5. The next concrete step, if one is obvious.

Be concise — terse bullets beat paragraphs. Preserve exact identifiers, file paths, error
strings, and command lines verbatim.
Be concise. Terse bullets beat paragraphs. Preserve exact identifiers, file paths, error strings,
and command lines verbatim.
"};

/// Prepended to the synthetic post-compact user message materializing the summary into the
/// next turn. Phrasing tells the next-turn model to use the summary rather than re-asking what
/// to do — without this prefix the next turn often redundantly clarifies intent.
pub(crate) const SUMMARY_PREFIX: &str = indoc! {r"
This conversation has been compacted. The summary below covers the prior work; continue
from here without re-asking the engineer what to do.
/// Prefix for the synthetic post-compact user message. It tells the next turn to continue from
/// the summary.
const SUMMARY_PREFIX: &str = indoc! {r"
This conversation has been compacted. The summary below covers the prior work. Continue from here
without re-asking the engineer what to do.
"};

/// Drives the compaction request. Returns the trimmed summary text on success.
Expand Down Expand Up @@ -83,13 +76,16 @@ pub(crate) async fn compact_session(
let mut summary = String::new();
while let Some(event) = rx.recv().await {
match event? {
StreamEvent::ContentBlockDelta {
StreamEvent::ContentBlockStart {
content_block: ContentBlockInfo::Text { text },
..
}
| StreamEvent::ContentBlockDelta {
delta: Delta::TextDelta { text },
..
} => summary.push_str(&text),
StreamEvent::Error { error } => bail!("API error during compaction: {}", error.message),
StreamEvent::MessageStop => break,
// ContentBlockStart/Stop, MessageStart/Delta, Ping, thinking deltas, etc. — ignore.
_ => {}
}
}
Expand Down Expand Up @@ -139,9 +135,7 @@ fn build_user_message(instructions: Option<&str>) -> String {
}
}

/// Composes the synthetic post-compact user message. The boundary marker plus the summary
/// itself; lands in the JSONL as a normal `Entry::Message` and in the next turn's `messages`
/// array as the new chain head.
/// Composes the synthetic post-compact root message for the next turn.
pub(crate) fn synthesize_post_compact_message(summary: &str) -> Message {
Message::user(formatdoc! {"
{prefix}
Expand All @@ -163,12 +157,16 @@ mod tests {
// ── compact_session ──

fn streamed_summary_body(text: &str) -> String {
streamed_summary_body_parts("", text)
}

fn streamed_summary_body_parts(start_text: &str, delta_text: &str) -> String {
use std::fmt::Write as _;

let frames = [
json!({"type": "message_start", "message": {"id": "m", "model": "claude-haiku-4-5"}}).to_string(),
json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}).to_string(),
json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}}).to_string(),
json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": start_text}}).to_string(),
json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": delta_text}}).to_string(),
json!({"type": "content_block_stop", "index": 0}).to_string(),
json!({"type": "message_delta", "delta": {"stop_reason": "end_turn"}}).to_string(),
json!({"type": "message_stop"}).to_string(),
Expand Down Expand Up @@ -250,6 +248,26 @@ mod tests {
assert_eq!(summary, "fixed login bug");
}

#[tokio::test]
async fn compact_session_collects_initial_text_from_content_block_start() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(streamed_summary_body_parts(" fixed", " login bug \n"))
.insert_header("content-type", "text/event-stream"),
)
.mount(&server)
.await;

let client = test_client(server.uri(), api_key(), "claude-haiku-4-5");
let summary = compact_session(&client, &fake_transcript(), None)
.await
.unwrap();
assert_eq!(summary, "fixed login bug");
}

#[tokio::test]
async fn compact_session_empty_summary_errors() {
let server = MockServer::start().await;
Expand Down Expand Up @@ -302,6 +320,14 @@ mod tests {
},
],
};
transcript.push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_owned(),
content: "file body".to_owned(),
is_error: false,
}],
});
compact_session(&client, &transcript, Some("focus on auth"))
.await
.unwrap();
Expand All @@ -312,11 +338,11 @@ mod tests {

let body_text = body.as_str();
assert!(
!body_text.contains("\"tool_use\""),
!body_text.contains(r#""tool_use""#),
"tool_use stripped: {body}"
);
assert!(
!body_text.contains("\"tool_result\""),
!body_text.contains(r#""tool_result""#),
"tool_result stripped: {body}"
);
assert!(
Expand All @@ -327,15 +353,19 @@ mod tests {

#[tokio::test]
async fn compact_session_surfaces_stream_error_event() {
// Stream that opens cleanly then emits an in-band error frame (rate limit / overload) —
// the bail path inside the receive loop, distinct from HTTP-level failures.
let body = "event: ping\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"model\":\"claude-haiku-4-5\"}}\n\nevent: ping\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"servers overloaded\"}}\n\n";
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(body)
.set_body_string(indoc! {r#"
event: ping
data: {"type":"message_start","message":{"id":"m","model":"claude-haiku-4-5"}}

event: ping
data: {"type":"error","error":{"type":"overloaded_error","message":"servers overloaded"}}

"#})
.insert_header("content-type", "text/event-stream"),
)
.mount(&server)
Expand Down
1 change: 1 addition & 0 deletions crates/oxide-code/src/agent/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pub(crate) enum AgentEvent {
id: String,
title: Option<String>,
messages: Vec<crate::message::Message>,
compact: Option<crate::session::entry::CompactInfo>,
tool_metadata: std::collections::HashMap<String, crate::tool::ToolMetadata>,
},
/// `/compact` finished — summary captures the prior transcript, `pre_count` is for the
Expand Down
11 changes: 8 additions & 3 deletions crates/oxide-code/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ async fn run_tui(
let ResumedSession {
handle: session,
messages: resumed_messages,
compact: resumed_compact,
title: resumed_title,
tool_result_metadata: resumed_tool_metadata,
file_snapshots: _,
Expand All @@ -270,12 +271,15 @@ async fn run_tui(
theme,
session_info,
show_thinking,
resumed_title,
agent_rx,
user_tx,
&resumed_messages,
&resumed_tool_metadata,
Arc::clone(&tools),
tui::app::AppHistory {
messages: &resumed_messages,
compact: resumed_compact.as_ref(),
tool_metadata: &resumed_tool_metadata,
title: resumed_title,
},
);

let agent_handle = {
Expand Down Expand Up @@ -473,6 +477,7 @@ async fn apply_resume(
id: new_id,
title: outcome.title,
messages: outcome.messages,
compact: outcome.compact,
tool_metadata: outcome.tool_result_metadata,
}) {
// Channel closed mid-resume leaves the TUI on the OLD chat. Pinpoint the desync.
Expand Down
4 changes: 2 additions & 2 deletions crates/oxide-code/src/prompt/instructions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const INSTRUCTION_FILENAMES: &[&str] = &["CLAUDE.md", "AGENTS.md"];
/// Tool-agnostic subdirectories walked alongside each location (e.g. `<dir>/.claude/CLAUDE.md`).
const INSTRUCTION_DIRS: &[&str] = &[".claude"];

/// Candidates tried in order at a single location; the first hit wins.
/// Candidates tried in order at a single location. The first hit wins.
struct Slot {
candidates: Vec<PathBuf>,
label: &'static str,
Expand Down Expand Up @@ -73,7 +73,7 @@ fn candidate_slots(cwd: Option<&Path>, project_root: Option<&Path>) -> Vec<Slot>
slots
}

/// Every directory from `root` down to `cwd` inclusive; `[root]` when `cwd` is outside or `None`.
/// Every directory from `root` down to `cwd` inclusive. Falls back to `[root]`.
fn walk_root_to_cwd(root: &Path, cwd: Option<&Path>) -> Vec<PathBuf> {
let Some(cwd) = cwd else {
return vec![root.to_path_buf()];
Expand Down
Loading