diff --git a/README.md b/README.md index 53a8bcb6..59bc91fc 100644 --- a/README.md +++ b/README.md @@ -260,9 +260,9 @@ Open an issue or pull request to discuss changes. - - - Star History Chart + + + Star History Chart diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts index a2b409c5..77b7f5af 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts @@ -1731,6 +1731,73 @@ describe("ACP desktop SDK session mapping", () => { expect(await client.session.status()).toEqual({ data: { s1: { type: "idle" } } }) }) + test("emits compaction lifecycle events from wrapped original events with nested session metadata", async () => { + const transport = new FakeTransport((method) => { + if (method === "initialize") return initializeResult + if (method === "session/list") return { sessions: [sessionInfo] } + throw new Error(`unexpected request ${method}`) + }) + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + + await client.session.list() + transport.emitSessionUpdate({ + sessionId: "s1", + update: { sessionUpdate: "session_info_update" }, + _meta: { + "devo/originalMethod": "session/compaction/started", + "devo/originalEvent": { + kind: "session_compaction_started", + session: { + session_id: "s1", + title: "Existing session", + }, + }, + }, + } satisfies AcpSessionNotification) + transport.emitSessionUpdate({ + sessionId: "s1", + update: { sessionUpdate: "session_info_update" }, + _meta: { + "devo/originalMethod": "session/compaction/completed", + "devo/originalEvent": { + SessionCompactionCompleted: { + session: { + sessionId: "s1", + title: "Existing session", + }, + }, + }, + }, + } satisfies AcpSessionNotification) + transport.emitSessionUpdate({ + sessionId: "s1", + update: { sessionUpdate: "session_info_update" }, + _meta: { + "devo/originalMethod": "session/compaction/failed", + "devo/originalEvent": { + SessionCompactionFailed: { + session_id: "s1", + message: "provider unavailable", + }, + }, + }, + } satisfies AcpSessionNotification) + + expect(await nextPayloadOfType(stream, "session.compaction.started", "started")).toEqual({ + type: "session.compaction.started", + properties: { sessionID: "s1" }, + }) + expect(await nextPayloadOfType(stream, "session.compaction.completed", "completed")).toEqual({ + type: "session.compaction.completed", + properties: { sessionID: "s1" }, + }) + expect(await nextPayloadOfType(stream, "session.compaction.failed", "failed")).toEqual({ + type: "session.compaction.failed", + properties: { sessionID: "s1", message: "provider unavailable" }, + }) + }) + test("refreshes session status from session info update metadata", async () => { const transport = new FakeTransport((method) => { if (method === "initialize") return initializeResult diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index b093fb20..0ebd3acd 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -233,6 +233,29 @@ function sessionMeta(value: unknown): Record | undefined { return objectRecord(meta?.["devo/session"]) } +function providerRetryStatusFromOriginalEvent( + original: Record, + originalMethod?: string, +): Record | null { + if (originalMethod !== "turn/provider_retry_status" && !("TurnProviderRetryStatus" in original) && original.kind !== "turn_provider_retry_status") { + return null + } + const payload = objectRecord(original.TurnProviderRetryStatus) ?? original + const sessionID = String(payload.session_id ?? payload.sessionId ?? "") + const turnID = String(payload.turn_id ?? payload.turnId ?? "") + if (!sessionID || !turnID) return null + return { + sessionID, + turnID, + attempt: numberFromProtocol(payload.attempt), + backoffMs: numberFromProtocol(payload.backoff_ms ?? payload.backoffMs), + provider: String(payload.provider ?? ""), + model: String(payload.model ?? ""), + phase: String(payload.phase ?? ""), + message: String(payload.message ?? ""), + } +} + function sessionStatusFromMetadata(value: unknown): string | undefined { const meta = objectRecord(value) const nestedStatus = objectRecord(meta?.["devo/session"])?.status @@ -320,6 +343,14 @@ function sessionStatusChangedFromOriginalEvent( return typeof sessionId === "string" && typeof status === "string" ? { sessionId, status } : null } +function sessionIdFromCompactionPayload(payload: Record): string | null { + const direct = payload.session_id ?? payload.sessionId + if (typeof direct === "string" && direct) return direct + const session = objectRecord(payload.session) + const nested = session?.session_id ?? session?.sessionId + return typeof nested === "string" && nested ? nested : null +} + function sessionCompactionFromOriginalEvent( original: unknown, originalMethod?: string, @@ -369,8 +400,8 @@ function sessionCompactionFromOriginalEvent( } if (!status || !payload) return null - const sessionId = payload.session_id ?? payload.sessionId - if (typeof sessionId !== "string" || !sessionId) return null + const sessionId = sessionIdFromCompactionPayload(payload) + if (!sessionId) return null const message = payload.message return { sessionId, @@ -1525,6 +1556,14 @@ class AcpClient { this.handleDeletedSessionIds(deletedSessionIds, directory) return } + const retryStatus = providerRetryStatusFromOriginalEvent(original as Record, originalMethod) + if (retryStatus) { + this.emit(directory, { + type: "turn.provider_retry_status", + properties: retryStatus, + }) + return + } const changedStatus = sessionStatusChangedFromOriginalEvent(original, originalMethod) if (changedStatus) { this.rememberSessionStatus(changedStatus.sessionId, directory, changedStatus.status) @@ -1796,12 +1835,14 @@ class AcpClient { existingMessage, now, ) + const turnId = this.turnIdForUpdate(update) const message = { ...(existingMessage ?? {}), id: messageId, sessionID: sessionId, role, ...(parentID ? { parentID } : {}), + ...(turnId ? { turnID: turnId } : {}), time: { ...(existingMessage?.time ?? {}), created }, } as Message this.appendMessage(sessionId, message) @@ -1865,12 +1906,14 @@ class AcpClient { existingMessage, now, ) + const turnId = this.turnIdForUpdate(update) const message = { ...(existingMessage ?? {}), id: messageId, sessionID: sessionId, role: "assistant", ...(parentID ? { parentID } : {}), + ...(turnId ? { turnID: turnId } : {}), time: { ...(existingMessage?.time ?? {}), created }, } as Message const partEventTime = updateHistoryCreatedAt(update) ?? now diff --git a/apps/desktop/src/renderer/atoms/actions/event-processor.ts b/apps/desktop/src/renderer/atoms/actions/event-processor.ts index eb5ea86a..75eda5de 100644 --- a/apps/desktop/src/renderer/atoms/actions/event-processor.ts +++ b/apps/desktop/src/renderer/atoms/actions/event-processor.ts @@ -12,6 +12,7 @@ import { removePermissionAtom, removeQuestionAtom, removeSessionAtom, + setProviderRetryStatusAtom, setSessionErrorAtom, setSessionStatusAtom, upsertSessionAtom, @@ -102,6 +103,31 @@ export function processEvent(event: Event): void { set(removeSessionAtom, event.properties.info.id) break + case "turn.provider_retry_status": { + const properties = event.properties + const sessionId = properties.sessionID ?? properties.session_id + const turnId = properties.turnID ?? properties.turn_id + if (sessionId && turnId) { + const phase = String(properties.phase ?? "") + set(setProviderRetryStatusAtom, { + sessionId, + status: + phase === "resumed" + ? undefined + : { + turnId, + attempt: Number(properties.attempt ?? 0), + backoffMs: Number(properties.backoffMs ?? properties.backoff_ms ?? 0), + provider: String(properties.provider ?? ""), + model: String(properties.model ?? ""), + phase, + message: String(properties.message ?? ""), + }, + }) + } + break + } + case "session.status": set(setSessionStatusAtom, { sessionId: event.properties.sessionID, diff --git a/apps/desktop/src/renderer/atoms/derived/session-chat.ts b/apps/desktop/src/renderer/atoms/derived/session-chat.ts index 6192433b..66770ade 100644 --- a/apps/desktop/src/renderer/atoms/derived/session-chat.ts +++ b/apps/desktop/src/renderer/atoms/derived/session-chat.ts @@ -19,10 +19,34 @@ export interface ChatMessageEntry { */ export interface ChatTurn { id: string + /** Protocol turn id when available (`Message.turnID` / `devo/turnId`). */ + turnId?: string userMessage: ChatMessageEntry assistantMessages: ChatMessageEntry[] } +function messageProtocolTurnId(entry: ChatMessageEntry): string | undefined { + const info = entry.info as { + turnID?: unknown + turnId?: unknown + metadata?: Record + } + const direct = info.turnID ?? info.turnId + if (typeof direct === "string" && direct.length > 0) return direct + const metaTurnId = info.metadata?.["devo/turnId"] + return typeof metaTurnId === "string" && metaTurnId.length > 0 ? metaTurnId : undefined +} + +function protocolTurnIdForTurn( + userMessage: ChatMessageEntry, + assistantMessages: ChatMessageEntry[], +): string | undefined { + return ( + messageProtocolTurnId(userMessage) ?? + assistantMessages.map(messageProtocolTurnId).find((turnId): turnId is string => Boolean(turnId)) + ) +} + // ============================================================ // Turn grouping with structural sharing // ============================================================ @@ -59,7 +83,7 @@ function messageFingerprint(entry: ChatMessageEntry): string { function turnFingerprint(turn: ChatTurn): string { const assistantFps = turn.assistantMessages.map(messageFingerprint).join("|") - return `${messageFingerprint(turn.userMessage)}>${assistantFps}` + return `${turn.turnId ?? ""}:${messageFingerprint(turn.userMessage)}>${assistantFps}` } export function groupIntoTurns(entries: ChatMessageEntry[], prevTurns: ChatTurn[]): ChatTurn[] { @@ -76,6 +100,7 @@ export function groupIntoTurns(entries: ChatMessageEntry[], prevTurns: ChatTurn[ if (!currentUser) return const newTurn: ChatTurn = { id: currentUser.info.id, + turnId: protocolTurnIdForTurn(currentUser, currentAssistantMessages), userMessage: currentUser, assistantMessages: currentAssistantMessages, } diff --git a/apps/desktop/src/renderer/atoms/messages.test.ts b/apps/desktop/src/renderer/atoms/messages.test.ts index eea93e49..26384138 100644 --- a/apps/desktop/src/renderer/atoms/messages.test.ts +++ b/apps/desktop/src/renderer/atoms/messages.test.ts @@ -31,6 +31,41 @@ describe("message ordering", () => { expect(groupIntoTurns(entries, [])).toEqual([ { id: user.id, + turnId: undefined, + userMessage: { info: user, parts: [] }, + assistantMessages: [{ info: assistant, parts: [] }], + }, + ]) + }) + + test("propagates protocol turn ids onto chat turns", () => { + const user = { + id: "u1", + sessionID: "s1", + role: "user", + turnID: "protocol-turn-1", + time: { created: 1 }, + } + const assistant = { + id: "a1", + sessionID: "s1", + role: "assistant", + parentID: "u1", + turnID: "protocol-turn-1", + time: { created: 2 }, + } + const turns = groupIntoTurns( + [ + { info: user, parts: [] }, + { info: assistant, parts: [] }, + ], + [], + ) + + expect(turns).toEqual([ + { + id: "u1", + turnId: "protocol-turn-1", userMessage: { info: user, parts: [] }, assistantMessages: [{ info: assistant, parts: [] }], }, @@ -148,11 +183,13 @@ describe("message ordering", () => { expect(groupIntoTurns(entries, [])).toEqual([ { id: "u1", + turnId: undefined, userMessage: { info: firstUser, parts: [] }, assistantMessages: [{ info: firstAssistant, parts: [] }], }, { id: "u2", + turnId: undefined, userMessage: { info: secondUser, parts: [] }, assistantMessages: [{ info: secondAssistant, parts: [] }], }, @@ -230,6 +267,7 @@ describe("message ordering", () => { expect(groupIntoTurns(entries, [])).toEqual([ { id: "u1", + turnId: undefined, userMessage: { info: firstUser, parts: [] }, assistantMessages: [ { info: firstTool, parts: [firstToolPart] }, @@ -238,6 +276,7 @@ describe("message ordering", () => { }, { id: "u2", + turnId: undefined, userMessage: { info: secondUser, parts: [] }, assistantMessages: [ { info: secondTool, parts: [secondToolPart] }, diff --git a/apps/desktop/src/renderer/atoms/sessions.ts b/apps/desktop/src/renderer/atoms/sessions.ts index c33d2744..b75e576c 100644 --- a/apps/desktop/src/renderer/atoms/sessions.ts +++ b/apps/desktop/src/renderer/atoms/sessions.ts @@ -22,6 +22,16 @@ export type SessionError = { data: Record } +export type ProviderRetryStatus = { + turnId: string + attempt: number + backoffMs: number + provider: string + model: string + phase: "scheduled" | "resumed" | string + message: string +} + /** Phases of worktree setup shown in the chat view's empty state */ export type SessionSetupPhase = "creating-worktree" | "starting-session" | null @@ -58,6 +68,8 @@ export interface SessionEntry { setupPhase?: SessionSetupPhase /** Renderer-local marker for a completed background turn waiting to be read. */ hasUnreadCompletion?: boolean + /** Active provider retry status for the current turn. */ + retryStatus?: ProviderRetryStatus } // ============================================================ @@ -101,6 +113,7 @@ export const upsertSessionAtom = atom( error: existing?.error, setupPhase: existing?.setupPhase, hasUnreadCompletion: existing?.hasUnreadCompletion, + retryStatus: existing?.retryStatus, }) // Add to index @@ -156,7 +169,29 @@ export const setSessionStatusAtom = atom( : completedTurn ? get(viewedSessionIdAtom) !== args.sessionId : entry.hasUnreadCompletion - set(sessionFamily(args.sessionId), { ...entry, status: args.status, hasUnreadCompletion }) + set(sessionFamily(args.sessionId), { + ...entry, + status: args.status, + hasUnreadCompletion, + retryStatus: args.status.type === "idle" || args.status.type === "error" ? undefined : entry.retryStatus, + }) + }, +) + + +export const setProviderRetryStatusAtom = atom( + null, + ( + get, + set, + args: { + sessionId: string + status: ProviderRetryStatus | undefined + }, + ) => { + const entry = get(sessionFamily(args.sessionId)) + if (!entry) return + set(sessionFamily(args.sessionId), { ...entry, retryStatus: args.status }) }, ) @@ -359,6 +394,7 @@ export const setSessionsAtom = atom( error: existing?.error, setupPhase: existing?.setupPhase, hasUnreadCompletion: existing?.hasUnreadCompletion, + retryStatus: existing?.retryStatus, }) nextIds.add(session.id) } diff --git a/apps/desktop/src/renderer/components/chat/chat-turn.test.ts b/apps/desktop/src/renderer/components/chat/chat-turn.test.ts index 0f19da0c..73867f0e 100644 --- a/apps/desktop/src/renderer/components/chat/chat-turn.test.ts +++ b/apps/desktop/src/renderer/components/chat/chat-turn.test.ts @@ -302,6 +302,7 @@ describe("ChatTurnComponent transcript controls", () => { eventProcessorSource.includes("session.compaction.failed"), bridgesRuntimeCompactionEvents: clientSource.includes("sessionCompactionFromOriginalEvent") && + clientSource.includes("sessionIdFromCompactionPayload") && clientSource.includes("SessionCompactionCompleted") && clientSource.includes("session.compaction.${compaction.status}"), }).toEqual({ diff --git a/apps/desktop/src/renderer/components/chat/chat-turn.tsx b/apps/desktop/src/renderer/components/chat/chat-turn.tsx index c4302363..ba186b18 100644 --- a/apps/desktop/src/renderer/components/chat/chat-turn.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-turn.tsx @@ -23,6 +23,7 @@ import { import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react" import { useDisplayMode } from "../../hooks/use-agents" import type { SessionCompactionStatus } from "../../atoms/compaction" +import type { ProviderRetryStatus } from "../../atoms/sessions" import type { ChatMessageEntry, ChatTurn as ChatTurnType } from "../../hooks/use-session-chat" import { computeTurnCost, @@ -465,6 +466,7 @@ interface ChatTurnProps { pendingPermission?: PendingPermission isConnected?: boolean compactionStatus?: SessionCompactionStatus | null + retryStatus?: ProviderRetryStatus onApprovePermission?: ( agent: Agent, permissionSessionId: string, @@ -495,7 +497,19 @@ function pendingPermissionFingerprint(permission: PendingPermission | undefined) return `${permission.sessionId}:${requestId}` } -function WorkingTurnStatusStrip({ turn }: { turn: ChatTurnType }) { +function retryStatusText(status: ProviderRetryStatus): string { + if (status.message.trim()) return status.message + const seconds = Math.max(status.backoffMs / 1000, 0.1) + return `Retrying provider request in ${seconds.toFixed(1)}s (attempt ${status.attempt})` +} + +function WorkingTurnStatusStrip({ + turn, + retryStatus, +}: { + turn: ChatTurnType + retryStatus?: ProviderRetryStatus +}) { const [display, setDisplay] = useState(() => formatWorkDuration(computeTurnWorkTime(turn, { active: true })), ) @@ -512,8 +526,7 @@ function WorkingTurnStatusStrip({ turn }: { turn: ChatTurnType }) { return (
- {"Working for "} - {display} + {retryStatus ? retryStatusText(retryStatus) : <>Working for {display}}
@@ -588,6 +601,7 @@ export const ChatTurnComponent = memo( pendingPermission, isConnected = false, compactionStatus, + retryStatus, onApprovePermission, onDenyPermission, onRevertToMessage, @@ -642,12 +656,13 @@ export const ChatTurnComponent = memo( // Compute status by walking the last message's parts in reverse — no // need to flatMap all messages into a temporary array. const statusText = useMemo(() => { + if (retryStatus) return retryStatusText(retryStatus) for (let m = turn.assistantMessages.length - 1; m >= 0; m--) { const status = computeStatus(turn.assistantMessages[m].parts) if (status !== "Working...") return status } return "Working..." - }, [turn.assistantMessages]) + }, [retryStatus, turn.assistantMessages]) const working = isLast && isWorking @@ -780,7 +795,7 @@ export const ChatTurnComponent = memo( )} - {working && } + {working && } {!working && showWorkedForSummary && ( Promise + onLoadEarlier?: () => void | Promise scrollRef: RefObject }) { const { scrollRef: containerRef, stopScroll } = useStickToBottomContext() @@ -730,7 +730,7 @@ interface ChatViewProps { /** Whether there are earlier messages that can be loaded */ hasEarlierMessages: boolean /** Callback to load earlier messages */ - onLoadEarlier?: () => void + onLoadEarlier?: () => void | Promise agent: Agent isConnected: boolean onSendMessage?: ( @@ -976,17 +976,34 @@ export function ChatView({ ? "mx-auto w-full min-w-0" : "mx-auto w-full min-w-0 max-w-3xl" + const retryStatus = sessionEntry?.retryStatus + const renderTurn = useCallback( - (turn: ChatTurn, index: number) => ( + (turn: ChatTurn, index: number) => { + const isLastTurn = index === turns.length - 1 + // Prefer protocol turn id when present. Fall back to the active last turn + // because the server already scopes retry status to the session's active turn. + const activeRetryStatus: ProviderRetryStatus | undefined = + !retryStatus || !isLastTurn + ? undefined + : turn.turnId + ? retryStatus.turnId === turn.turnId + ? retryStatus + : undefined + : isWorking + ? retryStatus + : undefined + return ( - ), + ) + }, [ agent, effectivePermission, @@ -1012,6 +1030,7 @@ export function ChatView({ onDeletePart, onForkFromTurn, onRevertToMessage, + retryStatus, turns, ], ) diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index baece56e..927dd4b8 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -354,6 +354,16 @@ enum PromptJsonlEvent<'a> { is_error: bool, summary: &'a str, }, + #[serde(rename = "turn.provider_retry_status")] + ProviderRetryStatus { + session_id: &'a str, + attempt: usize, + backoff_ms: u64, + provider: &'a str, + model: &'a str, + phase: &'static str, + message: &'a str, + }, #[serde(rename = "turn.usage_delta")] UsageDelta { session_id: &'a str, @@ -449,6 +459,20 @@ fn write_query_event_jsonl(session_id: &str, event: &QueryEvent) -> Result<()> { session_id, item_type: "reasoning", }), + QueryEvent::ProviderRetryStatus(status) => { + write_jsonl(&PromptJsonlEvent::ProviderRetryStatus { + session_id, + attempt: status.attempt, + backoff_ms: status.backoff_ms, + provider: status.provider.as_str(), + model: status.model.as_str(), + phase: match status.phase { + devo_core::QueryProviderRetryPhase::Scheduled => "scheduled", + devo_core::QueryProviderRetryPhase::Resumed => "resumed", + }, + message: status.message.as_str(), + }) + } QueryEvent::UsageDelta { usage } => write_jsonl(&PromptJsonlEvent::UsageDelta { session_id, usage: PromptUsageDelta::new(usage), diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 166432de..386b07de 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -1016,6 +1016,7 @@ fn acp_session_metadata_from_start_params( total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, } @@ -1052,6 +1053,7 @@ fn acp_session_metadata_from_session_info(session_info: &AcpSessionInfo) -> Sess total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, } diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs index 38e2ce71..69fc4637 100644 --- a/crates/client/src/stdio.rs +++ b/crates/client/src/stdio.rs @@ -661,6 +661,7 @@ mod tests { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }; @@ -751,6 +752,7 @@ mod tests { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }] @@ -799,6 +801,7 @@ mod tests { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }; diff --git a/crates/core/src/context/context_changes.rs b/crates/core/src/context/context_changes.rs index dec1b369..71ba975e 100644 --- a/crates/core/src/context/context_changes.rs +++ b/crates/core/src/context/context_changes.rs @@ -32,6 +32,13 @@ impl ContextChangesFragment { } } + /// Returns true when this fragment communicates a real change (mode transition + /// and/or metadata diffs). First-turn fragments with only the current mode are + /// treated as changes because there is no prior turn context yet. + pub fn has_changes(&self) -> bool { + self.collaboration_mode.previous.is_some() || !self.metadata.is_empty() + } + pub fn to_message(&self) -> Message { Message::user(self.render()) } diff --git a/crates/core/src/context/execution_context.rs b/crates/core/src/context/execution_context.rs index ef280e0f..1aea0db2 100644 --- a/crates/core/src/context/execution_context.rs +++ b/crates/core/src/context/execution_context.rs @@ -243,18 +243,26 @@ impl TurnContext { } } - pub fn context_changes_since(&self, previous: Option<&TurnContext>) -> ContextChangesFragment { + /// Returns a context-changes fragment when there is something to communicate. + /// + /// The first turn (no previous context) always emits a fragment with the current + /// collaboration mode. Later turns emit a fragment only when collaboration mode + /// or other turn metadata actually changed. + pub fn context_changes_since( + &self, + previous: Option<&TurnContext>, + ) -> Option { let mut metadata = Vec::new(); let mut previous_collaboration_mode = None; let mut collaboration_mode_note = None; let Some(previous) = previous else { - return ContextChangesFragment::new( + return Some(ContextChangesFragment::new( self.collaboration_mode, previous_collaboration_mode, collaboration_mode_note, metadata, - ); + )); }; if self.environment.cwd != previous.environment.cwd { @@ -320,12 +328,13 @@ impl TurnContext { .to_string(), ); } - ContextChangesFragment::new( + let fragment = ContextChangesFragment::new( self.collaboration_mode, previous_collaboration_mode, collaboration_mode_note, metadata, - ) + ); + fragment.has_changes().then_some(fragment) } } @@ -607,7 +616,9 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, }; - let diff = current.context_changes_since(Some(&previous)); + let diff = current + .context_changes_since(Some(&previous)) + .expect("metadata changes should produce a fragment"); let rendered = diff.render(); assert!(rendered.contains("")); assert!(rendered.contains("model")); @@ -635,7 +646,9 @@ mod tests { observed_agents_snapshot: None, collaboration_mode: devo_protocol::CollaborationMode::Build, }; - let fragment = context.context_changes_since(None); + let fragment = context + .context_changes_since(None) + .expect("first turn should emit context changes"); let message = fragment.to_message(); assert_eq!(message.role, devo_protocol::Role::User); @@ -668,7 +681,9 @@ mod tests { ..previous.clone() }; - let diff = current.context_changes_since(Some(&previous)); + let diff = current + .context_changes_since(Some(&previous)) + .expect("mode change should produce a fragment"); let rendered = diff.render(); assert!(rendered.contains("")); @@ -682,6 +697,30 @@ mod tests { assert!(!rendered.contains("")); } + #[test] + fn turn_context_diff_skips_unchanged_subsequent_turns() { + let previous = TurnContext { + environment: EnvironmentContext { + cwd: PathBuf::from("/tmp/a"), + shell: "bash".into(), + current_date: "2026-04-27".into(), + timezone: "UTC".into(), + }, + persona: super::Persona::Default, + model: Model { + slug: "model-a".into(), + ..Model::default() + }, + reasoning_effort_selection: None, + reasoning_effort: None, + observed_agents_snapshot: None, + collaboration_mode: devo_protocol::CollaborationMode::Build, + }; + let current = previous.clone(); + + assert_eq!(current.context_changes_since(Some(&previous)), None); + } + #[test] fn session_context_system_prompt_uses_stable_mode_introductions() { let context = SessionContext::capture( diff --git a/crates/core/src/context/turn_aborted.rs b/crates/core/src/context/turn_aborted.rs index d8523da4..d4aa089c 100644 --- a/crates/core/src/context/turn_aborted.rs +++ b/crates/core/src/context/turn_aborted.rs @@ -8,7 +8,7 @@ pub(crate) struct TurnAborted { #[allow(dead_code)] impl TurnAborted { - pub(crate) const INTERRUPTED_GUIDANCE: &'static str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed."; + pub(crate) const INTERRUPTED_GUIDANCE: &'static str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed. Treat interrupted tool results as incomplete and do not assume those tools finished successfully."; pub(crate) const INTERRUPTED_DEVELOPER_GUIDANCE: &'static str = "The previous turn was interrupted on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed."; pub(crate) fn new(guidance: impl Into) -> Self { diff --git a/crates/core/src/conversation/mod.rs b/crates/core/src/conversation/mod.rs index 1846b194..dafa90d2 100644 --- a/crates/core/src/conversation/mod.rs +++ b/crates/core/src/conversation/mod.rs @@ -4,9 +4,9 @@ pub use devo_protocol::{ItemId, SessionId, SessionTitleState, TurnId, TurnStatus pub use records::{ ApprovalDecisionItem, ApprovalRequestItem, CommandExecutionItem, CompactionSnapshotLine, ItemLine, ItemRecord, MessageEditRecordedLine, ResearchArtifactItem, ResearchArtifactType, - RolloutLine, SessionMetaLine, SessionRecord, SessionRollbackLine, SessionTitleUpdatedLine, - TextItem, ToolCallItem, ToolProgressItem, ToolResultItem, TurnError, TurnItem, TurnLine, - TurnRecord, TurnSupersededLine, TurnWorkspaceChangeRecordedLine, + RolloutLine, SessionContextUpdatedLine, SessionMetaLine, SessionRecord, SessionRollbackLine, + SessionTitleUpdatedLine, TextItem, ToolCallItem, ToolProgressItem, ToolResultItem, TurnError, + TurnItem, TurnLine, TurnRecord, TurnSupersededLine, TurnWorkspaceChangeRecordedLine, TurnWorkspaceCheckpointRecordedLine, TurnWorkspaceRestoreCompletedLine, TurnWorkspaceRestoreStartedLine, Worklog, }; diff --git a/crates/core/src/conversation/records.rs b/crates/core/src/conversation/records.rs index e6e10d16..b1c3953c 100644 --- a/crates/core/src/conversation/records.rs +++ b/crates/core/src/conversation/records.rs @@ -392,6 +392,19 @@ pub struct ItemLine { pub item: ItemRecord, } +/// Stores the locked session context once per durable rollout file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionContextUpdatedLine { + /// The time when this rollout line was persisted. + pub timestamp: DateTime, + /// The session whose stable context was recorded. + pub session_id: SessionId, + /// The locked session context captured for replay. + pub session_context: SessionContext, + /// The schema version for persisted session-context metadata. + pub schema_version: u32, +} + /// Stores one session-title update line in the rollout file. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionTitleUpdatedLine { @@ -504,6 +517,8 @@ pub enum RolloutLine { Item(ItemLine), /// Session-title update line. SessionTitleUpdated(SessionTitleUpdatedLine), + /// Locked session-context update line. + SessionContextUpdated(Box), /// Compaction snapshot line. CompactionSnapshot(Box), /// Accepted message-edit record line. @@ -528,6 +543,7 @@ mod tests { use pretty_assertions::assert_eq; use super::*; + use crate::Model; use crate::conversation::{ItemId, SessionId, SessionTitleState, TurnId, TurnStatus}; // ── SessionRecord ────────────────────────────────────────── @@ -856,6 +872,32 @@ mod tests { title_state: SessionTitleState::Provisional, previous_title: Some("Old Title".into()), }), + RolloutLine::SessionContextUpdated(Box::new(SessionContextUpdatedLine { + timestamp: Utc::now(), + session_id: session.id, + session_context: SessionContext { + base_instructions: "base".into(), + available_skills: None, + workspace_instructions: None, + locked_agents_snapshot: None, + environment: crate::EnvironmentContext { + cwd: ".".into(), + shell: "bash".into(), + current_date: "2026-07-08".into(), + timezone: "UTC".into(), + }, + language: crate::LanguageContext::default(), + persona: crate::Persona::Default, + model: Model { + slug: "test-model".into(), + ..Model::default() + }, + reasoning_effort_selection: None, + reasoning_effort: None, + system_prompt_mode: crate::SystemPromptMode::CodingAgent, + }, + schema_version: 1, + })), RolloutLine::CompactionSnapshot(Box::new(CompactionSnapshotLine { timestamp: Utc::now(), session_id: session.id, diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index 1189f978..b8700731 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -21,6 +21,7 @@ use devo_protocol::TruncationPolicy; use futures::StreamExt; use futures::future::BoxFuture; use tokio::time::sleep; +use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::info; use tracing::info_span; @@ -119,6 +120,8 @@ fn estimate_request_prompt_tokens(request: &ModelRequest) -> usize { /// Events emitted during a query for the caller (CLI/UI) to observe. #[derive(Debug, Clone)] pub enum QueryEvent { + /// Provider request retry status. + ProviderRetryStatus(ProviderRetryStatus), /// Incremental text from the assistant. TextDelta(String), /// Incremental reasoning text from the assistant. @@ -182,6 +185,27 @@ pub enum QueryEvent { /// the old synchronous callback bridge. pub type EventCallback = Arc BoxFuture<'static, ()> + Send + Sync>; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderRetryStatus { + pub provider: String, + pub model: String, + pub attempt: usize, + pub backoff_ms: u64, + pub phase: QueryProviderRetryPhase, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QueryProviderRetryPhase { + Scheduled, + Resumed, +} + +#[derive(Debug, Clone, Default)] +pub struct QueryOptions { + pub cancel_token: Option, +} + async fn emit_query_event(on_event: &Option, event: QueryEvent) { if let Some(callback) = on_event { callback(event).await; @@ -354,6 +378,10 @@ fn classify_error(e: &anyhow::Error) -> ErrorClass { || msg.contains("nodename nor servname") || msg.contains("could not resolve host") || msg.contains("unexpected eof") + || msg.contains("invalidcontenttype") + || msg.contains("invalid content-type") + || msg.contains("invalid header value") + || msg.contains("text/event-stream") { ErrorClass::NetworkError } else if msg.contains("400") @@ -718,6 +746,17 @@ fn assistant_content_contains_dsml_tool_call_text(content: &[ContentBlock]) -> b }) } +fn assistant_content_has_visible_content(content: &[ContentBlock]) -> bool { + content.iter().any(|block| match block { + ContentBlock::Text { text } + | ContentBlock::Reasoning { text } + | ContentBlock::ToolResult { content: text, .. } => !text.trim().is_empty(), + ContentBlock::ProviderReasoning { .. } + | ContentBlock::ToolUse { .. } + | ContentBlock::HostedToolUse { .. } => true, + }) +} + fn dsml_text_tool_call_continuation_message( request_tools: &[ToolDefinition], hosted_tools: &[HostedToolDefinition], @@ -780,6 +819,27 @@ pub async fn query( registry: Arc, runtime: &ToolRuntime, on_event: Option, +) -> Result<(), AgentError> { + query_with_options( + session, + turn_config, + provider, + registry, + runtime, + on_event, + QueryOptions::default(), + ) + .await +} + +pub async fn query_with_options( + session: &mut SessionState, + turn_config: &TurnConfig, + provider: Arc, + registry: Arc, + runtime: &ToolRuntime, + on_event: Option, + options: QueryOptions, ) -> Result<(), AgentError> { let agents_md_manager = AgentsMdManager::new(session.config.agents_md.clone()); let current_agents_snapshot = load_workspace_instructions(&session.cwd, &agents_md_manager); @@ -806,9 +866,11 @@ pub async fn query( } let current_turn_context = TurnContext::capture(session, turn_config, current_agents_snapshot.clone()); - let context_changes = - current_turn_context.context_changes_since(session.latest_turn_context.as_ref()); - session.insert_context_message(context_changes.to_message()); + if let Some(context_changes) = + current_turn_context.context_changes_since(session.latest_turn_context.as_ref()) + { + session.insert_context_message(context_changes.to_message()); + } if let Some(previous_turn_context) = session.latest_turn_context.as_ref() && let Some(diff) = AgentsMdManager::diff( previous_turn_context.observed_agents_snapshot.as_ref(), @@ -837,22 +899,20 @@ pub async fn query( session.start_turn(devo_protocol::TurnKind::Regular); } + // Explicit interrupted-turn notice for the next user message after a user + // interrupt. Placed before pending-input processing so it sits just above the + // latest user text in prompt construction (after any context diff). + let previous_turn_interrupted = session.take_last_turn_interrupted(); + if previous_turn_interrupted { + let fragment = TurnAborted::new(TurnAborted::INTERRUPTED_GUIDANCE); + if let ResponseItem::Message(msg) = fragment.to_response_item() { + session.insert_context_message(msg); + } + } + 'query_loop: loop { let pending = session.take_turn_pending_input(); - // If the user interrupted the assistant mid-turn, explain the interruption - if !pending.is_empty() - && session - .messages - .last() - .is_some_and(|m| m.role == Role::Assistant) - { - let fragment = TurnAborted::new(TurnAborted::INTERRUPTED_GUIDANCE); - if let ResponseItem::Message(msg) = fragment.to_response_item() { - session.push_message(msg); - } - } - for item in &pending { match &item.kind { devo_protocol::PendingInputKind::UserText { text } => { @@ -1061,7 +1121,15 @@ pub async fn query( backoff_ms = backoff.as_millis(), "transient provider error - retrying with exponential backoff" ); - sleep(backoff).await; + wait_for_provider_retry( + &on_event, + options.cancel_token.as_ref(), + provider.name(), + &turn_config.model.slug, + retry_count, + backoff, + ) + .await?; session.turn_count -= 1; continue; } @@ -1242,7 +1310,15 @@ pub async fn query( backoff_ms = backoff.as_millis(), "transient provider stream error - retrying with exponential backoff" ); - sleep(backoff).await; + wait_for_provider_retry( + &on_event, + options.cancel_token.as_ref(), + provider.name(), + &turn_config.model.slug, + retry_count, + backoff, + ) + .await?; session.turn_count -= 1; continue 'query_loop; } @@ -1520,10 +1596,12 @@ pub async fn query( let assistant_content_contains_dsml_tool_call = assistant_content_contains_dsml_tool_call_text(&assistant_content); - session.push_message(Message { - role: Role::Assistant, - content: assistant_content, - }); + if assistant_content_has_visible_content(&assistant_content) { + session.push_message(Message { + role: Role::Assistant, + content: assistant_content, + }); + } if deepseek_v4_thinking_only_end_turn { if deepseek_v4_thinking_only_continuation_used { @@ -1677,6 +1755,17 @@ pub async fn query( role: Role::User, content: result_content, }); + + // If the turn was cancelled while tools were running, keep the + // interrupted tool results above and stop without another model call. + if options + .cancel_token + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + session.end_turn(); + return Err(AgentError::Aborted); + } } } @@ -1744,6 +1833,54 @@ pub async fn test_model_connection( Ok(preview.to_string()) } +async fn wait_for_provider_retry( + on_event: &Option, + cancel_token: Option<&CancellationToken>, + provider: &str, + model: &str, + attempt: usize, + backoff: Duration, +) -> Result<(), AgentError> { + let backoff_ms = backoff.as_millis().min(u128::from(u64::MAX)) as u64; + emit_query_event( + on_event, + QueryEvent::ProviderRetryStatus(ProviderRetryStatus { + provider: provider.to_string(), + model: model.to_string(), + attempt, + backoff_ms, + phase: QueryProviderRetryPhase::Scheduled, + message: format!("Retrying provider request in {:.1}s", backoff.as_secs_f64()), + }), + ) + .await; + + if let Some(cancel_token) = cancel_token { + tokio::select! { + biased; + () = cancel_token.cancelled() => return Err(AgentError::Aborted), + () = sleep(backoff) => {} + } + } else { + sleep(backoff).await; + } + + emit_query_event( + on_event, + QueryEvent::ProviderRetryStatus(ProviderRetryStatus { + provider: provider.to_string(), + model: model.to_string(), + attempt, + backoff_ms: 0, + phase: QueryProviderRetryPhase::Resumed, + message: "Retrying provider request now".to_string(), + }), + ) + .await; + + Ok(()) +} + fn retry_backoff_duration(attempt: usize) -> Duration { let exponent = attempt.saturating_sub(1).min(10) as u32; let multiplier = 2u64.pow(exponent); @@ -1771,6 +1908,7 @@ mod tests { use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistryBuilder; use crate::tools::router::PermissionChecker; + use crate::tools::router::ToolExecutionOptions; use crate::tools::tool_handler::ToolHandler; use crate::tools::tool_spec::ToolExecutionMode; use crate::tools::tool_spec::ToolOutputMode; @@ -1794,19 +1932,72 @@ mod tests { use futures::Stream; use pretty_assertions::assert_eq; use serde_json::json; + use tokio_util::sync::CancellationToken; use super::QueryEvent; + use super::QueryOptions; use super::hosted_tools_for_web_search; use super::insert_subagent_request_reminders; use super::query; + use super::query_with_options; use super::test_model_connection; use super::truncate_tool_result_for_model; + use crate::AgentError; use crate::ContentBlock; use crate::Message; use crate::Model; use crate::ReasoningEffort; use crate::Role; + #[test] + fn assistant_content_visibility_requires_visible_content() { + assert!(!super::assistant_content_has_visible_content(&[])); + assert!(!super::assistant_content_has_visible_content(&[ + ContentBlock::Text { + text: " \n\t".to_string(), + }, + ])); + assert!(!super::assistant_content_has_visible_content(&[ + ContentBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: String::new(), + is_error: false, + }, + ])); + + for content in [ + vec![ContentBlock::Text { + text: "visible".to_string(), + }], + vec![ContentBlock::Reasoning { + text: "reasoning".to_string(), + }], + vec![ContentBlock::ProviderReasoning { + provider: "test".to_string(), + payload: serde_json::json!({"thinking":"hidden"}), + }], + vec![ContentBlock::ToolUse { + id: "call-1".to_string(), + name: "read".to_string(), + input: serde_json::json!({"filePath":"README.md"}), + }], + vec![ContentBlock::HostedToolUse { + id: "hosted-1".to_string(), + name: "web_search".to_string(), + input: serde_json::json!({"query":"docs"}), + output: None, + status: None, + }], + vec![ContentBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: "result".to_string(), + is_error: false, + }], + ] { + assert!(super::assistant_content_has_visible_content(&content)); + } + } + #[test] fn hosted_tools_follow_resolved_web_search_mode() { let hosted = hosted_tools_for_web_search(&devo_config::ResolvedWebSearchConfig::Provider); @@ -1845,6 +2036,9 @@ mod tests { ), anyhow::anyhow!("dns error: failed to lookup address information"), anyhow::anyhow!("network is unreachable"), + anyhow::anyhow!( + "anthropic stream error for model deepseek-v4-flash: invalid header value: \"text/html; charset=utf-8\"; debug=InvalidContentType(\"text/html; charset=utf-8\")" + ), anyhow::anyhow!("Invalid status code: 408 Request Timeout"), anyhow::Error::new(std::io::Error::new( std::io::ErrorKind::TimedOut, @@ -2858,6 +3052,66 @@ mod tests { ); } + #[tokio::test(start_paused = true)] + async fn query_cancels_stream_creation_retry_backoff() { + let provider = Arc::new(TransientStreamCreateProvider { + attempts: AtomicUsize::new(0), + }); + let provider_sdk: Arc = provider.clone(); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let result = query_with_options( + &mut session, + &TurnConfig::new(Model::default(), None), + provider_sdk, + registry, + &runtime, + None, + QueryOptions { + cancel_token: Some(cancel_token), + }, + ) + .await; + + assert!(matches!(result, Err(AgentError::Aborted))); + assert_eq!(provider.attempts.load(Ordering::SeqCst), 1); + } + + #[tokio::test(start_paused = true)] + async fn query_cancels_stream_event_retry_backoff() { + let provider = Arc::new(TransientStreamEventProvider { + attempts: AtomicUsize::new(0), + }); + let provider_sdk: Arc = provider.clone(); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let result = query_with_options( + &mut session, + &TurnConfig::new(Model::default(), None), + provider_sdk, + registry, + &runtime, + None, + QueryOptions { + cancel_token: Some(cancel_token), + }, + ) + .await; + + assert!(matches!(result, Err(AgentError::Aborted))); + assert_eq!(provider.attempts.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn query_exposes_stable_tools_and_appends_subagent_warning() { let requests = Arc::new(Mutex::new(Vec::new())); @@ -4069,6 +4323,229 @@ mod tests { assert!(text.contains("model-b")); } + #[tokio::test] + async fn query_skips_context_diff_when_turn_metadata_unchanged() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "model-a".into(), + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + + session.push_message(Message::user("hello")); + query( + &mut session, + &TurnConfig::new(model.clone(), None), + Arc::clone(&provider), + Arc::clone(®istry), + &runtime, + None, + ) + .await + .expect("first query should succeed"); + + session.push_message(Message::user("follow up")); + query( + &mut session, + &TurnConfig::new(model, None), + Arc::clone(&provider), + registry, + &runtime, + None, + ) + .await + .expect("second query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let follow_up_index = request_message_index_containing(&captured[1], "follow up"); + assert!( + follow_up_index > 0, + "follow-up user message should not be the first prompt message" + ); + assert!( + !message_contains( + &captured[1].messages[follow_up_index - 1], + "" + ), + "unchanged metadata after a completed turn should not insert a new context_changes before the next user message" + ); + } + + #[tokio::test] + async fn query_inserts_interrupted_notice_before_next_user_message() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + session.push_message(Message::assistant_text("partial")); + session.mark_last_turn_interrupted(); + session.push_message(Message::user("continue please")); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider, + registry, + &runtime, + None, + ) + .await + .expect("query should succeed"); + + let abort_index = session + .messages + .iter() + .position(|message| { + message.content.iter().any(|block| { + matches!( + block, + ContentBlock::Text { text } if text.contains("") + ) + }) + }) + .expect("interrupted notice should be inserted"); + let continue_index = session + .messages + .iter() + .position(|message| { + message.content.iter().any(|block| { + matches!( + block, + ContentBlock::Text { text } if text.contains("continue please") + ) + }) + }) + .expect("user message should remain"); + assert!(abort_index < continue_index); + assert!(!session.last_turn_interrupted); + } + + #[tokio::test] + async fn query_pairs_interrupted_tool_result_when_cancel_fires_during_tool() { + #[derive(Debug)] + struct HangingMutatingTool; + + #[async_trait] + impl ToolHandler for HangingMutatingTool { + fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { + Box::leak(Box::new(crate::tools::tool_spec::ToolSpec { + name: "mutating_tool".into(), + description: "hangs until cancelled".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + })) + } + + async fn handle( + &self, + _ctx: crate::tools::contracts::ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> Result + { + std::future::pending::<()>().await; + unreachable!("tool should be cancelled") + } + } + + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(HangingMutatingTool)); + builder.push_spec(crate::tools::tool_spec::ToolSpec { + name: "mutating_tool".into(), + description: "hangs until cancelled".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let cancel_token = CancellationToken::new(); + let cancel_for_task = cancel_token.clone(); + let runtime = ToolRuntime::new_with_context_and_options( + Arc::clone(®istry), + PermissionChecker::always_allow(), + ToolRuntimeContext::default(), + ToolExecutionOptions { + cancel_token: cancel_token.clone(), + ..ToolExecutionOptions::default() + }, + ); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tool")); + let turn_config = TurnConfig::new(Model::default(), None); + let provider: Arc = Arc::new(SingleToolUseProvider { + requests: AtomicUsize::new(0), + }); + + let result = { + let mut query_future = std::pin::pin!(query_with_options( + &mut session, + &turn_config, + provider, + registry, + &runtime, + None, + QueryOptions { + cancel_token: Some(cancel_token), + }, + )); + tokio::select! { + result = &mut query_future => { + panic!("query completed before cancel: {result:?}"); + } + () = tokio::time::sleep(std::time::Duration::from_millis(50)) => { + cancel_for_task.cancel(); + } + } + query_future.await + }; + assert!(matches!(result, Err(AgentError::Aborted))); + + let tool_use = session.messages.iter().find(|message| { + message + .content + .iter() + .any(|block| matches!(block, ContentBlock::ToolUse { id, .. } if id == "tool-1")) + }); + assert!(tool_use.is_some(), "tool call should be retained"); + + let tool_result = session.messages.iter().find_map(|message| { + message.content.iter().find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } if tool_use_id == "tool-1" => Some((content.clone(), *is_error)), + _ => None, + }) + }); + let (content, is_error) = tool_result.expect("interrupted tool result should exist"); + assert!(is_error); + assert_eq!(content, crate::tools::INTERRUPTED_TOOL_RESULT_MESSAGE); + } + #[tokio::test] async fn query_drops_orphaned_tool_calls_from_prompt_history() { let requests = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index 957c6a44..9f59e405 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -251,6 +251,9 @@ pub struct SessionState { /// Total context tokens reported by the model for the most recent turn. /// This includes input plus output and drives automatic compaction. pub last_turn_tokens: usize, + /// True when the most recently finished turn was user-interrupted. + /// Consumed when the next query inserts the `` notice. + pub last_turn_interrupted: bool, /// Thread-safe queue for pending turn inputs. /// - Source: user sends `turn/start` while a turn is active. /// - Lifecycle: preserved across turns; unconsumed items are pushed back @@ -285,6 +288,7 @@ impl SessionState { prompt_token_estimate: 0, last_input_tokens: 0, last_turn_tokens: 0, + last_turn_interrupted: false, pending_turn_queue: Arc::new(Mutex::new(VecDeque::new())), btw_input_queue: Arc::new(Mutex::new(VecDeque::new())), turn_state: None, @@ -312,12 +316,24 @@ impl SessionState { prompt_token_estimate: self.prompt_token_estimate, last_input_tokens: self.last_input_tokens, last_turn_tokens: self.last_turn_tokens, + last_turn_interrupted: self.last_turn_interrupted, pending_turn_queue: Arc::clone(&self.pending_turn_queue), btw_input_queue: Arc::clone(&self.btw_input_queue), turn_state: None, } } + /// Marks that the previous turn ended as interrupted so the next query can + /// insert an explicit aborted-turn notice. + pub fn mark_last_turn_interrupted(&mut self) { + self.last_turn_interrupted = true; + } + + /// Takes and clears the interrupted-turn flag for the next query preamble. + pub fn take_last_turn_interrupted(&mut self) -> bool { + std::mem::take(&mut self.last_turn_interrupted) + } + pub fn push_message(&mut self, msg: Message) { if let Some(prompt_messages) = self.prompt_messages.as_mut() { self.messages.push(msg.clone()); diff --git a/crates/core/src/tools/router.rs b/crates/core/src/tools/router.rs index 475e8aa2..db6cac0e 100644 --- a/crates/core/src/tools/router.rs +++ b/crates/core/src/tools/router.rs @@ -34,6 +34,8 @@ type ExecutionStartCallbackArc = Arc; type PermissionFuture = futures::future::BoxFuture<'static, Result<(), String>>; type PermissionCheckFn = dyn Fn(ToolPermissionRequest) -> PermissionFuture + Send + Sync; const PROGRESS_DRAIN_GRACE_MS: u64 = 50; +/// Content written into a tool-result item when a tool is cancelled mid-execution. +pub const INTERRUPTED_TOOL_RESULT_MESSAGE: &str = "tool execution was interrupted"; #[derive(Debug, Clone)] pub struct ToolCall { @@ -128,6 +130,10 @@ impl ToolRuntime { } } + pub fn cancel_token(&self) -> &CancellationToken { + &self.execution_options.cancel_token + } + pub async fn execute_batch(&self, calls: &[ToolCall]) -> Vec { self.execute_batch_inner( calls, /*on_progress*/ None, /*on_completion*/ None, @@ -323,7 +329,12 @@ impl ToolRuntime { }; let input = self.input_for_tool_call(tool_name, &call.input); - let result = tool.handle(ctx, input, progress_sender).await; + let cancel_token = self.execution_options.cancel_token.clone(); + let result = tokio::select! { + biased; + () = cancel_token.cancelled() => Err(crate::contracts::ToolCallError::Cancelled), + result = tool.handle(ctx, input, progress_sender) => result, + }; if let Some(progress_task) = progress_task && tokio::time::timeout( Duration::from_millis(PROGRESS_DRAIN_GRACE_MS), @@ -379,6 +390,17 @@ impl ToolRuntime { } result } + Err(crate::contracts::ToolCallError::Cancelled) => { + let message = INTERRUPTED_TOOL_RESULT_MESSAGE; + super::hook_events::post_tool_use_failure( + self.context.hooks.as_ref(), + call, + tool_name, + message, + ) + .await; + ToolCallResult::error(&call.id, message) + } Err(e) => { let message = e.to_string(); super::hook_events::post_tool_use_failure( @@ -1834,8 +1856,6 @@ mod tests { supports_cancellation: None, supports_streaming: None, }); - let cancel_token = CancellationToken::new(); - cancel_token.cancel(); let runtime = ToolRuntime::new_with_context_and_options( Arc::new(builder.build()), PermissionChecker::always_allow(), @@ -1845,7 +1865,7 @@ mod tests { output_limit_bytes: 7, wall_time_limit_ms: Some(11), }, - cancel_token, + cancel_token: CancellationToken::new(), on_tool_execution_start: None, }, ); @@ -1863,13 +1883,146 @@ mod tests { Some(CapturedContextOptions { output_limit_bytes: 7, wall_time_limit_ms: Some(11), - cancel_token_cancelled: true, + cancel_token_cancelled: false, agent_coordinator_configured: false, agent_scope: ToolAgentScope::Parent, }) ); } + #[tokio::test] + async fn runtime_cancels_tool_when_cancel_token_already_fired() { + let seen = Arc::new(std::sync::Mutex::new(None)); + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler( + "capture_context", + Arc::new(ContextCaptureTool::new(Arc::clone(&seen))), + ); + builder.push_spec(ToolSpec { + name: "capture_context".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + let runtime = ToolRuntime::new_with_context_and_options( + Arc::new(builder.build()), + PermissionChecker::always_allow(), + ToolRuntimeContext::default(), + ToolExecutionOptions { + budgets: ToolBudgets { + output_limit_bytes: 7, + wall_time_limit_ms: Some(11), + }, + cancel_token, + on_tool_execution_start: None, + }, + ); + let call = ToolCall { + id: "ctx".into(), + name: "capture_context".into(), + input: serde_json::json!({}), + }; + + let result = runtime.execute_single(&call, &None).await; + + assert!(result.is_error); + assert_eq!( + result.content.into_string(), + INTERRUPTED_TOOL_RESULT_MESSAGE + ); + assert!(seen.lock().expect("seen lock").is_none()); + } + + #[tokio::test] + async fn runtime_interrupts_hanging_tool_via_cancel_token() { + #[derive(Debug)] + struct HangingTool; + + #[async_trait] + impl ToolHandler for HangingTool { + fn spec(&self) -> &ToolSpec { + Box::leak(Box::new(ToolSpec { + name: "hanging".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + })) + } + + async fn handle( + &self, + _ctx: ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> Result { + std::future::pending::<()>().await; + unreachable!("hanging tool should be cancelled") + } + } + + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("hanging", Arc::new(HangingTool)); + builder.push_spec(ToolSpec { + name: "hanging".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let cancel_token = CancellationToken::new(); + let runtime = ToolRuntime::new_with_context_and_options( + Arc::new(builder.build()), + PermissionChecker::always_allow(), + ToolRuntimeContext::default(), + ToolExecutionOptions { + cancel_token: cancel_token.clone(), + ..ToolExecutionOptions::default() + }, + ); + let call = ToolCall { + id: "hang-1".into(), + name: "hanging".into(), + input: serde_json::json!({}), + }; + + let execute = runtime.execute_single(&call, &None); + tokio::pin!(execute); + tokio::select! { + _ = &mut execute => panic!("hanging tool should not complete before cancel"), + () = tokio::time::sleep(Duration::from_millis(10)) => { + cancel_token.cancel(); + } + } + let result = execute.await; + assert!(result.is_error); + assert_eq!( + result.content.into_string(), + INTERRUPTED_TOOL_RESULT_MESSAGE + ); + } + #[derive(Debug, Default)] struct FakeAgentCoordinator; diff --git a/crates/protocol/bindings/ProviderRetryPhase.ts b/crates/protocol/bindings/ProviderRetryPhase.ts new file mode 100644 index 00000000..6e3b8660 --- /dev/null +++ b/crates/protocol/bindings/ProviderRetryPhase.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProviderRetryPhase = "scheduled" | "resumed"; diff --git a/crates/protocol/bindings/TurnProviderRetryStatusPayload.ts b/crates/protocol/bindings/TurnProviderRetryStatusPayload.ts new file mode 100644 index 00000000..fa24dd69 --- /dev/null +++ b/crates/protocol/bindings/TurnProviderRetryStatusPayload.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProviderRetryPhase } from "./ProviderRetryPhase"; + +export type TurnProviderRetryStatusPayload = { session_id: SessionId, turn_id: TurnId, attempt: number, backoff_ms: bigint, provider: string, model: string, phase: ProviderRetryPhase, message: string, }; diff --git a/crates/protocol/src/acp_session.rs b/crates/protocol/src/acp_session.rs index 4a960536..21099734 100644 --- a/crates/protocol/src/acp_session.rs +++ b/crates/protocol/src/acp_session.rs @@ -195,6 +195,7 @@ mod tests { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }; diff --git a/crates/protocol/src/acp_ts.rs b/crates/protocol/src/acp_ts.rs index dc8dc2cd..5cb64866 100644 --- a/crates/protocol/src/acp_ts.rs +++ b/crates/protocol/src/acp_ts.rs @@ -165,6 +165,8 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 4f17b243..159d743a 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -1,6 +1,8 @@ use chrono::{DateTime, Utc}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use smol_str::SmolStr; +use ts_rs::TS; use crate::command_exec::{CommandExecExitedPayload, CommandExecOutputDeltaPayload}; use crate::parse_command::ParsedCommand; @@ -116,6 +118,27 @@ pub struct TurnPlanUpdatedPayload { pub plan: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum ProviderRetryPhase { + Scheduled, + Resumed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct TurnProviderRetryStatusPayload { + pub session_id: SessionId, + pub turn_id: TurnId, + pub attempt: usize, + pub backoff_ms: u64, + pub provider: String, + pub model: String, + pub phase: ProviderRetryPhase, + pub message: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TurnUsageUpdatedPayload { pub session_id: SessionId, @@ -340,6 +363,7 @@ pub enum ServerEvent { TurnPlanUpdated(TurnPlanUpdatedPayload), TurnDiffUpdated(TurnEventPayload), TurnUsageUpdated(TurnUsageUpdatedPayload), + TurnProviderRetryStatus(TurnProviderRetryStatusPayload), WorkspaceChangesUpdated(WorkspaceChangesUpdatedPayload), ToolCallStatusUpdated(ToolCallStatusUpdatedPayload), RequestUserInput(RequestUserInputPayload), @@ -383,6 +407,7 @@ impl ServerEvent { | Self::TurnDiffUpdated(payload) => Some(payload.session_id), Self::TurnPlanUpdated(payload) => Some(payload.session_id), Self::TurnUsageUpdated(payload) => Some(payload.session_id), + Self::TurnProviderRetryStatus(payload) => Some(payload.session_id), Self::WorkspaceChangesUpdated(payload) => Some(payload.session_id), Self::ToolCallStatusUpdated(payload) => Some(payload.session_id), Self::RequestUserInput(payload) => Some(payload.request.session_id), @@ -424,6 +449,7 @@ impl ServerEvent { Self::TurnPlanUpdated(_) => "turn/plan/updated", Self::TurnDiffUpdated(_) => "turn/diff/updated", Self::TurnUsageUpdated(_) => "turn/usage/updated", + Self::TurnProviderRetryStatus(_) => "turn/provider_retry_status", Self::WorkspaceChangesUpdated(_) => "workspace/changes/updated", Self::ToolCallStatusUpdated(_) => "tool_call/status_updated", Self::RequestUserInput(_) => "item/tool/requestUserInput", @@ -460,6 +486,7 @@ impl ServerEvent { } Self::ItemDelta { payload, .. } => payload.context.seq = seq, Self::TurnUsageUpdated(_) + | Self::TurnProviderRetryStatus(_) | Self::WorkspaceChangesUpdated(_) | Self::ToolCallStatusUpdated(_) | Self::RequestUserInput(_) diff --git a/crates/protocol/src/session.rs b/crates/protocol/src/session.rs index 03185a9d..407737d5 100644 --- a/crates/protocol/src/session.rs +++ b/crates/protocol/src/session.rs @@ -13,6 +13,7 @@ use crate::ReasoningEffort; use crate::SessionId; use crate::SessionTitleState; use crate::TurnId; +use crate::TurnUsage; use crate::parse_command::ParsedCommand; use crate::protocol::FileChange; use crate::turn::TurnMetadata; @@ -60,10 +61,23 @@ pub struct SessionMetadata { pub total_cache_creation_tokens: usize, pub total_cache_read_tokens: usize, pub prompt_token_estimate: usize, - /// Last completed query display total. + /// Structured usage for the latest completed model query. /// - /// Provider-reported `total_tokens` is used when available; otherwise this - /// falls back to `input_tokens + output_tokens`. + /// Context length in the UI is based on this latest-query snapshot (display + /// total: provider `total_tokens` when available, otherwise + /// `input_tokens + output_tokens`). It is **not** the session cumulative + /// `total_input_tokens` / `total_output_tokens` / `total_tokens`. + /// + /// Refreshed on every completed model invoke so resume and clients can show + /// the latest completed-query usage rather than rolling session totals. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_query_usage: Option, + /// Legacy/derived projection of [`Self::last_query_usage`] display total. + /// + /// Prefer `last_query_usage` when available. When both are set, this should + /// equal `last_query_usage.display_total_tokens()`. Provider-reported + /// `total_tokens` is used when available; otherwise this falls back to + /// `input_tokens + output_tokens`. /// /// This value is refreshed on every completed model invoke so the UI can /// show the latest completed-query usage after each request, and it remains @@ -397,6 +411,14 @@ mod tests { total_cache_creation_tokens: 5, total_cache_read_tokens: 7, prompt_token_estimate: 21, + last_query_usage: Some(TurnUsage { + input_tokens: 10, + output_tokens: 11, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(21), + }), last_query_total_tokens: 21, status: SessionRuntimeStatus::Idle, }; @@ -406,6 +428,38 @@ mod tests { assert_eq!(restored, metadata); } + #[test] + fn session_metadata_deserializes_without_last_query_usage() { + let session_id = SessionId::new(); + let created_at = Utc::now(); + let payload = serde_json::json!({ + "session_id": session_id, + "cwd": "/tmp", + "created_at": created_at, + "updated_at": created_at, + "last_activity_at": created_at, + "title": null, + "title_state": "Unset", + "ephemeral": false, + "model": null, + "reasoning_effort": null, + "total_input_tokens": 100, + "total_output_tokens": 20, + "total_tokens": 120, + "total_cache_creation_tokens": 0, + "total_cache_read_tokens": 0, + "prompt_token_estimate": 50, + "last_query_total_tokens": 30, + "status": "idle" + }); + + let restored: SessionMetadata = + serde_json::from_value(payload).expect("deserialize legacy session metadata"); + assert_eq!(restored.last_query_usage, None); + assert_eq!(restored.last_query_total_tokens, 30); + assert_eq!(restored.total_input_tokens, 100); + } + #[test] fn message_edit_previous_params_accept_spec_shape() { let session_id = SessionId::new(); diff --git a/crates/provider/src/anthropic/messages.rs b/crates/provider/src/anthropic/messages.rs index eca3ec41..7b4bb792 100644 --- a/crates/provider/src/anthropic/messages.rs +++ b/crates/provider/src/anthropic/messages.rs @@ -42,6 +42,7 @@ use crate::ProviderAdapter; use crate::ProviderCapabilities; use crate::ProviderHttpOptions; use crate::dsml::DsmlToolCallHealer; +use crate::error::ProviderError; use crate::hosted_tools::append_anthropic_hosted_tools; use crate::http::invalid_status_error; use crate::merge_extra_body; @@ -409,10 +410,12 @@ impl ModelProviderSDK for AnthropicProvider { ) .await)? } - Err(error) => Err(anyhow::anyhow!( - "anthropic stream error for model {}: {}", - request.model, - format_eventsource_error(&error) + Err(error) => Err(stream_error( + format!( + "anthropic stream error for model {}: {}", + request.model, + format_eventsource_error(&error) + ) ))?, }; @@ -1363,6 +1366,13 @@ fn parse_stop_reason(value: &str) -> StopReason { } } +fn stream_error(message: String) -> ProviderError { + ProviderError::StreamError { + message, + bytes_received: None, + } +} + fn format_eventsource_error(error: &reqwest_eventsource::Error) -> String { let mut message = format!("{error}; debug={error:?}"); let mut source = error.source(); diff --git a/crates/provider/src/openai/chat_completions/stream.rs b/crates/provider/src/openai/chat_completions/stream.rs index 7975c9d1..b74a7fb9 100644 --- a/crates/provider/src/openai/chat_completions/stream.rs +++ b/crates/provider/src/openai/chat_completions/stream.rs @@ -21,6 +21,7 @@ use super::{ }; use crate::dsml::DsmlTextStreamFilter; use crate::dsml::DsmlToolCallHealer; +use crate::error::ProviderError; use crate::http::invalid_status_error; use crate::text_normalization::{TaggedTextFragment, TaggedTextParser}; use crate::timeout; @@ -110,10 +111,10 @@ pub(super) async fn completion_stream( ) .await)? } - Err(error) => Err(anyhow::anyhow!( + Err(error) => Err(stream_error(format!( "openai stream error for model {}: {error}", request.model - ))?, + )))?, }; match event { @@ -176,6 +177,13 @@ pub(super) async fn completion_stream( Ok(Box::pin(stream)) } +fn stream_error(message: String) -> ProviderError { + ProviderError::StreamError { + message, + bytes_received: None, + } +} + fn stream_trace_elapsed_ms() -> u128 { static STREAM_TRACE_START: OnceLock = OnceLock::new(); STREAM_TRACE_START diff --git a/crates/provider/src/openai/responses.rs b/crates/provider/src/openai/responses.rs index e1c29d1e..481c5acf 100644 --- a/crates/provider/src/openai/responses.rs +++ b/crates/provider/src/openai/responses.rs @@ -13,6 +13,7 @@ use reqwest_eventsource::{Event, EventSource}; use serde_json::{Value, json}; use tracing::debug; +use crate::error::ProviderError; use crate::hosted_tools::append_openai_responses_hosted_tools; use crate::http::invalid_status_error; use crate::text_normalization::{TaggedTextFragment, TaggedTextParser, split_tagged_text}; @@ -436,6 +437,13 @@ fn hosted_web_fetch_output(item: &Value) -> Option { .cloned() } +fn stream_error(message: String) -> ProviderError { + ProviderError::StreamError { + message, + bytes_received: None, + } +} + fn parse_usage(value: &Value) -> Option { Some(Usage { input_tokens: value @@ -567,10 +575,10 @@ impl ModelProviderSDK for OpenAIResponsesProvider { ) .await)? } - Err(error) => Err(anyhow::anyhow!( + Err(error) => Err(stream_error(format!( "openai responses stream error for model {}: {error}", request.model - ))?, + )))?, }; match event { diff --git a/crates/provider/tests/provider_http.rs b/crates/provider/tests/provider_http.rs index e2cbc209..a335ef68 100644 --- a/crates/provider/tests/provider_http.rs +++ b/crates/provider/tests/provider_http.rs @@ -244,6 +244,32 @@ async fn deepseek_anthropic_messages_hosted_web_search_stream_live_when_api_key_ assert_stream_includes_hosted_web_search_query(&events); } +#[tokio::test] +async fn anthropic_messages_stream_reports_invalid_content_type_before_content() { + let (base_url, _capture) = spawn_sse_server(ANTHROPIC_HTML_STREAM_RESPONSE).await; + let provider = AnthropicProvider::new(base_url); + let mut request = minimal_request(); + request.model = "deepseek-v4-flash".to_string(); + + let mut stream = provider + .completion_stream(request) + .await + .expect("anthropic stream"); + let event = stream + .next() + .await + .expect("stream should yield the content-type error"); + let error = event.expect_err("invalid content type should be a stream error"); + let message = error.to_string(); + + assert!(message.contains("deepseek-v4-flash"), "{message}"); + assert!( + message.contains("InvalidContentType") || message.contains("invalid header value"), + "{message}" + ); + assert!(stream.next().await.is_none()); +} + #[tokio::test] async fn anthropic_messages_stream_uses_proxy_friendly_sse_headers() { let (base_url, capture) = spawn_sse_server(ANTHROPIC_TEXT_SSE_RESPONSE).await; @@ -483,6 +509,14 @@ async fn spawn_json_server( (format!("http://{addr}"), handle) } +const ANTHROPIC_HTML_STREAM_RESPONSE: &str = concat!( + "HTTP/1.1 200 OK\r\n", + "content-type: text/html; charset=utf-8\r\n", + "connection: close\r\n", + "\r\n", + "transient upstream response", +); + const ANTHROPIC_TEXT_SSE_RESPONSE: &str = concat!( "HTTP/1.1 200 OK\r\n", "content-type: text/event-stream\r\n", diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs index 43e900ee..94d280b7 100644 --- a/crates/server/src/db.rs +++ b/crates/server/src/db.rs @@ -805,6 +805,7 @@ fn parse_session_metadata_row( total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }) @@ -906,6 +907,7 @@ mod tests { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, } diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index 1ebf74c0..46d853ad 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -233,6 +233,8 @@ pub(crate) struct RuntimeSession { pub(crate) session_approval_cache: ApprovalGrantCache, /// Turn-scoped approvals granted through ACP permission responses. pub(crate) turn_approval_cache: ApprovalGrantCache, + /// Whether the locked session context has been written to rollout storage. + pub(crate) session_context_recorded: bool, } #[cfg(test)] diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index e1b2ceb6..31de70a7 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -28,6 +28,8 @@ use devo_core::ResearchArtifactItem; use devo_core::ResearchArtifactType; use devo_core::Role; use devo_core::RolloutLine; +use devo_core::SessionContext; +use devo_core::SessionContextUpdatedLine; use devo_core::SessionId; use devo_core::SessionMetaLine; use devo_core::SessionRecord; @@ -206,6 +208,40 @@ impl RolloutStore { ) } + /// Appends the locked session context once to the durable rollout journal. + pub(crate) fn append_session_context_updated( + &self, + record: &SessionRecord, + session_context: SessionContext, + ) -> Result<()> { + self.append_line( + &record.rollout_path, + &RolloutLine::SessionContextUpdated(Box::new(SessionContextUpdatedLine { + timestamp: Utc::now(), + session_id: record.id, + session_context, + schema_version: 1, + })), + ) + } + + /// Appends one turn line, recording session context separately when needed. + pub(crate) fn append_turn_deduped( + &self, + record: &SessionRecord, + session_context_recorded: &mut bool, + turn: TurnRecord, + session_context: Option, + ) -> Result<()> { + if let Some(session_context) = session_context + && !*session_context_recorded + { + self.append_session_context_updated(record, session_context)?; + *session_context_recorded = true; + } + self.append_turn(record, turn) + } + /// Appends one compaction snapshot line to the durable rollout journal. pub(crate) fn append_compaction_snapshot( &self, @@ -582,6 +618,7 @@ struct ReplayState { session: Option, latest_turn: Option, latest_turn_metadata: Option, + latest_query_usage: Option, turn_records_by_id: HashMap, loaded_item_count: u64, next_item_seq: u64, @@ -594,7 +631,11 @@ struct ReplayState { last_input_tokens: usize, last_turn_tokens: usize, session_context: Option, + /// Session context loaded from a dedicated `SessionContextUpdated` rollout line. + /// Preserved across rollback because that line is session-scoped, not turn-scoped. + recorded_session_context: Option, latest_turn_context: Option, + session_context_recorded: bool, turn_kinds_by_id: HashMap, messages: Vec, history_items: Vec, @@ -674,11 +715,13 @@ impl ReplayState { usage.cache_read_input_tokens.unwrap_or(0) as usize; self.last_input_tokens = usage.input_tokens as usize; self.last_turn_tokens = usage.display_total_tokens(); + self.latest_query_usage = Some(usage.clone()); } self.latest_turn_metadata = Some(turn_metadata_from_record(&turn)); self.turn_kinds_by_id.insert(turn.id, turn.kind.clone()); if let Some(session_context) = turn.session_context.clone() { self.session_context = Some(session_context); + self.session_context_recorded = true; } if let Some(turn_context) = turn.turn_context.clone() { self.latest_turn_context = Some(turn_context); @@ -707,6 +750,20 @@ impl ReplayState { session.title_state = line.title_state; session.updated_at = line.timestamp; } + RolloutLine::SessionContextUpdated(line) => { + let line = *line; + if let Some(session) = self.session.as_mut() { + if session.id != line.session_id { + anyhow::bail!( + "session context update session id does not match session header" + ); + } + session.updated_at = line.timestamp; + } + self.recorded_session_context = Some(line.session_context.clone()); + self.session_context = Some(line.session_context); + self.session_context_recorded = true; + } RolloutLine::CompactionSnapshot(line) => { self.latest_compaction_snapshot = Some(*line); } @@ -973,10 +1030,10 @@ impl ReplayState { total_cache_creation_tokens: self.total_cache_creation_tokens, total_cache_read_tokens: self.total_cache_read_tokens, prompt_token_estimate: core_session.prompt_token_estimate, + last_query_usage: self.latest_query_usage.clone(), last_query_total_tokens: self - .latest_turn_metadata + .latest_query_usage .as_ref() - .and_then(|turn| turn.usage.as_ref()) .map(devo_protocol::TurnUsage::display_total_tokens) .unwrap_or(0), status: SessionRuntimeStatus::Idle, @@ -1006,6 +1063,7 @@ impl ReplayState { tool_registry: None, session_approval_cache: crate::execution::ApprovalGrantCache::default(), turn_approval_cache: crate::execution::ApprovalGrantCache::default(), + session_context_recorded: self.session_context_recorded, }) } @@ -1128,15 +1186,31 @@ impl ReplayState { .latest_turn_id .and_then(|turn_id| self.turn_records_by_id.get(&turn_id).cloned()); self.latest_turn_metadata = self.latest_turn.as_ref().map(turn_metadata_from_record); + // Prefer the dedicated SessionContextUpdated side-channel. Rollback prunes + // turn records but must not drop locked session context that was recorded + // once for the rollout file. self.session_context = self - .latest_turn - .as_ref() - .and_then(|turn| turn.session_context.clone()) + .recorded_session_context + .clone() + .or_else(|| { + self.latest_turn + .as_ref() + .and_then(|turn| turn.session_context.clone()) + }) .or_else(|| { self.session .as_ref() .and_then(|session| session.session_context.clone()) }); + self.session_context_recorded = self.recorded_session_context.is_some() + || self + .latest_turn + .as_ref() + .is_some_and(|turn| turn.session_context.is_some()) + || self + .session + .as_ref() + .is_some_and(|session| session.session_context.is_some()); self.latest_turn_context = None; self.loaded_item_count = u64::try_from(retained_item_ids.len()).unwrap_or(u64::MAX); self.next_item_seq = self @@ -1172,6 +1246,7 @@ impl ReplayState { self.total_cache_read_tokens = 0; self.last_input_tokens = 0; self.last_turn_tokens = 0; + self.latest_query_usage = None; for turn_id in &self.turn_order { let Some(turn) = self.turn_records_by_id.get(turn_id) else { @@ -1187,6 +1262,7 @@ impl ReplayState { self.total_cache_read_tokens += usage.cache_read_input_tokens.unwrap_or(0) as usize; self.last_input_tokens = usage.input_tokens as usize; self.last_turn_tokens = usage.display_total_tokens(); + self.latest_query_usage = Some(usage.clone()); } } } @@ -1451,6 +1527,7 @@ fn apply_prompt_turn_item( TurnItem::UserMessage(TextItem { text }) | TurnItem::SteerInput(TextItem { text }) => { messages.push(Message::user(text)); } + TurnItem::AgentMessage(TextItem { text }) if text.trim().is_empty() => {} TurnItem::AgentMessage(TextItem { text }) | TurnItem::Plan(TextItem { text }) | TurnItem::WebSearch(TextItem { text }) @@ -1652,6 +1729,7 @@ fn session_metadata_from_record( total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, } @@ -1794,6 +1872,7 @@ mod tests { use devo_core::SessionId; use devo_core::SessionMetaLine; use devo_core::SessionRecord; + use devo_core::SessionRollbackLine; use devo_core::SessionTitleState; use devo_core::TextItem; use devo_core::ToolCallItem; @@ -1886,6 +1965,80 @@ mod tests { assert_eq!(titles, vec!["assistant 1", "date"]); } + #[test] + fn replay_preserves_latest_query_usage_when_latest_turn_has_no_usage() { + use devo_protocol::TurnUsage; + + let now = Utc.with_ymd_and_hms(2026, 7, 8, 10, 0, 0).unwrap(); + let session_id = SessionId::new(); + let mut replay = ReplayState::default(); + let usage = TurnUsage { + input_tokens: 30, + output_tokens: 12, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(42), + }; + + replay + .apply_line(RolloutLine::Turn(Box::new(TurnLine { + timestamp: now, + turn: TurnRecord { + id: TurnId::new(), + session_id, + sequence: 1, + started_at: now, + completed_at: Some(now), + status: TurnStatus::Completed, + kind: TurnKind::Regular, + model: "model-a".into(), + model_binding_id: None, + reasoning_effort_selection: None, + request_model: "model-a".into(), + request_thinking: None, + input_token_estimate: None, + usage: Some(usage.clone()), + stop_reason: None, + failure_reason: None, + session_context: None, + turn_context: None, + schema_version: 2, + }, + }))) + .expect("apply usage turn"); + replay + .apply_line(RolloutLine::Turn(Box::new(TurnLine { + timestamp: now, + turn: TurnRecord { + id: TurnId::new(), + session_id, + sequence: 2, + started_at: now, + completed_at: Some(now), + status: TurnStatus::Failed, + kind: TurnKind::Regular, + model: "model-a".into(), + model_binding_id: None, + reasoning_effort_selection: None, + request_model: "model-a".into(), + request_thinking: None, + input_token_estimate: None, + usage: None, + stop_reason: None, + failure_reason: Some(devo_protocol::TurnFailureReason::MaxTurnRequests), + session_context: None, + turn_context: None, + schema_version: 2, + }, + }))) + .expect("apply terminal turn without usage"); + + assert_eq!(replay.latest_query_usage, Some(usage)); + assert_eq!(replay.last_turn_tokens, 42); + assert_eq!(replay.last_input_tokens, 30); + } + #[test] fn replay_prunes_superseded_turn_from_rollout_projection() { let now = Utc.with_ymd_and_hms(2026, 6, 18, 8, 0, 0).unwrap(); @@ -2172,6 +2325,7 @@ mod tests { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }, @@ -2245,6 +2399,57 @@ mod tests { ); } + #[test] + fn replay_omits_empty_agent_messages_from_history_and_prompt() { + let mut messages = Vec::new(); + let mut history_items = Vec::new(); + let mut tool_names_by_id = HashMap::new(); + + for item in [ + TurnItem::UserMessage(TextItem { + text: "hello".to_string(), + }), + TurnItem::AgentMessage(TextItem { + text: String::new(), + }), + TurnItem::AgentMessage(TextItem { + text: " \n\t".to_string(), + }), + TurnItem::AgentMessage(TextItem { + text: "visible answer".to_string(), + }), + ] { + apply_turn_item( + &mut messages, + &mut history_items, + &mut tool_names_by_id, + &TurnKind::Regular, + item, + ); + } + + assert_eq!( + history_items + .iter() + .map(|item| (item.kind.clone(), item.body.clone())) + .collect::>(), + vec![ + (crate::SessionHistoryItemKind::User, "hello".to_string()), + ( + crate::SessionHistoryItemKind::Assistant, + "visible answer".to_string(), + ), + ] + ); + assert_eq!( + messages, + vec![ + Message::user("hello"), + Message::assistant_text("visible answer"), + ] + ); + } + #[test] fn replay_backfills_tool_result_name_from_prior_tool_call() { let mut messages = Vec::new(); @@ -2600,5 +2805,315 @@ mod tests { assert_eq!(replay.session_context, Some(session_context)); assert_eq!(replay.latest_turn_context, Some(turn_context)); + assert!(replay.session_context_recorded); + } + + fn sample_session_context(base_instructions: &str) -> SessionContext { + SessionContext { + base_instructions: base_instructions.into(), + available_skills: None, + workspace_instructions: Some("workspace".into()), + locked_agents_snapshot: None, + environment: EnvironmentContext { + cwd: PathBuf::from("/tmp/root"), + shell: "bash".into(), + current_date: "2026-04-27".into(), + timezone: "UTC".into(), + }, + language: LanguageContext::default(), + persona: Persona::Default, + model: Model { + slug: "model-a".into(), + ..Model::default() + }, + reasoning_effort_selection: None, + reasoning_effort: None, + system_prompt_mode: devo_core::SystemPromptMode::CodingAgent, + } + } + + #[test] + fn replay_restores_context_from_session_context_updated_line() { + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + let now = Utc.with_ymd_and_hms(2026, 4, 27, 8, 0, 0).unwrap(); + let session_context = sample_session_context("base"); + let turn_context = TurnContext { + environment: EnvironmentContext { + cwd: PathBuf::from("/tmp/next"), + shell: "bash".into(), + current_date: "2026-04-28".into(), + timezone: "UTC".into(), + }, + persona: Persona::Default, + model: Model { + slug: "model-b".into(), + ..Model::default() + }, + reasoning_effort_selection: Some("enabled".into()), + reasoning_effort: None, + observed_agents_snapshot: None, + collaboration_mode: devo_core::CollaborationMode::Build, + }; + let mut replay = ReplayState::default(); + + replay + .apply_line(RolloutLine::SessionMeta(Box::new(SessionMetaLine { + timestamp: now, + session: SessionRecord { + id: session_id, + rollout_path: PathBuf::from("rollout.jsonl"), + created_at: now, + updated_at: now, + last_activity_at: Some(now), + source: "cli".into(), + agent_nickname: None, + agent_role: None, + agent_path: None, + model_provider: "test".into(), + model: Some("model-a".into()), + model_binding_id: None, + reasoning_effort_selection: None, + cwd: PathBuf::from("/tmp/root"), + additional_directories: Vec::new(), + cli_version: "0.1.0".into(), + title: None, + title_state: SessionTitleState::Unset, + sandbox_policy: "workspace-write".into(), + approval_mode: "on-request".into(), + tokens_used: 0, + first_user_message: None, + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + parent_session_id: None, + session_context: None, + latest_turn_context: None, + schema_version: 2, + }, + }))) + .expect("apply session meta"); + replay + .apply_line(RolloutLine::SessionContextUpdated(Box::new( + devo_core::SessionContextUpdatedLine { + timestamp: now, + session_id, + session_context: session_context.clone(), + schema_version: 1, + }, + ))) + .expect("apply session context"); + replay + .apply_line(RolloutLine::Turn(Box::new(TurnLine { + timestamp: now, + turn: TurnRecord { + id: turn_id, + session_id, + sequence: 1, + started_at: now, + completed_at: Some(now), + status: TurnStatus::Completed, + kind: devo_core::TurnKind::Regular, + model: "model-b".into(), + model_binding_id: None, + reasoning_effort_selection: Some("enabled".into()), + request_model: "model-b".into(), + request_thinking: Some("enabled".into()), + input_token_estimate: None, + usage: None, + stop_reason: None, + failure_reason: None, + session_context: None, + turn_context: Some(turn_context.clone()), + schema_version: 2, + }, + }))) + .expect("apply turn line"); + + assert_eq!(replay.session_context, Some(session_context)); + assert_eq!(replay.latest_turn_context, Some(turn_context)); + assert!(replay.session_context_recorded); + } + + #[test] + fn replay_preserves_session_context_updated_across_rollback() { + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + let now = Utc.with_ymd_and_hms(2026, 4, 27, 8, 0, 0).unwrap(); + let session_context = sample_session_context("base"); + let turn_context = TurnContext { + environment: EnvironmentContext { + cwd: PathBuf::from("/tmp/next"), + shell: "bash".into(), + current_date: "2026-04-28".into(), + timezone: "UTC".into(), + }, + persona: Persona::Default, + model: Model { + slug: "model-b".into(), + ..Model::default() + }, + reasoning_effort_selection: Some("enabled".into()), + reasoning_effort: None, + observed_agents_snapshot: None, + collaboration_mode: devo_core::CollaborationMode::Build, + }; + let mut replay = ReplayState::default(); + + replay + .apply_line(RolloutLine::SessionMeta(Box::new(SessionMetaLine { + timestamp: now, + session: SessionRecord { + id: session_id, + rollout_path: PathBuf::from("rollout.jsonl"), + created_at: now, + updated_at: now, + last_activity_at: Some(now), + source: "cli".into(), + agent_nickname: None, + agent_role: None, + agent_path: None, + model_provider: "test".into(), + model: Some("model-a".into()), + model_binding_id: None, + reasoning_effort_selection: None, + cwd: PathBuf::from("/tmp/root"), + additional_directories: Vec::new(), + cli_version: "0.1.0".into(), + title: None, + title_state: SessionTitleState::Unset, + sandbox_policy: "workspace-write".into(), + approval_mode: "on-request".into(), + tokens_used: 0, + first_user_message: None, + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + parent_session_id: None, + session_context: None, + latest_turn_context: None, + schema_version: 2, + }, + }))) + .expect("apply session meta"); + replay + .apply_line(RolloutLine::SessionContextUpdated(Box::new( + devo_core::SessionContextUpdatedLine { + timestamp: now, + session_id, + session_context: session_context.clone(), + schema_version: 1, + }, + ))) + .expect("apply session context"); + replay + .apply_line(RolloutLine::Turn(Box::new(TurnLine { + timestamp: now, + turn: TurnRecord { + id: turn_id, + session_id, + sequence: 1, + started_at: now, + completed_at: Some(now), + status: TurnStatus::Completed, + kind: TurnKind::Regular, + model: "model-b".into(), + model_binding_id: None, + reasoning_effort_selection: Some("enabled".into()), + request_model: "model-b".into(), + request_thinking: Some("enabled".into()), + input_token_estimate: None, + usage: None, + stop_reason: None, + failure_reason: None, + session_context: None, + turn_context: Some(turn_context), + schema_version: 2, + }, + }))) + .expect("apply turn line"); + replay + .apply_line(RolloutLine::SessionRollback(Box::new( + SessionRollbackLine { + timestamp: now, + session_id, + retained_turn_ids: Vec::new(), + retained_item_ids: Vec::new(), + latest_turn_id: None, + schema_version: 1, + }, + ))) + .expect("apply rollback"); + + assert_eq!(replay.session_context, Some(session_context)); + assert!(replay.session_context_recorded); + assert!(replay.latest_turn.is_none()); + } + + #[test] + fn append_turn_deduped_writes_session_context_once() { + use crate::turn::TurnMetadata; + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let data_root = dir.path().to_path_buf(); + let session_id = SessionId::new(); + let now = Utc::now(); + let rollout_store = super::RolloutStore::new(data_root.clone()); + let record = rollout_store.create_session_record( + session_id, + now, + data_root.clone(), + Vec::new(), + Some("dedupe test".into()), + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + + let session_context = sample_session_context("unique-base-instruction-marker"); + let mut session_context_recorded = false; + let turn_metadata = |sequence: u32| TurnMetadata { + turn_id: TurnId::new(), + session_id, + sequence, + status: TurnStatus::Completed, + kind: TurnKind::Regular, + model: "test-model".into(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + request_model: "test-model".into(), + request_thinking: None, + started_at: now, + completed_at: Some(now), + usage: None, + stop_reason: None, + failure_reason: None, + }; + + for sequence in 1..=2 { + let metadata = turn_metadata(sequence); + rollout_store + .append_turn_deduped( + &record, + &mut session_context_recorded, + super::build_turn_record(&metadata, None, None), + Some(session_context.clone()), + ) + .expect("append deduped turn"); + } + + assert!(session_context_recorded); + let rollout = std::fs::read_to_string(&record.rollout_path).expect("read rollout"); + assert_eq!(rollout.matches("unique-base-instruction-marker").count(), 1); + assert!(rollout.contains("SessionContextUpdated")); } } diff --git a/crates/server/src/projection.rs b/crates/server/src/projection.rs index 2adcbf69..21d5edca 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -156,6 +156,7 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option None, TurnItem::AgentMessage(TextItem { text }) | TurnItem::WebSearch(TextItem { text }) | TurnItem::ImageGeneration(TextItem { text }) @@ -522,6 +523,7 @@ impl SessionProjector for DefaultProjection { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status, } @@ -598,6 +600,24 @@ mod tests { SessionPlanStepStatus, }; + #[test] + fn history_projection_omits_empty_agent_message() { + let item = TurnItem::AgentMessage(TextItem { + text: String::new(), + }); + + assert_eq!(history_item_from_turn_item(&item), None); + } + + #[test] + fn history_projection_omits_whitespace_only_agent_message() { + let item = TurnItem::AgentMessage(TextItem { + text: " \n\t".to_string(), + }); + + assert_eq!(history_item_from_turn_item(&item), None); + } + #[test] fn history_projection_prefers_tool_result_display_content() { let item = TurnItem::ToolResult(ToolResultItem { diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index 59c8cb7d..2ef95a1a 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -41,7 +41,6 @@ use devo_core::history::compaction::CompactionKind; use devo_core::history::compaction::compact_history; use devo_core::history::summarizer::DefaultHistorySummarizer; use devo_core::message_to_response_items; -use devo_core::query; use devo_core::tools::AgentToolCoordinator; use devo_core::tools::ClientFilesystem; use devo_core::tools::ClientTerminal; @@ -135,7 +134,6 @@ use crate::goal::GoalMutation; use crate::goal_durable::GoalDurableStore; use crate::persistence::RolloutStore; use crate::persistence::build_item_record; -use crate::persistence::build_turn_record; use crate::projection::history_item_from_turn_item; pub(crate) use crate::runtime::handlers::goal::GoalStore; use crate::subagent::AgentPath; diff --git a/crates/server/src/runtime/agents.rs b/crates/server/src/runtime/agents.rs index 913078ae..5958db39 100644 --- a/crates/server/src/runtime/agents.rs +++ b/crates/server/src/runtime/agents.rs @@ -186,6 +186,7 @@ impl ServerRuntime { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: core_session.prompt_token_estimate, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }; @@ -231,6 +232,7 @@ impl ServerRuntime { tool_registry: parent_tool_registry, session_approval_cache: crate::execution::ApprovalGrantCache::default(), turn_approval_cache: crate::execution::ApprovalGrantCache::default(), + session_context_recorded: false, }; let child_state = SessionActorState::from_runtime_session(child_session); self.insert_session_actor(child_state).await; @@ -570,7 +572,7 @@ impl ServerRuntime { } async fn append_turn_start( - &self, + self: &Arc, session_id: SessionId, turn: &TurnMetadata, ) -> Result<(), ToolCallError> { @@ -585,17 +587,12 @@ impl ServerRuntime { "failed to snapshot turn persistence: {session_id}" )) })?; - let (record, session_context, turn_context) = ( - persistence_snapshot.record.clone(), - persistence_snapshot.session_context.clone(), - persistence_snapshot.latest_turn_context.clone(), - ); - if let Some(record) = record { - self.rollout_store - .append_turn( - &record, - build_turn_record(turn, session_context, turn_context), - ) + // Child agents can be the first durable write for their rollout; route through + // actor-owned dedupe so SessionContextUpdated is recorded even if the process + // crashes before terminal turn finalization. + if persistence_snapshot.record.is_some() { + self.persist_turn_line_deduped(session_id, turn) + .await .map_err(|error| ToolCallError::InternalError(error.to_string()))?; } Ok(()) diff --git a/crates/server/src/runtime/goal_continuation.rs b/crates/server/src/runtime/goal_continuation.rs index df1cab7f..cf482d67 100644 --- a/crates/server/src/runtime/goal_continuation.rs +++ b/crates/server/src/runtime/goal_continuation.rs @@ -320,7 +320,7 @@ impl ServerRuntime { } async fn append_goal_continuation_turn_start( - &self, + self: &Arc, session_id: SessionId, turn: &TurnMetadata, ) -> anyhow::Result<()> { @@ -330,16 +330,8 @@ impl ServerRuntime { let Some(persistence) = session_handle.turn_persistence_snapshot().await else { return Ok(()); }; - let (record, session_context, turn_context) = ( - persistence.record, - persistence.session_context, - persistence.latest_turn_context, - ); - if let Some(record) = record { - self.rollout_store.append_turn( - &record, - build_turn_record(turn, session_context, turn_context), - )?; + if persistence.record.is_some() { + self.persist_turn_line_deduped(session_id, turn).await?; } let goal = { let stores = self.goal_stores.lock().await; diff --git a/crates/server/src/runtime/handlers/message_edit.rs b/crates/server/src/runtime/handlers/message_edit.rs index e0fde744..8c18b4e0 100644 --- a/crates/server/src/runtime/handlers/message_edit.rs +++ b/crates/server/src/runtime/handlers/message_edit.rs @@ -197,13 +197,9 @@ impl ServerRuntime { let requested_reasoning_effort_selection = session.summary.reasoning_effort_selection.clone(); let runtime_context = Arc::clone(&session.runtime_context); - let (session_context, turn_context, collaboration_mode) = { + let collaboration_mode = { let core_session = session.core_session.lock().await; - ( - core_session.session_context.clone(), - core_session.latest_turn_context.clone(), - core_session.collaboration_mode, - ) + core_session.collaboration_mode }; drop(session); @@ -365,10 +361,10 @@ impl ServerRuntime { } else { None }; - if let Err(error) = self.rollout_store.append_turn( - &record, - build_turn_record(&replacement_turn, session_context, turn_context), - ) { + if let Err(error) = self + .persist_turn_line_deduped(params.session_id, &replacement_turn) + .await + { return self.error_response( request_id, ProtocolErrorCode::InternalError, diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index f67826f7..afd64315 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -86,6 +86,7 @@ impl ServerRuntime { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }; @@ -131,6 +132,7 @@ impl ServerRuntime { tool_registry, session_approval_cache: crate::execution::ApprovalGrantCache::default(), turn_approval_cache: crate::execution::ApprovalGrantCache::default(), + session_context_recorded: false, }; self.insert_session_actor(actor_state).await; self.subscribe_connection_to_session(connection_id, session_id, None) @@ -884,6 +886,11 @@ impl ServerRuntime { None }; + let latest_query_usage = source + .summary + .last_query_usage + .clone() + .or_else(|| latest_turn.as_ref().and_then(|turn| turn.usage.clone())); let updated_at = Utc::now(); let summary = crate::SessionMetadata { session_id, @@ -909,10 +916,10 @@ impl ServerRuntime { total_cache_creation_tokens: source_core_session.total_cache_creation_tokens, total_cache_read_tokens: source_core_session.total_cache_read_tokens, prompt_token_estimate: source_core_session.prompt_token_estimate, - last_query_total_tokens: latest_turn + last_query_usage: latest_query_usage.clone(), + last_query_total_tokens: latest_query_usage .as_ref() - .and_then(|turn| turn.usage.as_ref()) - .map(|usage| usage.input_tokens as usize + usage.output_tokens as usize) + .map(devo_protocol::TurnUsage::display_total_tokens) .unwrap_or(0), status: SessionRuntimeStatus::Idle, }; @@ -945,6 +952,7 @@ impl ServerRuntime { tool_registry: source.tool_registry.clone(), session_approval_cache: crate::execution::ApprovalGrantCache::default(), turn_approval_cache: crate::execution::ApprovalGrantCache::default(), + session_context_recorded: source.session_context_recorded, }) } } diff --git a/crates/server/src/runtime/handlers/turn.rs b/crates/server/src/runtime/handlers/turn.rs index b27ade67..660f5a69 100644 --- a/crates/server/src/runtime/handlers/turn.rs +++ b/crates/server/src/runtime/handlers/turn.rs @@ -316,15 +316,10 @@ impl ServerRuntime { self.maybe_prepare_title_generation_from_user_input(params.session_id, &display_input) .await; if let Some(persistence) = session_handle.turn_persistence_snapshot().await - && let Some(record) = persistence.record - && let Err(error) = self.rollout_store.append_turn( - &record, - build_turn_record( - &turn, - persistence.session_context, - persistence.latest_turn_context, - ), - ) + && persistence.record.is_some() + && let Err(error) = self + .persist_turn_line_deduped(params.session_id, &turn) + .await { let _ = session_handle .clear_active_turn_if_matches(turn.turn_id) @@ -529,10 +524,10 @@ impl ServerRuntime { } if let Some(persistence) = session_handle.turn_persistence_snapshot().await - && let Some(record) = persistence.record + && persistence.record.is_some() && let Err(error) = self - .rollout_store - .append_turn(&record, build_turn_record(&turn, None, None)) + .persist_turn_line_deduped(params.session_id, &turn) + .await { self.clear_active_turn_reservation(&session_handle, turn.turn_id) .await; diff --git a/crates/server/src/runtime/handlers/turn_interrupt.rs b/crates/server/src/runtime/handlers/turn_interrupt.rs index 7c455925..3fb05840 100644 --- a/crates/server/src/runtime/handlers/turn_interrupt.rs +++ b/crates/server/src/runtime/handlers/turn_interrupt.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use std::time::Duration; use super::super::*; -use crate::persistence::build_turn_record; const TURN_INTERRUPT_TERMINAL_TIMEOUT: Duration = Duration::from_secs(5); @@ -133,7 +132,9 @@ impl ServerRuntime { self.clear_active_turn_runtime_handles(session_id).await; let deferred = session_handle.take_deferred_items().await; - if let Some((item_id, item_seq, text)) = deferred.assistant { + if let Some((item_id, item_seq, text)) = deferred.assistant + && !text.trim().is_empty() + { self.complete_item( session_id, interrupted_turn.turn_id, @@ -158,15 +159,10 @@ impl ServerRuntime { .await; } if let Some(persistence) = session_handle.turn_persistence_snapshot().await - && let Some(record) = persistence.record - && let Err(error) = self.rollout_store.append_turn( - &record, - build_turn_record( - &interrupted_turn, - persistence.session_context, - persistence.latest_turn_context, - ), - ) + && persistence.record.is_some() + && let Err(error) = self + .persist_turn_line_deduped(session_id, &interrupted_turn) + .await { return self.error_response( request_id, diff --git a/crates/server/src/runtime/lifecycle.rs b/crates/server/src/runtime/lifecycle.rs index de3f10fa..73522e13 100644 --- a/crates/server/src/runtime/lifecycle.rs +++ b/crates/server/src/runtime/lifecycle.rs @@ -251,7 +251,9 @@ impl ServerRuntime { continue; }; - if let Some((item_id, item_seq, text)) = snapshot.deferred_assistant { + if let Some((item_id, item_seq, text)) = snapshot.deferred_assistant + && !text.trim().is_empty() + { self.complete_item( session_id, turn_id, @@ -284,16 +286,10 @@ impl ServerRuntime { continue; } - if let Some(record) = snapshot.record - && let Some(persistence) = session_handle.turn_persistence_snapshot().await - && let Err(error) = self.rollout_store.append_turn( - &record, - build_turn_record( - &interrupted_turn, - persistence.session_context, - persistence.latest_turn_context, - ), - ) + if snapshot.record.is_some() + && let Err(error) = self + .persist_turn_line_deduped(session_id, &interrupted_turn) + .await { tracing::warn!( session_id = %session_id, diff --git a/crates/server/src/runtime/research.rs b/crates/server/src/runtime/research.rs index 153482f3..e4fd1f87 100644 --- a/crates/server/src/runtime/research.rs +++ b/crates/server/src/runtime/research.rs @@ -292,15 +292,10 @@ impl ServerRuntime { ) .await; if let Some(persistence) = session_handle.turn_persistence_snapshot().await - && let Some(record) = persistence.record - && let Err(error) = self.rollout_store.append_turn( - &record, - build_turn_record( - &turn, - persistence.session_context, - persistence.latest_turn_context, - ), - ) + && persistence.record.is_some() + && let Err(error) = self + .persist_turn_line_deduped(params.session_id, &turn) + .await { self.clear_active_turn_runtime_handles(params.session_id) .await; @@ -816,7 +811,7 @@ impl ServerRuntime { .await?; let artifact = stage.artifact(); let query_result = { - let query_future = query( + let query_future = devo_core::query_with_options( scratch, &stage_turn_config, runtime @@ -825,6 +820,9 @@ impl ServerRuntime { Arc::clone(®istry), &tool_runtime, Some(callback), + devo_core::QueryOptions { + cancel_token: Some(tool_runtime.cancel_token().clone()), + }, ); tokio::pin!(query_future); let mut event_channel_closed = false; @@ -1136,20 +1134,9 @@ impl ServerRuntime { turn.usage = Some(usage.clone()); if let Some(session_handle) = self.session(session_id).await { session_handle.set_session_idle(Some(turn.clone())).await; - } - if let Some(session_handle) = self.session(session_id).await - && let Some(persistence) = session_handle.turn_persistence_snapshot().await - && let Some(record) = persistence.record - && let Err(error) = self.rollout_store.append_turn( - &record, - build_turn_record( - &turn, - persistence.session_context, - persistence.latest_turn_context, - ), - ) - { - tracing::warn!(session_id = %session_id, error = %error, "failed to persist research turn finish"); + if let Err(error) = self.persist_turn_line_deduped(session_id, &turn).await { + tracing::warn!(session_id = %session_id, error = %error, "failed to persist research turn finish"); + } } self.finalize_turn_workspace_changes(session_id, &turn) .await; diff --git a/crates/server/src/runtime/research_events.rs b/crates/server/src/runtime/research_events.rs index 52b88030..570ba556 100644 --- a/crates/server/src/runtime/research_events.rs +++ b/crates/server/src/runtime/research_events.rs @@ -35,6 +35,11 @@ impl ServerRuntime { artifact: Option<&StreamedResearchArtifact>, event: QueryEvent, ) -> anyhow::Result<()> { + if let QueryEvent::ProviderRetryStatus(status) = event { + self.broadcast_provider_retry_status(context.session_id, context.turn_id, status) + .await; + return Ok(()); + } match capture { ResearchStageCapture::Clarification(capture) => { let artifact = artifact @@ -74,6 +79,34 @@ impl ServerRuntime { Ok(()) } + async fn broadcast_provider_retry_status( + &self, + session_id: SessionId, + turn_id: TurnId, + status: devo_core::ProviderRetryStatus, + ) { + self.broadcast_event(ServerEvent::TurnProviderRetryStatus( + devo_protocol::TurnProviderRetryStatusPayload { + session_id, + turn_id, + attempt: status.attempt, + backoff_ms: status.backoff_ms, + provider: status.provider, + model: status.model, + phase: match status.phase { + devo_core::QueryProviderRetryPhase::Scheduled => { + devo_protocol::ProviderRetryPhase::Scheduled + } + devo_core::QueryProviderRetryPhase::Resumed => { + devo_protocol::ProviderRetryPhase::Resumed + } + }, + message: status.message, + }, + )) + .await; + } + async fn handle_research_artifact_query_event( &self, context: ResearchArtifactEventContext<'_>, @@ -144,7 +177,8 @@ impl ServerRuntime { QueryEvent::ToolUseStart { .. } | QueryEvent::ToolExecutionStart { .. } | QueryEvent::ToolProgress { .. } - | QueryEvent::ToolResult { .. } => {} + | QueryEvent::ToolResult { .. } + | QueryEvent::ProviderRetryStatus(_) => {} } } @@ -303,7 +337,9 @@ impl ServerRuntime { self.complete_reasoning_item(session_id, turn_id, &mut capture.reasoning) .await; } - QueryEvent::TurnComplete { .. } | QueryEvent::ToolProgress { .. } => {} + QueryEvent::TurnComplete { .. } + | QueryEvent::ToolProgress { .. } + | QueryEvent::ProviderRetryStatus(_) => {} } } @@ -454,7 +490,7 @@ impl ServerRuntime { QueryEvent::TurnComplete { .. } => { capture.turn_completed = true; } - QueryEvent::ToolProgress { .. } => {} + QueryEvent::ToolProgress { .. } | QueryEvent::ProviderRetryStatus(_) => {} } } @@ -554,7 +590,8 @@ impl ServerRuntime { } QueryEvent::TurnComplete { .. } | QueryEvent::ToolExecutionStart { .. } - | QueryEvent::ToolProgress { .. } => {} + | QueryEvent::ToolProgress { .. } + | QueryEvent::ProviderRetryStatus(_) => {} } } } diff --git a/crates/server/src/runtime/session_actor/commands.rs b/crates/server/src/runtime/session_actor/commands.rs index 2d59f389..8b504a87 100644 --- a/crates/server/src/runtime/session_actor/commands.rs +++ b/crates/server/src/runtime/session_actor/commands.rs @@ -200,6 +200,11 @@ pub(crate) enum SessionCommand { EndInlineTurn { reply: oneshot::Sender<()>, }, + PersistTurnLine { + runtime: Arc, + turn: TurnMetadata, + reply: oneshot::Sender>, + }, Shutdown { reply: oneshot::Sender<()>, }, diff --git a/crates/server/src/runtime/session_actor/handle.rs b/crates/server/src/runtime/session_actor/handle.rs index 2c18320a..7ade2ff7 100644 --- a/crates/server/src/runtime/session_actor/handle.rs +++ b/crates/server/src/runtime/session_actor/handle.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use std::sync::Arc; +use anyhow::Context; use devo_protocol::ApprovalScopeValue; use devo_protocol::CollaborationMode; use devo_protocol::PendingInputItem; @@ -194,6 +195,25 @@ impl SessionHandle { reply_rx.await.ok() } + pub(crate) async fn persist_turn_line( + &self, + runtime: Arc, + turn: crate::TurnMetadata, + ) -> anyhow::Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + if !self + .send(SessionCommand::PersistTurnLine { + runtime, + turn, + reply: reply_tx, + }) + .await + { + anyhow::bail!("session actor shut down"); + } + reply_rx.await.context("persist turn reply dropped")? + } + pub(crate) async fn shell_exec_context( &self, cwd: std::path::PathBuf, diff --git a/crates/server/src/runtime/session_actor/loop_.rs b/crates/server/src/runtime/session_actor/loop_.rs index 10d3e1f3..ba19dfbf 100644 --- a/crates/server/src/runtime/session_actor/loop_.rs +++ b/crates/server/src/runtime/session_actor/loop_.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use anyhow::Context; use chrono::Utc; use devo_core::SessionTitleFinalSource; use devo_core::SessionTitleState; @@ -17,6 +18,7 @@ use super::state::SessionActorState; use super::turn::execute_turn_in_actor; use crate::SessionRuntimeStatus; use crate::execution::PendingApproval; +use crate::persistence::build_turn_record; use crate::runtime::session_model_selection; pub(super) async fn run_session_actor( @@ -110,8 +112,6 @@ pub(super) async fn run_session_actor( SessionCommand::GetTurnPersistenceSnapshot { reply } => { let _ = reply.send(TurnPersistenceSnapshot { record: state.record.clone(), - session_context: state.core.session_context.clone(), - latest_turn_context: state.core.latest_turn_context.clone(), }); } SessionCommand::GetShellExecContext { cwd, reply } => { @@ -280,6 +280,7 @@ pub(super) async fn run_session_actor( SessionCommand::BeginActiveTurn { turn, turn_config } => { let now = Utc::now(); apply_turn_config_to_session_summary(&mut state.summary, &turn_config); + ensure_session_context_locked(&mut state, &turn_config); state.summary.status = SessionRuntimeStatus::ActiveTurn; state.summary.updated_at = now; state.summary.last_activity_at = now; @@ -315,6 +316,7 @@ pub(super) async fn run_session_actor( SessionCommand::ActivateQueuedTurn { turn, turn_config } => { let now = Utc::now(); apply_turn_config_to_session_summary(&mut state.summary, &turn_config); + ensure_session_context_locked(&mut state, &turn_config); state.summary.status = SessionRuntimeStatus::ActiveTurn; state.summary.updated_at = now; state.summary.last_activity_at = now; @@ -367,6 +369,9 @@ pub(super) async fn run_session_actor( state.latest_turn = Some(turn.clone()); turn }); + if interrupted.is_some() { + state.core.mark_last_turn_interrupted(); + } let _ = reply.send(interrupted); } SessionCommand::ExportRuntimeSession { reply } => { @@ -468,6 +473,7 @@ pub(super) async fn run_session_actor( } let now = Utc::now(); apply_turn_config_to_session_summary(&mut state.summary, &turn_config); + ensure_session_context_locked(&mut state, &turn_config); state.summary.status = SessionRuntimeStatus::ActiveTurn; state.summary.updated_at = now; state.summary.last_activity_at = now; @@ -499,6 +505,25 @@ pub(super) async fn run_session_actor( } let _ = reply.send(()); } + SessionCommand::PersistTurnLine { + runtime, + turn, + reply, + } => { + let result = (|| { + let record = state + .record + .as_ref() + .context("missing session record for turn persistence")?; + runtime.rollout_store.append_turn_deduped( + record, + &mut state.session_context_recorded, + build_turn_record(&turn, None, state.core.latest_turn_context.clone()), + state.core.session_context.clone(), + ) + })(); + let _ = reply.send(result); + } SessionCommand::Shutdown { reply } => { let _ = reply.send(()); break; @@ -516,6 +541,27 @@ fn apply_turn_config_to_session_summary( summary.reasoning_effort_selection = turn_config.reasoning_effort_selection.clone(); } +/// Capture locked session context before the first durable turn start is written. +/// +/// This must happen before `PersistTurnLine` so a process crash between turn start +/// persistence and query finalization still leaves `SessionContextUpdated` in the +/// rollout journal. +fn ensure_session_context_locked(state: &mut SessionActorState, turn_config: &TurnConfig) { + if state.core.session_context.is_some() { + return; + } + let agents_md_manager = devo_core::AgentsMdManager::new(state.core.config.agents_md.clone()); + let locked_agents_snapshot = + devo_core::load_workspace_instructions(&state.core.cwd, &agents_md_manager); + state.core.session_context = Some(devo_core::SessionContext::capture( + &turn_config.model, + turn_config.reasoning_effort_selection.as_deref(), + &state.core.cwd, + locked_agents_snapshot, + state.core.config.available_skills_instructions.clone(), + )); +} + fn pop_queued_turn_input_data( item: devo_protocol::PendingInputItem, ) -> Option { diff --git a/crates/server/src/runtime/session_actor/snapshots.rs b/crates/server/src/runtime/session_actor/snapshots.rs index b8143a86..4f48a8dc 100644 --- a/crates/server/src/runtime/session_actor/snapshots.rs +++ b/crates/server/src/runtime/session_actor/snapshots.rs @@ -3,9 +3,7 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; use devo_core::SessionConfig; -use devo_core::SessionContext; use devo_core::SessionRecord; -use devo_core::TurnContext; use devo_core::TurnKind; use devo_protocol::PendingInputItem; use devo_protocol::SessionId; @@ -44,8 +42,6 @@ pub(crate) struct HookContextSnapshot { #[derive(Clone)] pub(crate) struct TurnPersistenceSnapshot { pub(crate) record: Option, - pub(crate) session_context: Option, - pub(crate) latest_turn_context: Option, } /// Permission and tool context for shell-command turns. diff --git a/crates/server/src/runtime/session_actor/state.rs b/crates/server/src/runtime/session_actor/state.rs index b416e898..ee1b0b26 100644 --- a/crates/server/src/runtime/session_actor/state.rs +++ b/crates/server/src/runtime/session_actor/state.rs @@ -88,6 +88,7 @@ pub(crate) struct SessionActorState { pub(crate) tool_registry: Option>, pub(crate) session_approval_cache: crate::execution::ApprovalGrantCache, pub(crate) turn_approval_cache: crate::execution::ApprovalGrantCache, + pub(crate) session_context_recorded: bool, } impl SessionActorState { @@ -167,6 +168,7 @@ impl SessionActorState { tool_registry: session.tool_registry, session_approval_cache: session.session_approval_cache, turn_approval_cache: session.turn_approval_cache, + session_context_recorded: session.session_context_recorded, } } @@ -197,6 +199,7 @@ impl SessionActorState { tool_registry: self.tool_registry.clone(), session_approval_cache: self.session_approval_cache.clone(), turn_approval_cache: self.turn_approval_cache.clone(), + session_context_recorded: self.session_context_recorded, } } } diff --git a/crates/server/src/runtime/session_actor/turn_inline.rs b/crates/server/src/runtime/session_actor/turn_inline.rs index 872ca111..eec457a5 100644 --- a/crates/server/src/runtime/session_actor/turn_inline.rs +++ b/crates/server/src/runtime/session_actor/turn_inline.rs @@ -82,6 +82,7 @@ impl TurnInlineState { state.summary.total_tokens = self.summary.total_tokens; state.summary.total_cache_creation_tokens = self.summary.total_cache_creation_tokens; state.summary.total_cache_read_tokens = self.summary.total_cache_read_tokens; + state.summary.last_query_usage = self.summary.last_query_usage.clone(); state.summary.last_query_total_tokens = self.summary.last_query_total_tokens; state.core.total_input_tokens = self.summary.total_input_tokens; state.core.total_output_tokens = self.summary.total_output_tokens; diff --git a/crates/server/src/runtime/subagent_usage.rs b/crates/server/src/runtime/subagent_usage.rs index 8ec0db51..a135a7c5 100644 --- a/crates/server/src/runtime/subagent_usage.rs +++ b/crates/server/src/runtime/subagent_usage.rs @@ -667,6 +667,8 @@ impl ParentUsageSnapshot { summary.total_tokens = self.session_totals.total_tokens; summary.total_cache_creation_tokens = self.session_totals.cache_creation_input_tokens; summary.total_cache_read_tokens = self.session_totals.cache_read_input_tokens; + // Context length is latest query usage, not cumulative session totals. + summary.last_query_usage = Some(self.latest_query_usage.to_turn_usage()); summary.last_query_total_tokens = self.latest_query_usage.total_tokens; } } diff --git a/crates/server/src/runtime/turn_exec/event_stream.rs b/crates/server/src/runtime/turn_exec/event_stream.rs index 2a672927..2dbdc8b9 100644 --- a/crates/server/src/runtime/turn_exec/event_stream.rs +++ b/crates/server/src/runtime/turn_exec/event_stream.rs @@ -101,6 +101,29 @@ pub(crate) fn spawn_turn_event_stream( while let Some(event) = event_rx.recv().await { log_dequeued_query_event(&event); match event { + devo_core::QueryEvent::ProviderRetryStatus(status) => { + runtime + .broadcast_event(ServerEvent::TurnProviderRetryStatus( + devo_protocol::TurnProviderRetryStatusPayload { + session_id, + turn_id: turn_for_events.turn_id, + attempt: status.attempt, + backoff_ms: status.backoff_ms, + provider: status.provider, + model: status.model, + phase: match status.phase { + devo_core::QueryProviderRetryPhase::Scheduled => { + devo_protocol::ProviderRetryPhase::Scheduled + } + devo_core::QueryProviderRetryPhase::Resumed => { + devo_protocol::ProviderRetryPhase::Resumed + } + }, + message: status.message, + }, + )) + .await; + } devo_core::QueryEvent::TextDelta(text) => { if let Some(parser) = proposed_plan_parser.as_mut() { let segments = parser.push_str(&text); @@ -323,6 +346,15 @@ pub(crate) fn spawn_turn_event_stream( turn_for_events.turn_id, ) .await; + complete_pending_tool_calls_as_interrupted( + &runtime, + session_id, + turn_for_events.turn_id, + &turn_for_plan_updates, + &tool_names_by_id, + &mut pending_tool_calls, + ) + .await; tracing::debug!( session_id = %session_id, turn_id = %turn_for_events.turn_id, @@ -593,6 +625,67 @@ async fn handle_tool_result( .await; } +async fn complete_pending_tool_calls_as_interrupted( + runtime: &Arc, + session_id: SessionId, + turn_id: TurnId, + turn_for_plan_updates: &crate::TurnMetadata, + tool_names_by_id: &std::collections::HashMap, + pending_tool_calls: &mut std::collections::HashMap, +) { + if pending_tool_calls.is_empty() { + return; + } + let pending = std::mem::take(pending_tool_calls); + for (tool_use_id, pending) in pending { + let tool_name = tool_names_by_id + .get(&tool_use_id) + .cloned() + .unwrap_or_default(); + let content = devo_core::tools::ToolContent::Text( + devo_core::tools::INTERRUPTED_TOOL_RESULT_MESSAGE.to_string(), + ); + let summary = if tool_name.is_empty() { + "interrupted".to_string() + } else { + format!("{tool_name}: interrupted") + }; + let suppress_separate_result = if pending.item_id.is_some() && pending.item_seq.is_some() { + complete_pending_tool_call( + runtime, + session_id, + turn_id, + turn_for_plan_updates, + &tool_use_id, + (!tool_name.is_empty()).then(|| tool_name.clone()), + &pending, + &content, + None, + /*is_error*/ true, + &summary, + ) + .await + } else { + false + }; + if !suppress_separate_result { + tool_results::emit_tool_result_item( + runtime, + session_id, + turn_id, + tool_use_id, + (!tool_name.is_empty()).then_some(tool_name), + Some(pending.input), + content, + None, + /*is_error*/ true, + summary, + ) + .await; + } + } +} + async fn handle_tool_progress( runtime: &Arc, session_id: SessionId, diff --git a/crates/server/src/runtime/turn_exec/finalize.rs b/crates/server/src/runtime/turn_exec/finalize.rs index e8d7a56d..d72b8469 100644 --- a/crates/server/src/runtime/turn_exec/finalize.rs +++ b/crates/server/src/runtime/turn_exec/finalize.rs @@ -112,6 +112,11 @@ impl ServerRuntime { session_prompt_token_estimate, ) .await; + if matches!(final_turn.status, TurnStatus::Interrupted) { + state.core.mark_last_turn_interrupted(); + } else { + state.core.last_turn_interrupted = false; + } self.clear_btw_input_queue(state, session_id).await; self.append_terminal_turn_record(state, session_id, &final_turn) .await; @@ -168,6 +173,9 @@ impl ServerRuntime { state.summary.total_cache_read_tokens = session_total_cache_read_tokens; state.summary.prompt_token_estimate = session_prompt_token_estimate; if let Some(usage) = &final_turn.usage { + // Context length uses latest-query display total, not session + // cumulative total_input/output/tokens. + state.summary.last_query_usage = Some(usage.clone()); state.summary.last_query_total_tokens = usage.display_total_tokens(); } state.core.total_input_tokens = session_total_input_tokens; @@ -223,17 +231,19 @@ impl ServerRuntime { async fn append_terminal_turn_record( self: &Arc, - state: &SessionActorState, + state: &mut SessionActorState, session_id: SessionId, final_turn: &crate::TurnMetadata, ) { let record = state.record.clone(); - let session_context = state.core.session_context.clone(); let turn_context = state.core.latest_turn_context.clone(); + let session_context = state.core.session_context.clone(); if let Some(record) = record - && let Err(error) = self.rollout_store.append_turn( + && let Err(error) = self.rollout_store.append_turn_deduped( &record, - build_turn_record(final_turn, session_context, turn_context), + &mut state.session_context_recorded, + build_turn_record(final_turn, None, turn_context), + session_context, ) { tracing::warn!(session_id = %session_id, error = %error, "failed to persist terminal turn line"); diff --git a/crates/server/src/runtime/turn_exec/item_stream.rs b/crates/server/src/runtime/turn_exec/item_stream.rs index aa439071..2b69739d 100644 --- a/crates/server/src/runtime/turn_exec/item_stream.rs +++ b/crates/server/src/runtime/turn_exec/item_stream.rs @@ -36,6 +36,9 @@ pub(super) async fn complete_assistant_item( item_seq: u64, text: String, ) { + if text.trim().is_empty() { + return; + } runtime .complete_item( session_id, diff --git a/crates/server/src/runtime/turn_exec/mod.rs b/crates/server/src/runtime/turn_exec/mod.rs index b5796be4..895c93f5 100644 --- a/crates/server/src/runtime/turn_exec/mod.rs +++ b/crates/server/src/runtime/turn_exec/mod.rs @@ -16,6 +16,8 @@ pub(crate) use types::ExecuteTurnRequest; use std::sync::Arc; +use anyhow::Context; + use super::*; impl ServerRuntime { @@ -27,6 +29,20 @@ impl ServerRuntime { handle.execute_turn(Arc::clone(&self), request).await; } + pub(crate) async fn persist_turn_line_deduped( + self: &Arc, + session_id: devo_core::SessionId, + turn: &crate::TurnMetadata, + ) -> anyhow::Result<()> { + let handle = self + .session(session_id) + .await + .context("session not found")?; + handle + .persist_turn_line(Arc::clone(self), turn.clone()) + .await + } + pub(super) async fn prepare_turn_execution_for_actor( self: &Arc, state: &mut SessionActorState, diff --git a/crates/server/src/runtime/turn_exec/query.rs b/crates/server/src/runtime/turn_exec/query.rs index b2a0bc58..98fc7064 100644 --- a/crates/server/src/runtime/turn_exec/query.rs +++ b/crates/server/src/runtime/turn_exec/query.rs @@ -4,7 +4,7 @@ use devo_core::tools::{ AgentToolCoordinator, ClientFilesystem, ClientTerminal, ToolAgentScope, ToolCall, ToolExecutionOptions, ToolRuntime, ToolRuntimeContext, }; -use devo_core::{Message, QueryEvent, TurnConfig, query}; +use devo_core::{Message, QueryEvent, QueryOptions, TurnConfig, query_with_options}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -170,17 +170,43 @@ impl ServerRuntime { // the actor's reply, not the actor itself. Race the query against the // turn's cancellation token so interrupting a turn actually unblocks the // actor's mailbox instead of hanging it forever. - let result = tokio::select! { - biased; - () = query_cancel_token.cancelled() => Err(devo_core::AgentError::Aborted), - result = query( + // + // Important: do not drop `query_with_options` immediately on cancel. + // Tool handlers observe the same cancel token and still need a chance to + // return interrupted tool results that keep tool-use / tool-result pairs + // intact in session history. + let result = { + let mut query_future = std::pin::pin!(query_with_options( &mut state.core, turn_config, runtime_context.provider_for_route(turn_config.provider_route.clone()), registry, &runtime, Some(callback), - ) => result, + QueryOptions { + cancel_token: Some(query_cancel_token.clone()), + }, + )); + tokio::select! { + biased; + () = query_cancel_token.cancelled() => { + // Give the query loop a short window to drain cancelled tool + // results into session messages / event stream, then map any + // non-abort completion to Interrupted as well. + match tokio::time::timeout( + std::time::Duration::from_secs(2), + &mut query_future, + ) + .await + { + Ok(Ok(())) | Ok(Err(devo_core::AgentError::Aborted)) | Err(_) => { + Err(devo_core::AgentError::Aborted) + } + Ok(Err(error)) => Err(error), + } + } + result = &mut query_future => result, + } }; TurnQueryOutcome { result, diff --git a/crates/server/src/runtime/turn_exec/shell.rs b/crates/server/src/runtime/turn_exec/shell.rs index 6d2b80ac..25b27ce3 100644 --- a/crates/server/src/runtime/turn_exec/shell.rs +++ b/crates/server/src/runtime/turn_exec/shell.rs @@ -172,15 +172,10 @@ impl ServerRuntime { return; }; if let Some(persistence) = session_handle.turn_persistence_snapshot().await - && let Some(record) = persistence.record - && let Err(error) = self.rollout_store.append_turn( - &record, - build_turn_record( - &final_turn, - persistence.session_context, - persistence.latest_turn_context, - ), - ) + && persistence.record.is_some() + && let Err(error) = self + .persist_turn_line_deduped(session_id, &final_turn) + .await { tracing::warn!(session_id = %session_id, error = %error, "failed to persist shell command turn line"); } diff --git a/crates/server/src/runtime/turn_exec/trace.rs b/crates/server/src/runtime/turn_exec/trace.rs index 72d8c534..26b1194c 100644 --- a/crates/server/src/runtime/turn_exec/trace.rs +++ b/crates/server/src/runtime/turn_exec/trace.rs @@ -13,6 +13,7 @@ pub(super) fn stream_trace_elapsed_ms() -> u128 { pub(super) fn query_event_trace_kind(event: &QueryEvent) -> &'static str { match event { + QueryEvent::ProviderRetryStatus(_) => "provider_retry_status", QueryEvent::TextDelta(_) => "text_delta", QueryEvent::ReasoningDelta(_) => "reasoning_delta", QueryEvent::ReasoningCompleted => "reasoning_completed", @@ -40,6 +41,7 @@ pub(super) fn query_event_trace_delta_len(event: &QueryEvent) -> usize { progress: devo_core::tools::ToolProgress::Terminal { .. }, .. } + | QueryEvent::ProviderRetryStatus(_) | QueryEvent::ReasoningCompleted | QueryEvent::ToolUseStart { .. } | QueryEvent::ToolExecutionStart { .. } diff --git a/crates/server/tests/goal_lifecycle_interrupts.rs b/crates/server/tests/goal_lifecycle_interrupts.rs index 52611d41..24c6f9ad 100644 --- a/crates/server/tests/goal_lifecycle_interrupts.rs +++ b/crates/server/tests/goal_lifecycle_interrupts.rs @@ -201,19 +201,14 @@ async fn replacing_goal_interrupts_old_hidden_turn_and_starts_new_goal_cleanly() let response: devo_server::SuccessResponse = serde_json::from_value(status_response)?; assert_eq!( - response.result.goal.map(|goal| { - ( - goal.objective, - goal.status, - goal.tokens_used, - goal.time_used_seconds, - ) - }), + response + .result + .goal + .map(|goal| { (goal.objective, goal.status, goal.tokens_used,) }), Some(( "new replacement goal".to_string(), devo_protocol::ThreadGoalStatus::Active, 0, - 0, )) ); assert_eq!(provider.requests.load(Ordering::SeqCst), 2); diff --git a/crates/server/tests/persistence_resume.rs b/crates/server/tests/persistence_resume.rs index ef504fe3..ffb1c5ae 100644 --- a/crates/server/tests/persistence_resume.rs +++ b/crates/server/tests/persistence_resume.rs @@ -1877,6 +1877,16 @@ async fn first_usage_update_after_resume_preserves_historical_session_totals() - .result .session; assert_eq!(resumed.total_input_tokens, 100); + // Context length comes from the latest query snapshot, not cumulative totals. + let last_query_usage = resumed + .last_query_usage + .expect("resumed session should include last_query_usage"); + assert_eq!(last_query_usage.input_tokens, 100); + assert_eq!(last_query_usage.output_tokens, 25); + assert_eq!(last_query_usage.total_tokens, Some(125)); + assert_eq!(resumed.last_query_total_tokens, 125); + // Cumulative session totals remain distinct from latest-query context length. + assert_ne!(resumed.total_input_tokens, resumed.last_query_total_tokens); let second_turn_response = rebuilt_runtime .handle_incoming( @@ -1905,6 +1915,185 @@ async fn first_usage_update_after_resume_preserves_historical_session_totals() - Ok(()) } +#[tokio::test] +async fn rollout_writes_base_instructions_once_across_multiple_turns() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path())?; + let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; + + let start_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 1, + "method": "session/start", + "params": { + "cwd": data_root.path(), + "ephemeral": false, + "title": "Rollout dedupe", + "model": "test-model" + } + }), + ) + .await + .context("session/start response")?; + let session_id = serde_json::from_value::< + devo_server::SuccessResponse, + >(start_response)? + .result + .session + .session_id; + + for (request_id, prompt) in [(2, "first turn"), (3, "second turn")] { + let turn_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": request_id, + "method": "_devo/turn/start", + "params": { + "session_id": session_id, + "input": [{ "type": "text", "text": prompt }], + "model": null, + "sandbox": null, + "approval_policy": null, + "cwd": null + } + }), + ) + .await + .context("turn/start response")?; + let _: devo_server::SuccessResponse = + serde_json::from_value(turn_response)?; + wait_for_turn_completed(&mut notifications_rx).await?; + } + + runtime.refresh_session_index()?; + let db = devo_server::db::Database::open(data_root.path().join("test_persistence.db"))?; + let index = db.get_session_index(&session_id)?.expect("indexed session"); + let rollout_path = index.rollout_path.expect("rollout path"); + let rollout = std::fs::read_to_string(&rollout_path)?; + let mut session_context_lines = 0usize; + let mut turn_lines_with_session_context = 0usize; + for line in rollout.lines().filter(|line| !line.trim().is_empty()) { + let rollout_line: RolloutLine = serde_json::from_str(line)?; + match rollout_line { + RolloutLine::SessionContextUpdated(_) => session_context_lines += 1, + RolloutLine::Turn(turn_line) if turn_line.turn.session_context.is_some() => { + turn_lines_with_session_context += 1; + } + _ => {} + } + } + assert_eq!(session_context_lines, 1); + assert_eq!(turn_lines_with_session_context, 0); + + let rebuilt_runtime = build_runtime(data_root.path())?; + let (rebuilt_connection_id, _) = initialize_connection(&rebuilt_runtime).await?; + let resume_response = rebuilt_runtime + .handle_incoming( + rebuilt_connection_id, + serde_json::json!({ + "id": 4, + "method": "_devo/session/resume", + "params": { "session_id": session_id } + }), + ) + .await + .context("session/resume response")?; + let _: devo_server::SuccessResponse = + serde_json::from_value(resume_response)?; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_persists_session_context_before_turn_completes() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path())?; + let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; + + let start_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 1, + "method": "session/start", + "params": { + "cwd": data_root.path(), + "ephemeral": false, + "title": "Turn start crash window", + "model": "test-model" + } + }), + ) + .await + .context("session/start response")?; + let session_id = serde_json::from_value::< + devo_server::SuccessResponse, + >(start_response)? + .result + .session + .session_id; + + let turn_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 2, + "method": "_devo/turn/start", + "params": { + "session_id": session_id, + "input": [{ "type": "text", "text": "crash before complete" }], + "model": null, + "sandbox": null, + "approval_policy": null, + "cwd": null + } + }), + ) + .await + .context("turn/start response")?; + let _: devo_server::SuccessResponse = + serde_json::from_value(turn_response)?; + + // Inspect the rollout before waiting for turn completion to simulate a crash + // after durable turn-start persistence. + runtime.refresh_session_index()?; + let db = devo_server::db::Database::open(data_root.path().join("test_persistence.db"))?; + let index = db.get_session_index(&session_id)?.expect("indexed session"); + let rollout_path = index.rollout_path.expect("rollout path"); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let (session_context_lines, turn_lines) = loop { + let rollout = std::fs::read_to_string(&rollout_path)?; + let mut session_context_lines = 0usize; + let mut turn_lines = 0usize; + for line in rollout.lines().filter(|line| !line.trim().is_empty()) { + let rollout_line: RolloutLine = serde_json::from_str(line)?; + match rollout_line { + RolloutLine::SessionContextUpdated(_) => session_context_lines += 1, + RolloutLine::Turn(_) => turn_lines += 1, + _ => {} + } + } + if session_context_lines >= 1 && turn_lines >= 1 { + break (session_context_lines, turn_lines); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for turn-start SessionContextUpdated; context_lines={session_context_lines} turn_lines={turn_lines}" + ); + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + assert_eq!(session_context_lines, 1); + assert!(turn_lines >= 1); + + wait_for_turn_completed(&mut notifications_rx).await?; + Ok(()) +} + #[tokio::test] async fn lazy_resume_loads_parent_session_from_rollout_on_map_miss() -> Result<()> { let data_root = TempDir::new()?; @@ -2302,6 +2491,7 @@ fn sample_indexed_session( total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: devo_protocol::SessionRuntimeStatus::Idle, } diff --git a/crates/server/tests/protocol_contract.rs b/crates/server/tests/protocol_contract.rs index fdeb93d3..0e64a48e 100644 --- a/crates/server/tests/protocol_contract.rs +++ b/crates/server/tests/protocol_contract.rs @@ -356,6 +356,7 @@ fn session_title_updated_event_serializes_expected_kind() { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }, @@ -391,6 +392,7 @@ fn session_compaction_events_serialize_expected_kinds() { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }; diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 8172fdba..f16e1068 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -280,6 +280,7 @@ pub(crate) struct ChatWidget { exit_after_onboarding: bool, resume_browser: Option, resume_browser_loading: bool, + resuming_session: bool, subagent_monitor: SubagentMonitorState, picker_mode: Option, pending_model_selection: Option, @@ -323,6 +324,57 @@ impl ChatWidget { self.next_seq += 1; seq } + + pub(super) fn begin_session_resume(&mut self) { + self.resuming_session = true; + self.bottom_pane.ensure_status_indicator(); + if let Some(status) = self.bottom_pane.status_widget_mut() { + status.update_header("Resuming session...".to_string()); + status.set_interrupt_hint_visible(false); + status.set_working_tip_visible(false); + status.update_inline_message(None); + } + self.bottom_pane.set_composer_input_enabled( + /*enabled*/ false, + Some("Resuming session...".to_string()), + ); + self.set_status_message("Resuming session..."); + } + + pub(super) fn finish_session_resume(&mut self) { + if !self.resuming_session { + return; + } + self.resuming_session = false; + self.bottom_pane.hide_status_indicator(); + self.bottom_pane + .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); + } + + pub(super) fn block_input_during_resume(&mut self) -> bool { + if !self.resuming_session { + return false; + } + self.set_status_message("Cannot send while resuming session"); + true + } + + #[cfg(test)] + pub(crate) fn is_resuming_session_for_test(&self) -> bool { + self.resuming_session + } + + #[cfg(test)] + pub(crate) fn status_message_for_test(&self) -> &str { + &self.status_message + } + + #[cfg(test)] + pub(crate) fn status_indicator_header_for_test(&self) -> Option<&str> { + self.bottom_pane + .status_widget() + .map(crate::status_indicator_widget::StatusIndicatorWidget::header) + } } impl ChatWidget { @@ -452,6 +504,7 @@ impl ChatWidget { exit_after_onboarding, resume_browser: None, resume_browser_loading: false, + resuming_session: false, subagent_monitor: SubagentMonitorState::default(), picker_mode: None, pending_model_selection: None, diff --git a/crates/tui/src/chatwidget/input.rs b/crates/tui/src/chatwidget/input.rs index 6b5dc358..9511233c 100644 --- a/crates/tui/src/chatwidget/input.rs +++ b/crates/tui/src/chatwidget/input.rs @@ -100,6 +100,9 @@ impl ChatWidget { input_mode, collaboration_mode, } => { + if self.block_input_during_resume() { + return; + } let user_message = UserMessage { text, local_images, @@ -135,6 +138,9 @@ impl ChatWidget { } } InputResult::ShellCommand { command } => { + if self.block_input_during_resume() { + return; + } if self.busy { self.set_status_message("Cannot run shell command while generating"); } else { @@ -147,6 +153,9 @@ impl ChatWidget { } } InputResult::ShellInput { command } => { + if self.block_input_during_resume() { + return; + } if self.busy { self.set_status_message("Cannot run shell command while generating"); } else { @@ -157,6 +166,9 @@ impl ChatWidget { } } InputResult::Command { command, argument } => { + if self.block_input_during_resume() { + return; + } self.handle_slash_command(command, argument); } InputResult::ModelSelected { model } => match self.picker_mode.take() { @@ -321,6 +333,9 @@ impl ChatWidget { collaboration_mode: CollaborationMode, input_mode: InputMode, ) { + if self.block_input_during_resume() { + return; + } if user_message.text.trim().is_empty() { return; } diff --git a/crates/tui/src/chatwidget/restored_session.rs b/crates/tui/src/chatwidget/restored_session.rs index f0f882ea..636095b0 100644 --- a/crates/tui/src/chatwidget/restored_session.rs +++ b/crates/tui/src/chatwidget/restored_session.rs @@ -209,14 +209,7 @@ impl ChatWidget { match item.kind { devo_protocol::SessionHistoryItemKind::User => { - self.add_history_entry_without_redraw(Box::new(history_cell::new_user_prompt( - item.body.clone(), - Vec::new(), - Vec::new(), - Vec::new(), - self.active_accent_color(), - InputMode::Build, - ))); + self.add_restored_user_prompt(item.body.clone()); } devo_protocol::SessionHistoryItemKind::Assistant => { self.add_markdown_history_without_redraw("Assistant", &item.body); @@ -275,6 +268,23 @@ impl ChatWidget { true } + pub(super) fn add_restored_user_prompt(&mut self, body: String) { + self.add_history_entry_without_redraw(Box::new(history_cell::PlainHistoryCell::new(vec![ + Line::from(""), + ]))); + self.add_history_entry_without_redraw(Box::new(history_cell::new_user_prompt( + body, + Vec::new(), + Vec::new(), + Vec::new(), + self.active_accent_color(), + InputMode::Build, + ))); + self.add_history_entry_without_redraw(Box::new(history_cell::PlainHistoryCell::new(vec![ + Line::from(""), + ]))); + } + fn add_restored_file_change_item( &mut self, item: &SessionHistoryItem, diff --git a/crates/tui/src/chatwidget/resume_browser.rs b/crates/tui/src/chatwidget/resume_browser.rs index 7a21e565..3771fd74 100644 --- a/crates/tui/src/chatwidget/resume_browser.rs +++ b/crates/tui/src/chatwidget/resume_browser.rs @@ -123,6 +123,7 @@ impl ChatWidget { let session_id = selected.session_id; self.resume_browser = None; self.clear_for_session_switch(); + self.begin_session_resume(); self.app_event_tx .send(AppEvent::Command(AppCommand::switch_session(session_id))); } diff --git a/crates/tui/src/chatwidget/session_header.rs b/crates/tui/src/chatwidget/session_header.rs index 3e7e1d5a..73f269a4 100644 --- a/crates/tui/src/chatwidget/session_header.rs +++ b/crates/tui/src/chatwidget/session_header.rs @@ -240,6 +240,11 @@ impl ChatWidget { } } + /// Context length for the status bar. + /// + /// Uses the latest completed query's display total (`input_tokens + + /// output_tokens`, or provider `total_tokens` when available). This is + /// **not** the session cumulative `total_input_tokens`. pub(super) fn context_usage(&self) -> Option<(usize, usize, usize)> { let model = self.session.model.as_ref()?; let total = model.context_window as usize; @@ -635,6 +640,30 @@ mod tests { assert_eq!(widget.context_usage(), Some((9, 200_000, 0))); } + #[test] + fn usage_updated_prompt_estimate_uses_last_query_input_not_cumulative_total() { + let mut widget = widget_for_summary_bench(); + widget.total_input_tokens = 500; + widget.last_query_total_tokens = 42; + widget.last_query_input_tokens = 30; + widget.prompt_token_estimate = 30; + + widget.handle_worker_event(crate::events::WorkerEvent::UsageUpdated { + total_input_tokens: 550, + total_output_tokens: 110, + total_tokens: 660, + total_cache_read_tokens: 60, + last_query_total_tokens: 48, + last_query_input_tokens: 35, + }); + + assert_eq!(widget.total_input_tokens, 550); + assert_eq!(widget.last_query_total_tokens, 48); + assert_eq!(widget.last_query_input_tokens, 35); + assert_eq!(widget.prompt_token_estimate, 35); + assert_eq!(widget.context_usage(), Some((48, 200_000, 0))); + } + #[test] #[ignore] fn bench_render_progress_bar() { diff --git a/crates/tui/src/chatwidget/session_history.rs b/crates/tui/src/chatwidget/session_history.rs index 03c07626..3efef0f5 100644 --- a/crates/tui/src/chatwidget/session_history.rs +++ b/crates/tui/src/chatwidget/session_history.rs @@ -353,14 +353,7 @@ impl ChatWidget { pub(super) fn add_transcript_item_without_redraw(&mut self, item: TranscriptItem) { match item.kind { TranscriptItemKind::User => { - self.add_history_entry_without_redraw(Box::new(history_cell::new_user_prompt( - item.body, - Vec::new(), - Vec::new(), - Vec::new(), - self.active_accent_color(), - InputMode::Build, - ))); + self.add_restored_user_prompt(item.body); } TranscriptItemKind::Assistant => { self.add_markdown_history_without_redraw("Assistant", &item.body) diff --git a/crates/tui/src/chatwidget/worker_events.rs b/crates/tui/src/chatwidget/worker_events.rs index f7ae7f89..31a1fa72 100644 --- a/crates/tui/src/chatwidget/worker_events.rs +++ b/crates/tui/src/chatwidget/worker_events.rs @@ -5,6 +5,7 @@ use std::time::Instant; +use devo_protocol::ProviderRetryPhase; use devo_protocol::parse_command::ParsedCommand; use devo_protocol::protocol::ExecCommandSource; use ratatui::text::Line; @@ -32,6 +33,11 @@ use super::SKILLS_TRANSCRIPT_TITLE; use super::session_header::is_web_search_title; use super::text_stream::ActiveTextItemId; +fn format_retry_status_message(attempt: usize, backoff_ms: u64) -> String { + let seconds = (backoff_ms as f64 / 1000.0).max(0.1); + format!("Retrying provider request in {seconds:.1}s (attempt {attempt})") +} + impl ChatWidget { fn start_command_execution_cell( &mut self, @@ -140,6 +146,40 @@ impl ChatWidget { self.stream_chunking_policy.reset(); self.bottom_pane.set_task_running(true); } + WorkerEvent::ProviderRetryStatus { + turn_id, + attempt, + backoff_ms, + provider: _, + model: _, + phase, + message, + } => { + if self.active_turn_id != Some(turn_id) { + return; + } + match phase { + ProviderRetryPhase::Scheduled => { + let retry_message = if message.trim().is_empty() { + format_retry_status_message(attempt, backoff_ms) + } else { + message + }; + self.bottom_pane.ensure_status_indicator(); + if let Some(status) = self.bottom_pane.status_widget_mut() { + status.update_inline_message(Some(retry_message.clone())); + } + self.set_status_message(&retry_message); + } + ProviderRetryPhase::Resumed => { + if let Some(status) = self.bottom_pane.status_widget_mut() { + status.update_inline_message(None); + } + self.set_status_message("Retrying provider request"); + } + } + self.frame_requester.schedule_frame(); + } WorkerEvent::TextItemStarted { item_id, kind, @@ -818,9 +858,11 @@ impl ChatWidget { self.total_input_tokens = total_input_tokens; self.total_output_tokens = total_output_tokens; self.total_cache_read_tokens = total_cache_read_tokens; + // Context length uses latest-query totals, not cumulative session + // total_input_tokens. self.last_query_total_tokens = last_query_total_tokens; self.last_query_input_tokens = last_query_input_tokens; - self.prompt_token_estimate = total_input_tokens; + self.prompt_token_estimate = last_query_input_tokens; self.frame_requester.schedule_frame(); } WorkerEvent::TurnFinished { @@ -911,6 +953,7 @@ impl ChatWidget { last_query_input_tokens, } => { self.resume_browser_loading = false; + self.finish_session_resume(); self.active_turn_is_research = false; self.commit_active_streams(DotStatus::Failed); self.active_tool_calls.clear(); @@ -1081,6 +1124,7 @@ impl ChatWidget { total_cache_read_tokens: _, } => { self.resume_browser_loading = false; + self.finish_session_resume(); self.session.cwd = cwd; self.update_session_model_selection(model, model_binding_id); self.reasoning_effort_selection = reasoning_effort_selection; @@ -1136,6 +1180,7 @@ impl ChatWidget { pending_texts, } => { self.resume_browser_loading = false; + self.finish_session_resume(); self.session.cwd = cwd; if let Some(model) = model { self.update_session_model_selection(model, model_binding_id); @@ -1231,33 +1276,40 @@ impl ChatWidget { WorkerEvent::SessionCompactionStarted => { self.busy = true; self.bottom_pane.set_task_running(true); + if let Some(status) = self.bottom_pane.status_widget_mut() { + status.update_header("Compacting session".to_string()); + } self.set_status_message("Session compaction in progress"); } WorkerEvent::SessionCompacted { total_input_tokens, total_output_tokens, total_tokens: _, + last_query_total_tokens, + last_query_input_tokens, prompt_token_estimate, } => { self.busy = false; self.bottom_pane.set_task_running(false); self.total_input_tokens = total_input_tokens; self.total_output_tokens = total_output_tokens; + self.last_query_total_tokens = last_query_total_tokens; + self.last_query_input_tokens = last_query_input_tokens; self.prompt_token_estimate = prompt_token_estimate; - self.add_to_history(history_cell::new_info_event( + self.add_to_history(history_cell::new_live_aligned_info_event( "Session compaction done".to_string(), None, )); self.set_status_message("Session compacted"); } WorkerEvent::ContextCompactionCompleted { title } => { - self.add_to_history(history_cell::new_info_event(title, None)); + self.add_to_history(history_cell::new_live_aligned_info_event(title, None)); self.set_status_message("Context compacted"); } WorkerEvent::SessionCompactionFailed { message } => { self.busy = false; self.bottom_pane.set_task_running(false); - self.add_to_history(history_cell::new_error_event_with_hint( + self.add_to_history(history_cell::new_live_aligned_error_event_with_hint( message, Some("session compaction failed".to_string()), )); diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 423a259f..b33cb3d9 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -620,6 +620,103 @@ fn resume_browser_enter_resumes_selected_scrolled_session() { ); } +#[test] +fn resume_browser_enter_blocks_prompt_submission_until_switch_completes() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let target_session_id = SessionId::new(); + widget.open_resume_browser_for_test(vec![crate::events::SessionListEntry { + session_id: target_session_id, + title: "Session".to_string(), + updated_at: "2026-05-18 10:00".to_string(), + is_active: false, + }]); + + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + app_event_rx.try_recv().expect("switch command"), + AppEvent::Command(AppCommand::switch_session(target_session_id)) + ); + assert!(widget.is_resuming_session_for_test()); + assert_eq!( + widget.status_indicator_header_for_test(), + Some("Resuming session...") + ); + assert!( + rendered_rows(&widget, 80, 12) + .iter() + .any(|row| row.contains("Resuming session")) + ); + + widget.submit_text("should not send".to_string()); + + assert!( + app_event_rx.try_recv().is_err(), + "prompt submission should not emit another app command while resuming" + ); + assert_eq!( + widget.status_message_for_test(), + "Cannot send while resuming session" + ); +} + +#[test] +fn session_switched_clears_resume_blocking_state() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let cwd = PathBuf::from("."); + let (mut widget, mut app_event_rx) = widget_with_model(model, cwd.clone()); + widget.open_resume_browser_for_test(vec![crate::events::SessionListEntry { + session_id: SessionId::new(), + title: "Session".to_string(), + updated_at: "2026-05-18 10:00".to_string(), + is_active: false, + }]); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let _ = app_event_rx.try_recv().expect("switch command"); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd, + title: Some("Resumed".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: Vec::new(), + rich_history_items: Vec::new(), + loaded_item_count: 0, + pending_texts: Vec::new(), + }); + + assert!(!widget.is_resuming_session_for_test()); + widget.submit_text("after resume".to_string()); + + let event = app_event_rx + .try_recv() + .expect("user turn should be emitted after session switch"); + assert!( + matches!(event, AppEvent::Command(AppCommand::UserTurn { .. })), + "expected user turn after resume, got {event:?}" + ); +} + #[test] fn resume_browser_supports_page_and_home_end_navigation() { let model = Model { @@ -3809,15 +3906,156 @@ fn session_switch_restores_header_and_spacing_before_user_input() { assert!(!committed_text.contains("session 1 lingering line")); assert!( committed_rows - .windows(4) - .any(|window| window[0].trim().is_empty() - && window[1].contains("▌ hello") + .windows(5) + .any(|window| window[0].contains("▌ hello") + && window[1].trim().is_empty() && window[2].trim().is_empty() && window[3].contains("world")), "expected restored spaced user prompt before assistant response: {committed_lines:?}" ); } +#[test] +fn restored_user_spacing_matches_live_turn_batch_spacing() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut live_widget, mut live_rx) = widget_with_model(model.clone(), cwd.clone()); + let (mut restored_widget, _restored_rx) = widget_with_model(model, cwd.clone()); + + let _ = live_widget.drain_scrollback_lines(80); + live_widget.submit_text("hello".to_string()); + let _ = live_rx.try_recv().expect("submitted user turn"); + let mut live_rows = scrollback_plain_lines(&live_widget.drain_scrollback_lines(80)); + live_widget.add_markdown_history("Assistant", "world"); + live_rows.extend(scrollback_plain_lines( + &live_widget.drain_scrollback_lines(80), + )); + + let _ = restored_widget.drain_scrollback_lines(80); + restored_widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd, + title: Some("Resumed".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 3, + total_output_tokens: 5, + total_tokens: 8, + total_cache_read_tokens: 0, + last_query_total_tokens: 8, + last_query_input_tokens: 3, + prompt_token_estimate: 3, + history_items: Vec::new(), + rich_history_items: vec![ + devo_protocol::SessionHistoryItem::new( + None, + devo_protocol::SessionHistoryItemKind::User, + String::new(), + "hello".to_string(), + ), + devo_protocol::SessionHistoryItem::new( + None, + devo_protocol::SessionHistoryItemKind::Assistant, + String::new(), + "world".to_string(), + ), + ], + loaded_item_count: 2, + pending_texts: vec![], + }); + let restored_rows = scrollback_plain_lines(&restored_widget.drain_scrollback_lines(80)); + + let live_user = live_rows + .iter() + .position(|row| row.contains("▌ hello")) + .expect("live user row"); + let live_assistant = live_rows + .iter() + .position(|row| row.contains("world")) + .expect("live assistant row"); + let restored_user = restored_rows + .iter() + .position(|row| row.contains("▌ hello")) + .expect("restored user row"); + let restored_assistant = restored_rows + .iter() + .position(|row| row.contains("world")) + .expect("restored assistant row"); + + let live_gap = &live_rows[live_user + 1..live_assistant]; + let restored_gap = &restored_rows[restored_user + 1..restored_assistant]; + assert_eq!( + live_gap, restored_gap, + "restored user-to-assistant spacing should match live turn batching\nlive: {live_rows:?}\nrestored: {restored_rows:?}" + ); +} + +#[test] +fn rich_session_switch_restores_user_spacing_before_assistant_response() { + let initial_cwd = std::env::current_dir().expect("current directory is available"); + let resumed_cwd = initial_cwd.join("resumed"); + let model = Model { + slug: "initial-model".to_string(), + display_name: "Initial Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, initial_cwd); + + let _ = widget.drain_scrollback_lines(80); + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: resumed_cwd, + title: Some("Resumed".to_string()), + model: Some("resumed-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 3, + total_output_tokens: 5, + total_tokens: 8, + total_cache_read_tokens: 0, + last_query_total_tokens: 8, + last_query_input_tokens: 3, + prompt_token_estimate: 3, + history_items: Vec::new(), + rich_history_items: vec![ + devo_protocol::SessionHistoryItem::new( + None, + devo_protocol::SessionHistoryItemKind::User, + String::new(), + "hello".to_string(), + ), + devo_protocol::SessionHistoryItem::new( + None, + devo_protocol::SessionHistoryItemKind::Assistant, + String::new(), + "world".to_string(), + ), + ], + loaded_item_count: 2, + pending_texts: vec![], + }); + + let committed_rows = scrollback_plain_lines(&widget.drain_scrollback_lines(80)); + assert!( + committed_rows + .windows(5) + .any(|window| window[0].contains("▌ hello") + && window[1].trim().is_empty() + && window[2].trim().is_empty() + && window[3].contains("world")), + "expected restored rich user prompt to keep live spacing before assistant response: {committed_rows:?}" + ); +} + #[test] fn turn_finished_does_not_add_completion_status_line_to_history() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -5466,6 +5704,106 @@ fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() assert!(finished_summary.contains("9/200k")); } +#[test] +fn session_compacted_updates_context_bar_to_compacted_prompt_estimate() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: std::env::current_dir().expect("current directory is available"), + title: Some("Resumed".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 10_000, + total_output_tokens: 1_000, + total_tokens: 11_000, + total_cache_read_tokens: 500, + last_query_total_tokens: 9_000, + last_query_input_tokens: 8_500, + prompt_token_estimate: 8_500, + history_items: Vec::new(), + rich_history_items: Vec::new(), + loaded_item_count: 0, + pending_texts: vec![], + }); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionCompacted { + total_input_tokens: 10_000, + total_output_tokens: 1_000, + total_tokens: 11_000, + last_query_total_tokens: 1_200, + last_query_input_tokens: 1_200, + prompt_token_estimate: 1_200, + }); + + let summary = widget.status_summary_text(); + assert!(summary.contains("↑10k")); + assert!(summary.contains("1k/200k")); + assert!(!summary.contains("9k/200k")); +} + +#[test] +fn usage_updated_keeps_context_bar_on_last_query_not_cumulative_totals() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: std::env::current_dir().expect("current directory is available"), + title: Some("Resumed".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + // Cumulative totals are intentionally larger than latest-query usage. + total_input_tokens: 500, + total_output_tokens: 100, + total_tokens: 600, + total_cache_read_tokens: 50, + last_query_total_tokens: 42, + last_query_input_tokens: 30, + prompt_token_estimate: 30, + history_items: Vec::new(), + rich_history_items: Vec::new(), + loaded_item_count: 0, + pending_texts: vec![], + }); + + let idle_summary = widget.status_summary_text(); + assert!(idle_summary.contains("↑500")); + assert!(idle_summary.contains("42/200k")); + assert!(!idle_summary.contains("500/200k")); + + widget.handle_worker_event(crate::events::WorkerEvent::UsageUpdated { + total_input_tokens: 550, + total_output_tokens: 110, + total_tokens: 660, + total_cache_read_tokens: 60, + last_query_total_tokens: 48, + last_query_input_tokens: 35, + }); + + let busy_summary = widget.status_summary_text(); + assert!(busy_summary.contains("↑550")); + assert!(busy_summary.contains("48/200k")); + assert!(!busy_summary.contains("550/200k")); +} + #[test] fn streaming_controller_is_initialized_and_commit_ticks_drain_lines() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -5716,6 +6054,69 @@ fn request_user_input_keeps_working_status_indicator_visible() { ); } +#[test] +fn session_compaction_live_rows_use_live_prefix_cols() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let live_prefix = " ".repeat(usize::from(LIVE_PREFIX_COLS)); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionCompactionStarted); + + let rows = rendered_rows(&widget, 120, 24); + assert!( + rows.iter() + .any(|row| { row.starts_with(&live_prefix) && row.contains("Compacting session") }), + "compaction in-progress row should align with live prefix:\n{}", + rows.join("\n") + ); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionCompacted { + total_input_tokens: 10, + total_output_tokens: 5, + total_tokens: 15, + last_query_total_tokens: 8, + last_query_input_tokens: 8, + prompt_token_estimate: 8, + }); + + let history = scrollback_plain_lines(&widget.drain_scrollback_lines(80)); + assert!( + history + .iter() + .any(|line| { line.starts_with(&format!("{live_prefix}Session compaction done")) }), + "compaction completion history should align with live prefix:\n{}", + history.join("\n") + ); + + widget.handle_worker_event(crate::events::WorkerEvent::ContextCompactionCompleted { + title: "Context compacted for turn".to_string(), + }); + let history = scrollback_plain_lines(&widget.drain_scrollback_lines(80)); + assert!( + history + .iter() + .any(|line| { line.starts_with(&format!("{live_prefix}Context compacted for turn")) }), + "context compaction history should align with live prefix:\n{}", + history.join("\n") + ); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionCompactionFailed { + message: "compaction timed out".to_string(), + }); + let history = scrollback_plain_lines(&widget.drain_scrollback_lines(80)); + assert!( + history + .iter() + .any(|line| { line.starts_with(&format!("{live_prefix}■ compaction timed out")) }), + "compaction failure history should align with live prefix:\n{}", + history.join("\n") + ); +} + #[test] fn request_user_input_and_subagent_live_list_are_visible_together() { let model = Model { diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index 99462e4b..3b2cc4c7 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -10,6 +10,7 @@ use devo_protocol::AcpAvailableCommand; use devo_protocol::AcpCost; use devo_protocol::AcpSessionConfigOption; use devo_protocol::ProviderModelBinding; +use devo_protocol::ProviderRetryPhase; use devo_protocol::ProviderVendor; use devo_protocol::ProviderWireApi; use devo_protocol::ReasoningEffort; @@ -181,6 +182,16 @@ pub(crate) enum WorkerEvent { }, /// A steer (/btw) was accepted by the server. SteerAccepted { turn_id: TurnId }, + /// Provider retry status for the active turn. + ProviderRetryStatus { + turn_id: TurnId, + attempt: usize, + backoff_ms: u64, + provider: String, + model: String, + phase: ProviderRetryPhase, + message: String, + }, /// A streamed assistant, reasoning, or research artifact text item started. TextItemStarted { item_id: ItemId, @@ -338,9 +349,9 @@ pub(crate) enum WorkerEvent { total_tokens: usize, /// Total cached input tokens accumulated in the session. total_cache_read_tokens: usize, - /// Last completed query token usage, measured as input plus output tokens. + /// Latest completed query display total (not session cumulative totals). last_query_total_tokens: usize, - /// Input tokens consumed by the current or last completed query. + /// Input tokens from the latest completed query. last_query_input_tokens: usize, }, /// The current turn completed successfully. @@ -357,9 +368,9 @@ pub(crate) enum WorkerEvent { total_tokens: usize, /// Total cached input tokens accumulated in the session. total_cache_read_tokens: usize, - /// Last completed turn token usage, measured as input plus output tokens. + /// Latest completed query display total (not session cumulative totals). last_query_total_tokens: usize, - /// Input tokens consumed by the last completed query. + /// Input tokens from the latest completed query. last_query_input_tokens: usize, /// Estimated prompt tokens for the just-completed request. prompt_token_estimate: usize, @@ -520,9 +531,9 @@ pub(crate) enum WorkerEvent { reasoning_effort: Option, /// Contextual footer label for the active child agent, when viewing one. active_agent_label: Option, - /// Last completed turn token usage for the fresh session. + /// Latest completed query display total for the fresh session. last_query_total_tokens: usize, - /// Last completed query input tokens for the fresh session. + /// Latest completed query input tokens for the fresh session. last_query_input_tokens: usize, /// Total cached input tokens accumulated in the fresh session. total_cache_read_tokens: usize, @@ -553,9 +564,9 @@ pub(crate) enum WorkerEvent { total_tokens: usize, /// Total cached input tokens accumulated for the resumed session. total_cache_read_tokens: usize, - /// Last completed turn token usage, measured as input plus output tokens. + /// Latest completed query display total (not session cumulative totals). last_query_total_tokens: usize, - /// Input tokens consumed by the last completed query. + /// Input tokens from the latest completed query. last_query_input_tokens: usize, /// Estimated prompt tokens currently visible to the model. prompt_token_estimate: usize, @@ -585,6 +596,10 @@ pub(crate) enum WorkerEvent { total_output_tokens: usize, /// Display total tokens accumulated in the compacted session. total_tokens: usize, + /// Latest/context display total after compaction. + last_query_total_tokens: usize, + /// Input tokens currently visible to the model after compaction. + last_query_input_tokens: usize, /// Estimated prompt tokens currently visible to the model. prompt_token_estimate: usize, }, diff --git a/crates/tui/src/history_cell.rs b/crates/tui/src/history_cell.rs index 355acf0d..8f5eccff 100644 --- a/crates/tui/src/history_cell.rs +++ b/crates/tui/src/history_cell.rs @@ -1394,6 +1394,19 @@ pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHist PlainHistoryCell { lines } } +pub(crate) fn new_live_aligned_info_event( + message: String, + hint: Option, +) -> PlainHistoryCell { + let mut line = vec![" ".repeat(LIVE_PREFIX_COLS as usize).into(), message.into()]; + if let Some(hint) = hint { + line.push(" ".into()); + line.push(hint.dark_gray()); + } + let lines: Vec> = vec![line.into()]; + PlainHistoryCell { lines } +} + pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { new_error_event_with_hint(message, /*hint*/ None) } @@ -1409,6 +1422,25 @@ pub(crate) fn new_error_event_with_hint(message: String, hint: Option) - PlainHistoryCell { lines } } +pub(crate) fn new_live_aligned_error_event_with_hint( + message: String, + hint: Option, +) -> PlainHistoryCell { + let prefix = " ".repeat(LIVE_PREFIX_COLS as usize); + let mut lines: Vec> = + vec![vec![prefix.into(), format!("■ {message}").red()].into()]; + if let Some(hint) = hint { + lines.push( + vec![ + " ".repeat(LIVE_PREFIX_COLS as usize).into(), + hint.dark_gray(), + ] + .into(), + ); + } + PlainHistoryCell { lines } +} + /// A transient history cell that shows an animated spinner while the MCP /// inventory RPC is in flight. /// diff --git a/crates/tui/src/insert_history.rs b/crates/tui/src/insert_history.rs index 1194424b..33c24922 100644 --- a/crates/tui/src/insert_history.rs +++ b/crates/tui/src/insert_history.rs @@ -432,6 +432,38 @@ mod tests { assert!(ResetScrollRegion.is_ansi_code_supported()); } + #[test] + fn vt100_preserves_user_cell_boundary_blank_lines() { + let width: u16 = 40; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let lines = vec![ + ScrollbackLine::new(Line::from(" ")), + ScrollbackLine::new(Line::from("▌ hello")), + ScrollbackLine::new(Line::from(" ")), + ]; + insert_history_lines(&mut term, lines).expect("insert history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + let user_row = rows + .iter() + .position(|row| row.contains("▌ hello")) + .unwrap_or_else(|| panic!("expected user row, rows: {rows:?}")); + + assert!( + user_row > 0 && rows[user_row - 1].trim().is_empty(), + "expected blank row above restored user cell, rows: {rows:?}" + ); + assert!( + user_row + 1 < rows.len() && rows[user_row + 1].trim().is_empty(), + "expected blank row below restored user cell, rows: {rows:?}" + ); + } + #[test] fn vt100_blockquote_line_emits_green_fg() { // Set up a small off-screen terminal diff --git a/crates/tui/src/interactive.rs b/crates/tui/src/interactive.rs index 52197c48..2cb35ec7 100644 --- a/crates/tui/src/interactive.rs +++ b/crates/tui/src/interactive.rs @@ -312,6 +312,10 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result { loop_state.busy = false; @@ -969,6 +975,7 @@ fn handle_worker_event( | WorkerEvent::RequestUserInput { .. } | WorkerEvent::ApprovalDecision { .. } | WorkerEvent::SteerAccepted { .. } + | WorkerEvent::ProviderRetryStatus { .. } | WorkerEvent::GoalStatusLoaded { .. } | WorkerEvent::GoalUpdated { .. } | WorkerEvent::GoalReplaceConfirmationRequested { .. } diff --git a/crates/tui/src/status_indicator_widget.rs b/crates/tui/src/status_indicator_widget.rs index 5587bad8..cc3a795f 100644 --- a/crates/tui/src/status_indicator_widget.rs +++ b/crates/tui/src/status_indicator_widget.rs @@ -64,6 +64,7 @@ pub(crate) struct StatusIndicatorWidget { /// Optional suffix rendered after the elapsed/interrupt segment. inline_message: Option, show_interrupt_hint: bool, + show_working_tip: bool, subagent_hint_visible: bool, elapsed_running: Duration, @@ -103,6 +104,7 @@ impl StatusIndicatorWidget { details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, inline_message: None, show_interrupt_hint: true, + show_working_tip: true, subagent_hint_visible: false, elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), @@ -163,6 +165,10 @@ impl StatusIndicatorWidget { self.show_interrupt_hint = visible; } + pub(crate) fn set_working_tip_visible(&mut self, visible: bool) { + self.show_working_tip = visible; + } + pub(crate) fn set_subagent_hint_visible(&mut self, visible: bool) { self.subagent_hint_visible = visible; } @@ -246,7 +252,7 @@ impl StatusIndicatorWidget { } fn working_tip_at(&self, elapsed: Duration) -> Option<&'static str> { - if WORKING_TIPS.is_empty() { + if !self.show_working_tip || WORKING_TIPS.is_empty() { return None; } @@ -344,7 +350,7 @@ impl Renderable for StatusIndicatorWidget { } debug_assert_eq!(LIVE_PREFIX_COLS, 2); - let left_padding = Span::raw(" "); + let left_padding = Span::raw(" ".repeat(LIVE_PREFIX_COLS as usize)); let lines = prefix_lines(lines, left_padding.clone(), left_padding); Paragraph::new(Text::from(lines)).render_ref(area, buf); } @@ -387,6 +393,28 @@ mod tests { assert!(tip_row.contains(WORKING_TIPS[0])); } + #[test] + fn status_indicator_can_hide_working_tip() { + let (app_event_tx, _app_event_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut widget = StatusIndicatorWidget::new( + AppEventSender::new(app_event_tx), + FrameRequester::test_dummy(), + false, + ); + widget.update_header("Resuming session...".to_string()); + widget.set_working_tip_visible(false); + + assert_eq!(widget.desired_height(80), 1); + + let area = Rect::new(0, 0, 100, 2); + let mut buf = Buffer::empty(area); + widget.render(area, &mut buf); + + let rendered = [row_text(&buf, area.width, 0), row_text(&buf, area.width, 1)].join("\n"); + assert!(rendered.contains("Resuming session...")); + assert!(!rendered.contains("Tip:")); + } + #[test] fn status_indicator_rotates_working_tip_every_six_seconds() { let (app_event_tx, _app_event_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index b9bb0f10..e771e5f8 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -123,6 +123,22 @@ fn active_agent_label_from_session(session: &devo_server::SessionMetadata) -> Op .map(|label| format!("Agent: {label}")) } +/// Prefer structured session `last_query_usage`, then latest-turn usage, then the +/// legacy scalar. Context length is latest-query display total, not cumulative +/// session totals. +fn last_query_tokens_from_resume( + session: &devo_server::SessionMetadata, + latest_turn: Option<&devo_protocol::TurnMetadata>, +) -> (usize, usize) { + if let Some(usage) = session.last_query_usage.as_ref() { + return (usage.display_total_tokens(), usage.input_tokens as usize); + } + if let Some(usage) = latest_turn.and_then(|turn| turn.usage.as_ref()) { + return (usage.display_total_tokens(), usage.input_tokens as usize); + } + (session.last_query_total_tokens, 0) +} + struct EnsureSessionOutcome { session_id: SessionId, model: Option, @@ -873,6 +889,8 @@ async fn run_worker_inner( session_id = Some(initial_session_id); session_cwd = resumed.session.cwd.clone(); let active_agent_label = active_agent_label_from_session(&resumed.session); + let (last_query_total, last_query_input) = + last_query_tokens_from_resume(&resumed.session, resumed.latest_turn.as_ref()); let _ = event_tx.send(WorkerEvent::SessionSwitched { session_id: initial_session_id.to_string(), cwd: resumed.session.cwd, @@ -886,13 +904,8 @@ async fn run_worker_inner( total_output_tokens: resumed.session.total_output_tokens, total_tokens: resumed.session.total_tokens, total_cache_read_tokens: resumed.session.total_cache_read_tokens, - last_query_total_tokens: resumed.session.last_query_total_tokens, - last_query_input_tokens: resumed - .latest_turn - .as_ref() - .and_then(|turn| turn.usage.as_ref()) - .map(|usage| usage.input_tokens as usize) - .unwrap_or(0), + last_query_total_tokens: last_query_total, + last_query_input_tokens: last_query_input, prompt_token_estimate: resumed.session.prompt_token_estimate, history_items: project_history_items(&resumed.history_items), rich_history_items: resumed.history_items.clone(), @@ -906,7 +919,8 @@ async fn run_worker_inner( total_output_tokens = resumed.session.total_output_tokens; total_tokens = resumed.session.total_tokens; total_cache_read_tokens = resumed.session.total_cache_read_tokens; - last_query_total_tokens = resumed.session.last_query_total_tokens; + last_query_total_tokens = last_query_total; + last_query_input_tokens = last_query_input; has_authoritative_usage_totals = true; } Err(error) => { @@ -1630,6 +1644,11 @@ async fn run_worker_inner( input_history_cursor = None; let active_agent_label = active_agent_label_from_session(&result.session); + let (last_query_total, last_query_input) = + last_query_tokens_from_resume( + &result.session, + result.latest_turn.as_ref(), + ); let _ = event_tx.send(WorkerEvent::SessionSwitched { session_id: next_session_id.to_string(), @@ -1644,15 +1663,8 @@ async fn run_worker_inner( total_output_tokens: result.session.total_output_tokens, total_tokens: result.session.total_tokens, total_cache_read_tokens: result.session.total_cache_read_tokens, - last_query_total_tokens: result - .session - .last_query_total_tokens, - last_query_input_tokens: result - .latest_turn - .as_ref() - .and_then(|turn| turn.usage.as_ref()) - .map(|usage| usage.input_tokens as usize) - .unwrap_or(0), + last_query_total_tokens: last_query_total, + last_query_input_tokens: last_query_input, prompt_token_estimate: result.session.prompt_token_estimate, history_items: project_history_items(&result.history_items), rich_history_items: result.history_items.clone(), @@ -1673,8 +1685,8 @@ async fn run_worker_inner( let _ = emit_skills_list(&mut client, &session_cwd, event_tx, false) .await; - last_query_total_tokens = - result.session.last_query_total_tokens; + last_query_total_tokens = last_query_total; + last_query_input_tokens = last_query_input; has_authoritative_usage_totals = true; } Err(error) => { @@ -1776,6 +1788,11 @@ async fn run_worker_inner( input_history_cursor = None; let active_agent_label = active_agent_label_from_session(&result.session); + let (last_query_total, last_query_input) = + last_query_tokens_from_resume( + &result.session, + result.latest_turn.as_ref(), + ); let _ = event_tx.send(WorkerEvent::SessionSwitched { session_id: active_session_id.to_string(), cwd: result.session.cwd, @@ -1789,15 +1806,8 @@ async fn run_worker_inner( total_output_tokens: result.session.total_output_tokens, total_tokens: result.session.total_tokens, total_cache_read_tokens: result.session.total_cache_read_tokens, - last_query_total_tokens: result - .session - .last_query_total_tokens, - last_query_input_tokens: result - .latest_turn - .as_ref() - .and_then(|turn| turn.usage.as_ref()) - .map(|usage| usage.input_tokens as usize) - .unwrap_or(0), + last_query_total_tokens: last_query_total, + last_query_input_tokens: last_query_input, prompt_token_estimate: result.session.prompt_token_estimate, history_items: project_history_items(&result.history_items), rich_history_items: result.history_items.clone(), @@ -1811,8 +1821,8 @@ async fn run_worker_inner( total_output_tokens = result.session.total_output_tokens; total_tokens = result.session.total_tokens; total_cache_read_tokens = result.session.total_cache_read_tokens; - last_query_total_tokens = - result.session.last_query_total_tokens; + last_query_total_tokens = last_query_total; + last_query_input_tokens = last_query_input; has_authoritative_usage_totals = true; } Err(error) => { @@ -1887,6 +1897,11 @@ async fn run_worker_inner( input_history_cursor = None; let active_agent_label = active_agent_label_from_session(&resumed.session); + let (last_query_total, last_query_input) = + last_query_tokens_from_resume( + &resumed.session, + resumed.latest_turn.as_ref(), + ); let _ = event_tx.send(WorkerEvent::SessionSwitched { session_id: next_session_id.to_string(), cwd: resumed.session.cwd, @@ -1900,15 +1915,8 @@ async fn run_worker_inner( total_output_tokens: resumed.session.total_output_tokens, total_tokens: resumed.session.total_tokens, total_cache_read_tokens: resumed.session.total_cache_read_tokens, - last_query_total_tokens: resumed - .session - .last_query_total_tokens, - last_query_input_tokens: resumed - .latest_turn - .as_ref() - .and_then(|turn| turn.usage.as_ref()) - .map(|usage| usage.input_tokens as usize) - .unwrap_or(0), + last_query_total_tokens: last_query_total, + last_query_input_tokens: last_query_input, prompt_token_estimate: resumed.session.prompt_token_estimate, history_items: project_history_items(&resumed.history_items), rich_history_items: resumed.history_items.clone(), @@ -1922,8 +1930,8 @@ async fn run_worker_inner( total_output_tokens = resumed.session.total_output_tokens; total_tokens = resumed.session.total_tokens; total_cache_read_tokens = resumed.session.total_cache_read_tokens; - last_query_total_tokens = - resumed.session.last_query_total_tokens; + last_query_total_tokens = last_query_total; + last_query_input_tokens = last_query_input; has_authoritative_usage_totals = true; } Err(error) => { @@ -2693,6 +2701,19 @@ async fn run_worker_inner( latest_completed_agent_message = None; } } + "turn/provider_retry_status" => { + if let ServerEvent::TurnProviderRetryStatus(payload) = event { + let _ = event_tx.send(WorkerEvent::ProviderRetryStatus { + turn_id: payload.turn_id, + attempt: payload.attempt, + backoff_ms: payload.backoff_ms, + provider: payload.provider, + model: payload.model, + phase: payload.phase, + message: payload.message, + }); + } + } "turn/usage/updated" => { if let ServerEvent::TurnUsageUpdated(payload) = event { saw_usage_update_for_turn = true; @@ -2700,6 +2721,7 @@ async fn run_worker_inner( total_output_tokens = payload.total_output_tokens; total_tokens = payload.total_tokens; total_cache_read_tokens = payload.total_cache_read_tokens; + last_query_total_tokens = payload.usage.display_total_tokens(); last_query_input_tokens = payload.last_query_input_tokens; has_authoritative_usage_totals = true; let _ = event_tx.send(WorkerEvent::UsageUpdated { @@ -2851,10 +2873,24 @@ async fn run_worker_inner( total_input_tokens = payload.session.total_input_tokens; total_output_tokens = payload.session.total_output_tokens; total_tokens = payload.session.total_tokens; + let (compacted_last_query_total, compacted_last_query_input) = + last_query_tokens_from_resume(&payload.session, None); + last_query_total_tokens = if payload.session.prompt_token_estimate > 0 { + payload.session.prompt_token_estimate + } else { + compacted_last_query_total + }; + last_query_input_tokens = if payload.session.prompt_token_estimate > 0 { + payload.session.prompt_token_estimate + } else { + compacted_last_query_input + }; let _ = event_tx.send(WorkerEvent::SessionCompacted { total_input_tokens, total_output_tokens, total_tokens, + last_query_total_tokens, + last_query_input_tokens, prompt_token_estimate: payload.session.prompt_token_estimate, }); } @@ -4548,11 +4584,13 @@ mod tests { use super::acp_terminal_snapshot_delta; use super::handle_completed_item; use super::is_stale_turn_interrupt_error; + use super::last_query_tokens_from_resume; use super::next_shell_command_exec_start; use super::normalize_display_output; use super::project_history_items; use super::render_skill_list_body; use super::research_artifact_delta_event; + use super::should_apply_terminal_turn_usage_fallback; use super::should_pause_goal_before_session_leave; use super::summarize_tool_call; use super::tool_call_started_actions; @@ -5491,21 +5529,14 @@ mod tests { assert_eq!( events, - vec![ - WorkerEvent::AcpUsageUpdated { - used: 90, - size: 200_000, - cost: None, - }, - WorkerEvent::UsageUpdated { - total_input_tokens: 70, - total_output_tokens: 20, - total_tokens: 90, - total_cache_read_tokens: 12, - last_query_total_tokens: 11, - last_query_input_tokens: 7, - }, - ] + vec![WorkerEvent::UsageUpdated { + total_input_tokens: 70, + total_output_tokens: 20, + total_tokens: 90, + total_cache_read_tokens: 12, + last_query_total_tokens: 11, + last_query_input_tokens: 7, + }] ); } @@ -6076,11 +6107,128 @@ mod tests { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, } } + #[test] + fn last_query_tokens_from_resume_prefers_session_last_query_usage() { + use devo_protocol::TurnKind; + use devo_protocol::TurnMetadata; + use devo_protocol::TurnStatus; + use devo_protocol::TurnUsage; + + let session_id = SessionId::new(); + let mut session = test_session_metadata(session_id, None); + session.total_input_tokens = 500; + session.last_query_total_tokens = 999; + session.last_query_usage = Some(TurnUsage { + input_tokens: 30, + output_tokens: 12, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(42), + }); + let turn = TurnMetadata { + turn_id: TurnId::new(), + session_id, + sequence: 1, + status: TurnStatus::Completed, + kind: TurnKind::Regular, + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + request_model: "test-model".to_string(), + request_thinking: None, + started_at: Utc::now(), + completed_at: Some(Utc::now()), + usage: Some(TurnUsage { + input_tokens: 7, + output_tokens: 2, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(9), + }), + stop_reason: None, + failure_reason: None, + }; + + assert_eq!( + last_query_tokens_from_resume(&session, Some(&turn)), + (42, 30) + ); + + session.last_query_usage = None; + assert_eq!(last_query_tokens_from_resume(&session, Some(&turn)), (9, 7)); + + assert_eq!(last_query_tokens_from_resume(&session, None), (999, 0)); + } + + #[test] + fn usage_update_state_keeps_latest_total_for_terminal_event_without_usage() { + use devo_protocol::TurnUsage; + + let mut last_query_total_tokens = 42usize; + let has_authoritative_usage_totals = false; + + let usage = TurnUsage { + input_tokens: 35, + output_tokens: 13, + cache_creation_input_tokens: None, + cache_read_input_tokens: Some(5), + reasoning_output_tokens: None, + total_tokens: Some(48), + }; + assert_eq!(last_query_total_tokens, 42); + + let saw_usage_update_for_turn = true; + let total_input_tokens = 550; + let total_output_tokens = 110; + let total_tokens = 660; + let total_cache_read_tokens = 60; + last_query_total_tokens = usage.display_total_tokens(); + let last_query_input_tokens = usage.input_tokens as usize; + + if !should_apply_terminal_turn_usage_fallback( + saw_usage_update_for_turn, + has_authoritative_usage_totals, + ) { + // Simulates a terminal turn/completed event without embedded usage. + } + + let terminal_event = WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + last_query_total_tokens, + last_query_input_tokens, + prompt_token_estimate: total_input_tokens, + }; + + assert_eq!( + terminal_event, + WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + total_input_tokens: 550, + total_output_tokens: 110, + total_tokens: 660, + total_cache_read_tokens: 60, + last_query_total_tokens: 48, + last_query_input_tokens: 35, + prompt_token_estimate: 550, + } + ); + } + #[test] fn acp_session_info_update_discovers_child_subagent_metadata() { let parent = SessionId::new(); @@ -6444,6 +6592,7 @@ mod tests { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }; @@ -6487,6 +6636,7 @@ mod tests { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, }; diff --git a/crates/tui/src/worker/acp_events.rs b/crates/tui/src/worker/acp_events.rs index cfb24ecc..1ce191e2 100644 --- a/crates/tui/src/worker/acp_events.rs +++ b/crates/tui/src/worker/acp_events.rs @@ -594,18 +594,18 @@ fn worker_events_from_acp_usage_update( cost: Option, meta: Option, ) -> Vec { - let mut events = vec![WorkerEvent::AcpUsageUpdated { used, size, cost }]; if let Some(payload) = turn_usage_payload_from_acp_meta(meta.as_ref()) { - events.push(WorkerEvent::UsageUpdated { + return vec![WorkerEvent::UsageUpdated { total_input_tokens: payload.total_input_tokens, total_output_tokens: payload.total_output_tokens, total_tokens: payload.total_tokens, total_cache_read_tokens: payload.total_cache_read_tokens, last_query_total_tokens: payload.usage.display_total_tokens(), last_query_input_tokens: payload.last_query_input_tokens, - }); + }]; } - events + + vec![WorkerEvent::AcpUsageUpdated { used, size, cost }] } fn turn_usage_payload_from_acp_meta(