Skip to content

Commit 519a324

Browse files
authored
feat(session,client): AI session titles, path resume, and per-model anthropic-beta gating (#19)
## Summary Three user-visible session features, plus a per-request beta-header fix and `[1m]` opt-in for 1M context: - **AI-generated session titles** — the first user prompt on a fresh session spawns a detached Haiku call; the result lands as a new `Entry::Title { source: AiGenerated }` and refreshes the TUI status bar live. One-shot per session; failures warn-log and leave the first-prompt fallback title in place. Haiku is pinned to a `{title: string}` JSON schema via the `structured-outputs-2025-12-15` beta so the reply can never drift into a conversational refusal. - **Resume by external path** — `ox -c ` resumes session files that live outside the XDG project subdirs. UUID-shaped prefixes keep working unchanged. - **Session title in the TUI status bar** — new slot between model and status, with ellipsis truncation and graceful drop under narrow widths. - **Per-request `anthropic-beta` gated on target model** — `Client::new` no longer stuffs a one-size-fits-all beta set into default headers. `compute_betas(model, auth, is_agentic, want_structured)` now emits the right subset per call, fixing `HTTP 400` on subscriptions / gateways that reject unsupported betas (Haiku + 1M being the common failure). Full mapping in `docs/research/anthropic-api.md` → "Per-model beta sets". - **`[1m]` tag for 1M-context opt-in** — append `[1m]` to `model` (e.g., `claude-opus-4-7[1m]`) to enable the 1M-context beta; family-based auto-enable was removed for the same subscription-mismatch reason. Tag is stripped before the wire, and a shared `tag_offset` helper keeps `has_1m_tag` and `api_model_id` in lockstep on case. Preparatory refactors land as separate commits: - New `crate::model` module — single `MODELS` table with per-row `Capabilities` (interleaved thinking, context management, effort, 1M, structured outputs) spelled out per model, replacing scattered `is_opus_*` / `is_sonnet_*` / `is_haiku_*` substring checks in both `client::anthropic` and `prompt::environment`. Bumping for a new model is now a single-row edit, and a test pins every row's substring-derived flags against the `modelSupports*`-style predicates the 3P gateway expects. - `Action` → `UserAction` collapse across the TUI reducer. - Shared assistant-markdown renderer extracted from `ChatView`. - New `session::history` module hoists `ToolUse` / `ToolResult` pairing out of `ChatView`. - Non-streaming `Client::complete` helper for background tasks, now also threading an optional `OutputFormat` for JSON-schema-constrained replies. - `docs/research/session-persistence.md` restructured to match `tui.md`'s research-doc shape. Review-pass follow-ups before merge: - `session/writer.rs` moves from 0% to ~99% coverage via direct tests of `record_session_message` and all four branches of `log_session_err`; negative-result test idiom standardized on `.err().unwrap()`; scattered error paths covered in `session/store`, `session/resolver`, and `tui/components/chat`. - `store::read_session_info` now performs a single linear full-file scan tracking the latest `Entry::Title` / `Entry::Summary` by `updated_at`. The prior head-plus-4KB-tail scan left a dead zone that buried AI-generated titles behind large `tool_result` entries, so `ox --list` silently fell back to the first-prompt title. ## Test plan - [x] `cargo fmt --all --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (733 pass) - [x] `pnpm lint`, `pnpm spellcheck` - [x] `cargo llvm-cov` — ~93% line / ~92% region - [x] Manual: first prompt triggers the AI title within a few seconds and the schema blocks conversational replies from Haiku; `ox --list` picks up AI titles even when preceded by multi-KB tool outputs; UUID-prefix and `.jsonl` path resume both work; `claude-opus-4-7[1m]` opts into 1M, plain `claude-opus-4-7` does not. ## Deferred HTTP round-trip coverage for `Client::complete`, `stream_message`, and `title_generator::generate_and_record` needs a live mock. The principled fix is adopting `wiremock` as a dev-dep in a follow-up PR rather than a hand-rolled workaround here. `/model` slash command and related runtime-switch plumbing is also a follow-up — the `[1m]` tag convention is designed so the switch can cleanly toggle it alongside the model string, and `crate::model::MODELS` is now the natural data source for its picker.
1 parent 171a7ef commit 519a324

29 files changed

Lines changed: 4005 additions & 719 deletions

.cspell/words.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
agentic
12
anthropic
23
anthropics
34
anyhow

.markdownlint-cli2.jsonc

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,11 @@
4848
"MD050": { "style": "asterisk" },
4949
},
5050

51-
"ignores": [".claude/agent-memory-local/", ".claude/plans/", ".venv/", "node_modules/"],
51+
"ignores": [
52+
".claude/agent-memory-local/",
53+
".claude/plans/",
54+
".venv/",
55+
"node_modules/",
56+
"target/",
57+
],
5258
}

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ ox # Start an interactive session
3636
│ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, directory-based advisory lock
3737
├── main.rs # CLI entry point, mode dispatch (TUI / REPL / headless), signal handling
3838
├── message.rs # Conversation message types
39+
├── model.rs # Ground-truth table of known Claude models (marketing name, cutoff, capability flags)
3940
├── prompt.rs # System prompt builder (section assembly)
4041
├── prompt/
4142
│ ├── environment.rs # Runtime environment detection (platform, git, date, marketing name)
@@ -44,11 +45,13 @@ ox # Start an interactive session
4445
├── session.rs # Session module root
4546
├── session/
4647
│ ├── entry.rs # JSONL entry types (Header, Message, Title, Summary) and metadata structs
48+
│ ├── history.rs # Transcript → display interaction stream (pair ToolUse with ToolResult inline)
4749
│ ├── list_view.rs # `ox --list` table rendering (writes to any `impl Write`)
4850
│ ├── manager.rs # SessionManager: lifecycle (start, resume, record, finish)
4951
│ ├── path.rs # Filesystem-safe project subdirectory derivation (sanitize_cwd)
5052
│ ├── resolver.rs # CLI `--continue` argument resolution (ResumeMode, resolve_session)
5153
│ ├── store.rs # SessionStore / SessionWriter: file I/O, XDG path, listing
54+
│ ├── title_generator.rs # Background AI title generation (Haiku) with detached task
5255
│ └── writer.rs # Session-write helpers (record_session_message, log_session_err)
5356
├── tool.rs # Tool trait, registry, definitions
5457
├── tool/
@@ -61,7 +64,7 @@ ox # Start an interactive session
6164
├── tui.rs # TUI module root
6265
├── tui/
6366
│ ├── app.rs # Root App struct, tokio::select! event loop, render dispatch
64-
│ ├── component.rs # Component trait and Action enum
67+
│ ├── component.rs # Component trait (components report UserAction back to the agent loop)
6568
│ ├── components.rs # Components module root
6669
│ ├── components/
6770
│ │ ├── chat.rs # Scrollable chat with markdown, tool styling, thinking display

crates/oxide-code/src/agent/event.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ pub(crate) enum AgentEvent {
4141
/// The current assistant turn is complete (text-only response, no more
4242
/// tool calls).
4343
TurnComplete,
44+
/// A newly-generated session title (e.g., AI-generated via Haiku). The
45+
/// TUI updates the status bar slot; other sinks ignore it.
46+
SessionTitleUpdated(String),
4447
/// A fatal error from the API or agent loop.
4548
Error(String),
4649
}
@@ -131,10 +134,103 @@ impl AgentSink for StdioSink {
131134
// Newline after streamed text.
132135
println!();
133136
}
137+
AgentEvent::SessionTitleUpdated(_) => {
138+
// Titles are a TUI-only affordance; the stdio sink has no
139+
// persistent header to rewrite.
140+
}
134141
AgentEvent::Error(msg) => {
135142
eprintln!("Error: {msg}");
136143
}
137144
}
138145
Ok(())
139146
}
140147
}
148+
149+
#[cfg(test)]
150+
mod tests {
151+
use super::*;
152+
use crate::tool::ToolRegistry;
153+
154+
// ── StdioSink::send ──
155+
//
156+
// `send` writes to stdout/stderr, which cargo's test harness captures and
157+
// discards on success — so these tests exercise the match-arm dispatch
158+
// and the Result contract rather than asserting on rendered bytes.
159+
// Formatting-assertion tests belong behind an extracted rendering helper
160+
// (see `docs/roadmap.md` → Test Coverage).
161+
162+
fn test_sink(show_thinking: bool) -> StdioSink {
163+
StdioSink::new(show_thinking, Arc::new(ToolRegistry::new(Vec::new())))
164+
}
165+
166+
#[test]
167+
fn send_session_title_updated_is_silent_ok() {
168+
// AI-generated titles are a TUI-only affordance; the stdio path has
169+
// no persistent header to rewrite, so the arm must no-op cleanly.
170+
let sink = test_sink(false);
171+
sink.send(AgentEvent::SessionTitleUpdated("New title".to_owned()))
172+
.unwrap();
173+
}
174+
175+
#[test]
176+
fn send_stream_token_writes_body_without_error() {
177+
let sink = test_sink(false);
178+
sink.send(AgentEvent::StreamToken("hello".to_owned()))
179+
.unwrap();
180+
}
181+
182+
#[test]
183+
fn send_thinking_token_respects_show_thinking_flag() {
184+
// show_thinking = false must swallow the block entirely, not just
185+
// strip the dim escape codes — otherwise the stream lines bleed
186+
// into the transcript unformatted.
187+
test_sink(false)
188+
.send(AgentEvent::ThinkingToken("muted".to_owned()))
189+
.unwrap();
190+
test_sink(true)
191+
.send(AgentEvent::ThinkingToken("visible".to_owned()))
192+
.unwrap();
193+
}
194+
195+
#[test]
196+
fn send_tool_call_start_renders_label_and_falls_back_to_name() {
197+
let sink = test_sink(false);
198+
sink.send(AgentEvent::ToolCallStart {
199+
id: "t1".to_owned(),
200+
name: "unregistered".to_owned(),
201+
input: serde_json::Value::Null,
202+
})
203+
.unwrap();
204+
}
205+
206+
#[test]
207+
fn send_tool_call_end_handles_every_field_nullability() {
208+
let sink = test_sink(false);
209+
sink.send(AgentEvent::ToolCallEnd {
210+
id: "t1".to_owned(),
211+
title: Some("ls".to_owned()),
212+
content: "file1\nfile2\n".to_owned(),
213+
is_error: false,
214+
})
215+
.unwrap();
216+
sink.send(AgentEvent::ToolCallEnd {
217+
id: "t2".to_owned(),
218+
title: None,
219+
content: " \n".to_owned(),
220+
is_error: true,
221+
})
222+
.unwrap();
223+
}
224+
225+
#[test]
226+
fn send_turn_complete_emits_trailing_newline_without_error() {
227+
test_sink(false).send(AgentEvent::TurnComplete).unwrap();
228+
}
229+
230+
#[test]
231+
fn send_error_routes_message_to_stderr() {
232+
test_sink(false)
233+
.send(AgentEvent::Error("boom".to_owned()))
234+
.unwrap();
235+
}
236+
}

0 commit comments

Comments
 (0)