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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 51 additions & 12 deletions src-tauri/src/acp/session_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -771,13 +771,16 @@ impl SessionState {
meta.as_ref(),
images.as_deref(),
);
// Anchor the tool call in `live_message.content` so snapshot
// reload preserves position relative to surrounding text /
// thinking blocks. Idempotent by id: a second ToolCall (or a
// ToolCallUpdate, see below) for the same id must not push a
// duplicate ref. Mirrors text/thinking deltas in lazily
// creating `live_message` if absent.
self.push_tool_call_ref_if_absent(tool_call_id);
// Anchor foreground tool calls in `live_message.content` so a
// snapshot preserves their stream position. Out-of-turn calls
// (for example MCP startup diagnostics while Connected) stay
// in `active_tool_calls` for permission enrichment, but must
// not create a ghost assistant message. A pre-status text
// delta may already have opened the live message, so preserve
// that ordering edge as foreground work.
if self.status == ConnectionStatus::Prompting || self.live_message.is_some() {
self.push_tool_call_ref_if_absent(tool_call_id);
}
}
AcpEvent::ToolCallUpdate {
tool_call_id,
Expand All @@ -803,11 +806,12 @@ impl SessionState {
meta.as_ref(),
images.as_deref(),
);
// Defensive: if a ToolCallUpdate arrives before its initial
// ToolCall (unusual ordering / replay), ensure the ref block
// still gets anchored. Idempotent so the normal-flow case is
// a no-op here.
self.push_tool_call_ref_if_absent(tool_call_id);
// Same foreground-only guard as ToolCall. An out-of-turn
// update may enrich a permission snapshot, but it cannot
// manufacture a transcript message on its own.
if self.status == ConnectionStatus::Prompting || self.live_message.is_some() {
self.push_tool_call_ref_if_absent(tool_call_id);
}
}
AcpEvent::PermissionRequest {
request_id,
Expand Down Expand Up @@ -2414,6 +2418,7 @@ mod tests {

// A tool call with no trailing text / thinking → `running tool:` prefix.
let mut s = fresh_state();
s.status = ConnectionStatus::Prompting;
s.apply_event(&AcpEvent::ToolCall {
tool_call_id: "tc-9".into(),
title: "grep files".into(),
Expand Down Expand Up @@ -3861,6 +3866,7 @@ mod tests {
#[test]
fn tool_call_ref_push_is_idempotent() {
let mut s = fresh_state();
s.status = ConnectionStatus::Prompting;
s.apply_event(&tool_call_event("tc-1", "ls"));
// Defensive: second ToolCall with the same id (replay/unusual ordering)
// must NOT push a duplicate ref block.
Expand All @@ -3877,6 +3883,7 @@ mod tests {
#[test]
fn tool_call_update_does_not_duplicate_ref() {
let mut s = fresh_state();
s.status = ConnectionStatus::Prompting;
s.apply_event(&tool_call_event("tc-1", "ls"));
s.apply_event(&AcpEvent::ToolCallUpdate {
tool_call_id: "tc-1".into(),
Expand All @@ -3902,6 +3909,38 @@ mod tests {
);
}

#[test]
fn out_of_turn_tool_call_does_not_create_ghost_live_message() {
let mut s = fresh_state();
s.status = ConnectionStatus::Connected;

s.apply_event(&tool_call_event("startup-1", "MCP startup failed"));
assert!(s.active_tool_calls.contains_key("startup-1"));
assert!(s.live_message.is_none());

s.apply_event(&AcpEvent::ToolCallUpdate {
tool_call_id: "startup-1".into(),
title: None,
status: Some("failed".into()),
content: Some("server unavailable".into()),
raw_input: None,
raw_output: None,
raw_output_append: None,
locations: None,
meta: None,
images: None,
});
assert!(s.live_message.is_none());

// A separate call from a real prompting turn still anchors normally.
s.status = ConnectionStatus::Prompting;
s.apply_event(&tool_call_event("turn-1", "Read file"));
assert_eq!(
live_block_summary(&s),
vec![("tool_call_ref", "turn-1".to_string())]
);
}

#[test]
fn tool_call_state_carries_locations_and_meta() {
let mut s = fresh_state();
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/src/commands/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ use crate::web::event_bridge::{
IMPORT_SCAN_PROGRESS_EVENT, TABS_CHANGED_EVENT,
};

// Transcript parsers materialize the whole native session before the response
// window is sliced. A single large Codex rollout can therefore consume several
// GiB transiently, and `spawn_blocking` keeps running even after its HTTP caller
// disconnects. Serialize these parses so tab restores/refetches cannot overlap
// multiple copies and drive the server into swap/OOM. Waiters remain ordinary
// cancellable async tasks until they own the permit.
static TRANSCRIPT_PARSE_CONCURRENCY: tokio::sync::Semaphore =
tokio::sync::Semaphore::const_new(1);

#[derive(Default)]
pub(crate) struct ListAllConversationsOptions {
pub(crate) folder_ids: Option<Vec<i32>>,
Expand Down Expand Up @@ -1113,6 +1122,13 @@ pub async fn get_folder_conversation_core(

let (mut turns, session_stats, resolved_ext_id, parsed_title, parsed_model, transcript_watermark) =
if let Some(ref ext_id) = summary.external_id {
let _parse_permit = TRANSCRIPT_PARSE_CONCURRENCY
.acquire()
.await
.map_err(|error| {
AppCommandError::task_execution_failed("Conversation parser is unavailable")
.with_detail(error.to_string())
})?;
let at = summary.agent_type;
let eid = ext_id.clone();
let db_created_at = summary.created_at;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ describe("ConversationDetailPanel split-group render model", () => {
expect(source).toContain("showActiveFlow={(isSplit || canTileG) && active}")
})

it("keeps hidden session controllers but suspends transcript work", () => {
expect(source).toContain("isVisible={visible}")
expect(source).toContain(
"useConversationDetail(effectiveConversationId, { enabled: isVisible })"
)
expect(source).toContain("if (!isVisible) return null")
})

it("gives each split group its own strip and divider overlays only while split", () => {
expect(source).toContain("<TabBar groupId={groupId} />")
const handlesIdx = source.indexOf("groupHandles.map((handle) => (")
Expand Down
14 changes: 13 additions & 1 deletion src/components/conversations/conversation-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ interface ConversationTabViewProps {
agentType: AgentType
workingDir?: string
isActive: boolean
/** Whether this tab is currently painted (selected, tiled, or split).
* Background tabs keep their connection controller mounted, but must not
* fetch or render the heavyweight transcript subtree. */
isVisible: boolean
/** Drive the composer's flowing active-session border. True only for the
* active tab while several sessions are visible (tiled within a group
* and/or split across groups) — the places the flow serves as the "which
Expand Down Expand Up @@ -235,6 +239,7 @@ const ConversationTabView = memo(function ConversationTabView({
agentType,
workingDir,
isActive,
isVisible,
showActiveFlow,
reloadSignal,
groupId,
Expand Down Expand Up @@ -461,7 +466,7 @@ const ConversationTabView = memo(function ConversationTabView({
loading: detailLoading,
error: detailError,
acpLoadError,
} = useConversationDetail(effectiveConversationId)
} = useConversationDetail(effectiveConversationId, { enabled: isVisible })

// Subscribe to only the fields this panel actually reads from its runtime
// session — NOT the whole session object. The live-message sink rewrites the
Expand Down Expand Up @@ -1903,6 +1908,12 @@ const ConversationTabView = memo(function ConversationTabView({
[feedbackSteer]
)

// Keep every tab's connection/lifecycle hooks resident so background agents
// continue running, but do not keep a second copy of the transcript renderer
// (and all of its streaming derivations) alive for every persisted tab. The
// detail stays in the runtime cache, so returning to the tab is immediate.
if (!isVisible) return null

return (
<ConversationShell
topBanner={
Expand Down Expand Up @@ -2554,6 +2565,7 @@ export function ConversationDetailPanel() {
agentType={tab.agentType}
workingDir={tab.workingDir ?? folderPath}
isActive={active}
isVisible={visible}
showActiveFlow={(isSplit || canTileG) && active}
reloadSignal={reloadByTabId[tab.id] ?? 0}
groupId={groupId}
Expand Down
71 changes: 71 additions & 0 deletions src/contexts/acp-connections-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { useTranslations } from "next-intl"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
AcpConnectionsProvider,
ToolCallUpdateAccumulator,
boundLiveToolOutputChunks,
useAcpActions,
useConnectionStore,
type ToolCallUpdatePayload,
} from "@/contexts/acp-connections-context"
import { parsePermissionToolCall } from "@/lib/permission-request"
import { subscribe } from "@/lib/platform"
Expand Down Expand Up @@ -112,6 +115,74 @@ vi.mock("@/lib/api", () => ({
}),
}))

function toolUpdate(
overrides: Partial<ToolCallUpdatePayload> = {}
): ToolCallUpdatePayload {
return {
contextKey: "tab-1",
tool_call_id: "tool-1",
title: null,
fallback_title: "Tool",
fallback_kind: "tool",
status: null,
content: null,
raw_input: null,
raw_output: null,
locations: null,
meta: null,
images: null,
...overrides,
}
}

describe("tool-call update memory bounds", () => {
it("coalesces append streams without changing replacement semantics", () => {
const updates = new ToolCallUpdateAccumulator(
toolUpdate({ status: "in_progress" })
)
updates.add(toolUpdate({ raw_output: "a", raw_output_append: true }))
const firstAppend = updates.finish()
expect(firstAppend.raw_output).toBe("a")
expect(firstAppend.raw_output_append).toBe(true)

updates.add(toolUpdate({ raw_output: "b", raw_output_append: true }))
const secondAppend = updates.finish()
expect(secondAppend.raw_output).toBe("ab")
expect(secondAppend.raw_output_append).toBe(true)

updates.add(toolUpdate({ raw_output: "new", raw_output_append: false }))
updates.add(toolUpdate({ raw_output: " tail", raw_output_append: true }))
const replacementThenAppend = updates.finish()
expect(replacementThenAppend.raw_output).toBe("new tail")
expect(replacementThenAppend.raw_output_append).toBe(false)
})

it("bounds both pending output text and retained chunk count", () => {
const updates = new ToolCallUpdateAccumulator(
toolUpdate({ raw_output: "seed", raw_output_append: true })
)
updates.add(
toolUpdate({
raw_output: "x".repeat(210_000),
raw_output_append: true,
})
)
const oversized = updates.finish()
expect(oversized.raw_output).toHaveLength(200_000)

const bounded = boundLiveToolOutputChunks(
Array.from({ length: 65 }, () => "x")
)
expect(bounded.chunks).toHaveLength(1)
expect(bounded.total).toBe(65)

const singleOversized = boundLiveToolOutputChunks(["z".repeat(210_000)])
expect(singleOversized.chunks).toHaveLength(1)
expect(singleOversized.chunks[0]).toHaveLength(200_000)
expect(singleOversized.total).toBe(200_000)
})
})

function Probe() {
const actions = useAcpActions()
const store = useConnectionStore()
Expand Down
Loading
Loading