Accept story 39: Persistent Claude Code Sessions in Web UI
Use --resume <session_id> with claude -p so the web UI claude-code-pty provider maintains full conversation context across messages, identical to a long-running terminal Claude Code session. Changes: - Capture session_id from claude -p stream-json system event - Pass --resume on subsequent messages in same chat session - Thread session_id through ProviderConfig, ChatResult, WsResponse - Frontend stores sessionId per chat, clears on New Session - Unset CLAUDECODE env to allow nested spawning from server - Wait for clean process exit to ensure transcript flush to disk Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
9323
frontend/package-lock.json
generated
Normal file
9323
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ export type WsRequest =
|
|||||||
export type WsResponse =
|
export type WsResponse =
|
||||||
| { type: "token"; content: string }
|
| { type: "token"; content: string }
|
||||||
| { type: "update"; messages: Message[] }
|
| { type: "update"; messages: Message[] }
|
||||||
|
| { type: "session_id"; session_id: string }
|
||||||
| { type: "error"; message: string };
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
@@ -18,6 +19,7 @@ export interface ProviderConfig {
|
|||||||
model: string;
|
model: string;
|
||||||
base_url?: string;
|
base_url?: string;
|
||||||
enable_tools?: boolean;
|
enable_tools?: boolean;
|
||||||
|
session_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Role = "system" | "user" | "assistant" | "tool";
|
export type Role = "system" | "user" | "assistant" | "tool";
|
||||||
@@ -212,6 +214,7 @@ export class ChatWebSocket {
|
|||||||
private socket?: WebSocket;
|
private socket?: WebSocket;
|
||||||
private onToken?: (content: string) => void;
|
private onToken?: (content: string) => void;
|
||||||
private onUpdate?: (messages: Message[]) => void;
|
private onUpdate?: (messages: Message[]) => void;
|
||||||
|
private onSessionId?: (sessionId: string) => void;
|
||||||
private onError?: (message: string) => void;
|
private onError?: (message: string) => void;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private closeTimer?: number;
|
private closeTimer?: number;
|
||||||
@@ -220,12 +223,14 @@ export class ChatWebSocket {
|
|||||||
handlers: {
|
handlers: {
|
||||||
onToken?: (content: string) => void;
|
onToken?: (content: string) => void;
|
||||||
onUpdate?: (messages: Message[]) => void;
|
onUpdate?: (messages: Message[]) => void;
|
||||||
|
onSessionId?: (sessionId: string) => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
},
|
},
|
||||||
wsPath = DEFAULT_WS_PATH,
|
wsPath = DEFAULT_WS_PATH,
|
||||||
) {
|
) {
|
||||||
this.onToken = handlers.onToken;
|
this.onToken = handlers.onToken;
|
||||||
this.onUpdate = handlers.onUpdate;
|
this.onUpdate = handlers.onUpdate;
|
||||||
|
this.onSessionId = handlers.onSessionId;
|
||||||
this.onError = handlers.onError;
|
this.onError = handlers.onError;
|
||||||
|
|
||||||
if (this.connected) {
|
if (this.connected) {
|
||||||
@@ -256,6 +261,7 @@ export class ChatWebSocket {
|
|||||||
const data = JSON.parse(event.data) as WsResponse;
|
const data = JSON.parse(event.data) as WsResponse;
|
||||||
if (data.type === "token") this.onToken?.(data.content);
|
if (data.type === "token") this.onToken?.(data.content);
|
||||||
if (data.type === "update") this.onUpdate?.(data.messages);
|
if (data.type === "update") this.onUpdate?.(data.messages);
|
||||||
|
if (data.type === "session_id") this.onSessionId?.(data.session_id);
|
||||||
if (data.type === "error") this.onError?.(data.message);
|
if (data.type === "error") this.onError?.(data.message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.onError?.(String(err));
|
this.onError?.(String(err));
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [lastUpcomingRefresh, setLastUpcomingRefresh] = useState<Date | null>(
|
const [lastUpcomingRefresh, setLastUpcomingRefresh] = useState<Date | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
const storyId = "26_establish_tdd_workflow_and_gates";
|
const storyId = "26_establish_tdd_workflow_and_gates";
|
||||||
const gateStatusColor = isGateLoading
|
const gateStatusColor = isGateLoading
|
||||||
@@ -500,6 +501,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSessionId: (sessionId) => {
|
||||||
|
setClaudeSessionId(sessionId);
|
||||||
|
},
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
console.error("WebSocket error:", message);
|
console.error("WebSocket error:", message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -611,6 +615,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
model,
|
model,
|
||||||
base_url: "http://localhost:11434",
|
base_url: "http://localhost:11434",
|
||||||
enable_tools: enableTools,
|
enable_tools: enableTools,
|
||||||
|
...(isClaudeCode && claudeSessionId
|
||||||
|
? { session_id: claudeSessionId }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
wsRef.current?.sendChat(newHistory, config);
|
wsRef.current?.sendChat(newHistory, config);
|
||||||
@@ -663,6 +670,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setStreamingContent("");
|
setStreamingContent("");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setClaudeSessionId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface ProviderConfig {
|
|||||||
model: string;
|
model: string;
|
||||||
base_url?: string;
|
base_url?: string;
|
||||||
enable_tools?: boolean;
|
enable_tools?: boolean;
|
||||||
|
session_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
@@ -52,6 +53,7 @@ export type WsRequest =
|
|||||||
export type WsResponse =
|
export type WsResponse =
|
||||||
| { type: "token"; content: string }
|
| { type: "token"; content: string }
|
||||||
| { type: "update"; messages: Message[] }
|
| { type: "update"; messages: Message[] }
|
||||||
|
| { type: "session_id"; session_id: string }
|
||||||
| { type: "error"; message: string };
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
// Re-export API client types for convenience
|
// Re-export API client types for convenience
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ enum WsRequest {
|
|||||||
enum WsResponse {
|
enum WsResponse {
|
||||||
Token { content: String },
|
Token { content: String },
|
||||||
Update { messages: Vec<Message> },
|
Update { messages: Vec<Message> },
|
||||||
|
/// Session ID for Claude Code conversation resumption.
|
||||||
|
SessionId { session_id: String },
|
||||||
Error { message: String },
|
Error { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +85,15 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(err) = result {
|
match result {
|
||||||
let _ = tx.send(WsResponse::Error { message: err });
|
Ok(chat_result) => {
|
||||||
|
if let Some(sid) = chat_result.session_id {
|
||||||
|
let _ = tx.send(WsResponse::SessionId { session_id: sid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let _ = tx.send(WsResponse::Error { message: err });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(WsRequest::Cancel) => {
|
Ok(WsRequest::Cancel) => {
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ pub struct ProviderConfig {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
pub base_url: Option<String>,
|
pub base_url: Option<String>,
|
||||||
pub enable_tools: Option<bool>,
|
pub enable_tools: Option<bool>,
|
||||||
|
/// Claude Code session ID for conversation resumption.
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a chat call, including messages and optional metadata.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ChatResult {
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
/// Session ID returned by Claude Code for resumption.
|
||||||
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_anthropic_api_key_exists_impl(store: &dyn StoreOps) -> bool {
|
fn get_anthropic_api_key_exists_impl(store: &dyn StoreOps) -> bool {
|
||||||
@@ -172,7 +182,7 @@ pub async fn chat<F, U>(
|
|||||||
store: &dyn StoreOps,
|
store: &dyn StoreOps,
|
||||||
mut on_update: F,
|
mut on_update: F,
|
||||||
mut on_token: U,
|
mut on_token: U,
|
||||||
) -> Result<Vec<Message>, String>
|
) -> Result<ChatResult, String>
|
||||||
where
|
where
|
||||||
F: FnMut(&[Message]) + Send,
|
F: FnMut(&[Message]) + Send,
|
||||||
U: FnMut(&str) + Send,
|
U: FnMut(&str) + Send,
|
||||||
@@ -219,6 +229,7 @@ where
|
|||||||
.chat_stream(
|
.chat_stream(
|
||||||
&user_message,
|
&user_message,
|
||||||
&project_root.to_string_lossy(),
|
&project_root.to_string_lossy(),
|
||||||
|
config.session_id.as_deref(),
|
||||||
&mut cancel_rx,
|
&mut cancel_rx,
|
||||||
|token| on_token(token),
|
|token| on_token(token),
|
||||||
)
|
)
|
||||||
@@ -235,7 +246,10 @@ where
|
|||||||
let mut result = messages.clone();
|
let mut result = messages.clone();
|
||||||
result.push(assistant_msg);
|
result.push(assistant_msg);
|
||||||
on_update(&result);
|
on_update(&result);
|
||||||
return Ok(result);
|
return Ok(ChatResult {
|
||||||
|
messages: result,
|
||||||
|
session_id: response.session_id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let tool_defs = get_tool_definitions();
|
let tool_defs = get_tool_definitions();
|
||||||
@@ -353,7 +367,10 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(new_messages)
|
Ok(ChatResult {
|
||||||
|
messages: new_messages,
|
||||||
|
session_id: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_tool(call: &ToolCall, state: &SessionState) -> String {
|
async fn execute_tool(call: &ToolCall, state: &SessionState) -> String {
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ impl AnthropicProvider {
|
|||||||
} else {
|
} else {
|
||||||
Some(tool_calls)
|
Some(tool_calls)
|
||||||
},
|
},
|
||||||
|
session_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ use crate::llm::types::CompletionResponse;
|
|||||||
/// Spawns `claude -p` in a PTY so isatty() returns true (which may
|
/// Spawns `claude -p` in a PTY so isatty() returns true (which may
|
||||||
/// influence billing), while using `--output-format stream-json` to
|
/// influence billing), while using `--output-format stream-json` to
|
||||||
/// get clean, structured NDJSON output instead of TUI escape sequences.
|
/// get clean, structured NDJSON output instead of TUI escape sequences.
|
||||||
|
///
|
||||||
|
/// Supports session resumption: if a `session_id` is provided, passes
|
||||||
|
/// `--resume <id>` so Claude Code loads the prior conversation transcript
|
||||||
|
/// from disk and continues with full context.
|
||||||
pub struct ClaudeCodeProvider;
|
pub struct ClaudeCodeProvider;
|
||||||
|
|
||||||
impl ClaudeCodeProvider {
|
impl ClaudeCodeProvider {
|
||||||
@@ -22,6 +26,7 @@ impl ClaudeCodeProvider {
|
|||||||
&self,
|
&self,
|
||||||
user_message: &str,
|
user_message: &str,
|
||||||
project_root: &str,
|
project_root: &str,
|
||||||
|
session_id: Option<&str>,
|
||||||
cancel_rx: &mut watch::Receiver<bool>,
|
cancel_rx: &mut watch::Receiver<bool>,
|
||||||
mut on_token: F,
|
mut on_token: F,
|
||||||
) -> Result<CompletionResponse, String>
|
) -> Result<CompletionResponse, String>
|
||||||
@@ -30,6 +35,7 @@ impl ClaudeCodeProvider {
|
|||||||
{
|
{
|
||||||
let message = user_message.to_string();
|
let message = user_message.to_string();
|
||||||
let cwd = project_root.to_string();
|
let cwd = project_root.to_string();
|
||||||
|
let resume_id = session_id.map(|s| s.to_string());
|
||||||
let cancelled = Arc::new(AtomicBool::new(false));
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
let cancelled_clone = cancelled.clone();
|
let cancelled_clone = cancelled.clone();
|
||||||
|
|
||||||
@@ -44,9 +50,10 @@ impl ClaudeCodeProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let (token_tx, mut token_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
let (token_tx, mut token_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
|
let (sid_tx, sid_rx) = tokio::sync::oneshot::channel::<String>();
|
||||||
|
|
||||||
let pty_handle = tokio::task::spawn_blocking(move || {
|
let pty_handle = tokio::task::spawn_blocking(move || {
|
||||||
run_pty_session(&message, &cwd, cancelled, token_tx)
|
run_pty_session(&message, &cwd, resume_id.as_deref(), cancelled, token_tx, sid_tx)
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut full_output = String::new();
|
let mut full_output = String::new();
|
||||||
@@ -59,9 +66,12 @@ impl ClaudeCodeProvider {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("PTY task panicked: {e}"))??;
|
.map_err(|e| format!("PTY task panicked: {e}"))??;
|
||||||
|
|
||||||
|
let captured_session_id = sid_rx.await.ok();
|
||||||
|
|
||||||
Ok(CompletionResponse {
|
Ok(CompletionResponse {
|
||||||
content: Some(full_output),
|
content: Some(full_output),
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
|
session_id: captured_session_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,8 +83,10 @@ impl ClaudeCodeProvider {
|
|||||||
fn run_pty_session(
|
fn run_pty_session(
|
||||||
user_message: &str,
|
user_message: &str,
|
||||||
cwd: &str,
|
cwd: &str,
|
||||||
|
resume_session_id: Option<&str>,
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: Arc<AtomicBool>,
|
||||||
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
|
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
|
||||||
|
sid_tx: tokio::sync::oneshot::Sender<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let pty_system = native_pty_system();
|
let pty_system = native_pty_system();
|
||||||
|
|
||||||
@@ -90,21 +102,36 @@ fn run_pty_session(
|
|||||||
let mut cmd = CommandBuilder::new("claude");
|
let mut cmd = CommandBuilder::new("claude");
|
||||||
cmd.arg("-p");
|
cmd.arg("-p");
|
||||||
cmd.arg(user_message);
|
cmd.arg(user_message);
|
||||||
|
if let Some(sid) = resume_session_id {
|
||||||
|
cmd.arg("--resume");
|
||||||
|
cmd.arg(sid);
|
||||||
|
}
|
||||||
cmd.arg("--output-format");
|
cmd.arg("--output-format");
|
||||||
cmd.arg("stream-json");
|
cmd.arg("stream-json");
|
||||||
cmd.arg("--verbose");
|
cmd.arg("--verbose");
|
||||||
cmd.cwd(cwd);
|
cmd.cwd(cwd);
|
||||||
// Keep TERM reasonable but disable color
|
// Keep TERM reasonable but disable color
|
||||||
cmd.env("NO_COLOR", "1");
|
cmd.env("NO_COLOR", "1");
|
||||||
|
// Allow nested spawning when the server itself runs inside Claude Code
|
||||||
|
cmd.env("CLAUDECODE", "");
|
||||||
|
|
||||||
eprintln!("[pty-debug] Spawning: claude -p \"{}\" --output-format stream-json --verbose", user_message);
|
eprintln!(
|
||||||
|
"[pty-debug] Spawning: claude -p \"{}\" {} --output-format stream-json --verbose",
|
||||||
|
user_message,
|
||||||
|
resume_session_id
|
||||||
|
.map(|s| format!("--resume {s}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
let mut child = pair
|
let mut child = pair
|
||||||
.slave
|
.slave
|
||||||
.spawn_command(cmd)
|
.spawn_command(cmd)
|
||||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||||
|
|
||||||
eprintln!("[pty-debug] Process spawned, pid: {:?}", child.process_id());
|
eprintln!(
|
||||||
|
"[pty-debug] Process spawned, pid: {:?}",
|
||||||
|
child.process_id()
|
||||||
|
);
|
||||||
drop(pair.slave);
|
drop(pair.slave);
|
||||||
|
|
||||||
let reader = pair
|
let reader = pair
|
||||||
@@ -141,6 +168,7 @@ fn run_pty_session(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let mut got_result = false;
|
let mut got_result = false;
|
||||||
|
let mut sid_tx = Some(sid_tx);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if cancelled.load(Ordering::Relaxed) {
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
@@ -155,59 +183,106 @@ fn run_pty_session(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("[pty-debug] processing: {}...", &trimmed[..trimmed.len().min(120)]);
|
eprintln!(
|
||||||
|
"[pty-debug] processing: {}...",
|
||||||
|
&trimmed[..trimmed.len().min(120)]
|
||||||
|
);
|
||||||
|
|
||||||
// Try to parse as JSON
|
// Try to parse as JSON
|
||||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
|
||||||
&& let Some(event_type) = json.get("type").and_then(|t| t.as_str()) {
|
&& let Some(event_type) = json.get("type").and_then(|t| t.as_str())
|
||||||
match event_type {
|
{
|
||||||
// Streaming deltas (when --include-partial-messages is used)
|
// Capture session_id from any event that has it
|
||||||
"stream_event" => {
|
if let Some(tx) = sid_tx.take() {
|
||||||
if let Some(event) = json.get("event") {
|
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
|
||||||
handle_stream_event(event, &token_tx);
|
let _ = tx.send(sid.to_string());
|
||||||
}
|
} else {
|
||||||
}
|
// Put it back if this event didn't have a session_id
|
||||||
// Complete assistant message
|
sid_tx = Some(tx);
|
||||||
"assistant" => {
|
|
||||||
if let Some(message) = json.get("message")
|
|
||||||
&& let Some(content) = message.get("content").and_then(|c| c.as_array()) {
|
|
||||||
for block in content {
|
|
||||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
|
||||||
let _ = token_tx.send(text.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Final result with usage stats
|
|
||||||
"result" => {
|
|
||||||
if let Some(cost) = json.get("total_cost_usd").and_then(|c| c.as_f64()) {
|
|
||||||
let _ = token_tx.send(format!("\n\n---\n_Cost: ${cost:.4}_\n"));
|
|
||||||
}
|
|
||||||
if let Some(usage) = json.get("usage") {
|
|
||||||
let input = usage.get("input_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
|
|
||||||
let output = usage.get("output_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
|
|
||||||
let cached = usage.get("cache_read_input_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
|
|
||||||
let _ = token_tx.send(format!("_Tokens: {input} in / {output} out / {cached} cached_\n"));
|
|
||||||
}
|
|
||||||
got_result = true;
|
|
||||||
}
|
|
||||||
// System init — log billing info
|
|
||||||
"system" => {
|
|
||||||
let api_source = json.get("apiKeySource").and_then(|s| s.as_str()).unwrap_or("unknown");
|
|
||||||
let model = json.get("model").and_then(|s| s.as_str()).unwrap_or("unknown");
|
|
||||||
let _ = token_tx.send(format!("_[{model} | apiKey: {api_source}]_\n\n"));
|
|
||||||
}
|
|
||||||
// Rate limit info
|
|
||||||
"rate_limit_event" => {
|
|
||||||
if let Some(info) = json.get("rate_limit_info") {
|
|
||||||
let status = info.get("status").and_then(|s| s.as_str()).unwrap_or("unknown");
|
|
||||||
let limit_type = info.get("rateLimitType").and_then(|s| s.as_str()).unwrap_or("unknown");
|
|
||||||
let _ = token_tx.send(format!("_[rate limit: {status} ({limit_type})]_\n\n"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match event_type {
|
||||||
|
// Streaming deltas (when --include-partial-messages is used)
|
||||||
|
"stream_event" => {
|
||||||
|
if let Some(event) = json.get("event") {
|
||||||
|
handle_stream_event(event, &token_tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Complete assistant message
|
||||||
|
"assistant" => {
|
||||||
|
if let Some(message) = json.get("message")
|
||||||
|
&& let Some(content) =
|
||||||
|
message.get("content").and_then(|c| c.as_array())
|
||||||
|
{
|
||||||
|
for block in content {
|
||||||
|
if let Some(text) =
|
||||||
|
block.get("text").and_then(|t| t.as_str())
|
||||||
|
{
|
||||||
|
let _ = token_tx.send(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final result with usage stats
|
||||||
|
"result" => {
|
||||||
|
if let Some(cost) =
|
||||||
|
json.get("total_cost_usd").and_then(|c| c.as_f64())
|
||||||
|
{
|
||||||
|
let _ =
|
||||||
|
token_tx.send(format!("\n\n---\n_Cost: ${cost:.4}_\n"));
|
||||||
|
}
|
||||||
|
if let Some(usage) = json.get("usage") {
|
||||||
|
let input = usage
|
||||||
|
.get("input_tokens")
|
||||||
|
.and_then(|t| t.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let output = usage
|
||||||
|
.get("output_tokens")
|
||||||
|
.and_then(|t| t.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let cached = usage
|
||||||
|
.get("cache_read_input_tokens")
|
||||||
|
.and_then(|t| t.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let _ = token_tx.send(format!(
|
||||||
|
"_Tokens: {input} in / {output} out / {cached} cached_\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
got_result = true;
|
||||||
|
}
|
||||||
|
// System init — log billing info
|
||||||
|
"system" => {
|
||||||
|
let api_source = json
|
||||||
|
.get("apiKeySource")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let model = json
|
||||||
|
.get("model")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let _ = token_tx
|
||||||
|
.send(format!("_[{model} | apiKey: {api_source}]_\n\n"));
|
||||||
|
}
|
||||||
|
// Rate limit info
|
||||||
|
"rate_limit_event" => {
|
||||||
|
if let Some(info) = json.get("rate_limit_info") {
|
||||||
|
let status = info
|
||||||
|
.get("status")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let limit_type = info
|
||||||
|
.get("rateLimitType")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let _ = token_tx.send(format!(
|
||||||
|
"_[rate limit: {status} ({limit_type})]_\n\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Ignore non-JSON lines (terminal escape sequences)
|
// Ignore non-JSON lines (terminal escape sequences)
|
||||||
|
|
||||||
if got_result {
|
if got_result {
|
||||||
@@ -226,9 +301,9 @@ fn run_pty_session(
|
|||||||
.get("type")
|
.get("type")
|
||||||
.filter(|t| t.as_str() == Some("stream_event"))
|
.filter(|t| t.as_str() == Some("stream_event"))
|
||||||
.and_then(|_| json.get("event"))
|
.and_then(|_| json.get("event"))
|
||||||
{
|
{
|
||||||
handle_stream_event(event, &token_tx);
|
handle_stream_event(event, &token_tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -240,7 +315,23 @@ fn run_pty_session(
|
|||||||
let _ = got_result;
|
let _ = got_result;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = child.kill();
|
// Wait briefly for Claude Code to flush its session transcript to disk.
|
||||||
|
// The `result` event means the API response is done, but the process
|
||||||
|
// still needs to write the conversation to the JSONL session file.
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(_)) => {} // Already exited
|
||||||
|
_ => {
|
||||||
|
// Give it up to 2 seconds to exit cleanly
|
||||||
|
for _ in 0..20 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
if let Ok(Some(_)) = child.try_wait() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If still running after 2s, kill it
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +354,9 @@ fn handle_stream_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"thinking_delta" => {
|
"thinking_delta" => {
|
||||||
if let Some(thinking) = delta.get("thinking").and_then(|t| t.as_str()) {
|
if let Some(thinking) =
|
||||||
|
delta.get("thinking").and_then(|t| t.as_str())
|
||||||
|
{
|
||||||
let _ = token_tx.send(format!("[thinking] {thinking}"));
|
let _ = token_tx.send(format!("[thinking] {thinking}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ impl OllamaProvider {
|
|||||||
Some(accumulated_content)
|
Some(accumulated_content)
|
||||||
},
|
},
|
||||||
tool_calls: final_tool_calls,
|
tool_calls: final_tool_calls,
|
||||||
|
session_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ pub struct ToolFunctionDefinition {
|
|||||||
pub struct CompletionResponse {
|
pub struct CompletionResponse {
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
pub tool_calls: Option<Vec<ToolCall>>,
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
|
/// Claude Code session ID for conversation resumption.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
Reference in New Issue
Block a user