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
902 changes: 846 additions & 56 deletions src-tauri/src/acp/connection.rs

Large diffs are not rendered by default.

65 changes: 61 additions & 4 deletions src-tauri/src/acp/event_stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,10 @@ fn user_block_size(block: &UserMessageBlock) -> usize {
///
/// `emit_with_state` calls this on every event while holding the `SessionState`
/// write lock. The hot, high-frequency, and potentially-large variants —
/// streaming text/thinking deltas, tool calls and updates and user messages
/// (which can carry multi-MB base64 images), and forwarded Claude SDK messages —
/// are therefore estimated STRUCTURALLY from their string/JSON fields, with no
/// streaming text/thinking deltas, tool calls and updates, user messages
/// (which can carry multi-MB base64 images), Agent Teams member chunks (one
/// per member token), and forwarded Claude SDK messages — are therefore
/// estimated STRUCTURALLY from their string/JSON fields, with no
/// serialization: serializing a per-token delta or a multi-MB image on that
/// locked hot path only to measure and discard the bytes was the cost this
/// replaced. Every other variant is small and infrequent, so it falls back to an
Expand Down Expand Up @@ -464,6 +465,31 @@ fn estimate_envelope_size(envelope: &EventEnvelope) -> usize {
})
.sum::<usize>()
}
// High-frequency on an Agent Teams session (one delta per token per
// member), so sized structurally like the other streaming deltas
// instead of falling through to the serialize-fallback — which would
// re-serialize every member token under the `SessionState` write lock.
AcpEvent::MemberChunk {
member,
text,
tool_call_id,
title,
status,
history_item_id,
// Spelled out (not `..`) so a newly-added large field forces this
// estimator to be revisited rather than silently under-counted.
kind: _,
} => {
json_str_len(member)
+ opt_str_size(text)
+ opt_str_size(tool_call_id)
+ opt_str_size(title)
+ opt_str_size(status)
+ opt_str_size(history_item_id)
// `"kind":"<variant>",` — the longest tag is
// `tool_call_update` (~28 B with quotes/colon/comma).
+ 64
}
// Small, infrequent variants: an exact serialized length is cheap here
// and preserves the prior threshold behavior; the 256 fallback only
// guards the (practically impossible) serialization failure.
Expand Down Expand Up @@ -576,6 +602,7 @@ fn agent_stats_size(stats: &crate::models::message::AgentExecutionStats) -> usiz
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::types::MemberChunkKind;

fn make_envelope(seq: u64, text: &str) -> Arc<EventEnvelope> {
Arc::new(EventEnvelope {
Expand Down Expand Up @@ -859,7 +886,8 @@ mod tests {
// any structural branch — else an oversized event would be wrongly
// retained and replayed. Covers plain/escape-heavy text, thinking, nested
// Claude SDK JSON, a fully-populated ToolCall, an all-`null`-but-near-cap
// ToolCallUpdate, and user messages with many blocks / an image.
// ToolCallUpdate, member-channel chunks (full and minimal), and user
// messages with many blocks / an image.
let cases: Vec<Arc<EventEnvelope>> = vec![
make_envelope(1, "plain"),
make_envelope(2, &"\"\\\n\t".repeat(500)),
Expand Down Expand Up @@ -954,6 +982,35 @@ mod tests {
}),
user_message_with_image(8, "A".repeat(1000)),
tool_update_with_image(9, "A".repeat(1000)),
// Member-channel chunks: a fully-populated tool frame (every
// optional field present, escape-heavy strings)…
Arc::new(EventEnvelope {
seq: 33,
connection_id: "conn-xyz".into(),
payload: AcpEvent::MemberChunk {
member: "impl\"x\n".into(),
kind: MemberChunkKind::ToolCallUpdate,
text: Some("te\"xt\n".into()),
tool_call_id: Some("call_1".into()),
title: Some("Send\"Message".into()),
status: Some("completed".into()),
history_item_id: Some("h\"1".into()),
},
}),
// …and the minimal text delta (every option absent).
Arc::new(EventEnvelope {
seq: 34,
connection_id: "conn-xyz".into(),
payload: AcpEvent::MemberChunk {
member: "impl-x".into(),
kind: MemberChunkKind::Message,
text: Some("你".into()),
tool_call_id: None,
title: None,
status: None,
history_item_id: None,
},
}),
];
for env in &cases {
assert_ge_serialized(env);
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/acp/session_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1514,13 +1514,17 @@ impl SessionState {
| AcpEvent::TurnRetrying { .. }
| AcpEvent::NativeSessionTitle { .. }
| AcpEvent::TranscriptRolledOver { .. }
| AcpEvent::UserPromptSent { .. } => {
| AcpEvent::UserPromptSent { .. }
| AcpEvent::MemberChunk { .. } => {
// 这些事件不直接修改 SessionState 的可见字段。
// UserPromptSent 是纯通知事件,仅供 chat-channel 推送消费。
// TurnRetrying 与 Claude 的 api_retry 一样是前端瞬态提示(重试横幅),
// 不进快照——回合边界会清除它。
// ConfigOptionRejected 是对一次交互的提示(选择器被降级/拒绝),
// 权威值已由紧随其后的 SessionConfigOptions 落进快照。
// MemberChunk 是 Agent Teams 成员的实时流:前端按成员名分桶渲染,
// 刻意不进快照——会话重开后由上游 memberEvent 全量重放重建
// (codeg 侧不做成员持久化)。
}
}
self.last_activity_at = Utc::now();
Expand Down
61 changes: 61 additions & 0 deletions src-tauri/src/acp/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,47 @@ pub enum AcpEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_tool_use_id: Option<String>,
},
/// One chunk of an Agent Teams member's own conversation stream, rerouted
/// off the main thread (LOCAL, codeg-fix 2026-10-14).
///
/// CodeBuddy CLI 2.164.0 (`AcpTeamBridge`) tags every frame of an in-process
/// team member — the member's live text/thought deltas, its tool calls, its
/// user-message echo, and the committed record those deltas precede — with
/// `_meta["codebuddy.ai/memberEvent"] = <member name>` and NO parent link.
/// The CLI documents the client contract for these frames ("客户端收到带
/// memberEvent 标记的事件后,应将其路由到对应成员的对话时间线,而非主对话区"
/// — `docs/cn/cli/acp.md` §成员流式消息; same line in the EN doc). 480568c9
/// enforced the second half of that contract — keep them out of the main
/// thread — and this event is the first half: the same frames, routed to
/// the frontend's per-member timeline instead of being dropped.
///
/// `kind` picks the payload arm: text kinds carry `text`, tool kinds carry
/// `tool_call_id` / `title` / `status`. `history_item_id` is
/// `codebuddy.ai/memberHistoryItemId`, set on the committed-record echo (and
/// on the member's `user_message_chunk`); the echo's text is the whole
/// record, so the frontend replaces the member's streaming buffer with it
/// and keys the finished item by this id — which is what makes the agent's
/// periodic full-history replay idempotent.
///
/// Notification-only: `SessionState::apply_event` keeps none of it, and no
/// snapshot carries it. A reopened session rebuilds the timeline from the
/// agent's own memberEvent replay (`AcpTeamBridge::subscribeExistingMembers`
/// runs at bridge start and on the 3s progress interval).
MemberChunk {
/// `codebuddy.ai/memberEvent` — the member's name (bucket key).
member: String,
kind: MemberChunkKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
history_item_id: Option<String>,
},
/// Raw SDK message forwarded from Claude ACP extension notification
ClaudeSdkMessage {
session_id: String,
Expand Down Expand Up @@ -846,6 +887,26 @@ pub enum AcpEvent {
},
}

/// Which kind of member-stream payload an [`AcpEvent::MemberChunk`] carries —
/// the ACP `sessionUpdate` kind the tagged frame arrived as, reduced to the
/// five arms the member timeline consumes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemberChunkKind {
/// `agent_message_chunk` — the member's prose.
Message,
/// `agent_thought_chunk` — the member's reasoning.
Thought,
/// `user_message_chunk` — a prompt / teammate message delivered TO the
/// member. Always an already-committed record (`history_item_id` set in
/// practice), so the timeline files it as a user line outright.
UserEcho,
/// `tool_call` — the member opened a tool call.
ToolCall,
/// `tool_call_update` — status/result progress for one of its calls.
ToolCallUpdate,
}

/// One background task settled by a `<task-notification>` transcript record,
/// carried on [`AcpEvent::BackgroundActivity`]. `task_id` is the launch ack's
/// `agentId` (async sub-agent) or `backgroundTaskId` (background shell);
Expand Down
145 changes: 145 additions & 0 deletions src/components/chat/member-timeline-overlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { act, fireEvent, render, screen } from "@testing-library/react"
import { NextIntlClientProvider } from "next-intl"
import { beforeEach, describe, expect, it, vi } from "vitest"

import { MemberTimelineOverlay } from "./member-timeline-overlay"
import enMessages from "@/i18n/messages/en.json"
import {
useMemberTimelineStore,
type MemberChunkEvent,
} from "@/stores/member-timeline-store"

// Streamdown (the real Markdown renderer) and the reasoning collapsible are
// heavy import chains; the overlay only wires them up, so stub both to
// sentinels that expose exactly what the panel passes down.
vi.mock("@/components/ai-elements/message", () => ({
MessageResponse: ({ children }: { children: string }) => (
<div data-testid="message-response">{children}</div>
),
}))

vi.mock("@/components/ai-elements/reasoning", () => ({
Reasoning: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
ReasoningTrigger: () => <div data-testid="reasoning-trigger">thinking</div>,
ReasoningContent: ({ children }: { children: string }) => (
<div data-testid="reasoning-content">{children}</div>
),
}))

function chunk(overrides: Partial<MemberChunkEvent>): MemberChunkEvent {
return {
type: "member_chunk",
member: "impl-x",
kind: "message",
...overrides,
}
}

function apply(contextKey: string, ...chunks: MemberChunkEvent[]) {
for (const c of chunks) {
act(() => {
useMemberTimelineStore.getState().applyMemberChunk(contextKey, c)
})
}
}

function renderWithIntl(ui: React.ReactElement) {
return render(
<NextIntlClientProvider locale="en" messages={enMessages}>
{ui}
</NextIntlClientProvider>
)
}

beforeEach(() => {
useMemberTimelineStore.getState().clearAll()
})

describe("MemberTimelineOverlay", () => {
it("renders nothing without a contextKey or before any member chunk", () => {
const { container } = renderWithIntl(
<MemberTimelineOverlay contextKey="tab-1" />
)
expect(container.firstChild).toBeNull()

const bare = renderWithIntl(<MemberTimelineOverlay />)
expect(bare.container.firstChild).toBeNull()
})

it("collapses to a chip summarizing the member count, then expands to the members", () => {
apply(
"tab-1",
chunk({ member: "impl-x", kind: "message", text: "正在处理任务分配" }),
chunk({
member: "impl-y",
kind: "tool_call",
tool_call_id: "c1",
title: "Read file",
status: "completed",
})
)
renderWithIntl(<MemberTimelineOverlay contextKey="tab-1" />)

// Chip first — content hidden.
expect(screen.getByText("Team members 2")).toBeInTheDocument()
expect(screen.queryByText("正在处理任务分配")).not.toBeInTheDocument()

fireEvent.click(screen.getByText("Team members 2").closest("button")!)
expect(screen.getByText("Team members")).toBeInTheDocument()
expect(screen.getByText("impl-x")).toBeInTheDocument()
expect(screen.getByText("impl-y")).toBeInTheDocument()
// Member sections open by default: prose through the main-thread presenter,
// the tool frame as a weak line with its localized status.
expect(screen.getByText("正在处理任务分配")).toBeInTheDocument()
expect(screen.getByText("Read file")).toBeInTheDocument()
expect(screen.getByText("Completed")).toBeInTheDocument()
})

it("renders thoughts through the reasoning presenter and user echoes distinctly", () => {
apply(
"tab-1",
chunk({
kind: "user_echo",
text: "请写两句进度说明",
history_item_id: "u1",
}),
chunk({
kind: "thought",
text: "The lead asks me",
history_item_id: "h1",
})
)
renderWithIntl(<MemberTimelineOverlay contextKey="tab-1" />)
fireEvent.click(screen.getByText("Team members 1").closest("button")!)

expect(screen.getByText("Message received")).toBeInTheDocument()
expect(screen.getByText("请写两句进度说明")).toBeInTheDocument()
expect(screen.getByTestId("reasoning-content").textContent).toBe(
"The lead asks me"
)
})

it("re-renders live as chunks arrive after mount", () => {
renderWithIntl(<MemberTimelineOverlay contextKey="tab-1" />)
expect(screen.queryByText("Team members 1")).not.toBeInTheDocument()

apply("tab-1", chunk({ kind: "message", text: "第一句" }))
expect(screen.getByText("Team members 1")).toBeInTheDocument()

fireEvent.click(screen.getByText("Team members 1").closest("button")!)
expect(screen.getByText("第一句")).toBeInTheDocument()

apply("tab-1", chunk({ kind: "message", text: ",第二句" }))
expect(screen.getByText("第一句,第二句")).toBeInTheDocument()
})

it("keeps one connection's members out of another's panel", () => {
apply("tab-2", chunk({ kind: "message", text: "other tab" }))
const { container } = renderWithIntl(
<MemberTimelineOverlay contextKey="tab-1" />
)
expect(container.firstChild).toBeNull()
})
})
Loading
Loading