From 6003aa934631acaebf826843f2320ffd0cb7e694 Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 30 Sep 2026 19:29:12 -0700 Subject: [PATCH 01/20] Present current agent activity in individual composer popups Keep coordination messages unchanged. Reuse profile tabs for activity navigation and show readable tools before expandable progress and diagnostics. Co-authored-by: Carl Signed-off-by: Carl --- .../agent-activity/ActivityAccessory.test.tsx | 173 ++++++ .../agent-activity/ActivityAccessory.tsx | 208 +++++++ .../agent-activity/ActivityStream.test.tsx | 101 ++++ src/bundled/agent-activity/ActivityStream.tsx | 155 +++++ .../activity-presentation.test.ts | 143 +++++ .../agent-activity/activity-presentation.ts | 137 +++++ .../agent-activity/failed-activity.test.ts | 14 + src/bundled/agent-activity/failed-activity.ts | 14 + .../agent-activity/request-activity.test.ts | 257 +++++++++ .../agent-activity/request-activity.ts | 140 +++++ .../agent-activity/thread-activity.test.ts | 172 ++++++ src/bundled/agent-activity/thread-activity.ts | 74 +++ src/bundled/agent-activity/transcript.test.ts | 539 ++++++++++++++++++ src/bundled/agent-activity/transcript.ts | 424 ++++++++++++++ src/bundled/profiles/ProfileActivity.tsx | 8 +- src/bundled/profiles/ProfilePanel.test.tsx | 134 ++++- src/bundled/profiles/ProfilePanel.tsx | 57 +- src/bundled/profiles/index.tsx | 8 +- .../conversation/typing-presentation.tsx | 111 ++++ src/features/messages/MessageComposer.tsx | 13 +- src/features/messages/MessageRow.test.tsx | 57 +- src/features/messages/MessageRow.tsx | 27 +- src/features/messages/ThreadPanel.tsx | 133 ++--- src/features/messages/TypingIndicator.tsx | 7 +- .../messages/loaded-thread-messages.ts | 7 + src/features/profiles/target.test.ts | 20 + src/features/profiles/target.ts | 17 + .../browser/agent-activity-overhaul.spec.mjs | 164 ++++++ 28 files changed, 3230 insertions(+), 84 deletions(-) create mode 100644 src/bundled/agent-activity/ActivityAccessory.test.tsx create mode 100644 src/bundled/agent-activity/ActivityAccessory.tsx create mode 100644 src/bundled/agent-activity/ActivityStream.test.tsx create mode 100644 src/bundled/agent-activity/ActivityStream.tsx create mode 100644 src/bundled/agent-activity/activity-presentation.test.ts create mode 100644 src/bundled/agent-activity/activity-presentation.ts create mode 100644 src/bundled/agent-activity/failed-activity.test.ts create mode 100644 src/bundled/agent-activity/failed-activity.ts create mode 100644 src/bundled/agent-activity/request-activity.test.ts create mode 100644 src/bundled/agent-activity/request-activity.ts create mode 100644 src/bundled/agent-activity/thread-activity.test.ts create mode 100644 src/bundled/agent-activity/thread-activity.ts create mode 100644 src/bundled/agent-activity/transcript.test.ts create mode 100644 src/bundled/agent-activity/transcript.ts create mode 100644 src/features/conversation/typing-presentation.tsx create mode 100644 src/features/messages/loaded-thread-messages.ts create mode 100644 tests/browser/agent-activity-overhaul.spec.mjs diff --git a/src/bundled/agent-activity/ActivityAccessory.test.tsx b/src/bundled/agent-activity/ActivityAccessory.test.tsx new file mode 100644 index 000000000..3f4a01a80 --- /dev/null +++ b/src/bundled/agent-activity/ActivityAccessory.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +import { act, cleanup, render, screen } from "@testing-library/react"; + +import { afterEach, expect, it, vi } from "vitest"; +import { ActivityAccessory } from "./ActivityAccessory"; +import { createAgentActivity } from "../../features/agents/activity"; +import { createAgentLibrary } from "../../features/agents/library"; +import type { RelaySession } from "../../features/relay/session"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +const A = "b".repeat(64), + B = "c".repeat(64), + viewer = "a".repeat(64), + root = "1".repeat(64), + next = "2".repeat(64); +function fixture() { + vi.useFakeTimers(); + vi.setSystemTime(1000000); + let generation = 0, + serial = 0; + const activity = createAgentActivity( + true, + (g) => { + generation = g ?? 0; + }, + () => true, + ); + const release = activity.queries.activate(); + const live = { + status: "connected" as const, + routes: [ + { id: "observer", status: "live" as const, replay: "unknown" as const }, + ], + }; + activity.state(live); + const profiles = new Map([ + [A, { name: "Blossom", isAgent: true }], + [B, { name: "Bubbles", isAgent: true }], + ]); + const session = { + viewer, + agentActivity: activity.queries, + agentChoices: createAgentLibrary(undefined).queries, + profiles: { + snapshot: () => profiles, + subscribe: () => () => {}, + ensure: vi.fn(async () => {}), + }, + live: { retry: () => activity.state(live) }, + media: () => undefined, + } as unknown as RelaySession; + const send = ( + agent: string, + turnId: string, + seq: number, + kind: string, + payload: unknown = {}, + ) => + act(() => { + activity.receive( + { + id: (++serial).toString(16).padStart(64, "0"), + agent, + createdAt: Math.floor(Date.now() / 1000), + plaintext: JSON.stringify({ + kind, + turnId, + channelId: "c", + sessionId: "S", + seq, + timestamp: new Date().toISOString(), + payload, + }), + }, + generation, + ); + }); + const open = vi.fn(() => true); + const props = { + session, + scope: "test", + channelId: "c", + threadRootId: root, + canOpen: () => true, + open, + }; + return { + activity, + release, + send, + props, + live, + profiles, + dispose: () => { + release(); + activity.dispose(); + }, + }; +} + +it("shows one line per agent across multiple turns, removing only the settled agent", () => { + const f = fixture(); + const view = render(); + try { + f.send(A, "a1", 1, "turn_started", { triggeringEventIds: [root] }); + f.send(A, "a2", 1, "turn_started", { triggeringEventIds: [root] }); + f.send(B, "b1", 1, "turn_started", { triggeringEventIds: [root] }); + expect( + screen.getAllByRole("button", { name: /View activity for Blossom/ }), + ).toHaveLength(1); + expect( + screen.getAllByRole("button", { name: /View activity for Bubbles/ }), + ).toHaveLength(1); + f.send(A, "a1", 2, "turn_completed"); + expect( + screen.getByRole("button", { name: /View activity for Blossom/ }), + ).toBeInTheDocument(); + f.send(A, "a2", 2, "turn_completed"); + expect( + screen.queryByRole("button", { name: /Blossom/ }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /View activity for Bubbles/ }), + ).toBeInTheDocument(); + f.send(B, "b1", 2, "turn_completed"); + expect( + screen.queryByLabelText("Agent activity in this thread"), + ).not.toBeInTheDocument(); + } finally { + view.unmount(); + f.dispose(); + } +}); +it("does not show sibling thread work and reports lost freshness without pretending completion", () => { + const f = fixture(); + const view = render(); + try { + f.send(A, "other", 1, "turn_started", { triggeringEventIds: [next] }); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + f.send(B, "here", 1, "turn_started", { triggeringEventIds: [root] }); + act(() => vi.advanceTimersByTime(31000)); + expect( + screen.getByRole("button", { name: /View activity for Bubbles/ }), + ).toHaveTextContent("work status unknown"); + f.send(B, "here", 2, "turn_completed"); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + } finally { + view.unmount(); + f.dispose(); + } +}); +it("keeps namesakes as separate exact identities", () => { + const f = fixture(); + f.profiles.set(B, { name: "Blossom", isAgent: true }); + const view = render(); + try { + f.send(A, "a", 1, "turn_started", { triggeringEventIds: [root] }); + f.send(B, "b", 1, "turn_started", { triggeringEventIds: [root] }); + const buttons = screen.getAllByRole("button", { name: /Blossom/ }); + expect(buttons).toHaveLength(2); + expect(buttons[0]?.textContent).not.toBe(buttons[1]?.textContent); + } finally { + view.unmount(); + f.dispose(); + } +}); diff --git a/src/bundled/agent-activity/ActivityAccessory.tsx b/src/bundled/agent-activity/ActivityAccessory.tsx new file mode 100644 index 000000000..d9fa6ce90 --- /dev/null +++ b/src/bundled/agent-activity/ActivityAccessory.tsx @@ -0,0 +1,208 @@ +import { usePresenceStatus } from "../../features/presence/react"; +import { + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; +import { AgentAvatar } from "../../features/agents/AgentAvatar"; +import type { ComposerAccessoryProps } from "../../features/conversation/contracts"; +import { useChannelIdentityNames } from "../../features/identity-names/react"; +import { LoadedThreadMessages } from "../../features/messages/loaded-thread-messages"; +import { activityRecords } from "../../features/agents/activity-records"; +import { activityTarget } from "../../features/agents/activity-target"; +import { profileActivityViewTarget } from "../../features/profiles/target"; +import { publicKeyLabels } from "../../shared/identity/public-key"; +import { Button } from "../../shared/design-system/ui/Button"; +import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; +import { + PopoverRoot, + PopoverTrigger, + PopoverPopup, + PopoverTitle, + PopoverDescription, + PopoverClose, +} from "../../shared/design-system/ui/Popover"; +import { ActivityStream } from "./ActivityStream"; +import { threadActivity } from "./thread-activity"; +import { useTypingReplacement } from "../../features/conversation/typing-presentation"; +import styles from "./ActivityAccessory.module.css"; + +/** One bottom group per conversation. Exact identities, never one row per message. */ +export function ActivityAccessory(props: ComposerAccessoryProps) { + const { session, channelId, threadRootId, canOpen } = props; + const snapshot = useSyncExternalStore( + session.agentActivity.subscribe, + session.agentActivity.snapshot, + ); + const messages = useContext(LoadedThreadMessages); + const entries = useMemo(() => { + if (threadRootId) + return threadActivity(snapshot, channelId, threadRootId, messages).filter( + (entry) => + entry.working || entry.turns.some((turn) => turn.state === "unknown"), + ); + const agents = new Set([ + ...snapshot.turns + .filter( + (turn) => turn.channelId === channelId && turn.state !== "ended", + ) + .map((turn) => turn.agent), + ...snapshot.typing + .filter((entry) => entry.channelId === channelId && !entry.threadRootId) + .map((entry) => entry.agent), + ]); + return [...agents].sort().map((agent) => ({ + agent, + working: + snapshot.status === "listening" && + (snapshot.turns.some( + (turn) => + turn.agent === agent && + turn.channelId === channelId && + turn.state === "working", + ) || + snapshot.typing.some( + (entry) => + entry.agent === agent && + entry.channelId === channelId && + !entry.threadRootId, + )), + selected: { + records: activityRecords(snapshot.records, agent, channelId), + }, + })); + }, [snapshot, channelId, threadRootId, messages]); + const profiles = useSyncExternalStore( + session.profiles.subscribe, + session.profiles.snapshot, + ); + const resolveName = useChannelIdentityNames(session, channelId); + const keys = entries.map((entry) => entry.agent); + const identityKeys = keys.join(":"); + useEffect(() => { + if (identityKeys) + void session.profiles + .ensure(identityKeys.split(":"), "background") + .catch(() => {}); + }, [session.profiles, identityKeys]); + const suffixes = publicKeyLabels(keys); + const names = keys.map((key) => + resolveName(key, profiles.get(key)?.name ?? suffixes.get(key) ?? "Agent"), + ); + const visible = entries.filter((entry) => + canOpen(activityTarget(entry.agent, channelId)), + ); + if (!visible.length) return null; + return ( +
+
+ {visible.map((entry) => { + const name = names[keys.indexOf(entry.agent)] ?? "Agent"; + const label = + names.filter((value) => value === name).length > 1 + ? `${name} · ${suffixes.get(entry.agent)}` + : name; + return ( + + ); + })} +
+
+ ); +} +function ActivityEntry({ + session, + channelId, + threadRootId, + canOpen, + open, + agent, + name, + picture, + working, + records, +}: ComposerAccessoryProps & { + agent: string; + name: string; + picture: string | undefined; + working: boolean; + records: ReturnType; +}) { + const presence = usePresenceStatus(session.presence, agent); + const [expanded, setExpanded] = useState(false); + const target = profileActivityViewTarget(agent); + useTypingReplacement( + { session, channelId, threadRootId, pubkey: agent }, + working, + ); + return ( + + + } + aria-label={`View activity for ${name} ${agent.slice(0, 12)}${presence === "unknown" ? "" : `, Presence: ${presence}`}`} + label={`${name} · ${working ? "working…" : "work status unknown"}`} + /> + } + /> + + {name} + + {working ? "Working" : "Activity interrupted or out of date"} + {threadRootId + ? " in this thread" + : " in this channel, including threads"} + + {expanded && } + {target && canOpen(target) && ( + + )} + + Close + + } + /> + + + ); +} diff --git a/src/bundled/agent-activity/ActivityStream.test.tsx b/src/bundled/agent-activity/ActivityStream.test.tsx new file mode 100644 index 000000000..ed2f86554 --- /dev/null +++ b/src/bundled/agent-activity/ActivityStream.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { ActivityStream } from "./ActivityStream"; +import { activityRecords } from "../../features/agents/activity-records"; + +afterEach(cleanup); +function records() { + return activityRecords( + [ + { + kind: "acp_read", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + toolCallId: "read", + title: "buzz-dev-mcp__read_file", + status: "completed", + rawInput: { path: "/project/README.md" }, + rawOutput: "", + }, + }, + }, + }, + { + kind: "acp_read", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Checking the result" }, + }, + }, + }, + }, + { kind: "turn_error", payload: { error: "Connection lost" } }, + ].map((value, index) => ({ + id: String(index), + agent: "agent", + kind: value.kind, + receivedAt: index, + plaintext: JSON.stringify({ + ...value, + turnId: "turn", + channelId: "channel", + sessionId: "session", + }), + })), + "agent", + "channel", + ); +} +it("puts tools first and keeps progress and diagnostics behind separate disclosures", () => { + const view = render(); + expect( + screen.getAllByRole("button").map((button) => button.textContent), + ).toEqual([ + "Read file · README.md", + "Progress and responses (1)", + "Diagnostics (1) · Error reported", + ]); + expect(screen.queryByText("Checking the result")).not.toBeInTheDocument(); + expect(screen.queryByText("Connection lost")).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Read file · README.md" }), + ); + expect( + screen.getByText(JSON.stringify("")), + ).toBeInTheDocument(); + expect(view.container.querySelector("script")).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Progress and responses (1)" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Thinking" })); + expect(screen.getByText("Checking the result")).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Diagnostics (1) · Error reported" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Turn error" })); + expect(screen.getByText("Connection lost")).toBeInTheDocument(); +}); +it("drops details when the underlying capture is cleared", () => { + const view = render(); + fireEvent.click( + screen.getByRole("button", { name: "Read file · README.md" }), + ); + view.rerender(); + expect( + screen.queryByText(JSON.stringify("")), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Read file/ }), + ).not.toBeInTheDocument(); + expect( + screen.getByText("No tool activity captured yet."), + ).toBeInTheDocument(); +}); diff --git a/src/bundled/agent-activity/ActivityStream.tsx b/src/bundled/agent-activity/ActivityStream.tsx new file mode 100644 index 000000000..33f43637e --- /dev/null +++ b/src/bundled/agent-activity/ActivityStream.tsx @@ -0,0 +1,155 @@ +import { useMemo, useState } from "react"; +import type { ActivityRecord } from "../../features/agents/activity-records"; +import { Accordion } from "../../shared/design-system/ui/Accordion"; +import { activityTranscript, type TranscriptEntry } from "./transcript"; +import { + activityCategory, + activityPresentation, +} from "./activity-presentation"; + +/** Presentation of the caller's scoped capture, never a second activity store. */ +export function ActivityStream({ + records, +}: { + records: readonly ActivityRecord[]; +}) { + const transcript = useMemo(() => activityTranscript(records), [records]); + const entries = transcript.groups.flatMap((group) => group.entries); + const operations = entries.filter( + (entry) => activityCategory(entry) === "operation", + ); + const communication = entries.filter( + (entry) => activityCategory(entry) === "communication", + ); + const diagnostics = entries.filter( + (entry) => activityCategory(entry) === "diagnostic", + ); + const items = (rows: TranscriptEntry[]) => + rows.map((entry) => { + const presentation = activityPresentation(entry); + return { + value: entry.id, + title: `${presentation.title}${presentation.target ? ` · ${presentation.target}` : ""}${presentation.shellOutput?.failed || entry.status === "failed" ? " · Failed" : entry.status === "pending" ? " · Last reported pending" : entry.status === "in_progress" ? " · Last reported running" : ""}`, + content: ( +
+ {entry.body && ( +
+                {entry.body}
+              
+ )} + {entry.input && ( +
+

+ {presentation.command ? "Command" : "Input"} +

+
+                  {presentation.command ?? entry.input}
+                
+
+ )} + {entry.output && ( +
+

Output

+
+                  
+                    {presentation.shellOutput
+                      ? [
+                          presentation.shellOutput.stdout,
+                          presentation.shellOutput.stderr,
+                          presentation.shellOutput.note,
+                        ]
+                          .filter(Boolean)
+                          .join("\n") || "No text output."
+                      : entry.output}
+                  
+                
+
+ )} + +
+ ), + }; + }); + return ( +
+ {transcript.omitted > 0 && ( +

+ Older activity is outside this display window. +

+ )} + {operations.length ? ( + + ) : ( +

No tool activity captured yet.

+ )} + + ), + }, + ] + : []), + ...(diagnostics.length + ? [ + { + value: "diagnostics", + title: `Diagnostics (${diagnostics.length})${diagnostics.some((entry) => /error/i.test(entry.title)) ? " · Error reported" : ""}`, + content: ( + + ), + }, + ] + : []), + ]} + /> +
+ ); +} + +function RawSource({ + entry, + transcript, +}: { + entry: TranscriptEntry; + transcript: ReturnType; +}) { + const [expanded, setExpanded] = useState([]); + return ( + + {entry.sourceIds.map((id) => { + const source = transcript.source(id); + return source ? ( +
+                    {source.plaintext}
+                  
+ ) : null; + })} + + ) : null, + }, + ]} + /> + ); +} diff --git a/src/bundled/agent-activity/activity-presentation.test.ts b/src/bundled/agent-activity/activity-presentation.test.ts new file mode 100644 index 000000000..f5e4fa75b --- /dev/null +++ b/src/bundled/agent-activity/activity-presentation.test.ts @@ -0,0 +1,143 @@ +import { expect, it } from "vitest"; +import { activityPresentation } from "./activity-presentation"; +import type { TranscriptEntry } from "./transcript"; +const entry = ( + toolName: string, + input: unknown = {}, + output = "", +): TranscriptEntry => ({ + id: "tool", + kind: "tool", + title: toolName, + toolName, + input: JSON.stringify(input), + output, + body: "", + status: "completed", + sourceIds: [], +}); +it("maps only exact tools and structured paths; commands remain indivisible", () => { + expect( + activityPresentation( + entry("buzz-dev-mcp__read_file", { path: "/work/src/Composer.tsx" }), + ), + ).toMatchObject({ title: "Read file", target: "Composer.tsx" }); + expect( + activityPresentation( + entry("buzz-dev-mcp__str_replace", { path: "C:\\work\\Composer.tsx" }), + ), + ).toMatchObject({ title: "Edit file", target: "Composer.tsx" }); + expect( + activityPresentation( + entry("buzz-dev-mcp__shell", { + workdir: "/work/project", + command: "read file; send message", + }), + ), + ).toMatchObject({ title: "Run command", target: "read file; send message" }); + expect( + activityPresentation(entry("unknown_shell", { path: "/work/secret" })), + ).toMatchObject({ title: "unknown_shell", target: undefined }); +}); +it("never invents a file target from malformed or directory paths", () => { + for (const path of [ + "", + "/", + "/work/", + "C:\\", + "..", + 12, + { path: "file" }, + "a\nb", + ]) { + expect( + activityPresentation(entry("buzz-dev-mcp__read_file", { path })), + ).toMatchObject({ title: "buzz-dev-mcp__read_file", target: undefined }); + } +}); +it("preserves stderr, empty output and wrapper warnings without interpreting markup", () => { + const result = { + stdout: "", + stderr: "warning", + exit_code: 2, + timed_out: false, + stdout_truncated: true, + stderr_truncated: false, + }; + expect( + activityPresentation( + entry("buzz-dev-mcp__shell", {}, JSON.stringify(result)), + ).shellOutput, + ).toEqual({ + stdout: "", + stderr: "warning", + failed: true, + note: "Exit code 2 · Output was truncated by the tool", + }); + expect( + activityPresentation( + entry( + "buzz-dev-mcp__shell", + {}, + JSON.stringify({ ...result, stdout: "", stderr: "" }), + ), + ).shellOutput?.stdout, + ).toBe(""); + expect( + activityPresentation(entry("other", {}, JSON.stringify(result))) + .shellOutput, + ).toBeUndefined(); + for (const output of [ + "broken", + JSON.stringify({ ...result, stderr: null }), + JSON.stringify({ stdout: "hi" }), + ]) + expect( + activityPresentation(entry("buzz-dev-mcp__shell", {}, output)) + .shellOutput, + ).toBeUndefined(); +}); +it("previews distinct command text rather than repeated working directories", () => { + const first = activityPresentation( + entry("buzz-dev-mcp__shell", { + command: "\ncat > REPORT.md <<'EOF'\nprivate text", + workdir: ".buzz", + }), + ); + const second = activityPresentation( + entry("buzz-dev-mcp__shell", { + command: "wc -w REPORT.md", + workdir: ".buzz", + }), + ); + expect(first.target).toBe("cat > REPORT.md <<'EOF'"); + expect(second.target).toBe("wc -w REPORT.md"); + expect(first.target).not.toContain("private text"); + expect( + activityPresentation( + entry("buzz-dev-mcp__shell", { command: "x".repeat(120) }), + ).target, + ).toBe(`${"x".repeat(90)}…`); + expect( + activityPresentation( + entry("buzz-dev-mcp__shell", { command: "\t\u001bwc -w file" }), + ).target, + ).toBe("wc -w file"); +}); + +it("classifies tools by explicit event kind, never by familiar harness/tool names or completion", async () => { + const { activityCategory } = await import("./activity-presentation"); + const tool = entry("harness_specific_tool"); + for (const status of ["pending", "in_progress", "completed", "failed"]) { + expect(activityCategory({ ...tool, status })).toBe("operation"); + } + expect(activityCategory({ ...tool, kind: "plan" })).toBe("operation"); + for (const kind of ["prompt", "thought", "message"] as const) + expect(activityCategory({ ...tool, kind })).toBe("communication"); + expect( + activityCategory({ ...tool, kind: "event", title: "unknown_tool" }), + ).toBe("diagnostic"); + expect(activityCategory({ ...tool, kind: "prompt", diagnostic: true })).toBe( + "diagnostic", + ); +}); diff --git a/src/bundled/agent-activity/activity-presentation.ts b/src/bundled/agent-activity/activity-presentation.ts new file mode 100644 index 000000000..6e7db80ef --- /dev/null +++ b/src/bundled/agent-activity/activity-presentation.ts @@ -0,0 +1,137 @@ +import type { TranscriptEntry } from "./transcript"; +const object = (value: unknown): Record | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +function parsed(value: string) { + try { + return object(JSON.parse(value)); + } catch { + return; + } +} +function basename(value: unknown): string | undefined { + if ( + typeof value !== "string" || + !value.trim() || + /[/\\]$/.test(value) || + /[\r\n]/.test(value) + ) + return; + const name = value.split(/[/\\]/).at(-1); + return name && name !== "." && name !== ".." ? name : undefined; +} + +const toolActions = { + "buzz-dev-mcp__shell": { + action: "command", + active: "Running a command…", + completed: "Last action: Run command", + }, + "buzz-dev-mcp__read_file": { + action: "read", + active: "Reading file…", + completed: "Last action: Read file", + }, + "buzz-dev-mcp__str_replace": { + action: "edit", + active: "Editing file…", + completed: "Last action: Edit file", + }, + "buzz-dev-mcp__view_image": { + action: "image", + active: "Viewing image…", + completed: "Last action: View image", + }, +} as const; +function knownTool(entry: TranscriptEntry) { + const name = entry.toolName ?? entry.title; + return Object.hasOwn(toolActions, name) + ? toolActions[name as keyof typeof toolActions] + : undefined; +} +/** Exact reported kinds only. Shell input never changes its action category. */ +export function activityAction(entry: TranscriptEntry) { + if (entry.kind === "tool") return knownTool(entry)?.action ?? "tool"; + if (entry.kind === "thought") return "thought"; + if (entry.kind === "message" || entry.kind === "prompt") return "message"; + return "event"; +} + +/** Display only. Never feeds send association, status, permissions or execution. */ +export function activityPresentation(entry: TranscriptEntry) { + const input = parsed(entry.input); + const name = entry.toolName ?? entry.title; + let title = entry.title, + target: string | undefined; + if (name === "buzz-dev-mcp__shell") { + title = "Run command"; + const line = + typeof input?.command === "string" + ? input.command + .split(/\r?\n/) + .find((part) => part.trim()) + ?.replace(/\p{Cc}/gu, " ") + .trim() + : undefined; + target = line + ? `${line.slice(0, 90)}${line.length > 90 ? "…" : ""}` + : undefined; + } else if ( + name === "buzz-dev-mcp__read_file" || + name === "buzz-dev-mcp__str_replace" || + name === "buzz-dev-mcp__view_image" + ) { + target = basename(input?.path); + title = target + ? name.endsWith("read_file") + ? "Read file" + : name.endsWith("str_replace") + ? "Edit file" + : "View image" + : entry.title; + } + // Only unwrap the observed shell wrapper. Missing/malformed fields retain the + // exact output text; raw source is accessible for every presentation below. + const result = + name === "buzz-dev-mcp__shell" ? parsed(entry.output) : undefined; + const shellOutput = + result && + typeof result.stdout === "string" && + typeof result.stderr === "string" && + Number.isInteger(result.exit_code) && + typeof result.timed_out === "boolean" && + typeof result.stdout_truncated === "boolean" && + typeof result.stderr_truncated === "boolean" + ? { + stdout: result.stdout, + stderr: result.stderr, + failed: result.exit_code !== 0 || result.timed_out, + note: [ + result.exit_code !== 0 ? `Exit code ${result.exit_code}` : "", + result.timed_out ? "Command timed out" : "", + result.stdout_truncated || result.stderr_truncated + ? "Output was truncated by the tool" + : "", + ] + .filter(Boolean) + .join(" · "), + } + : undefined; + const command = + name === "buzz-dev-mcp__shell" && typeof input?.command === "string" + ? input.command + : undefined; + return { title, target, shellOutput, command }; +} + +/** Explicit projection categories, independent of harness names and completion state. */ +export function activityCategory( + entry: TranscriptEntry, +): "operation" | "communication" | "diagnostic" { + if (entry.diagnostic) return "diagnostic"; + if (entry.kind === "tool" || entry.kind === "plan") return "operation"; + if (["message", "prompt", "thought"].includes(entry.kind)) + return "communication"; + return "diagnostic"; +} diff --git a/src/bundled/agent-activity/failed-activity.test.ts b/src/bundled/agent-activity/failed-activity.test.ts new file mode 100644 index 000000000..f42c29388 --- /dev/null +++ b/src/bundled/agent-activity/failed-activity.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from "vitest"; +import { activityErrorText } from "./failed-activity"; +it("bounds error prose without parsing HTML or arbitrary objects", () => { + expect(activityErrorText({ error: "" })).toBe( + "", + ); + expect( + activityErrorText({ error: { message: "Model request rejected" } }), + ).toBe("Model request rejected"); + expect(activityErrorText({ error: {} })).toContain("See raw details"); + expect(activityErrorText({ error: "x".repeat(5000) })).toContain( + "Error shortened", + ); +}); diff --git a/src/bundled/agent-activity/failed-activity.ts b/src/bundled/agent-activity/failed-activity.ts new file mode 100644 index 000000000..a07dc25f9 --- /dev/null +++ b/src/bundled/agent-activity/failed-activity.ts @@ -0,0 +1,14 @@ +const object = (value: unknown): { [key: string]: unknown } | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as { [key: string]: unknown }) + : undefined; + +export function activityErrorText(payload: unknown): string { + const error = object(payload)?.error; + const detail = typeof error === "string" ? error : object(error)?.message; + if (typeof detail !== "string" || !detail.trim()) + return "The agent reported an error. See raw details for the received event."; + return detail.length > 4096 + ? `${detail.slice(0, 4096)}\n… Error shortened; full retained event is available in Raw events.` + : detail; +} diff --git a/src/bundled/agent-activity/request-activity.test.ts b/src/bundled/agent-activity/request-activity.test.ts new file mode 100644 index 000000000..77b5824df --- /dev/null +++ b/src/bundled/agent-activity/request-activity.test.ts @@ -0,0 +1,257 @@ +import { expect, it } from "vitest"; +import type { ActivityTurn } from "../../features/agents/activity"; +import { requestActivity, requestActivityState } from "./request-activity"; +const request = "a".repeat(64), + agent = "b".repeat(64); +const row = ( + seq: number, + kind: string, + turnId = "turn", + trigger = request, + channelId = "alpha", +) => ({ + id: String(seq), + agent, + kind, + receivedAt: 0, + plaintext: JSON.stringify({ + seq, + kind, + turnId, + channelId, + timestamp: new Date(0).toISOString(), + payload: { triggeringEventIds: [trigger] }, + }), +}); +it("uses exact request/agent/channel starts and excludes earlier same-turn records", () => { + const rows = [ + row(0, "acp_read"), + row(1, "turn_started"), + row(2, "acp_read"), + row(3, "turn_started", "other", "c".repeat(64)), + row(4, "acp_read", "other"), + ]; + expect( + requestActivity(rows, agent, "alpha", request).records.map((r) => r.id), + ).toEqual(["1", "2"]); + expect([...requestActivity(rows, agent, "alpha", request).turnIds]).toEqual([ + "turn", + ]); + expect(requestActivity(rows, "other", "alpha", request).records).toEqual([]); + expect(requestActivity(rows, agent, "beta", request).records).toEqual([]); + expect(requestActivity(rows, agent, "alpha", "d".repeat(64)).records).toEqual( + [], + ); + expect( + requestActivity( + rows.filter((r) => r.kind !== "turn_started"), + agent, + "alpha", + request, + ).records, + ).toEqual([]); +}); +it("does not combine reused turn IDs, conflicting sequences or inherited batch context", () => { + const rows = [row(1, "turn_started"), row(2, "acp_read")]; + expect( + requestActivity([...rows, row(4, "turn_started")], agent, "alpha", request) + .records, + ).toEqual([]); + expect( + requestActivity([...rows, row(2, "acp_read")], agent, "alpha", request) + .records, + ).toEqual([]); + const batch = { + ...rows[0], + id: "batch", + agent, + kind: "batch", + receivedAt: 0, + plaintext: JSON.stringify({ + kind: "batch", + channelId: "alpha", + turnId: "parent", + payload: { events: rows.map((r) => JSON.parse(r.plaintext)) }, + }), + }; + expect( + requestActivity([batch], agent, "alpha", request).records, + ).toHaveLength(2); + batch.plaintext = JSON.stringify({ + kind: "batch", + channelId: "alpha", + payload: { + events: rows.map((r) => ({ + ...JSON.parse(r.plaintext), + channelId: "beta", + })), + }, + }); + expect(requestActivity([batch], agent, "alpha", request).records).toEqual([]); +}); + +const state = ( + turnId: string, + status: ActivityTurn["state"], +): ActivityTurn => ({ + agent, + channelId: "alpha", + turnId, + state: status, + timestamp: 0, +}); +const select = (rows: ReturnType[]) => + requestActivity(rows, agent, "alpha", request); +it.each(["turn_completed", "turn_error", "agent_panic"])( + "requires lifecycle end and exact terminal evidence for %s", + (kind) => { + const selected = select([row(1, "turn_started"), row(2, kind)]); + expect(requestActivityState(selected, [state("turn", "ended")])).toBe( + kind === "turn_completed" ? "ended" : "error", + ); + expect(requestActivityState(selected, [state("turn", "working")])).toBe( + "working", + ); + expect(requestActivityState(selected, [state("turn", "unknown")])).toBe( + "unknown", + ); + expect(requestActivityState(selected, [])).toBe("unknown"); + }, +); +it("does not settle multiple turns while another is active, unknown, missing or ambiguous", () => { + const ended = [row(1, "turn_started"), row(2, "turn_error")]; + const both = [ + ...ended, + row(1, "turn_started", "second"), + row(2, "turn_completed", "second"), + ]; + for (const [other, expected] of [ + ["working", "working"], + ["unknown", "unknown"], + ["ended", "error"], + ] as const) { + expect( + requestActivityState(select(both), [ + state("turn", "ended"), + state("second", other), + ]), + ).toBe(expected); + } + expect(requestActivityState(select(both), [state("turn", "ended")])).toBe( + "unknown", + ); + const duplicate = select([...both, row(3, "turn_started", "second")]); + expect(duplicate.uncertain).toBe(true); + expect(requestActivityState(duplicate, [state("turn", "ended")])).toBe( + "unknown", + ); + expect(requestActivityState(duplicate, [state("turn", "working")])).toBe( + "working", + ); + const invalidSeq = row(3, "turn_started", "second"); + invalidSeq.plaintext = invalidSeq.plaintext.replace('"seq":3', '"seq":"3"'); + expect( + requestActivityState(select([...ended, invalidSeq]), [ + state("turn", "ended"), + ]), + ).toBe("unknown"); +}); +it("fails closed for missing terminal or evicted association, never infers from tool error", () => { + expect( + requestActivityState(select([row(1, "turn_started"), row(2, "acp_read")]), [ + state("turn", "ended"), + ]), + ).toBe("unknown"); + expect( + requestActivityState(select([row(2, "turn_error")]), [ + state("turn", "ended"), + ]), + ).toBe("waiting"); + expect(requestActivityState(select([]), [state("turn", "ended")])).toBe( + "waiting", + ); + expect( + requestActivityState( + select([row(1, "turn_started"), row(2, "turn_error", "other")]), + [state("turn", "unknown")], + ), + ).toBe("unknown"); +}); +it("validates terminal timestamps and preserves first admitted terminal receipt order", () => { + const start = row(1, "turn_started"); + const terminal = row(2, "turn_error"); + for (const timestamp of ["invalid", new Date(5001).toISOString()]) { + const invalid = { + ...terminal, + plaintext: JSON.stringify({ + ...JSON.parse(terminal.plaintext), + timestamp, + }), + }; + expect( + requestActivityState(select([start, invalid]), [state("turn", "ended")]), + ).toBe("unknown"); + expect( + requestActivityState(select([start, invalid, row(3, "turn_completed")]), [ + state("turn", "ended"), + ]), + ).toBe("ended"); + } + expect( + requestActivityState(select([start, row(3, "turn_completed"), terminal]), [ + state("turn", "ended"), + ]), + ).toBe("ended"); + expect( + requestActivityState(select([start, terminal, row(3, "turn_completed")]), [ + state("turn", "ended"), + ]), + ).toBe("error"); + expect( + requestActivityState(select([row(0, "turn_completed"), start, terminal]), [ + state("turn", "ended"), + ]), + ).toBe("unknown"); +}); +it("ignores wrong identities/channels and unscoped batch terminals", () => { + const start = row(1, "turn_started"); + const batch = { + ...row(2, "batch"), + plaintext: JSON.stringify({ + kind: "batch", + channelId: "alpha", + payload: { + events: [ + { + ...JSON.parse(row(2, "turn_error").plaintext), + channelId: undefined, + }, + ], + }, + }), + }; + const selected = select([ + start, + { ...row(2, "turn_error"), agent: "c".repeat(64) }, + row(3, "turn_error", "turn", request, "beta"), + batch, + ]); + expect(requestActivityState(selected, [state("turn", "ended")])).toBe( + "unknown", + ); +}); + +it("does not claim aggregate end after another linked start was trimmed", () => { + // A's start was evicted but its lifecycle still says active; retained B ended. + const selected = select([ + row(2, "acp_read"), + row(3, "turn_started", "B"), + row(4, "turn_completed", "B"), + ]); + const allTurns = [state("turn", "working"), state("B", "ended")]; + expect(requestActivityState(selected, allTurns, 1)).toBe("unknown"); + expect(requestActivityState(selected, [state("B", "working")], 1)).toBe( + "working", + ); + expect(requestActivityState(select([]), allTurns, 1)).toBe("unknown"); +}); diff --git a/src/bundled/agent-activity/request-activity.ts b/src/bundled/agent-activity/request-activity.ts new file mode 100644 index 000000000..4b90f8208 --- /dev/null +++ b/src/bundled/agent-activity/request-activity.ts @@ -0,0 +1,140 @@ +import type { ActivityTurn } from "../../features/agents/activity"; +import { + activityRecords, + type ActivityRecord, +} from "../../features/agents/activity-records"; + +/** Live request association from explicit captured trigger IDs only. No time/name + * inference, backfill, or broader channel fallback when the start is missing. */ +export function requestActivity( + records: readonly Omit[], + agent: string, + channelId: string, + requestId: string | readonly string[], +) { + const parsed = activityRecords(records, agent, channelId).flatMap( + (record) => { + try { + const value = JSON.parse(record.plaintext); + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + typeof value.turnId !== "string" || + !value.turnId || + value.turnId.length > 256 + ) + return []; + return [ + { + record, + value: value as { + kind: string; + turnId: string; + seq: number; + timestamp?: unknown; + payload?: { triggeringEventIds?: unknown }; + }, + }, + ]; + } catch { + return []; + } + }, + ); + const turnIds = new Set(); + const selected: ActivityRecord[] = []; + const terminal = new Map(); + let uncertain = false; + const result = () => ({ records: selected, turnIds, terminal, uncertain }); + const triggersWanted = new Set( + (typeof requestId === "string" ? [requestId] : requestId).filter((id) => + /^[0-9a-f]{64}$/.test(id), + ), + ); + if (!triggersWanted.size) return result(); + for (const start of parsed.filter( + ({ value }) => value.kind === "turn_started", + )) { + const triggers = start.value.payload?.triggeringEventIds; + if ( + !Array.isArray(triggers) || + !triggers.some((id) => triggersWanted.has(id)) + ) + continue; + const turn = parsed.filter( + ({ value }) => value.turnId === start.value.turnId, + ); + // Duplicate starts/sequence resets leave identity ambiguous. Do not mix runs. + if ( + turn.filter(({ value }) => value.kind === "turn_started").length !== 1 || + turn.some( + ({ value }) => !Number.isSafeInteger(value.seq) || value.seq < 0, + ) + ) { + uncertain = true; + continue; + } + const after = turn + .filter(({ value }) => value.seq >= start.value.seq) + .sort((a, b) => a.value.seq - b.value.seq); + if (new Set(after.map(({ value }) => value.seq)).size !== after.length) { + uncertain = true; + continue; + } + turnIds.add(start.value.turnId); + selected.push(...after.map(({ record }) => record)); + // Match the lifecycle owner's timestamp admission. Raw future/malformed + // terminal-looking records remain inspectable but cannot classify outcome. + const admitted = ({ value, record }: (typeof parsed)[number]) => + typeof value.timestamp === "string" && + Number.isFinite(Date.parse(value.timestamp)) && + Date.parse(value.timestamp) <= record.receivedAt + 5000; + if (!admitted(start)) { + uncertain = true; + continue; + } + // The lifecycle owner latches the first admitted terminal frame in receipt + // order, not the largest seq. A later error must not rewrite an earlier end. + const end = turn.find( + (item) => + ["turn_completed", "turn_error", "agent_panic"].includes( + item.value.kind, + ) && admitted(item), + ); + if (end && end.value.seq >= start.value.seq) + terminal.set( + start.value.turnId, + end.value.kind === "turn_completed" ? "ended" : "error", + ); + } + return result(); +} + +/** Already scoped lifecycle evidence, never a claim that a reply was delivered. + * Working dominates; all linked turns must agree before an aggregate end label. + * Any feed trimming can have erased another linked start; do not claim completeness. */ +export function requestActivityState( + selected: ReturnType, + turns: readonly ActivityTurn[], + trimmed = 0, +): "waiting" | "working" | "unknown" | "ended" | "error" { + const linked = [...selected.turnIds].map((id) => + turns.find((turn) => turn.turnId === id), + ); + if (linked.some((turn) => turn?.state === "working")) return "working"; + if ( + trimmed > 0 || + selected.uncertain || + linked.some((turn) => !turn || turn.state === "unknown") + ) + return "unknown"; + if (!linked.length) return "waiting"; + if ( + linked.some( + (turn) => turn?.state !== "ended" || !selected.terminal.has(turn.turnId), + ) + ) + return "unknown"; + return [...selected.terminal.values()].includes("error") ? "error" : "ended"; +} diff --git a/src/bundled/agent-activity/thread-activity.test.ts b/src/bundled/agent-activity/thread-activity.test.ts new file mode 100644 index 000000000..ddf76015c --- /dev/null +++ b/src/bundled/agent-activity/thread-activity.test.ts @@ -0,0 +1,172 @@ +import { expect, it } from "vitest"; +import { threadActivity } from "./thread-activity"; +import type { RelaySession } from "../../features/relay/session"; +import type { ChannelMessage } from "../../features/relay/contracts"; +const root = "a".repeat(64), + handoff = "b".repeat(64), + agent = "c".repeat(64), + peer = "d".repeat(64); +type Snapshot = ReturnType; +function record( + id: string, + author: string, + turn: string, + seq: number, + kind: string, + trigger = root, + channelId = "alpha", +) { + return { + id, + agent: author, + kind, + channelIds: [channelId], + receivedAt: 1000, + createdAt: 1, + plaintext: JSON.stringify({ + kind, + turnId: turn, + channelId, + seq, + timestamp: new Date(1000).toISOString(), + payload: + kind === "turn_started" + ? { triggeringEventIds: [trigger] } + : { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + toolCallId: id, + title: "Later tool", + status: "in_progress", + }, + }, + }, + }), + }; +} +const message = { + id: handoff, + channelId: "alpha", + threadRootId: root, + content: "Handoff", + createdAt: 1, + mentions: [], + attachments: [], + reactions: [], + participants: [], + replyCount: 0, + authorId: agent, +} satisfies ChannelMessage; +const snapshot = (): Snapshot => ({ + status: "listening", + trimmed: 0, + typing: [], + records: [ + record("1", agent, "initial", 1, "turn_started"), + record("2", agent, "initial", 2, "turn_completed"), + record("3", peer, "handoff", 1, "turn_started", handoff), + record("4", peer, "handoff", 2, "acp_read", handoff), + ], + turns: [ + { + agent, + channelId: "alpha", + turnId: "initial", + timestamp: 1000, + state: "ended", + }, + { + agent: peer, + channelId: "alpha", + turnId: "handoff", + timestamp: 1000, + state: "working", + }, + ], +}); +it("includes later hidden-handoff turns and new agents only with loaded exact-thread evidence", () => { + expect( + threadActivity(snapshot(), "alpha", root).filter((e) => e.working), + ).toEqual([]); + const work = threadActivity(snapshot(), "alpha", root, [message]); + expect( + work.find((e) => e.agent === peer)?.selected.records.map((r) => r.id), + ).toEqual(["3", "4"]); + expect(work.find((e) => e.agent === peer)?.working).toBe(true); + expect(work.find((e) => e.agent === agent)?.state).toBe("ended"); + for (const wrong of [ + { ...message, threadRootId: "f".repeat(64) }, + { ...message, channelId: "beta" }, + ]) + expect( + threadActivity(snapshot(), "alpha", root, [wrong]).some( + (e) => e.agent === peer, + ), + ).toBe(false); +}); +it("fresh exact typing can show later work without attributing missing tool details", () => { + const source = snapshot(); + const typing = { + agent: peer, + channelId: "alpha", + threadRootId: root, + timestamp: 1100, + startedAt: 1100, + working: true, + expiresAt: 9000, + }; + const result = threadActivity( + { ...source, typing: [typing] }, + "alpha", + root, + ).find((e) => e.agent === peer); + expect(result?.working).toBe(true); + expect(result?.selected.records).toEqual([]); + expect( + threadActivity( + { ...source, status: "interrupted", typing: [typing] }, + "alpha", + root, + ).some((e) => e.working), + ).toBe(false); + expect( + threadActivity( + { ...source, typing: [{ ...typing, agent, timestamp: 999 }] }, + "alpha", + root, + ).find((e) => e.agent === agent)?.working, + ).toBe(false); +}); +it("does not admit ambiguous starts or confuse coordination/turn completion with a visible answer", () => { + const source = snapshot(); + const duplicate = record("5", peer, "handoff", 3, "turn_started", handoff); + expect( + threadActivity( + { ...source, records: [...source.records, duplicate] }, + "alpha", + root, + [message], + ).some((e) => e.agent === peer), + ).toBe(false); + expect( + threadActivity({ ...source, trimmed: 1 }, "alpha", root, [message]).find( + (e) => e.agent === agent, + )?.state, + ).toBe("unknown"); +}); + +it("keeps retained linked records inspectable when their independent turn-state slot was evicted", () => { + const source = snapshot(); + const work = threadActivity({ ...source, turns: [] }, "alpha", root, [ + message, + ]); + const entry = work.find((entry) => entry.agent === peer); + expect(entry?.selected.records.map((record) => record.id)).toEqual([ + "3", + "4", + ]); + expect(entry?.state).toBe("unknown"); + expect(entry?.working).toBe(false); +}); diff --git a/src/bundled/agent-activity/thread-activity.ts b/src/bundled/agent-activity/thread-activity.ts new file mode 100644 index 000000000..03b85d128 --- /dev/null +++ b/src/bundled/agent-activity/thread-activity.ts @@ -0,0 +1,74 @@ +import type { RelaySession } from "../../features/relay/session"; +import type { ChannelMessage } from "../../features/relay/contracts"; +import { requestActivity, requestActivityState } from "./request-activity"; + +type Snapshot = ReturnType; +/** Shared thread evidence for the channel control, live tail and hover details. + * Loaded hidden replies are valid trigger IDs, never proof of delivery or work. + * Adapted from PR #406; retains ended records for inspection, not live status. */ +export function threadActivity( + snapshot: Snapshot, + channelId: string, + rootId: string, + messages: readonly ChannelMessage[] = [], + requestId?: string, +) { + const ids = [ + rootId, + ...messages + .filter( + (row) => + row.channelId === channelId && + (row.id === rootId || row.threadRootId === rootId), + ) + .map((row) => row.id), + ...(requestId ? [requestId] : []), + ]; + const typing = + snapshot.status === "listening" + ? snapshot.typing.filter( + (entry) => + entry.channelId === channelId && entry.threadRootId === rootId, + ) + : []; + const agents = new Set([ + ...snapshot.turns + .filter((turn) => turn.channelId === channelId) + .map((turn) => turn.agent), + ...typing.map((entry) => entry.agent), + ...snapshot.records + .filter((record) => record.channelIds.includes(channelId)) + .map((record) => record.agent), + ]); + return [...agents] + .sort() + .map((agent) => { + const selected = requestActivity(snapshot.records, agent, channelId, ids); + const turns = snapshot.turns.filter( + (turn) => + turn.agent === agent && + turn.channelId === channelId && + selected.turnIds.has(turn.turnId), + ); + const terminal = Math.max( + -Infinity, + ...turns + .filter((turn) => turn.state === "ended") + .map((turn) => turn.timestamp), + ); + const typed = typing.some( + (entry) => entry.agent === agent && entry.timestamp > terminal, + ); + const state = requestActivityState(selected, turns, snapshot.trimmed); + const working = + snapshot.status === "listening" && (state === "working" || typed); + return { + agent, + selected, + turns, + working, + state: working ? ("working" as const) : state, + }; + }) + .filter((entry) => entry.selected.turnIds.size > 0 || entry.working); +} diff --git a/src/bundled/agent-activity/transcript.test.ts b/src/bundled/agent-activity/transcript.test.ts new file mode 100644 index 000000000..26db133d3 --- /dev/null +++ b/src/bundled/agent-activity/transcript.test.ts @@ -0,0 +1,539 @@ +import { expect, it } from "vitest"; +import { activityRecords } from "../../features/agents/activity-records"; +import { activityTranscript, TRANSCRIPT_EVENT_LIMIT } from "./transcript"; +const event = ( + kind: string, + payload: unknown, + turnId: string | null = "turn", + channelId: string | null = "alpha", +) => ({ kind, payload, turnId, channelId, sessionId: "S" }); +const update = (sessionUpdate: string, extra = {}) => + event("acp_read", { + method: "session/update", + params: { sessionId: "S", update: { sessionUpdate, ...extra } }, + }); +const records = (values: unknown[], agent = "agent") => + activityRecords( + values.map((value, i) => ({ + id: String(i), + agent, + kind: "event", + receivedAt: i, + plaintext: JSON.stringify(value), + })), + agent, + ); + +it("groups exact agent/channel/turn identities and never inherits batch context", () => { + const raw = records([ + { + kind: "batch", + channelId: "alpha", + turnId: "parent", + payload: { + events: [ + event("turn_started", {}, "same", "alpha"), + event("turn_started", {}, "same", "beta"), + event("acp_read", {}, null, null), + ], + }, + }, + ]); + const result = activityTranscript(raw); + expect(result.groups.map((g) => [g.channelId, g.turnId])).toEqual([ + ["alpha", "same"], + ["beta", "same"], + [null, null], + ]); + const filtered = activityRecords(raw, "agent", "alpha"); + expect(activityTranscript(filtered).groups).toHaveLength(1); + expect( + activityTranscript([ + ...records([update("tool_call", { toolCallId: "one", title: "First" })]), + ...records( + [update("tool_call", { toolCallId: "one", title: "Second" })], + "other", + ), + ]).groups, + ).toHaveLength(2); +}); +it("renders prompts as inert text, coalesces contiguous chunks, and keeps separate prompt boundaries", () => { + const result = activityTranscript( + records([ + event("acp_write", { + method: "session/prompt", + params: { + prompt: [{ type: "text", text: "" }], + }, + }), + update("agent_message_chunk", { + content: { type: "text", text: "Hello " }, + }), + update("agent_message_chunk", { + content: { type: "text", text: "world" }, + }), + event("acp_write", { + method: "_session/steering", + params: { prompt: [{ type: "text", text: "Follow up" }] }, + }), + update("agent_message_chunk", { + content: { type: "text", text: "Second response" }, + }), + ]), + ); + const entries = result.groups[0]?.entries ?? []; + expect(entries.map((e) => [e.title, e.body])).toEqual([ + ["Request context", ""], + ["Response", "Hello world"], + ["Follow-up context", "Follow up"], + ["Response", "Second response"], + ]); + expect(new Set(entries.map((e) => e.id)).size).toBe(entries.length); + expect(entries[1]?.sourceIds).toEqual(["1", "2"]); +}); +it("folds tool snapshots only within a turn/session and retains terminal status through delayed starts", () => { + const start = update("tool_call", { + toolCallId: "tool", + title: "Read README.md", + status: "in_progress", + rawInput: { path: "README.md" }, + }); + const result = activityTranscript( + records([ + start, + update("tool_call_update", { + toolCallId: "tool", + status: "completed", + rawOutput: { text: "first" }, + }), + update("tool_call_update", { toolCallId: "tool", status: "in_progress" }), + { ...start, turnId: "different" }, + event("acp_read", { + method: "session/update", + params: { + sessionId: "other", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool", + title: "Other session", + }, + }, + }), + ]), + ); + expect(result.groups).toHaveLength(2); + const tools = result.groups[0]?.entries ?? []; + expect(tools).toHaveLength(2); + expect(tools[0]).toMatchObject({ + title: "Read README.md", + status: "completed", + sourceIds: ["0", "1", "2"], + input: '{\n "path": "README.md"\n}', + output: '{\n "text": "first"\n}', + }); + expect(tools[1]?.title).toBe("Other session"); +}); +it("keeps update-only, failed, unknown, nontext and malformed observations inspectable", () => { + const result = activityTranscript( + records([ + update("tool_call_update", { + toolCallId: "missing-start", + status: "failed", + content: [ + { + type: "content", + content: { type: "text", text: "Permission denied" }, + }, + ], + }), + update("agent_thought_chunk", { + content: { type: "image", data: "ignored" }, + }), + update("plan", { + entries: [ + { status: "completed", content: "Read file" }, + { status: "pending", content: "Run tests" }, + null, + ], + }), + event("acp_read", { text: "not a file read" }), + event("new-event", "literal"), + event("turn_error", {}), + event("turn_completed", {}), + ]), + ); + expect(result.groups[0]?.entries.map((e) => e.title)).toEqual([ + "Tool call", + "Thinking", + "Plan", + "ACP event", + "new-event", + "Turn error", + "Turn ended", + ]); + expect(result.groups[0]?.entries[0]).toMatchObject({ + status: "failed", + output: "Permission denied", + }); + expect(result.groups[0]?.entries[2]?.body).toBe("✓ Read file\n• Run tests"); + expect( + activityTranscript([ + { + id: "broken", + envelopeId: "broken", + agent: "agent", + receivedAt: 0, + kind: "unknown", + plaintext: "broken", + }, + ]).groups, + ).toEqual([]); +}); +it("does not merge tools without IDs or chunks with different message IDs", () => { + const result = activityTranscript( + records([ + update("tool_call", { title: "One" }), + update("tool_call", { title: "Two" }), + update("agent_message_chunk", { + messageId: "a", + content: { type: "text", text: "A" }, + }), + update("agent_message_chunk", { + messageId: "b", + content: { type: "text", text: "B" }, + }), + ]), + ); + expect(result.groups[0]?.entries).toHaveLength(4); +}); +it("bounds a large batch and recomputes from retained evidence without resurrecting removed details", () => { + const values = Array.from({ length: TRANSCRIPT_EVENT_LIMIT + 5 }, (_, i) => + event("acp_read", { text: String(i) }, String(i)), + ); + const raw = records([{ kind: "batch", payload: { events: values } }]); + const before = JSON.stringify(raw); + const result = activityTranscript(raw); + expect(result.omitted).toBe(5); + expect(result.groups).toHaveLength(TRANSCRIPT_EVENT_LIMIT); + expect(result.groups[0]?.turnId).toBe("5"); + expect(JSON.stringify(raw)).toBe(before); + expect(activityTranscript([])).toMatchObject({ groups: [], omitted: 0 }); + const onlyUpdate = activityTranscript( + records([ + update("tool_call_update", { + toolCallId: "missing", + status: "completed", + }), + ]), + ); + expect(onlyUpdate.groups[0]?.entries[0]?.title).toBe("Tool call"); +}); +it("does not unfold nested batches after exact-channel projection", () => { + const input = records([ + { + kind: "batch", + payload: { + events: [ + { + kind: "batch", + channelId: "alpha", + payload: { events: [event("acp_read", {}, "secret", "beta")] }, + }, + ], + }, + }, + ]); + const scoped = activityRecords(input, "agent", "alpha"); + expect(activityTranscript(scoped).groups.map((g) => g.channelId)).toEqual([ + "alpha", + ]); +}); +it("respects ACP session identity and intervening tool updates when joining chunks", () => { + const chunk = (body: string, session = "S") => + event("acp_read", { + method: "session/update", + params: { + sessionId: session, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: body }, + }, + }, + }); + const result = activityTranscript( + records([ + update("tool_call", { toolCallId: "tool", title: "Read file" }), + chunk("A"), + update("tool_call_update", { toolCallId: "tool", status: "completed" }), + chunk("B"), + chunk("C", "other"), + ]), + ); + expect( + result.groups[0]?.entries + .filter((e) => e.kind === "message") + .map((e) => e.body), + ).toEqual(["A", "B", "C"]); +}); +it("distinguishes omitted output from an explicitly cleared snapshot", () => { + const input = [ + update("tool_call", { toolCallId: "tool", rawOutput: "Old output" }), + update("tool_call_update", { toolCallId: "tool", status: "in_progress" }), + ]; + expect( + activityTranscript(records(input)).groups[0]?.entries[0]?.output, + ).toContain("Old output"); + input.push(update("tool_call_update", { toolCallId: "tool", content: [] })); + expect(activityTranscript(records(input)).groups[0]?.entries[0]?.output).toBe( + "", + ); +}); +it("classifies setup by direction, pairs only captured control responses, and never hides permission decisions", () => { + const result = activityTranscript( + records([ + event("acp_write", { id: 1, method: "initialize", params: {} }), + event("acp_read", { id: 1, result: {} }), + event("acp_read", { + id: 2, + method: "session/request_permission", + params: {}, + }), + event("acp_write", { + id: 2, + result: { outcome: { outcome: "selected", optionId: "allow" } }, + }), + event("acp_write", { id: 3, method: "session/new", params: {} }), + event("acp_read", { id: 3, error: { message: "no session" } }), + event("acp_read", { id: 4, result: {} }), + update("session_info_update", {}), + event("acp_read", { + method: "_goose/unstable/session/update", + params: { update: { sessionUpdate: "usage_update", used: 1 } }, + }), + event("acp_read", { id: 5, method: "initialize" }), + update("future_update", {}), + ]), + ); + expect( + result.groups[0]?.entries.map((e) => [e.title, !!e.diagnostic]), + ).toEqual([ + ["Initialize agent", true], + ["Session setup response", true], + ["Permission requested", false], + ["Permission response", false], + ["Prepare session", true], + ["Session setup response error", false], + ["ACP response", false], + ["Session update", true], + ["Usage update", true], + ["initialize", false], + ["future_update", false], + ]); +}); +it("raw source uses exact projected children and retains known and unknown evidence", () => { + const raw = records([ + { + kind: "batch", + payload: { + events: [ + event("new-event", "alpha secret", "T", "alpha"), + event("new-event", "beta secret", "T", "beta"), + ], + }, + }, + ]); + const general = activityTranscript(raw); + const first = general.groups[0]?.entries[0]?.sourceIds[0]; + expect(general.source(first ?? "")?.plaintext).toContain("alpha secret"); + expect(general.source(first ?? "")?.plaintext).not.toContain("beta secret"); + const scoped = activityTranscript(activityRecords(raw, "agent", "alpha")); + expect( + scoped.source(scoped.groups[0]?.entries[0]?.sourceIds[0] ?? "")?.plaintext, + ).not.toContain("beta secret"); + expect(scoped.source("evicted")).toBeUndefined(); +}); +it("pairs unassigned setup RPCs by stable scope without pairing across sessions", () => { + const req = event("acp_write", { id: 1, method: "initialize" }, null); + const res = event("acp_read", { id: 1, result: {} }, null); + const result = activityTranscript( + records([req, { ...res, sessionId: "other" }, res]), + ); + expect( + result.groups.flatMap((g) => + g.entries.map((e) => [e.title, !!e.diagnostic]), + ), + ).toEqual([ + ["Initialize agent", true], + ["ACP response", false], + ["Session setup response", true], + ]); +}); +it("preserves a known tool identity when the producer updates its display title", () => { + const result = activityTranscript( + records([ + update("tool_call", { + toolCallId: "shell", + title: "buzz-dev-mcp__shell", + rawInput: { command: "check" }, + }), + update("tool_call_update", { + toolCallId: "shell", + title: "Finished checking", + status: "completed", + }), + ]), + ); + expect(result.groups[0]?.entries[0]).toMatchObject({ + toolName: "buzz-dev-mcp__shell", + title: "Finished checking", + }); +}); +it("folds only exact recognized allowed permissions into details, with both raw halves retained", () => { + const request = (options: unknown) => + event("acp_read", { + id: 7, + method: "session/request_permission", + params: { options }, + }); + const response = (outcome: unknown) => + event("acp_write", { id: 7, result: { outcome } }); + const option = { optionId: "selected-option", kind: "allow_once" }; + const source = [ + request([option]), + response({ outcome: "selected", optionId: "selected-option" }), + ]; + const allowed = activityTranscript(records(source)); + expect(allowed.groups[0]?.entries).toHaveLength(1); + expect(allowed.groups[0]?.entries[0]).toMatchObject({ + title: "Permission allowed", + diagnostic: true, + sourceIds: ["0", "1"], + }); + expect(allowed.source("0")?.plaintext).toContain( + "session/request_permission", + ); + expect(allowed.source("1")?.plaintext).toContain("selected-option"); + for (const options of [ + [{ ...option, kind: "unknown" }], + [option, option], + [], + null, + ]) { + const entries = + activityTranscript(records([request(options), source[1]])).groups[0] + ?.entries ?? []; + expect(entries.every((e) => !e.diagnostic)).toBe(true); + expect(entries.map((e) => e.title)).not.toContain("Permission allowed"); + } + for (const kind of ["reject_once", "reject_always"]) { + const denied = activityTranscript( + records([request([{ ...option, kind }]), source[1]]), + ); + expect(denied.groups[0]?.entries[0]).toMatchObject({ + title: "Permission denied", + diagnostic: false, + }); + } + expect( + activityTranscript( + records([request([option]), response({ outcome: "cancelled" })]), + ).groups[0]?.entries[0], + ).toMatchObject({ title: "Permission cancelled", diagnostic: false }); + for (const values of [ + [source[1], source[0]], + [source[0], source[0], source[1]], + [source[0], { ...source[1], sessionId: "other" }], + ]) { + const entries = activityTranscript(records(values)).groups.flatMap( + (g) => g.entries, + ); + expect(entries.some((e) => e.title === "Permission allowed")).toBe(false); + } +}); +it("puts known context delivery in diagnostics and lets late failure override completion", () => { + const result = activityTranscript( + records([ + event("prompt_context_delivery", { promptBytes: 100 }), + update("tool_call", { toolCallId: "tool", status: "completed" }), + update("tool_call_update", { toolCallId: "tool", status: "failed" }), + update("tool_call_update", { toolCallId: "tool", status: "completed" }), + ]), + ); + expect(result.groups[0]?.entries[0]).toMatchObject({ + title: "Context delivered", + diagnostic: true, + }); + expect(result.groups[0]?.entries[1]?.status).toBe("failed"); +}); +it("keeps malformed empty permission choices primary and recovers after collided requests drain", () => { + const request = (options: unknown[]) => + event("acp_read", { + id: 1, + method: "session/request_permission", + params: { options }, + }); + const cancelled = event("acp_write", { + id: 1, + result: { outcome: { outcome: "cancelled" } }, + }); + const empty = + activityTranscript(records([request([]), cancelled])).groups[0]?.entries ?? + []; + expect(empty.map((e) => e.title)).toEqual([ + "Permission requested", + "Permission response", + ]); + expect(empty.every((e) => !e.diagnostic)).toBe(true); + const valid = request([{ optionId: "choice", kind: "allow_once" }]); + const allowed = event("acp_write", { + id: 1, + result: { outcome: { outcome: "selected", optionId: "choice" } }, + }); + const recovered = + activityTranscript( + records([valid, valid, allowed, allowed, valid, allowed]), + ).groups[0]?.entries ?? []; + expect( + recovered.filter((e) => e.title === "Permission allowed"), + ).toHaveLength(1); + expect(recovered.at(-1)?.sourceIds).toEqual(["4", "5"]); +}); + +it("preserves reported event and method names without inventing command semantics", () => { + const input = records([ + event("session_config_captured", { configOptions: [] }), + event("acp_write", { + id: 12, + method: "session/set_config_option", + params: {}, + }), + event("acp_read", { id: 12, result: {} }), + event("acp_read", { id: 13, error: { message: "failed" } }), + event("new_observer_event", {}), + event("acp_read", { method: "session/update", params: { update: {} } }), + event("", {}), + ]); + const transcript = activityTranscript(input); + expect( + transcript.groups.flatMap((group) => + group.entries.map((entry) => [ + entry.title, + !!entry.diagnostic, + entry.kind, + ]), + ), + ).toEqual([ + ["Session configuration captured", true, "event"], + ["session/set_config_option", false, "event"], + ["ACP response", false, "event"], + ["ACP error response", false, "event"], + ["new_observer_event", false, "event"], + ["session/update", false, "event"], + ["Unrecognized activity", false, "event"], + ]); + for (const group of transcript.groups) + for (const entry of group.entries) + expect( + transcript.source(entry.sourceIds[0] ?? "")?.plaintext, + ).toBeTruthy(); +}); diff --git a/src/bundled/agent-activity/transcript.ts b/src/bundled/agent-activity/transcript.ts new file mode 100644 index 000000000..e4f11d105 --- /dev/null +++ b/src/bundled/agent-activity/transcript.ts @@ -0,0 +1,424 @@ +import type { ActivityRecord } from "../../features/agents/activity-records"; +import { activityErrorText } from "./failed-activity"; + +// Presentation only: rebuilt from the current RAM window, never another journal. +export const TRANSCRIPT_EVENT_LIMIT = 500; +type ObjectValue = { [key: string]: unknown }; +const object = (value: unknown): ObjectValue | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as ObjectValue) + : undefined; +const text = (value: unknown) => (typeof value === "string" ? value : ""); +const identity = (value: unknown) => { + const result = text(value); + return result.length <= 256 ? result : ""; +}; +const json = (value: unknown) => + value === undefined ? "" : JSON.stringify(value, null, 2); + +/** ACP text blocks only. Never interpret prompt prose as identity or markup. */ +function contentText(value: unknown, depth = 0): string { + if (depth > 3) return ""; + if (Array.isArray(value)) + return value + .map((part) => { + const block = object(part); + return contentText(block?.content ?? block, depth + 1); + }) + .filter(Boolean) + .join("\n"); + const block = object(value); + return block?.type === "text" ? text(block.text) : ""; +} +export type TranscriptEntry = { + id: string; + kind: "prompt" | "message" | "thought" | "tool" | "plan" | "event"; + title: string; + body: string; + input: string; + output: string; + status: string; + toolName?: string; + diagnostic?: boolean; + chunkKey?: string; + /** References into the current raw projection, not another copy of telemetry. */ + sourceIds: string[]; +}; +export type TranscriptGroup = { + id: string; + agent: string; + channelId: string | null; + turnId: string | null; + sessionId: string | null; + receivedAt: number; + entries: TranscriptEntry[]; +}; +type Item = { record: ActivityRecord; value: ObjectValue; id: string }; +const toolStatuses = new Set(["pending", "in_progress", "completed", "failed"]); +const terminal = (status: string) => + status === "completed" || status === "failed"; + +export function activityTranscript(records: readonly ActivityRecord[]) { + const events: Item[] = []; + let total = 0; + // Iterate newest-first so only the bounded display window is allocated. + for (let index = records.length - 1; index >= 0; index--) { + const record = records[index]; + if (!record) continue; + let value: ObjectValue | undefined; + try { + value = object(JSON.parse(record.plaintext)); + } catch { + /* raw view remains */ + } + const children = + value?.kind === "batch" && record.id === record.envelopeId + ? object(value.payload)?.events + : undefined; + const values = Array.isArray(children) ? children : [value]; + for (let child = values.length - 1; child >= 0; child--) { + const item = object(values[child]); + if (!item) continue; + total++; + if (events.length < TRANSCRIPT_EVENT_LIMIT) + events.push({ + record, + value: item, + id: Array.isArray(children) ? `${record.id}:${child}` : record.id, + }); + } + } + const sources = new Map(events.map((item) => [item.id, item])); + const groups = new Map(); + const tools = new Map(); + const chunks = new Map(); + const control = new Map< + string, + { + title: string; + diagnostic: boolean; + entry: TranscriptEntry; + options?: Map; + } + >(); + const ambiguousControl = new Map(); + const sourceOrder = new Map(events.map((item, index) => [item.id, -index])); + for (const { record, value, id } of events.reverse()) { + // Never inherit context from an enclosing batch or adjacent event. + const channelId = identity(value.channelId) || null; + const turnId = identity(value.turnId) || null; + const sessionId = identity(value.sessionId) || null; + // A turn may resolve its session mid-run; the turn itself remains the group. + const key = JSON.stringify([ + record.agent, + channelId, + turnId ? ["turn", turnId] : ["record", id], + ]); + let group = groups.get(key); + if (!group) { + group = { + id: key, + agent: record.agent, + channelId, + turnId, + sessionId, + receivedAt: record.receivedAt, + entries: [], + }; + groups.set(key, group); + } + const kind = text(value.kind); + const payload = object(value.payload); + const entry: TranscriptEntry = { + id, + kind: "event", + title: "Unrecognized activity", + body: "", + input: "", + output: "", + status: "", + sourceIds: [id], + }; + const method = text(payload?.method); + const params = object(payload?.params); + // Preserve reported identity when the renderer has no friendly mapping. + entry.title = + identity(method) || + (kind === "acp_read" || kind === "acp_write" + ? payload?.error !== undefined + ? "ACP error response" + : payload?.result !== undefined + ? "ACP response" + : "ACP event" + : identity(kind) || "Unrecognized activity"); + const priorChunk = chunks.get(key); + chunks.delete(key); + const rpcScope = JSON.stringify([ + record.agent, + channelId, + turnId, + sessionId, + ]); + const rpcId = + typeof payload?.id === "string" || typeof payload?.id === "number" + ? JSON.stringify([ + rpcScope, + kind === "acp_read" ? "acp_write" : "acp_read", + payload.id, + ]) + : undefined; + const setup = + kind === "acp_write" && + ["initialize", "session/new", "session/load"].includes(method); + const permission = + kind === "acp_read" && method === "session/request_permission"; + const replyKey = + typeof payload?.id === "string" || typeof payload?.id === "number" + ? JSON.stringify([rpcScope, kind, payload.id]) + : undefined; + if ( + !method && + replyKey && + (payload?.result !== undefined || payload?.error !== undefined) + ) { + const outstanding = ambiguousControl.get(replyKey); + if (outstanding === 1) ambiguousControl.delete(replyKey); + else if (outstanding) ambiguousControl.set(replyKey, outstanding - 1); + } + const reply = !method && replyKey ? control.get(replyKey) : undefined; + if (setup || permission) { + entry.title = permission + ? "Permission requested" + : method === "initialize" + ? "Initialize agent" + : "Prepare session"; + entry.diagnostic = setup; + if (rpcId) { + if (control.has(rpcId) || ambiguousControl.has(rpcId)) { + const outstanding = ambiguousControl.get(rpcId) ?? 1; + control.delete(rpcId); + ambiguousControl.set(rpcId, outstanding + 1); + } else { + const options = new Map(); + let valid = + permission && + Array.isArray(params?.options) && + params.options.length > 0; + if (Array.isArray(params?.options)) + for (const value of params.options) { + const option = object(value); + if ( + !identity(option?.optionId) || + ![ + "allow_once", + "allow_always", + "reject_once", + "reject_always", + ].includes(text(option?.kind)) || + options.has(text(option?.optionId)) + ) + valid = false; + else options.set(text(option?.optionId), text(option?.kind)); + } + control.set(rpcId, { + title: permission + ? "Permission response" + : "Session setup response", + diagnostic: setup, + entry, + ...(valid ? { options } : {}), + }); + } + } + } else if ( + reply && + (payload?.result !== undefined || payload?.error !== undefined) + ) { + if (replyKey) control.delete(replyKey); + if (reply.title === "Permission response" && reply.options) { + const outcome = object(object(payload?.result)?.outcome); + const option = reply.options.get(text(outcome?.optionId)); + const allowed = + payload?.error === undefined && + outcome?.outcome === "selected" && + (option === "allow_once" || option === "allow_always"); + const denied = + outcome?.outcome === "selected" && + (option === "reject_once" || option === "reject_always"); + if ( + allowed || + denied || + outcome?.outcome === "cancelled" || + payload?.error !== undefined + ) { + reply.entry.title = allowed + ? "Permission allowed" + : denied + ? "Permission denied" + : payload?.error !== undefined + ? "Permission error" + : "Permission cancelled"; + reply.entry.diagnostic = allowed; + reply.entry.sourceIds.push(id); + continue; + } + } + entry.title = + payload?.error !== undefined ? `${reply.title} error` : reply.title; + entry.diagnostic = reply.diagnostic && payload?.error === undefined; + } else if (kind === "prompt_context_delivery") { + entry.title = "Context delivered"; + entry.diagnostic = true; + } else if ( + kind === "agent_initialized" || + kind === "session_config_captured" + ) { + entry.title = + kind === "agent_initialized" + ? "Agent initialized" + : "Session configuration captured"; + entry.diagnostic = true; + } else if ( + kind === "acp_write" && + [ + "session/prompt", + "_goose/unstable/session/steer", + "_session/steering", + ].includes(method) + ) { + entry.kind = "prompt"; + entry.title = + method === "session/prompt" ? "Request context" : "Follow-up context"; + entry.diagnostic = true; + entry.body = contentText(params?.prompt); + } else if ( + kind === "acp_read" && + (method === "session/update" || + method === "_goose/unstable/session/update") + ) { + const update = object(params?.update); + const updateKind = text(update?.sessionUpdate); + if (["session_info_update", "usage_update"].includes(updateKind)) { + entry.title = + updateKind === "usage_update" ? "Usage update" : "Session update"; + entry.diagnostic = true; + } else if ( + method === "session/update" && + (updateKind === "tool_call" || updateKind === "tool_call_update") + ) { + const toolId = identity(update?.toolCallId); + const toolKey = JSON.stringify([ + key, + identity(params?.sessionId) || sessionId, + toolId, + ]); + const previous = toolId ? tools.get(toolKey) : undefined; + const tool = previous ?? entry; + tool.kind = "tool"; + tool.title = + text(update?.title) || (previous ? tool.title : "Tool call"); + if (text(update?.title) && !tool.toolName) + tool.toolName = text(update?.title); + const status = text(update?.status); + if ( + toolStatuses.has(status) && + (!terminal(tool.status) || status === "failed") + ) + tool.status = status; + if (update?.rawInput !== undefined) tool.input = json(update.rawInput); + // Tool updates are snapshots; unlike message chunks, output is not appended. + const output = + contentText(update?.content) || + (update?.rawOutput !== undefined ? json(update.rawOutput) : ""); + if (update?.content !== undefined || update?.rawOutput !== undefined) + tool.output = output; + if (previous) { + if (!tool.sourceIds.includes(id)) tool.sourceIds.push(id); + continue; + } + if (toolId) tools.set(toolKey, tool); + } else if ( + [ + "agent_message_chunk", + "agent_thought_chunk", + "user_message_chunk", + ].includes(updateKind) + ) { + entry.kind = + updateKind === "agent_thought_chunk" + ? "thought" + : updateKind === "user_message_chunk" + ? "prompt" + : "message"; + entry.title = + entry.kind === "thought" + ? "Thinking" + : entry.kind === "prompt" + ? "Prompt echo" + : "Response"; + entry.body = contentText(update?.content); + // An echo cannot establish another authored message; keep it in context. + if (entry.kind === "prompt") entry.diagnostic = true; + // Only contiguous chunks within this exact turn and message may coalesce. + const previous = priorChunk; + const chunkKey = JSON.stringify([ + key, + identity(params?.sessionId) || sessionId, + identity(update?.messageId), + updateKind, + ]); + chunks.set(key, previous?.chunkKey === chunkKey ? previous : entry); + if (previous?.chunkKey === chunkKey) { + previous.body += entry.body; + if (!previous.sourceIds.includes(id)) previous.sourceIds.push(id); + continue; + } + entry.chunkKey = chunkKey; + } else if (updateKind === "plan" && Array.isArray(update?.entries)) { + entry.kind = "plan"; + entry.title = "Plan"; + entry.body = update.entries + .map((value) => { + const item = object(value); + return text(item?.content) + ? `${item?.status === "completed" ? "✓" : "•"} ${text(item?.content)}` + : ""; + }) + .filter(Boolean) + .join("\n"); + } else { + entry.title = identity(updateKind) || identity(method) || "ACP update"; + } + } else if (kind === "turn_completed") { + entry.title = "Turn ended"; + } else if (kind === "turn_error" || kind === "agent_panic") { + entry.title = "Turn error"; + entry.body = activityErrorText(payload); + } else if ( + ["turn_liveness", "turn_started", "session_resolved"].includes(kind) + ) { + // These provide group context, not repetitive reading rows or success claims. + continue; + } + group.entries.push(entry); + } + return { + groups: [...groups.values()], + // Folded tool/chunk rows keep display position; source order tracks later updates. + sourceOrder, + omitted: Math.max(0, total - TRANSCRIPT_EVENT_LIMIT), + /** JSON is serialized on disclosure only. Mixed batch parents are never used. */ + source: (id: string) => { + const item = sources.get(id); + return item + ? { + envelopeId: item.record.envelopeId, + plaintext: + item.id === item.record.id + ? item.record.plaintext + : json(item.value), + } + : undefined; + }, + }; +} diff --git a/src/bundled/profiles/ProfileActivity.tsx b/src/bundled/profiles/ProfileActivity.tsx index c624dfb04..1886f44e9 100644 --- a/src/bundled/profiles/ProfileActivity.tsx +++ b/src/bundled/profiles/ProfileActivity.tsx @@ -12,10 +12,12 @@ export function ProfileActivity({ session, pubkey, context, + onViewActivity, }: { session: RelaySession; pubkey: string; context: PanelProps["context"]; + onViewActivity?: (() => void) | undefined; }) { const snapshot = useSyncExternalStore( session.agentActivity.subscribe, @@ -82,7 +84,11 @@ export function ProfileActivity({
diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index 7d964e363..b0d9e5bae 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -9,7 +9,17 @@ import { afterEach, expect, it, vi } from "vitest"; import { createRelaySession } from "../../features/relay/session"; import type { RelayData } from "../../features/relay/service"; import { bindNames } from "../../features/identity-names/service"; -import { profileTarget } from "../../features/profiles/target"; +import { + profileTarget, + profileActivityViewTarget, +} from "../../features/profiles/target"; +import { Context } from "@deepseek-ai/cordis"; +import { PanelsService } from "../../features/panels/service"; +import { + activitySelection, + activityTarget, +} from "../../features/agents/activity-target"; +import { ActivityPanel } from "../agent-activity/ActivityPanel"; import { ProfilePanel } from "./ProfilePanel"; import { agentDirectory } from "../../features/identity-names/testing"; @@ -426,3 +436,125 @@ it("exposes thinking through the named profile avatar across tabs", async () => owner.dispose(); } }); + +it("opens profile Activity in its originating channel and retires a disabled contribution", async () => { + const root = new Context(); + root.provide("pluginStatus", { + isActive: () => true, + subscribe: () => () => {}, + }); + const panels = new PanelsService(root); + const owner = createRelaySession(null); + const activity = createAgentActivity( + true, + () => {}, + () => true, + ); + const activate = vi.fn(activity.queries.activate); + const session = { + ...owner.session, + agentActivity: { ...activity.queries, activate }, + }; + const snapshot = { status: "ready" as const, generation: 1, session }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const scope = root.extend({ + pluginOwner: { id: "activity", revision: "one" }, + }); + const fiber = scope.plugin((ctx) => + ctx.panels.register({ + id: "activity", + title: "Activity", + matches: (target) => !!activitySelection(target), + component: (props) => , + }), + ); + await fiber.await(); + const context = { + channelId: "originating-channel", + canOpen: (target: string) => !!panels.resolve(target), + open: vi.fn(() => true), + }; + try { + const view = render( + {}} + />, + ); + expect( + screen.getByRole("tab", { name: "Activity", selected: true }), + ).toBeVisible(); + expect(screen.getByRole("combobox", { name: "Channel" })).toHaveTextContent( + "originating-channel", + ); + expect(screen.getByText(key, { exact: true })).toBeVisible(); + expect(activate).not.toHaveBeenCalled(); + await act(() => fiber.dispose()); + expect( + screen.queryByRole("tab", { name: "Activity" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("region", { name: "Agent activity" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "Info", selected: true }), + ).toBeVisible(); + + const replacement = root.extend({ + pluginOwner: { id: "activity", revision: "two" }, + }); + await act(async () => { + await replacement + .plugin((ctx) => + ctx.panels.register({ + id: "activity", + title: "Activity", + matches: (target) => !!activitySelection(target), + component: ({ target }) =>

Replacement: {target}

, + }), + ) + .await(); + }); + expect( + screen.getByRole("tab", { name: "Info", selected: true }), + ).toBeVisible(); + await userEvent + .setup() + .click(screen.getByRole("tab", { name: "Activity" })); + expect( + screen.getByText( + `Replacement: ${activityTarget(key, context.channelId)}`, + ), + ).toBeVisible(); + view.rerender( + {}} + />, + ); + expect( + screen.queryByRole("tab", { name: "Activity" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/^Replacement:/)).not.toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "Info", selected: true }), + ).toBeVisible(); + expect(activate).not.toHaveBeenCalled(); + } finally { + cleanup(); + owner.dispose(); + activity.dispose(); + await root.fiber.dispose(); + } +}); diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 6445be4ac..4bf264c5b 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -1,3 +1,7 @@ +import { activityTarget } from "../../features/agents/activity-target"; +import { PanelView } from "../../features/panels/PanelView"; +import { contributionKey } from "../../features/conversation/ContributionBoundary"; +import type { Panels, RegisteredPanel } from "../../features/panels/service"; import { usePanelTabHost, usePanelTabTitle, @@ -26,6 +30,7 @@ import { Tabs } from "../../shared/design-system/ui/Tabs"; import { ProfileActivity } from "./ProfileActivity"; import type { PanelProps } from "../../features/panels/service"; import { + profileActivityViewKey, profileAgentHint, profilePanelKey, profileTarget, @@ -42,7 +47,12 @@ import { useAgent, useChannelChoices } from "../../features/agents/react"; import type { Agents } from "../../features/agents/service"; import { agentSections } from "../../features/agents/tabs"; +const noSubscribe = () => () => {}; +const emptyPanels: readonly RegisteredPanel[] = []; +const noPanels = () => emptyPanels; + export function ProfilePanel({ + panels, relay, target, context, @@ -50,22 +60,26 @@ export function ProfilePanel({ agents, close, }: PanelProps & { + panels?: Panels; relay: RelayData; navigation?: Navigation; /** Agents show their type's tabs. */ agents?: Agents; }) { const connection = useRelayConnection(relay); - const pubkey = profilePanelKey(target); + const activityKey = profileActivityViewKey(target); + const pubkey = profilePanelKey(target) ?? activityKey; if (!pubkey) return

Unsupported profile.

; if (connection.status !== "ready") return

Connect to a community to view this profile.

; return ( ( - "info", + const [tab, setTab] = useState<"info" | "activity" | "channels" | `agent:${string}`>( + initialTab, ); // An agent's sections come from its type and the app, not the harness. const plugin = useAgent(agents, pubkey); const channelChoices = useChannelChoices( plugin ? session.channels : undefined, ); + const available = useSyncExternalStore( + panels?.subscribe ?? noSubscribe, + panels?.snapshot ?? noPanels, + noPanels, + ); const sections = plugin && agents ? agentSections({ agents, ...plugin, channels: channelChoices }) : []; const section = sections.find((item) => item.value === tab); + const activity = activityTarget(pubkey, context?.channelId); + const resolvedActivity = panels?.resolve(activity); + const activityPanel = + resolvedActivity && + available.includes(resolvedActivity) && + context?.canOpen(activity) + ? resolvedActivity + : undefined; const tabHost = usePanelTabHost(); const tabbed = !!tabHost; const region = useRef(null); @@ -163,7 +194,7 @@ function ProfileDetails({ attempt, ); const verifiedOwner = ownership.owner; - const selectedTab = tab.startsWith("agent:") && !section ? "info" : tab; + const selectedTab = (tab === "activity" && !activityPanel) || (tab.startsWith("agent:") && !section) ? "info" : tab; let communityOrigin: string | undefined; if (scope && viewer && scope.endsWith(`:${viewer}`)) { try { @@ -288,12 +319,21 @@ function ProfileDetails({ { value: "info", label: "Info" }, ...sections.map(({ value, label }) => ({ value, label })), { value: "channels", label: "Channels" }, + ...(displayAgent && activityPanel ? [{ value: "activity" as const, label: "Activity" }] : []), ]} label="Profile sections" variant="panel" renderPanel={(selected) => (
- {selected.startsWith("agent:") ? ( + {selected === "activity" && activityPanel ? ( + + ) : selected.startsWith("agent:") ? ( sections.find((item) => item.value === selected)?.render() ) : selected === "info" ? ( <> @@ -324,6 +364,9 @@ function ProfileDetails({ )} {knownAgent && ( setTab("activity") : undefined + } session={session} pubkey={pubkey} context={context} diff --git a/src/bundled/profiles/index.tsx b/src/bundled/profiles/index.tsx index d59449e28..667dce116 100644 --- a/src/bundled/profiles/index.tsx +++ b/src/bundled/profiles/index.tsx @@ -1,5 +1,8 @@ import type { PluginModule } from "../../plugins/api"; -import { profilePanelKey } from "../../features/profiles/target"; +import { + profilePanelKey, + profileActivityViewKey, +} from "../../features/profiles/target"; import { ProfilePanel } from "./ProfilePanel"; export const inject = ["panels", "relay", "navigation", "agents"]; @@ -7,10 +10,11 @@ export const apply: PluginModule["apply"] = (ctx) => { ctx.panels.register({ id: "profile", title: "Profile", - matches: (target) => !!profilePanelKey(target), + matches: (target) => !!profilePanelKey(target) || !!profileActivityViewKey(target), component: (props) => ( ; +function createPresentation() { + const entries = new Map(); + const listeners = new Set<() => void>(); + let snapshot: readonly Replacement[] = []; + const publish = () => { + snapshot = [...entries.values()]; + for (const listener of listeners) listener(); + }; + return { + snapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + register(value: Replacement) { + const token = Symbol(); + entries.set(token, value); + publish(); + return () => { + if (entries.delete(token)) publish(); + }; + }, + }; +} +const Presentation = createContext< + | { + store: ReturnType; + active: boolean; + } + | undefined +>(undefined); +const empty: readonly Replacement[] = []; +const emptySnapshot = () => empty; +const noopSubscribe = () => () => {}; + +/** Mounted composer presentation only; no data, timers, or plugin identity checks. */ +export function TypingPresentation({ + active, + children, +}: { + active: boolean; + children: ReactNode; +}) { + const [store] = useState(createPresentation); + const value = useMemo(() => ({ store, active }), [store, active]); + return ( + {children} + ); +} +export function useTypingReplacement(value: Replacement, enabled: boolean) { + const context = useContext(Presentation); + const store = context?.store; + const active = context?.active; + const { session, channelId, threadRootId, pubkey } = value; + useLayoutEffect(() => { + if (active && enabled) + return store?.register({ + session, + channelId, + threadRootId, + pubkey, + }); + }, [store, active, enabled, session, channelId, threadRootId, pubkey]); +} +export function useReplacedTypers( + session: RelaySession, + channelId: string, + threadRootId: string | undefined, +) { + const context = useContext(Presentation); + const entries = useSyncExternalStore( + context?.store.subscribe ?? noopSubscribe, + context?.store.snapshot ?? emptySnapshot, + emptySnapshot, + ); + return useMemo( + () => + new Set( + context?.active + ? entries + .filter( + (entry) => + entry.session === session && + entry.channelId === channelId && + entry.threadRootId === threadRootId, + ) + .map((entry) => entry.pubkey) + : [], + ), + [context?.active, entries, session, channelId, threadRootId], + ); +} diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 8a27cfdac..79f494467 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -1,5 +1,6 @@ import { pendingSessionDraft } from "../sessions/pending-start"; import { useConversationPresentation } from "../conversation/ConversationPresentation"; +import { TypingPresentation } from "../conversation/typing-presentation"; import { useEffectEvent } from "react"; import { useMessageEditScope } from "./MessageEditScope"; import { useMessageDeletion } from "./MessageManagement"; @@ -236,10 +237,14 @@ export function MessageComposer(props: MessageComposerProps) { props.channelId, props.threadRootId, )}`; - return props.personalConversation ? ( - - ) : ( - + return ( + + {props.personalConversation ? ( + + ) : ( + + )} + ); } diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 8f90b9e29..60ad974af 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -14,7 +14,8 @@ import { within, } from "@testing-library/react"; import { messageCopyText } from "./message-copy"; -import { profileTarget } from "../profiles/target"; +import { profileTarget, profileActivityViewTarget } from "../profiles/target"; +import { activityTarget } from "../agents/activity-target"; import { renderToStaticMarkup } from "react-dom/server"; import { foldMessages } from "../relay/fold"; import { keypair, message, signed, summary } from "../relay/testing"; @@ -2139,3 +2140,57 @@ it("keeps an agents message that opens with prose, or is only a mention, as writ expect(line("thanks @Rivet")).toBe("Morgarita: thanks @Rivet"); expect(line("@Rivet")).toBe("Morgarita: @Rivet"); }); + +it.each([ + "available", + "human", + "activity disabled", + "profiles disabled", + "retired", +])( + "message activity action respects exact agent and active destinations: %s", + async (mode) => { + const author = "ab".repeat(32); + let retired = false; + const target = profileActivityViewTarget(author); + const raw = activityTarget(author, row.channelId); + const open = vi.fn(); + const canOpen = (value: string) => + !retired && + !(mode === "activity disabled" && value === raw) && + !(mode === "profiles disabled" && value === target); + try { + renderDom( + undefined} + onOpenLink={open} + canOpenLink={canOpen} + day={false} + retry={undefined} + />, + ); + fireEvent.click( + screen.getByRole("button", { name: "More message actions" }), + ); + await screen.findByRole("menu"); + if (mode === "available" || mode === "retired") { + const item = screen.getByRole("menuitem", { name: "View activity" }); + retired = mode === "retired"; + fireEvent.click(item); + if (retired) expect(open).not.toHaveBeenCalled(); + else expect(open).toHaveBeenCalledWith(target); + } else + expect( + screen.queryByRole("menuitem", { name: "View activity" }), + ).not.toBeInTheDocument(); + } finally { + cleanup(); + } + }, +); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 68350643f..55441db64 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -10,6 +10,7 @@ import { WorkflowByline } from "./WorkflowByline"; import { useConversationPresentation } from "../conversation/ConversationPresentation"; import { MessageLink } from "../conversation/MessageLink"; import { DayDivider, MessageTimestamp } from "./MessageTimestamp"; +import { activityTarget } from "../agents/activity-target"; import { useChannelIdentityNames } from "../identity-names/react"; import { Button } from "../../shared/design-system/ui/Button"; import { ReplySummary } from "./ReplySummary"; @@ -33,7 +34,7 @@ import type { RelaySession } from "../relay/session"; import type { UnreadCapability } from "../relay/unread"; import { MediaAttachment, type MediaPlayback } from "./MediaAttachment"; import { parseMediaTimeReply } from "./media-timecode"; -import { profileTarget } from "../profiles/target"; +import { profileTarget, profileActivityViewTarget } from "../profiles/target"; import { MessageBody } from "../conversation/MessageBody"; import { InlineText } from "../conversation/InlineText"; import type { ConversationExtensions } from "../conversation/contracts"; @@ -250,6 +251,29 @@ const FullMessageRow = memo(function MessageRow({ agent: !!(row.agentEnvelope || agentPubkeys?.has(row.authorId)), }); const clickable = target && canOpenLink?.(target); + const activityView = profileActivityViewTarget(row.authorId); + const canViewActivity = + !!( + row.agentEnvelope || + agentPubkeys?.has(row.authorId) || + profile?.isAgent + ) && + activityView && + canOpenLink?.(activityView) && + canOpenLink(activityTarget(row.authorId, row.channelId)); + const activityItem = canViewActivity && ( + { + if ( + canOpenLink?.(activityView) && + canOpenLink(activityTarget(row.authorId, row.channelId)) + ) + onOpenLink(activityView); + }} + > + View activity + + ); const avatarShape = row.agentEnvelope || agentPubkeys?.has(row.authorId) || profile?.isAgent ? "squircle" @@ -579,6 +603,7 @@ const FullMessageRow = memo(function MessageRow({ ) : undefined)} {actionItems} + {activityItem} {reportItem} } diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index b51ca30f7..e6d1e5d97 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -1,5 +1,6 @@ // biome-ignore-all lint/a11y/noNoninteractiveTabindex: The thread region supports keyboard scrolling and Escape. import { workflowLabel } from "../relay/workflow-attribution"; +import { LoadedThreadMessages } from "./loaded-thread-messages"; import { usePanelTabHost } from "../panels/PanelWorkspace"; import { MessageEditScope } from "./MessageEditScope"; import { useMessageSelectionCopy } from "./selection-copy"; @@ -1304,75 +1305,77 @@ function ThreadMessages({ (!requireReadyRoot || (snapshot.root.id === messageId && snapshot.targetStatus === "ready")) && ( - - Reply target is no longer available. - -
- ) : ( - selectedParent && ( + + -
- Replying to {senderName(selectedParent)} -

{selectedParent.content}

-
-
+ ) : ( + selectedParent && ( +
+
+ Replying to {senderName(selectedParent)} +

{selectedParent.content}

+
+
+ ) ) - ) - } - editMessages={rows} - focusRequest={replyFocus} - onOpenLink={onOpenLink} - canOpenLink={canOpenLink} - onDraftSaved={onDraftSaved} - onSend={(id) => { - targetAnchor.current = undefined; - positioned.current = true; - follow.current = !selectedParent; - if (selectedParent) - setExpanded( - (current) => - new Set([ - ...current, - selectedParent.id, - ...tree.ancestors(selectedParent.id), - ]), + } + editMessages={rows} + focusRequest={replyFocus} + onOpenLink={onOpenLink} + canOpenLink={canOpenLink} + onDraftSaved={onDraftSaved} + onSend={(id) => { + targetAnchor.current = undefined; + positioned.current = true; + follow.current = !selectedParent; + if (selectedParent) + setExpanded( + (current) => + new Set([ + ...current, + selectedParent.id, + ...tree.ancestors(selectedParent.id), + ]), + ); + // A background send may publish after the user picked a new target. + setReplyParent((current) => + current === replyParent ? undefined : current, ); - // A background send may publish after the user picked a new target. - setReplyParent((current) => - current === replyParent ? undefined : current, - ); - setSent(id); - }} - /> + setSent(id); + }} + /> + )} ); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index 02a351113..f7a20d408 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -1,3 +1,4 @@ +import { useReplacedTypers } from "../conversation/typing-presentation"; import { useChannelIdentityNames } from "../identity-names/react"; import { useCallback, @@ -43,6 +44,7 @@ export function TypingIndicator({ canOpenActivity?: ((target: string) => boolean) | undefined; openActivity?: ((target: string) => boolean) | undefined; }) { + const replaced = useReplacedTypers(session, channelId, threadRootId); const entries = useSyncExternalStore( session.typing.subscribe, session.typing.snapshot, @@ -83,13 +85,14 @@ export function TypingIndicator({ [session, channelId, needsRows], ); const rows = useSyncExternalStore(subscribeRows, getRows); - const agents = [...details.keys()].sort(); + const agents = [...details.keys()].filter((key) => !replaced.has(key)).sort(); const firstAgent = agents[0]; const humans = entries.filter( (entry) => entry.channelId === channelId && entry.threadRootId === threadRootId && - !details.has(entry.pubkey), + !details.has(entry.pubkey) && + !replaced.has(entry.pubkey), ); const name = (pubkey: string) => resolveName(pubkey, profiles.get(pubkey)?.name ?? pubkey.slice(0, 10)); diff --git a/src/features/messages/loaded-thread-messages.ts b/src/features/messages/loaded-thread-messages.ts new file mode 100644 index 000000000..0c9eb87a3 --- /dev/null +++ b/src/features/messages/loaded-thread-messages.ts @@ -0,0 +1,7 @@ +import { createContext } from "react"; +import type { ChannelMessage } from "../relay/contracts"; + +/** Already-loaded rows from this thread host. No additional reads or lifecycle. */ +export const LoadedThreadMessages = createContext( + [], +); diff --git a/src/features/profiles/target.test.ts b/src/features/profiles/target.test.ts index de447d6ef..c4ecf9743 100644 --- a/src/features/profiles/target.test.ts +++ b/src/features/profiles/target.test.ts @@ -1,6 +1,8 @@ import { expect, it } from "vitest"; import { profileAgentHint, + profileActivityViewTarget, + profileActivityViewKey, profileKey, profilePanelKey, profileTarget, @@ -46,3 +48,21 @@ it("keeps agent appearance in an exact app-local target without changing public expect(profileAgentHint(invalid)).toBe(false); } }); + +it("recognizes only the exact local profile Activity destination", () => { + const key = "ab".repeat(32); + const target = profileActivityViewTarget(key); + expect(profileActivityViewTarget(key.toUpperCase())).toBe(target); + expect(profileActivityViewKey(target ?? "")).toBe(key); + expect(profileKey(target ?? "")).toBeUndefined(); + for (const invalid of ["", "not-a-key", "g".repeat(64)]) + expect(profileActivityViewTarget(invalid)).toBeUndefined(); + for (const invalid of [ + `${target}&owner=true`, + `${target}#activity`, + `${target}&agent=${key}`, + `https://example.com?agent=${key}&tab=activity`, + `buzz:profile?agent=${key}&tab=info`, + ]) + expect(profileActivityViewKey(invalid)).toBeUndefined(); +}); diff --git a/src/features/profiles/target.ts b/src/features/profiles/target.ts index e4d412fb1..98c2e6c97 100644 --- a/src/features/profiles/target.ts +++ b/src/features/profiles/target.ts @@ -34,3 +34,20 @@ export function profileKey(target: string): string | undefined { return undefined; } } + +/** App-local Activity destination, never a public identity or authority claim. */ +export function profileActivityViewTarget(pubkey: string): string | undefined { + return profileTarget(pubkey) + ? `buzz:profile?agent=${pubkey.toLowerCase()}&tab=activity` + : undefined; +} +export function profileActivityViewKey(target: string): string | undefined { + try { + const agent = new URL(target).searchParams.get("agent"); + return agent && target === profileActivityViewTarget(agent) + ? agent + : undefined; + } catch { + return undefined; + } +} diff --git a/tests/browser/agent-activity-overhaul.spec.mjs b/tests/browser/agent-activity-overhaul.spec.mjs new file mode 100644 index 000000000..215d8f82d --- /dev/null +++ b/tests/browser/agent-activity-overhaul.spec.mjs @@ -0,0 +1,164 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; +import { generateSecretKey } from "nostr-tools"; + +test.use({ + productionBroker: true, + readState: true, + threadUnread: true, + historyCounts: { alpha: 2, beta: 1 }, + developmentReact: true, +}); + +// Browser-only boundary: real popup focus/geometry and existing tab navigation +// must retain the live thread. Identity/lifecycle matrices live in Vitest. +test("bottom activity opens one agent popup and profile Activity without losing the thread", async ({ + page, + app, +}, testInfo) => { + await open(page, app); + await expect.poll(() => app.relay.hasRoute("primary", "observer")).toBe(true); + const root = app.histories + .get("primary/alpha") + .find((row) => row.content.startsWith("Thread root")); + const row = page.locator( + `[data-channel-timeline] [data-message-id="${root.id}"]`, + ); + await row.hover(); + await row.getByRole("button", { name: /^View thread:/ }).click(); + const thread = page.getByRole("complementary", { + name: "Thread", + exact: true, + }); + const reply = thread.getByRole("textbox", { + name: "Reply to thread", + exact: true, + }); + await expect(reply).toBeVisible(); + const keys = [generateSecretKey(), generateSecretKey()]; + const send = (index, kind, seq, payload = {}) => + app.observer( + { + kind, + seq, + timestamp: new Date().toISOString(), + channelId: "alpha", + sessionId: "S", + turnId: `working-${index}`, + payload: + kind === "turn_started" ? { triggeringEventIds: [root.id] } : payload, + }, + keys[index], + ); + keys.forEach((_, index) => { + send(index, "turn_started", 1); + send(index, "acp_read", 2, { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + toolCallId: "read", + title: "buzz-dev-mcp__read_file", + status: "completed", + rawInput: { path: "/project/README.md" }, + content: [ + { + type: "content", + content: { type: "text", text: "Safe example output" }, + }, + ], + }, + }, + }); + send(index, "acp_read", 3, { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Checking the result" }, + }, + }, + }); + }); + const region = thread.getByRole("region", { + name: "Agent activity in this thread", + exact: true, + }); + const entries = region.getByRole("button", { name: /^View activity for/ }); + await expect(entries).toHaveCount(2); + const firstLabel = await entries + .first() + .locator(".navigation-item-label") + .innerText(); + await reply.fill("Keep this draft while inspecting activity"); + for (const width of [1440, 800, 390]) { + await page.setViewportSize({ width, height: 950 }); + await expect(entries.first()).toBeVisible(); + const groupBox = await region.boundingBox(); + const formBox = await thread + .getByRole("form", { name: "Reply to thread", exact: true }) + .boundingBox(); + expect(groupBox.y + groupBox.height).toBeLessThanOrEqual(formBox.y); + await entries.first().focus(); + await page.keyboard.press("Enter"); + const popup = page.getByRole("dialog", { + name: firstLabel.replace(" · working…", ""), + exact: true, + }); + await expect(popup).toBeVisible(); + await expect(popup.getByRole("tab")).toHaveCount(0); + await popup.getByRole("button", { name: "Read file · README.md" }).click(); + await expect( + popup.getByText("Safe example output", { exact: true }), + ).toBeVisible(); + await expect( + popup.getByRole("button", { name: "Progress and responses (1)" }), + ).toHaveAttribute("aria-expanded", "false"); + // Wait for the real disclosure expansion, not only mounted text, before geometry/screenshots. + await expect + .poll(() => + popup + .locator(".buzz-accordion-panel[data-open]") + .evaluateAll((panels) => + panels.every((panel) => panel.clientHeight >= panel.scrollHeight), + ), + ) + .toBe(true); + const box = await popup.boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(width); + await page.screenshot({ + path: testInfo.outputPath(`activity-${width}.png`), + }); + await page.keyboard.press("Escape"); + await expect(popup).toBeHidden(); + await expect(entries.first()).toBeFocused(); + } + await page.setViewportSize({ width: 1440, height: 950 }); + await entries.first().click(); + await page + .getByRole("dialog", { + name: firstLabel.replace(" · working…", ""), + exact: true, + }) + .getByRole("button", { name: "View activity", exact: true }) + .click(); + const profile = page.getByRole("region", { + name: "Profile details", + exact: true, + }); + await expect( + profile.getByRole("tab", { name: "Activity", selected: true }), + ).toBeVisible(); + await expect( + profile.getByRole("combobox", { name: "Channel", exact: true }), + ).toHaveText(/Alpha.*alpha/); + await page.getByRole("button", { name: /^Close (?!Thread).* tab$/ }).click(); + await expect(reply).toHaveText("Keep this draft while inspecting activity"); + keys.forEach((_, index) => { + send(index, "turn_completed", 4); + }); + await expect(region).toHaveCount(0); + await expect(reply).toHaveText("Keep this draft while inspecting activity"); + expect(firstLabel).toContain("working"); +}); From 0c3a37fe74b5edcd8a7803b67d7f31d3295c4b9d Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 30 Sep 2026 19:46:02 -0700 Subject: [PATCH 02/20] Make activity prose readable and show latest actions first Signed-off-by: Carl --- .../ActivityAccessory.module.css | 28 ++++++++++++++ .../agent-activity/ActivityStream.test.tsx | 38 +++++++++++++++++++ src/bundled/agent-activity/ActivityStream.tsx | 14 +++++-- .../agent-activity/activity-presentation.ts | 36 ------------------ .../browser/agent-activity-overhaul.spec.mjs | 23 ++++++++++- 5 files changed, 99 insertions(+), 40 deletions(-) create mode 100644 src/bundled/agent-activity/ActivityAccessory.module.css diff --git a/src/bundled/agent-activity/ActivityAccessory.module.css b/src/bundled/agent-activity/ActivityAccessory.module.css new file mode 100644 index 000000000..7897282a0 --- /dev/null +++ b/src/bundled/agent-activity/ActivityAccessory.module.css @@ -0,0 +1,28 @@ +.root { + min-width: 0; + flex-shrink: 0; + /* Leave 4px before the composer's 12px top margin. */ + margin: var(--space-3) var(--space-4) calc(-1 * var(--space-2)); + background: var(--surface-panel); +} +.agents { + display: flex; + flex-direction: column; + max-height: 8.25rem; + overflow-y: auto; + scrollbar-width: thin; +} +.agents > button { + width: 100%; + min-width: 0; + flex-shrink: 0; + justify-content: flex-start; + gap: var(--space-2); + text-align: left; +} +@media (max-width: 40.625rem) { + .root { + /* The narrow composer has an 8px top margin. */ + margin: var(--space-2) var(--space-3) calc(-1 * var(--space-1)); + } +} diff --git a/src/bundled/agent-activity/ActivityStream.test.tsx b/src/bundled/agent-activity/ActivityStream.test.tsx index ed2f86554..bdcaa4df9 100644 --- a/src/bundled/agent-activity/ActivityStream.test.tsx +++ b/src/bundled/agent-activity/ActivityStream.test.tsx @@ -99,3 +99,41 @@ it("drops details when the underlying capture is cleared", () => { screen.getByText("No tool activity captured yet."), ).toBeInTheDocument(); }); + +it("shows the most recently updated operation first without losing folded details", () => { + const source = activityRecords( + [ + ["first", "First operation"], + ["second", "Second operation"], + ["first", "First operation updated"], + ].map(([toolCallId, title], index) => ({ + id: String(index), + agent: "agent", + kind: "acp_read", + receivedAt: index, + plaintext: JSON.stringify({ + kind: "acp_read", + channelId: "channel", + turnId: "turn", + sessionId: "session", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + toolCallId, + title, + status: "completed", + }, + }, + }, + }), + })), + "agent", + "channel", + ); + render(); + expect( + screen.getAllByRole("button").map((button) => button.textContent), + ).toEqual(["First operation updated", "Second operation"]); +}); diff --git a/src/bundled/agent-activity/ActivityStream.tsx b/src/bundled/agent-activity/ActivityStream.tsx index 33f43637e..e22ee3138 100644 --- a/src/bundled/agent-activity/ActivityStream.tsx +++ b/src/bundled/agent-activity/ActivityStream.tsx @@ -14,7 +14,15 @@ export function ActivityStream({ records: readonly ActivityRecord[]; }) { const transcript = useMemo(() => activityTranscript(records), [records]); - const entries = transcript.groups.flatMap((group) => group.entries); + const latest = (entry: TranscriptEntry) => + Math.max( + ...entry.sourceIds.map( + (id) => transcript.sourceOrder.get(id) ?? -Infinity, + ), + ); + const entries = transcript.groups + .flatMap((group) => group.entries) + .sort((a, b) => latest(b) - latest(a)); const operations = entries.filter( (entry) => activityCategory(entry) === "operation", ); @@ -33,9 +41,9 @@ export function ActivityStream({ content: (
{entry.body && ( -
+              
{entry.body} -
+
)} {entry.input && (
diff --git a/src/bundled/agent-activity/activity-presentation.ts b/src/bundled/agent-activity/activity-presentation.ts index 6e7db80ef..ed5ae29f2 100644 --- a/src/bundled/agent-activity/activity-presentation.ts +++ b/src/bundled/agent-activity/activity-presentation.ts @@ -22,42 +22,6 @@ function basename(value: unknown): string | undefined { return name && name !== "." && name !== ".." ? name : undefined; } -const toolActions = { - "buzz-dev-mcp__shell": { - action: "command", - active: "Running a command…", - completed: "Last action: Run command", - }, - "buzz-dev-mcp__read_file": { - action: "read", - active: "Reading file…", - completed: "Last action: Read file", - }, - "buzz-dev-mcp__str_replace": { - action: "edit", - active: "Editing file…", - completed: "Last action: Edit file", - }, - "buzz-dev-mcp__view_image": { - action: "image", - active: "Viewing image…", - completed: "Last action: View image", - }, -} as const; -function knownTool(entry: TranscriptEntry) { - const name = entry.toolName ?? entry.title; - return Object.hasOwn(toolActions, name) - ? toolActions[name as keyof typeof toolActions] - : undefined; -} -/** Exact reported kinds only. Shell input never changes its action category. */ -export function activityAction(entry: TranscriptEntry) { - if (entry.kind === "tool") return knownTool(entry)?.action ?? "tool"; - if (entry.kind === "thought") return "thought"; - if (entry.kind === "message" || entry.kind === "prompt") return "message"; - return "event"; -} - /** Display only. Never feeds send association, status, permissions or execution. */ export function activityPresentation(entry: TranscriptEntry) { const input = parsed(entry.input); diff --git a/tests/browser/agent-activity-overhaul.spec.mjs b/tests/browser/agent-activity-overhaul.spec.mjs index 215d8f82d..afb263d8c 100644 --- a/tests/browser/agent-activity-overhaul.spec.mjs +++ b/tests/browser/agent-activity-overhaul.spec.mjs @@ -75,7 +75,12 @@ test("bottom activity opens one agent popup and profile Activity without losing params: { update: { sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Checking the result" }, + content: { + type: "text", + text: "Checking the result and explaining the next steps in ordinary prose. ".repeat( + 8, + ), + }, }, }, }); @@ -114,6 +119,22 @@ test("bottom activity opens one agent popup and profile Activity without losing await expect( popup.getByRole("button", { name: "Progress and responses (1)" }), ).toHaveAttribute("aria-expanded", "false"); + await popup + .getByRole("button", { name: "Progress and responses (1)" }) + .click(); + await popup.getByRole("button", { name: "Response", exact: true }).click(); + const prose = popup.getByText(/^Checking the result and explaining/); + await expect(prose).toBeVisible(); + await expect(prose).toHaveCSS("white-space", "pre-wrap"); + expect( + await prose.evaluate((element) => ({ + fits: element.scrollWidth <= element.clientWidth, + family: getComputedStyle(element).fontFamily, + })), + ).toEqual({ fits: true, family: expect.not.stringMatching(/mono/i) }); + await popup + .getByRole("button", { name: "Progress and responses (1)" }) + .click(); // Wait for the real disclosure expansion, not only mounted text, before geometry/screenshots. await expect .poll(() => From 3c10f34cda99094dd0a4f47161a2127a7740ad6e Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 30 Sep 2026 20:37:01 -0700 Subject: [PATCH 03/20] Capture expanded activity and verify disclosure state through reordering Signed-off-by: Carl --- .../agent-activity/ActivityStream.test.tsx | 17 +++++++++++++++-- tests/browser/agent-activity-overhaul.spec.mjs | 4 +--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/bundled/agent-activity/ActivityStream.test.tsx b/src/bundled/agent-activity/ActivityStream.test.tsx index bdcaa4df9..a8995fd80 100644 --- a/src/bundled/agent-activity/ActivityStream.test.tsx +++ b/src/bundled/agent-activity/ActivityStream.test.tsx @@ -132,8 +132,21 @@ it("shows the most recently updated operation first without losing folded detail "agent", "channel", ); - render(); + const view = render(); expect( screen.getAllByRole("button").map((button) => button.textContent), - ).toEqual(["First operation updated", "Second operation"]); + ).toEqual(["Second operation", "First operation"]); + fireEvent.click(screen.getByRole("button", { name: "Second operation" })); + expect( + screen.getByRole("button", { name: "Second operation" }), + ).toHaveAttribute("aria-expanded", "true"); + + view.rerender(); + const first = screen.getByRole("button", { name: "First operation updated" }); + const second = screen.getByRole("button", { name: "Second operation" }); + expect( + first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(first).toHaveAttribute("aria-expanded", "false"); + expect(second).toHaveAttribute("aria-expanded", "true"); }); diff --git a/tests/browser/agent-activity-overhaul.spec.mjs b/tests/browser/agent-activity-overhaul.spec.mjs index afb263d8c..d43a33f4a 100644 --- a/tests/browser/agent-activity-overhaul.spec.mjs +++ b/tests/browser/agent-activity-overhaul.spec.mjs @@ -132,9 +132,7 @@ test("bottom activity opens one agent popup and profile Activity without losing family: getComputedStyle(element).fontFamily, })), ).toEqual({ fits: true, family: expect.not.stringMatching(/mono/i) }); - await popup - .getByRole("button", { name: "Progress and responses (1)" }) - .click(); + // Keep the response expanded so screenshots show the wrapping just asserted. // Wait for the real disclosure expansion, not only mounted text, before geometry/screenshots. await expect .poll(() => From b7451bde3cd69ca7b6491383bb4cfabfc0fe68af Mon Sep 17 00:00:00 2001 From: Carl Date: Mon, 5 Oct 2026 11:24:45 -0400 Subject: [PATCH 04/20] Restore activity menu icon and report separation Signed-off-by: Carl --- src/features/messages/MessageRow.test.tsx | 9 ++++++- src/features/messages/MessageRow.tsx | 32 +++++++++++++++-------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 60ad974af..7dee1ed89 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -1765,7 +1765,12 @@ it("dismisses an unsubmitted report when its retained row is suspended", async ( fireEvent.click( screen.getByRole("button", { name: "More message actions" }), ); - fireEvent.click(await screen.findByRole("menuitem", { name: "Report" })); + const reportItem = await screen.findByRole("menuitem", { name: "Report" }); + expect(reportItem.previousElementSibling).toHaveAttribute( + "role", + "separator", + ); + fireEvent.click(reportItem); expect( await screen.findByRole("dialog", { name: "Report message" }), ).toBeInTheDocument(); @@ -2181,6 +2186,8 @@ it.each([ await screen.findByRole("menu"); if (mode === "available" || mode === "retired") { const item = screen.getByRole("menuitem", { name: "View activity" }); + expect(item.querySelector(".buzz-menu-icon svg")).toBeInTheDocument(); + expect(screen.queryByRole("separator")).not.toBeInTheDocument(); retired = mode === "retired"; fireEvent.click(item); if (retired) expect(open).not.toHaveBeenCalled(); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 55441db64..f89b9ca75 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -61,8 +61,12 @@ import { MessageReadStateItem, } from "./MessageManagement"; import { MessageActionBar } from "./MessageActionBar"; -import { FlagIcon } from "../../shared/design-system/icons"; -import { MenuIcon, MenuItem } from "../../shared/design-system/ui/Menu"; +import { FlagIcon, ListBulletsIcon } from "../../shared/design-system/icons"; +import { + MenuIcon, + MenuItem, + MenuSeparator, +} from "../../shared/design-system/ui/Menu"; import { ToastNotice } from "../../shared/design-system/ui/Toast"; import { ReportMessageDialog } from "./ReportMessageDialog"; import { messageCopyLink, messageCopyText } from "./message-copy"; @@ -271,6 +275,9 @@ const FullMessageRow = memo(function MessageRow({ onOpenLink(activityView); }} > + + + View activity ); @@ -345,15 +352,18 @@ const FullMessageRow = memo(function MessageRow({ ? session?.messages.report : undefined; const reportItem = report && ( - setReporting("open")} - > - - - - Report - + <> + + setReporting("open")} + > + + + + Report + + ); const actionItems = actions.map((action) => ( setOpenAction(action)}> From a0b26720dd0e4c1505ead4a699f95d271418d24e Mon Sep 17 00:00:00 2001 From: Carl Date: Thu, 8 Oct 2026 13:50:49 -0400 Subject: [PATCH 05/20] Checkpoint message-linked agent activity and thread resize behavior Signed-off-by: Carl --- src/app/pages.integration.test.mjs | 15 +- .../ActivityAccessory.module.css | 38 +- .../agent-activity/ActivityAccessory.test.tsx | 116 ++++- .../agent-activity/ActivityAccessory.tsx | 296 +++++++------ .../agent-activity/current-activity.test.ts | 82 ++++ .../agent-activity/current-activity.ts | 54 +++ src/bundled/agent-activity/index.tsx | 17 +- .../agent-activity/message-activity.test.ts | 117 +++++ .../agent-activity/message-activity.ts | 9 + .../agent-activity/request-activity.ts | 144 +----- .../agent-activity/thread-activity.test.ts | 8 + src/features/agents/activity.test.ts | 47 +- src/features/agents/activity.ts | 76 +++- src/features/agents/request-activity.ts | 137 ++++++ src/features/agents/request-work.ts | 42 ++ src/features/agents/thread-working.test.ts | 170 +++++++ src/features/agents/thread-working.ts | 42 ++ .../conversation/ComposerAccessories.test.tsx | 58 ++- .../conversation/ComposerAccessories.tsx | 13 +- src/features/conversation/contracts.ts | 4 + src/features/conversation/service.test.tsx | 2 + src/features/conversation/service.tsx | 5 + src/features/messages/MessageReactions.tsx | 6 +- src/features/messages/MessageRow.test.tsx | 91 +++- src/features/messages/MessageRow.tsx | 126 ++++-- src/features/messages/Messages.module.css | 75 +--- src/features/messages/ReplySummary.tsx | 48 +- .../messages/ThreadPanel.nesting.test.tsx | 9 + .../messages/ThreadPanel.profile.test.tsx | 9 + src/features/messages/ThreadPanel.test.tsx | 33 +- src/features/messages/ThreadPanel.tsx | 90 +++- .../messages/visible-reactions.test.ts | 23 + src/features/messages/visible-reactions.ts | 18 + src/features/relay/activity-typing.test.ts | 78 ++++ src/features/relay/session.ts | 3 + .../design-system/styles/components.css | 7 + src/shared/design-system/ui/Avatar.tsx | 2 +- .../design-system/ui/AvatarStack.module.css | 44 ++ .../design-system/ui/AvatarStack.test.tsx | 37 ++ src/shared/design-system/ui/AvatarStack.tsx | 48 ++ .../design-system/ui/TypingDots.module.css | 33 ++ src/shared/design-system/ui/TypingDots.tsx | 12 + src/shared/design-system/ui/registry.ts | 25 +- .../browser/agent-activity-overhaul.spec.mjs | 415 ++++++++++++++---- tests/browser/fixture.mjs | 17 + tests/browser/thread-resize.spec.mjs | 314 +++++++++++++ .../design-system/ui/componentSpecimens.tsx | 36 +- 47 files changed, 2546 insertions(+), 545 deletions(-) create mode 100644 src/bundled/agent-activity/current-activity.test.ts create mode 100644 src/bundled/agent-activity/current-activity.ts create mode 100644 src/bundled/agent-activity/message-activity.test.ts create mode 100644 src/bundled/agent-activity/message-activity.ts create mode 100644 src/features/agents/request-activity.ts create mode 100644 src/features/agents/request-work.ts create mode 100644 src/features/agents/thread-working.test.ts create mode 100644 src/features/agents/thread-working.ts create mode 100644 src/features/messages/visible-reactions.test.ts create mode 100644 src/features/messages/visible-reactions.ts create mode 100644 src/shared/design-system/ui/AvatarStack.module.css create mode 100644 src/shared/design-system/ui/AvatarStack.test.tsx create mode 100644 src/shared/design-system/ui/AvatarStack.tsx create mode 100644 src/shared/design-system/ui/TypingDots.module.css create mode 100644 src/shared/design-system/ui/TypingDots.tsx create mode 100644 tests/browser/thread-resize.spec.mjs diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index b6463f0f9..270cec7a8 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -291,7 +291,20 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .find((panel) => panel.pluginId === "buzz.agent-activity"); assert.equal(activity.title, "Agent Activity"); assert.equal(activity.launcher, undefined); - assert.equal(services.conversation.accessories.snapshot().length, 0); + assert.equal(services.conversation.accessories.snapshot().length, 2); + assert.deepEqual( + services.conversation.accessories + .snapshot() + .map(({ id, placement }) => [id, placement]), + [ + ["activity", "message"], + ["typing-presentation", undefined], + ], + ); + assert.equal( + services.conversation.accessories.snapshot()[0].pluginId, + "buzz.agent-activity", + ); assert.match( renderToStaticMarkup(createElement(activity.component)), /Connect to a community/, diff --git a/src/bundled/agent-activity/ActivityAccessory.module.css b/src/bundled/agent-activity/ActivityAccessory.module.css index 7897282a0..3ea620306 100644 --- a/src/bundled/agent-activity/ActivityAccessory.module.css +++ b/src/bundled/agent-activity/ActivityAccessory.module.css @@ -1,28 +1,30 @@ .root { + margin-block-start: var(--space-2); min-width: 0; flex-shrink: 0; - /* Leave 4px before the composer's 12px top margin. */ - margin: var(--space-3) var(--space-4) calc(-1 * var(--space-2)); - background: var(--surface-panel); } -.agents { +.identity { + display: flex; + align-items: center; + gap: var(--space-1); +} +.previews { display: flex; flex-direction: column; - max-height: 8.25rem; - overflow-y: auto; - scrollbar-width: thin; + gap: var(--space-4); } -.agents > button { - width: 100%; +.preview { min-width: 0; - flex-shrink: 0; - justify-content: flex-start; - gap: var(--space-2); - text-align: left; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); + overflow-wrap: anywhere; +} +.preview p { + margin: 0; } -@media (max-width: 40.625rem) { - .root { - /* The narrow composer has an 8px top margin. */ - margin: var(--space-2) var(--space-3) calc(-1 * var(--space-1)); - } +.detail { + color: var(--text-subtle); + white-space: pre-wrap; } diff --git a/src/bundled/agent-activity/ActivityAccessory.test.tsx b/src/bundled/agent-activity/ActivityAccessory.test.tsx index 3f4a01a80..92450daac 100644 --- a/src/bundled/agent-activity/ActivityAccessory.test.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.test.tsx @@ -2,7 +2,13 @@ import "@testing-library/jest-dom/vitest"; import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; stubAvatarBrowserApis(); -import { act, cleanup, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; import { ActivityAccessory } from "./ActivityAccessory"; @@ -46,6 +52,11 @@ function fixture() { ]); const session = { viewer, + presence: { + subscribe: () => () => {}, + status: () => "online", + limited: () => false, + }, agentActivity: activity.queries, agentChoices: createAgentLibrary(undefined).queries, profiles: { @@ -88,6 +99,18 @@ function fixture() { scope: "test", channelId: "c", threadRootId: root, + message: { + id: root, + channelId: "c", + authorId: viewer, + createdAt: 1, + content: "Request", + mentions: [], + attachments: [], + reactions: [], + participants: [], + replyCount: 0, + }, canOpen: () => true, open, }; @@ -105,7 +128,7 @@ function fixture() { }; } -it("shows one line per agent across multiple turns, removing only the settled agent", () => { +it("shows one bubble with one avatar per agent across multiple turns, removing only the settled agent", () => { const f = fixture(); const view = render(); try { @@ -113,25 +136,25 @@ it("shows one line per agent across multiple turns, removing only the settled ag f.send(A, "a2", 1, "turn_started", { triggeringEventIds: [root] }); f.send(B, "b1", 1, "turn_started", { triggeringEventIds: [root] }); expect( - screen.getAllByRole("button", { name: /View activity for Blossom/ }), - ).toHaveLength(1); - expect( - screen.getAllByRole("button", { name: /View activity for Bubbles/ }), + screen.getAllByRole("button", { name: /View agent activity/ }), ).toHaveLength(1); + expect(screen.getAllByTitle("Blossom")).toHaveLength(1); + expect(screen.getAllByTitle("Bubbles")).toHaveLength(1); + expect(view.container.querySelector(".buzz-avatar-status-dot")).toBeNull(); f.send(A, "a1", 2, "turn_completed"); expect( - screen.getByRole("button", { name: /View activity for Blossom/ }), + screen.getByRole("button", { name: /View agent activity: Blossom/ }), ).toBeInTheDocument(); f.send(A, "a2", 2, "turn_completed"); expect( screen.queryByRole("button", { name: /Blossom/ }), ).not.toBeInTheDocument(); expect( - screen.getByRole("button", { name: /View activity for Bubbles/ }), + screen.getByRole("button", { name: /View agent activity: Bubbles/ }), ).toBeInTheDocument(); f.send(B, "b1", 2, "turn_completed"); expect( - screen.queryByLabelText("Agent activity in this thread"), + screen.queryByLabelText("Agent activity on this message"), ).not.toBeInTheDocument(); } finally { view.unmount(); @@ -147,8 +170,8 @@ it("does not show sibling thread work and reports lost freshness without pretend f.send(B, "here", 1, "turn_started", { triggeringEventIds: [root] }); act(() => vi.advanceTimersByTime(31000)); expect( - screen.getByRole("button", { name: /View activity for Bubbles/ }), - ).toHaveTextContent("work status unknown"); + screen.getByRole("button", { name: /View agent activity: Bubbles/ }), + ).toHaveTextContent("Status unknown"); f.send(B, "here", 2, "turn_completed"); expect(screen.queryByRole("button")).not.toBeInTheDocument(); } finally { @@ -163,9 +186,74 @@ it("keeps namesakes as separate exact identities", () => { try { f.send(A, "a", 1, "turn_started", { triggeringEventIds: [root] }); f.send(B, "b", 1, "turn_started", { triggeringEventIds: [root] }); - const buttons = screen.getAllByRole("button", { name: /Blossom/ }); - expect(buttons).toHaveLength(2); - expect(buttons[0]?.textContent).not.toBe(buttons[1]?.textContent); + const avatars = screen.getAllByTitle(/Blossom/); + expect(avatars).toHaveLength(2); + expect(avatars[0]?.getAttribute("title")).not.toBe( + avatars[1]?.getAttribute("title"), + ); + } finally { + view.unmount(); + f.dispose(); + } +}); + +it("previews only live scoped actions and retains profile navigation", () => { + const f = fixture(); + const view = render(); + try { + f.send(A, "old", 1, "turn_started", { triggeringEventIds: [root] }); + const thought = (text: string) => ({ + method: "session/update", + params: { + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text }, + }, + }, + }); + f.send(A, "old", 2, "acp_read", thought("Old thought")); + f.send(A, "old", 3, "turn_completed"); + f.send(A, "new", 1, "turn_started", { triggeringEventIds: [root] }); + fireEvent.click( + screen.getByRole("button", { name: /View agent activity/ }), + ); + expect( + screen.getByText("Waiting for activity details"), + ).toBeInTheDocument(); + expect(screen.queryByText("Old thought")).not.toBeInTheDocument(); + f.send(A, "new", 2, "acp_read", thought("Current thought")); + expect(screen.getByText("Current thought")).toBeInTheDocument(); + f.send(B, "sibling", 1, "turn_started", { triggeringEventIds: [next] }); + f.send(B, "sibling", 2, "acp_read", thought("Private sibling thought")); + expect( + screen.queryByText("Private sibling thought"), + ).not.toBeInTheDocument(); + act(() => vi.advanceTimersByTime(31000)); + expect( + screen.getByText("Activity interrupted or out of date"), + ).toBeInTheDocument(); + expect(screen.queryByText("Current thought")).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "View activity for Blossom" }), + ); + expect(f.props.open).toHaveBeenCalledOnce(); + } finally { + view.unmount(); + f.dispose(); + } +}); +it("removes the whole popup when access is revoked", () => { + const f = fixture(); + const view = render(); + try { + f.send(A, "a", 1, "turn_started", { triggeringEventIds: [root] }); + fireEvent.click( + screen.getByRole("button", { name: /View agent activity/ }), + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + view.rerender( false} />); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); } finally { view.unmount(); f.dispose(); diff --git a/src/bundled/agent-activity/ActivityAccessory.tsx b/src/bundled/agent-activity/ActivityAccessory.tsx index d9fa6ce90..66bfd1f70 100644 --- a/src/bundled/agent-activity/ActivityAccessory.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.tsx @@ -1,21 +1,14 @@ -import { usePresenceStatus } from "../../features/presence/react"; -import { - useContext, - useEffect, - useMemo, - useState, - useSyncExternalStore, -} from "react"; -import { AgentAvatar } from "../../features/agents/AgentAvatar"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { AvatarStack } from "../../shared/design-system/ui/AvatarStack"; import type { ComposerAccessoryProps } from "../../features/conversation/contracts"; import { useChannelIdentityNames } from "../../features/identity-names/react"; -import { LoadedThreadMessages } from "../../features/messages/loaded-thread-messages"; -import { activityRecords } from "../../features/agents/activity-records"; +import type { activityRecords } from "../../features/agents/activity-records"; import { activityTarget } from "../../features/agents/activity-target"; import { profileActivityViewTarget } from "../../features/profiles/target"; import { publicKeyLabels } from "../../shared/identity/public-key"; import { Button } from "../../shared/design-system/ui/Button"; -import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; +import { TypingDots } from "../../shared/design-system/ui/TypingDots"; import { PopoverRoot, PopoverTrigger, @@ -24,56 +17,25 @@ import { PopoverDescription, PopoverClose, } from "../../shared/design-system/ui/Popover"; -import { ActivityStream } from "./ActivityStream"; -import { threadActivity } from "./thread-activity"; +import { currentActivity } from "./current-activity"; +import { messageActivity } from "./message-activity"; +import type { ActivityTurn } from "../../features/agents/activity"; +import { useKnownAgentPubkeys } from "../../features/agents/use-known"; import { useTypingReplacement } from "../../features/conversation/typing-presentation"; import styles from "./ActivityAccessory.module.css"; -/** One bottom group per conversation. Exact identities, never one row per message. */ +/** Activity belongs to the exact triggering message, not the composer. */ export function ActivityAccessory(props: ComposerAccessoryProps) { - const { session, channelId, threadRootId, canOpen } = props; + const { session, channelId, message, canOpen } = props; const snapshot = useSyncExternalStore( session.agentActivity.subscribe, session.agentActivity.snapshot, ); - const messages = useContext(LoadedThreadMessages); - const entries = useMemo(() => { - if (threadRootId) - return threadActivity(snapshot, channelId, threadRootId, messages).filter( - (entry) => - entry.working || entry.turns.some((turn) => turn.state === "unknown"), - ); - const agents = new Set([ - ...snapshot.turns - .filter( - (turn) => turn.channelId === channelId && turn.state !== "ended", - ) - .map((turn) => turn.agent), - ...snapshot.typing - .filter((entry) => entry.channelId === channelId && !entry.threadRootId) - .map((entry) => entry.agent), - ]); - return [...agents].sort().map((agent) => ({ - agent, - working: - snapshot.status === "listening" && - (snapshot.turns.some( - (turn) => - turn.agent === agent && - turn.channelId === channelId && - turn.state === "working", - ) || - snapshot.typing.some( - (entry) => - entry.agent === agent && - entry.channelId === channelId && - !entry.threadRootId, - )), - selected: { - records: activityRecords(snapshot.records, agent, channelId), - }, - })); - }, [snapshot, channelId, threadRootId, messages]); + const messageId = message?.id; + const entries = useMemo( + () => (messageId ? messageActivity(snapshot, channelId, messageId) : []), + [snapshot, channelId, messageId], + ); const profiles = useSyncExternalStore( session.profiles.subscribe, session.profiles.snapshot, @@ -99,101 +61,123 @@ export function ActivityAccessory(props: ComposerAccessoryProps) {
-
- {visible.map((entry) => { + { const name = names[keys.indexOf(entry.agent)] ?? "Agent"; - const label = - names.filter((value) => value === name).length > 1 - ? `${name} · ${suffixes.get(entry.agent)}` - : name; - return ( - - ); + return { + agent: entry.agent, + name: + names.filter((value) => value === name).length > 1 + ? `${name} · ${suffixes.get(entry.agent)}` + : name, + picture: profiles.get(entry.agent)?.picture, + working: entry.working, + records: entry.selected.records, + turns: entry.turns, + }; })} -
+ />
); } -function ActivityEntry({ - session, - channelId, - threadRootId, - canOpen, - open, - agent, - name, - picture, - working, - records, -}: ComposerAccessoryProps & { + +type Entry = { agent: string; name: string; picture: string | undefined; working: boolean; records: ReturnType; -}) { - const presence = usePresenceStatus(session.presence, agent); + turns: readonly ActivityTurn[]; +}; + +function ActivityBubble({ + entries, + ...props +}: ComposerAccessoryProps & { entries: Entry[] }) { const [expanded, setExpanded] = useState(false); - const target = profileActivityViewTarget(agent); - useTypingReplacement( - { session, channelId, threadRootId, pubkey: agent }, - working, - ); + const working = entries.some((entry) => entry.working); + const unknown = entries.some((entry) => !entry.working); return ( - } - aria-label={`View activity for ${name} ${agent.slice(0, 12)}${presence === "unknown" ? "" : `, Presence: ${presence}`}`} - label={`${name} · ${working ? "working…" : "work status unknown"}`} - /> + } /> - - {name} + + Agent activity - {working ? "Working" : "Activity interrupted or out of date"} - {threadRootId - ? " in this thread" - : " in this channel, including threads"} + Latest reported activity for this message. - {expanded && } - {target && canOpen(target) && ( - + {expanded && ( +
+ {entries.map((entry) => { + const target = profileActivityViewTarget(entry.agent); + const preview = entry.working + ? currentActivity(entry.records, entry.turns) + : undefined; + return ( +
+
+ + {entry.name} +
+

+ {!entry.working + ? "Activity interrupted or out of date" + : (preview?.title ?? "Waiting for activity details")} +

+ {preview?.detail && ( +

+ {preview.detail} +

+ )} + {target && props.canOpen(target) && ( + + )} +
+ ); + })} +
)} ); } + +/** Nonvisual composer contribution: agent typing belongs on messages, humans + * retain the ordinary typing indicator. Removal restores the default display. */ +export function ActivityTypingReplacement(props: ComposerAccessoryProps) { + const profiles = useSyncExternalStore( + props.session.profiles.subscribe, + props.session.profiles.snapshot, + ); + const known = useKnownAgentPubkeys(props.session, profiles); + const activity = useSyncExternalStore( + props.session.agentActivity.subscribe, + props.session.agentActivity.snapshot, + ); + const agents = new Set([ + ...known, + ...activity.records.map((record) => record.agent), + ]); + return ( + <> + {[...agents].map((agent) => ( + + ))} + + ); +} +function TypingReplacement({ + session, + channelId, + threadRootId, + agent, +}: ComposerAccessoryProps & { agent: string }) { + useTypingReplacement( + { session, channelId, threadRootId, pubkey: agent }, + true, + ); + return null; +} + +function ActivityAvatar({ + session, + entry, + size = "compact", +}: Pick & { + entry: Entry; + size?: "compact" | "small"; +}) { + return ( + + ); +} diff --git a/src/bundled/agent-activity/current-activity.test.ts b/src/bundled/agent-activity/current-activity.test.ts new file mode 100644 index 000000000..57c4b52f3 --- /dev/null +++ b/src/bundled/agent-activity/current-activity.test.ts @@ -0,0 +1,82 @@ +import { expect, it } from "vitest"; +import { currentActivity } from "./current-activity"; +import type { ActivityRecord } from "../../features/agents/activity-records"; +const frame = (id: string, turnId: string, update: object): ActivityRecord => ({ + id, + envelopeId: id, + agent: "a", + kind: "acp_read", + receivedAt: 1, + plaintext: JSON.stringify({ + kind: "acp_read", + channelId: "c", + turnId, + payload: { method: "session/update", params: { update } }, + }), +}); +const tool = { + sessionUpdate: "tool_call", + toolCallId: "t", + title: "buzz-dev-mcp__read_file", + status: "in_progress", + rawInput: { path: "/project/file.ts" }, +}; +it("does not borrow historical actions when typing has no live turn", () => { + const records = [frame("1", "old", tool)]; + expect(currentActivity(records, [])).toBeUndefined(); + expect( + currentActivity(records, [{ turnId: "old", state: "ended" }]), + ).toBeUndefined(); + expect( + currentActivity(records, [{ turnId: "old", state: "unknown" }]), + ).toBeUndefined(); + expect( + currentActivity(records, [{ turnId: "new", state: "working" }]), + ).toBeUndefined(); +}); +it("uses the last update, including folded tool completions and failures", () => { + const turns = [{ turnId: "t", state: "working" }]; + const records = [ + frame("1", "t", tool), + frame("2", "t", { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Thinking text" }, + }), + ]; + expect(currentActivity(records, turns)).toEqual({ + title: "Thinking", + detail: "Thinking text", + }); + records.push( + frame("3", "t", { + sessionUpdate: "tool_call_update", + toolCallId: "t", + status: "completed", + }), + ); + expect(currentActivity(records, turns)).toEqual({ + title: "Read file · Completed", + detail: "file.ts", + }); + records.push( + frame("4", "t", { + sessionUpdate: "tool_call_update", + toolCallId: "t", + status: "failed", + }), + ); + expect(currentActivity(records, turns)?.title).toBe("Read file · Failed"); +}); +it("bounds long preview text without rendering it as markup", () => { + const result = currentActivity( + [ + frame("1", "t", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "", - }, - }, - }, - }, - { - kind: "acp_read", - payload: { - method: "session/update", - params: { - update: { - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: "Checking the result" }, - }, - }, - }, - }, - { kind: "turn_error", payload: { error: "Connection lost" } }, - ].map((value, index) => ({ - id: String(index), - agent: "agent", - kind: value.kind, - receivedAt: index, - plaintext: JSON.stringify({ - ...value, - turnId: "turn", - channelId: "channel", - sessionId: "session", - }), - })), - "agent", - "channel", - ); -} -it("puts tools first and keeps progress and diagnostics behind separate disclosures", () => { - const view = render(); - expect( - screen.getAllByRole("button").map((button) => button.textContent), - ).toEqual([ - "Read file · README.md", - "Progress and responses (1)", - "Diagnostics (1) · Error reported", - ]); - expect(screen.queryByText("Checking the result")).not.toBeInTheDocument(); - expect(screen.queryByText("Connection lost")).not.toBeInTheDocument(); - fireEvent.click( - screen.getByRole("button", { name: "Read file · README.md" }), - ); - expect( - screen.getByText(JSON.stringify("")), - ).toBeInTheDocument(); - expect(view.container.querySelector("script")).toBeNull(); - fireEvent.click( - screen.getByRole("button", { name: "Progress and responses (1)" }), - ); - fireEvent.click(screen.getByRole("button", { name: "Thinking" })); - expect(screen.getByText("Checking the result")).toBeInTheDocument(); - fireEvent.click( - screen.getByRole("button", { name: "Diagnostics (1) · Error reported" }), - ); - fireEvent.click(screen.getByRole("button", { name: "Turn error" })); - expect(screen.getByText("Connection lost")).toBeInTheDocument(); -}); -it("drops details when the underlying capture is cleared", () => { - const view = render(); - fireEvent.click( - screen.getByRole("button", { name: "Read file · README.md" }), - ); - view.rerender(); - expect( - screen.queryByText(JSON.stringify("")), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: /Read file/ }), - ).not.toBeInTheDocument(); - expect( - screen.getByText("No tool activity captured yet."), - ).toBeInTheDocument(); -}); - -it("shows the most recently updated operation first without losing folded details", () => { - const source = activityRecords( - [ - ["first", "First operation"], - ["second", "Second operation"], - ["first", "First operation updated"], - ].map(([toolCallId, title], index) => ({ - id: String(index), - agent: "agent", - kind: "acp_read", - receivedAt: index, - plaintext: JSON.stringify({ - kind: "acp_read", - channelId: "channel", - turnId: "turn", - sessionId: "session", - payload: { - method: "session/update", - params: { - update: { - sessionUpdate: "tool_call", - toolCallId, - title, - status: "completed", - }, - }, - }, - }), - })), - "agent", - "channel", - ); - const view = render(); - expect( - screen.getAllByRole("button").map((button) => button.textContent), - ).toEqual(["Second operation", "First operation"]); - fireEvent.click(screen.getByRole("button", { name: "Second operation" })); - expect( - screen.getByRole("button", { name: "Second operation" }), - ).toHaveAttribute("aria-expanded", "true"); - - view.rerender(); - const first = screen.getByRole("button", { name: "First operation updated" }); - const second = screen.getByRole("button", { name: "Second operation" }); - expect( - first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - expect(first).toHaveAttribute("aria-expanded", "false"); - expect(second).toHaveAttribute("aria-expanded", "true"); -}); diff --git a/src/bundled/agent-activity/ActivityStream.tsx b/src/bundled/agent-activity/ActivityStream.tsx deleted file mode 100644 index e22ee3138..000000000 --- a/src/bundled/agent-activity/ActivityStream.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useMemo, useState } from "react"; -import type { ActivityRecord } from "../../features/agents/activity-records"; -import { Accordion } from "../../shared/design-system/ui/Accordion"; -import { activityTranscript, type TranscriptEntry } from "./transcript"; -import { - activityCategory, - activityPresentation, -} from "./activity-presentation"; - -/** Presentation of the caller's scoped capture, never a second activity store. */ -export function ActivityStream({ - records, -}: { - records: readonly ActivityRecord[]; -}) { - const transcript = useMemo(() => activityTranscript(records), [records]); - const latest = (entry: TranscriptEntry) => - Math.max( - ...entry.sourceIds.map( - (id) => transcript.sourceOrder.get(id) ?? -Infinity, - ), - ); - const entries = transcript.groups - .flatMap((group) => group.entries) - .sort((a, b) => latest(b) - latest(a)); - const operations = entries.filter( - (entry) => activityCategory(entry) === "operation", - ); - const communication = entries.filter( - (entry) => activityCategory(entry) === "communication", - ); - const diagnostics = entries.filter( - (entry) => activityCategory(entry) === "diagnostic", - ); - const items = (rows: TranscriptEntry[]) => - rows.map((entry) => { - const presentation = activityPresentation(entry); - return { - value: entry.id, - title: `${presentation.title}${presentation.target ? ` · ${presentation.target}` : ""}${presentation.shellOutput?.failed || entry.status === "failed" ? " · Failed" : entry.status === "pending" ? " · Last reported pending" : entry.status === "in_progress" ? " · Last reported running" : ""}`, - content: ( -
- {entry.body && ( -
- {entry.body} -
- )} - {entry.input && ( -
-

- {presentation.command ? "Command" : "Input"} -

-
-                  {presentation.command ?? entry.input}
-                
-
- )} - {entry.output && ( -
-

Output

-
-                  
-                    {presentation.shellOutput
-                      ? [
-                          presentation.shellOutput.stdout,
-                          presentation.shellOutput.stderr,
-                          presentation.shellOutput.note,
-                        ]
-                          .filter(Boolean)
-                          .join("\n") || "No text output."
-                      : entry.output}
-                  
-                
-
- )} - -
- ), - }; - }); - return ( -
- {transcript.omitted > 0 && ( -

- Older activity is outside this display window. -

- )} - {operations.length ? ( - - ) : ( -

No tool activity captured yet.

- )} - - ), - }, - ] - : []), - ...(diagnostics.length - ? [ - { - value: "diagnostics", - title: `Diagnostics (${diagnostics.length})${diagnostics.some((entry) => /error/i.test(entry.title)) ? " · Error reported" : ""}`, - content: ( - - ), - }, - ] - : []), - ]} - /> -
- ); -} - -function RawSource({ - entry, - transcript, -}: { - entry: TranscriptEntry; - transcript: ReturnType; -}) { - const [expanded, setExpanded] = useState([]); - return ( - - {entry.sourceIds.map((id) => { - const source = transcript.source(id); - return source ? ( -
-                    {source.plaintext}
-                  
- ) : null; - })} -
- ) : null, - }, - ]} - /> - ); -} diff --git a/src/bundled/agent-activity/activity-presentation.test.ts b/src/bundled/agent-activity/activity-presentation.test.ts index f5e4fa75b..4e72e97d1 100644 --- a/src/bundled/agent-activity/activity-presentation.test.ts +++ b/src/bundled/agent-activity/activity-presentation.test.ts @@ -1,20 +1,23 @@ import { expect, it } from "vitest"; import { activityPresentation } from "./activity-presentation"; -import type { TranscriptEntry } from "./transcript"; +import type { ToolItem } from "../../features/agents/activity-transcript"; const entry = ( toolName: string, input: unknown = {}, output = "", -): TranscriptEntry => ({ +): ToolItem => ({ id: "tool", - kind: "tool", + type: "tool", + kind: "other", + toolCallId: "tool", + at: 0, + diffs: [], + paths: [], + truncated: false, title: toolName, - toolName, input: JSON.stringify(input), output, - body: "", status: "completed", - sourceIds: [], }); it("maps only exact tools and structured paths; commands remain indivisible", () => { expect( @@ -124,20 +127,3 @@ it("previews distinct command text rather than repeated working directories", () ).target, ).toBe("wc -w file"); }); - -it("classifies tools by explicit event kind, never by familiar harness/tool names or completion", async () => { - const { activityCategory } = await import("./activity-presentation"); - const tool = entry("harness_specific_tool"); - for (const status of ["pending", "in_progress", "completed", "failed"]) { - expect(activityCategory({ ...tool, status })).toBe("operation"); - } - expect(activityCategory({ ...tool, kind: "plan" })).toBe("operation"); - for (const kind of ["prompt", "thought", "message"] as const) - expect(activityCategory({ ...tool, kind })).toBe("communication"); - expect( - activityCategory({ ...tool, kind: "event", title: "unknown_tool" }), - ).toBe("diagnostic"); - expect(activityCategory({ ...tool, kind: "prompt", diagnostic: true })).toBe( - "diagnostic", - ); -}); diff --git a/src/bundled/agent-activity/activity-presentation.ts b/src/bundled/agent-activity/activity-presentation.ts index ed5ae29f2..894c9f281 100644 --- a/src/bundled/agent-activity/activity-presentation.ts +++ b/src/bundled/agent-activity/activity-presentation.ts @@ -1,4 +1,4 @@ -import type { TranscriptEntry } from "./transcript"; +import type { ToolItem } from "../../features/agents/activity-transcript"; const object = (value: unknown): Record | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -23,9 +23,9 @@ function basename(value: unknown): string | undefined { } /** Display only. Never feeds send association, status, permissions or execution. */ -export function activityPresentation(entry: TranscriptEntry) { +export function activityPresentation(entry: ToolItem) { const input = parsed(entry.input); - const name = entry.toolName ?? entry.title; + const name = entry.title; let title = entry.title, target: string | undefined; if (name === "buzz-dev-mcp__shell") { @@ -88,14 +88,3 @@ export function activityPresentation(entry: TranscriptEntry) { : undefined; return { title, target, shellOutput, command }; } - -/** Explicit projection categories, independent of harness names and completion state. */ -export function activityCategory( - entry: TranscriptEntry, -): "operation" | "communication" | "diagnostic" { - if (entry.diagnostic) return "diagnostic"; - if (entry.kind === "tool" || entry.kind === "plan") return "operation"; - if (["message", "prompt", "thought"].includes(entry.kind)) - return "communication"; - return "diagnostic"; -} diff --git a/src/bundled/agent-activity/current-activity.test.ts b/src/bundled/agent-activity/current-activity.test.ts index 57c4b52f3..2ba8e0804 100644 --- a/src/bundled/agent-activity/current-activity.test.ts +++ b/src/bundled/agent-activity/current-activity.test.ts @@ -34,7 +34,7 @@ it("does not borrow historical actions when typing has no live turn", () => { currentActivity(records, [{ turnId: "new", state: "working" }]), ).toBeUndefined(); }); -it("uses the last update, including folded tool completions and failures", () => { +it("prefers the latest tool over later prose, including folded completions and failures", () => { const turns = [{ turnId: "t", state: "working" }]; const records = [ frame("1", "t", tool), @@ -44,8 +44,8 @@ it("uses the last update, including folded tool completions and failures", () => }), ]; expect(currentActivity(records, turns)).toEqual({ - title: "Thinking", - detail: "Thinking text", + title: "Read file · Running", + detail: "file.ts", }); records.push( frame("3", "t", { diff --git a/src/bundled/agent-activity/current-activity.ts b/src/bundled/agent-activity/current-activity.ts index fc944aee4..095c9c0fb 100644 --- a/src/bundled/agent-activity/current-activity.ts +++ b/src/bundled/agent-activity/current-activity.ts @@ -1,42 +1,41 @@ import type { ActivityRecord } from "../../features/agents/activity-records"; +import { + activityTranscript, + type TranscriptItem, +} from "../../features/agents/activity-transcript"; import { activityPresentation } from "./activity-presentation"; -import { activityTranscript } from "./transcript"; -/** Latest reported action from a live turn, never history borrowed by typing. */ +/** Compact preview of the same transcript shown in the full activity panel. */ export function currentActivity( records: readonly ActivityRecord[], turns: readonly { turnId: string; state: string }[], ) { + const agent = records[0]?.agent; + if (!agent) return; const live = new Set( turns.filter((turn) => turn.state === "working").map((turn) => turn.turnId), ); - const transcript = activityTranscript(records); - const latest = transcript.groups - .filter((group) => group.turnId && live.has(group.turnId)) - .flatMap((group) => group.entries) - .filter( - (entry) => - !entry.diagnostic && - ["tool", "thought", "message", "plan"].includes(entry.kind), + const entries = activityTranscript(records, { agent }) + .turns.filter((turn) => live.has(turn.turnId)) + .flatMap((turn) => turn.items) + .filter((item) => + ["tool", "thought", "message", "plan"].includes(item.type), ) - .sort( - (a, b) => - Math.max( - ...b.sourceIds.map( - (id) => transcript.sourceOrder.get(id) ?? -Infinity, - ), - ) - - Math.max( - ...a.sourceIds.map( - (id) => transcript.sourceOrder.get(id) ?? -Infinity, - ), - ), - )[0]; + .sort((a, b) => b.at - a.at); + const latest = entries.find((item) => item.type === "tool") ?? entries[0]; if (!latest) return; - const presentation = activityPresentation(latest); + const presentation = + latest.type === "tool" ? activityPresentation(latest) : undefined; + const title = + presentation?.title ?? + (latest.type === "thought" + ? "Thinking" + : latest.type === "plan" + ? "Plan" + : "Response"); const status = - latest.kind === "tool" - ? presentation.shellOutput?.failed || latest.status === "failed" + latest.type === "tool" + ? presentation?.shellOutput?.failed || latest.status === "failed" ? " · Failed" : latest.status === "completed" ? " · Completed" @@ -46,9 +45,16 @@ export function currentActivity( ? " · Running" : "" : ""; - const detail = presentation.target ?? latest.body; + const detail = presentation?.target ?? text(latest); return { - title: `${presentation.title}${status}`, + title: `${title}${status}`, detail: detail.length > 240 ? `${detail.slice(0, 240)}…` : detail, }; } + +function text(item: TranscriptItem): string { + if (item.type === "message" || item.type === "thought") return item.text; + if (item.type === "plan") + return item.entries.map((entry) => entry.content).join("\n"); + return ""; +} diff --git a/src/bundled/agent-activity/failed-activity.test.ts b/src/bundled/agent-activity/failed-activity.test.ts deleted file mode 100644 index f42c29388..000000000 --- a/src/bundled/agent-activity/failed-activity.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, it } from "vitest"; -import { activityErrorText } from "./failed-activity"; -it("bounds error prose without parsing HTML or arbitrary objects", () => { - expect(activityErrorText({ error: "" })).toBe( - "", - ); - expect( - activityErrorText({ error: { message: "Model request rejected" } }), - ).toBe("Model request rejected"); - expect(activityErrorText({ error: {} })).toContain("See raw details"); - expect(activityErrorText({ error: "x".repeat(5000) })).toContain( - "Error shortened", - ); -}); diff --git a/src/bundled/agent-activity/failed-activity.ts b/src/bundled/agent-activity/failed-activity.ts deleted file mode 100644 index a07dc25f9..000000000 --- a/src/bundled/agent-activity/failed-activity.ts +++ /dev/null @@ -1,14 +0,0 @@ -const object = (value: unknown): { [key: string]: unknown } | undefined => - value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as { [key: string]: unknown }) - : undefined; - -export function activityErrorText(payload: unknown): string { - const error = object(payload)?.error; - const detail = typeof error === "string" ? error : object(error)?.message; - if (typeof detail !== "string" || !detail.trim()) - return "The agent reported an error. See raw details for the received event."; - return detail.length > 4096 - ? `${detail.slice(0, 4096)}\n… Error shortened; full retained event is available in Raw events.` - : detail; -} diff --git a/src/bundled/agent-activity/message-activity.test.ts b/src/bundled/agent-activity/message-activity.test.ts index 01ab42d2b..72a00606d 100644 --- a/src/bundled/agent-activity/message-activity.test.ts +++ b/src/bundled/agent-activity/message-activity.test.ts @@ -115,3 +115,20 @@ it("never uses typing as message association or resurrects an ended request", () ), ).toEqual(["agent"]); }); + +it("keeps restored starts out of live message bubbles even if a heartbeat has the same turn ID", () => { + const source = fixture(); + expect( + messageActivity( + { + ...source, + records: source.records.map((record) => ({ + ...record, + historical: true, + })), + }, + "channel", + request, + ), + ).toEqual([]); +}); diff --git a/src/bundled/agent-activity/message-activity.ts b/src/bundled/agent-activity/message-activity.ts index 89579f0f1..f6337f32c 100644 --- a/src/bundled/agent-activity/message-activity.ts +++ b/src/bundled/agent-activity/message-activity.ts @@ -3,7 +3,7 @@ import { requestWork } from "../../features/agents/request-work"; /** Message and thread displays project the same request status. Pulses alone * cannot establish which individual message triggered work. */ export function messageActivity(...args: Parameters) { - return requestWork(...args).filter( - ({ state }) => state === "working" || state === "unknown", + return requestWork(...args).filter(({ state, lifecycle }) => + lifecycle ? state !== "done" : state === "working" || state === "unknown", ); } diff --git a/src/bundled/agent-activity/thread-activity.test.ts b/src/bundled/agent-activity/thread-activity.test.ts deleted file mode 100644 index ca55818e9..000000000 --- a/src/bundled/agent-activity/thread-activity.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { expect, it } from "vitest"; -import { threadActivity } from "./thread-activity"; -import type { RelaySession } from "../../features/relay/session"; -import type { ChannelMessage } from "../../features/relay/contracts"; -const root = "a".repeat(64), - handoff = "b".repeat(64), - agent = "c".repeat(64), - peer = "d".repeat(64); -type Snapshot = ReturnType; -function record( - id: string, - author: string, - turn: string, - seq: number, - kind: string, - trigger = root, - channelId = "alpha", -) { - return { - id, - agent: author, - kind, - channelIds: [channelId], - receivedAt: 1000, - historical: false, - createdAt: 1, - plaintext: JSON.stringify({ - kind, - turnId: turn, - channelId, - seq, - timestamp: new Date(1000).toISOString(), - payload: - kind === "turn_started" - ? { triggeringEventIds: [trigger] } - : { - method: "session/update", - params: { - update: { - sessionUpdate: "tool_call", - toolCallId: id, - title: "Later tool", - status: "in_progress", - }, - }, - }, - }), - }; -} -const message = { - id: handoff, - channelId: "alpha", - threadRootId: root, - content: "Handoff", - createdAt: 1, - mentions: [], - attachments: [], - reactions: [], - participants: [], - replyCount: 0, - authorId: agent, -} satisfies ChannelMessage; -const snapshot = (): Snapshot => ({ - status: "listening", - trimmed: 0, - history: "unavailable", - hasOlder: false, - historyOlder: false, - historySkipped: 0, - historyAgents: [], - capture: "off", - - typing: [], - records: [ - record("1", agent, "initial", 1, "turn_started"), - record("2", agent, "initial", 2, "turn_completed"), - record("3", peer, "handoff", 1, "turn_started", handoff), - record("4", peer, "handoff", 2, "acp_read", handoff), - ], - turns: [ - { - agent, - channelId: "alpha", - turnId: "initial", - timestamp: 1000, - state: "ended", - }, - { - agent: peer, - channelId: "alpha", - turnId: "handoff", - timestamp: 1000, - state: "working", - }, - ], -}); -it("includes later hidden-handoff turns and new agents only with loaded exact-thread evidence", () => { - expect( - threadActivity(snapshot(), "alpha", root).filter((e) => e.working), - ).toEqual([]); - const work = threadActivity(snapshot(), "alpha", root, [message]); - expect( - work.find((e) => e.agent === peer)?.selected.records.map((r) => r.id), - ).toEqual(["3", "4"]); - expect(work.find((e) => e.agent === peer)?.working).toBe(true); - expect(work.find((e) => e.agent === agent)?.state).toBe("ended"); - for (const wrong of [ - { ...message, threadRootId: "f".repeat(64) }, - { ...message, channelId: "beta" }, - ]) - expect( - threadActivity(snapshot(), "alpha", root, [wrong]).some( - (e) => e.agent === peer, - ), - ).toBe(false); -}); -it("fresh exact typing can show later work without attributing missing tool details", () => { - const source = snapshot(); - const typing = { - agent: peer, - channelId: "alpha", - threadRootId: root, - timestamp: 1100, - startedAt: 1100, - working: true, - expiresAt: 9000, - }; - const result = threadActivity( - { ...source, typing: [typing] }, - "alpha", - root, - ).find((e) => e.agent === peer); - expect(result?.working).toBe(true); - expect(result?.selected.records).toEqual([]); - expect( - threadActivity( - { ...source, status: "interrupted", typing: [typing] }, - "alpha", - root, - ).some((e) => e.working), - ).toBe(false); - expect( - threadActivity( - { ...source, typing: [{ ...typing, agent, timestamp: 999 }] }, - "alpha", - root, - ).find((e) => e.agent === agent)?.working, - ).toBe(false); -}); -it("does not admit ambiguous starts or confuse coordination/turn completion with a visible answer", () => { - const source = snapshot(); - const duplicate = record("5", peer, "handoff", 3, "turn_started", handoff); - expect( - threadActivity( - { ...source, records: [...source.records, duplicate] }, - "alpha", - root, - [message], - ).some((e) => e.agent === peer), - ).toBe(false); - expect( - threadActivity({ ...source, trimmed: 1 }, "alpha", root, [message]).find( - (e) => e.agent === agent, - )?.state, - ).toBe("unknown"); -}); - -it("keeps retained linked records inspectable when their independent turn-state slot was evicted", () => { - const source = snapshot(); - const work = threadActivity({ ...source, turns: [] }, "alpha", root, [ - message, - ]); - const entry = work.find((entry) => entry.agent === peer); - expect(entry?.selected.records.map((record) => record.id)).toEqual([ - "3", - "4", - ]); - expect(entry?.state).toBe("unknown"); - expect(entry?.working).toBe(false); -}); diff --git a/src/bundled/agent-activity/thread-activity.ts b/src/bundled/agent-activity/thread-activity.ts deleted file mode 100644 index 03b85d128..000000000 --- a/src/bundled/agent-activity/thread-activity.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { RelaySession } from "../../features/relay/session"; -import type { ChannelMessage } from "../../features/relay/contracts"; -import { requestActivity, requestActivityState } from "./request-activity"; - -type Snapshot = ReturnType; -/** Shared thread evidence for the channel control, live tail and hover details. - * Loaded hidden replies are valid trigger IDs, never proof of delivery or work. - * Adapted from PR #406; retains ended records for inspection, not live status. */ -export function threadActivity( - snapshot: Snapshot, - channelId: string, - rootId: string, - messages: readonly ChannelMessage[] = [], - requestId?: string, -) { - const ids = [ - rootId, - ...messages - .filter( - (row) => - row.channelId === channelId && - (row.id === rootId || row.threadRootId === rootId), - ) - .map((row) => row.id), - ...(requestId ? [requestId] : []), - ]; - const typing = - snapshot.status === "listening" - ? snapshot.typing.filter( - (entry) => - entry.channelId === channelId && entry.threadRootId === rootId, - ) - : []; - const agents = new Set([ - ...snapshot.turns - .filter((turn) => turn.channelId === channelId) - .map((turn) => turn.agent), - ...typing.map((entry) => entry.agent), - ...snapshot.records - .filter((record) => record.channelIds.includes(channelId)) - .map((record) => record.agent), - ]); - return [...agents] - .sort() - .map((agent) => { - const selected = requestActivity(snapshot.records, agent, channelId, ids); - const turns = snapshot.turns.filter( - (turn) => - turn.agent === agent && - turn.channelId === channelId && - selected.turnIds.has(turn.turnId), - ); - const terminal = Math.max( - -Infinity, - ...turns - .filter((turn) => turn.state === "ended") - .map((turn) => turn.timestamp), - ); - const typed = typing.some( - (entry) => entry.agent === agent && entry.timestamp > terminal, - ); - const state = requestActivityState(selected, turns, snapshot.trimmed); - const working = - snapshot.status === "listening" && (state === "working" || typed); - return { - agent, - selected, - turns, - working, - state: working ? ("working" as const) : state, - }; - }) - .filter((entry) => entry.selected.turnIds.size > 0 || entry.working); -} diff --git a/src/bundled/agent-activity/transcript.test.ts b/src/bundled/agent-activity/transcript.test.ts deleted file mode 100644 index 26db133d3..000000000 --- a/src/bundled/agent-activity/transcript.test.ts +++ /dev/null @@ -1,539 +0,0 @@ -import { expect, it } from "vitest"; -import { activityRecords } from "../../features/agents/activity-records"; -import { activityTranscript, TRANSCRIPT_EVENT_LIMIT } from "./transcript"; -const event = ( - kind: string, - payload: unknown, - turnId: string | null = "turn", - channelId: string | null = "alpha", -) => ({ kind, payload, turnId, channelId, sessionId: "S" }); -const update = (sessionUpdate: string, extra = {}) => - event("acp_read", { - method: "session/update", - params: { sessionId: "S", update: { sessionUpdate, ...extra } }, - }); -const records = (values: unknown[], agent = "agent") => - activityRecords( - values.map((value, i) => ({ - id: String(i), - agent, - kind: "event", - receivedAt: i, - plaintext: JSON.stringify(value), - })), - agent, - ); - -it("groups exact agent/channel/turn identities and never inherits batch context", () => { - const raw = records([ - { - kind: "batch", - channelId: "alpha", - turnId: "parent", - payload: { - events: [ - event("turn_started", {}, "same", "alpha"), - event("turn_started", {}, "same", "beta"), - event("acp_read", {}, null, null), - ], - }, - }, - ]); - const result = activityTranscript(raw); - expect(result.groups.map((g) => [g.channelId, g.turnId])).toEqual([ - ["alpha", "same"], - ["beta", "same"], - [null, null], - ]); - const filtered = activityRecords(raw, "agent", "alpha"); - expect(activityTranscript(filtered).groups).toHaveLength(1); - expect( - activityTranscript([ - ...records([update("tool_call", { toolCallId: "one", title: "First" })]), - ...records( - [update("tool_call", { toolCallId: "one", title: "Second" })], - "other", - ), - ]).groups, - ).toHaveLength(2); -}); -it("renders prompts as inert text, coalesces contiguous chunks, and keeps separate prompt boundaries", () => { - const result = activityTranscript( - records([ - event("acp_write", { - method: "session/prompt", - params: { - prompt: [{ type: "text", text: "" }], - }, - }), - update("agent_message_chunk", { - content: { type: "text", text: "Hello " }, - }), - update("agent_message_chunk", { - content: { type: "text", text: "world" }, - }), - event("acp_write", { - method: "_session/steering", - params: { prompt: [{ type: "text", text: "Follow up" }] }, - }), - update("agent_message_chunk", { - content: { type: "text", text: "Second response" }, - }), - ]), - ); - const entries = result.groups[0]?.entries ?? []; - expect(entries.map((e) => [e.title, e.body])).toEqual([ - ["Request context", ""], - ["Response", "Hello world"], - ["Follow-up context", "Follow up"], - ["Response", "Second response"], - ]); - expect(new Set(entries.map((e) => e.id)).size).toBe(entries.length); - expect(entries[1]?.sourceIds).toEqual(["1", "2"]); -}); -it("folds tool snapshots only within a turn/session and retains terminal status through delayed starts", () => { - const start = update("tool_call", { - toolCallId: "tool", - title: "Read README.md", - status: "in_progress", - rawInput: { path: "README.md" }, - }); - const result = activityTranscript( - records([ - start, - update("tool_call_update", { - toolCallId: "tool", - status: "completed", - rawOutput: { text: "first" }, - }), - update("tool_call_update", { toolCallId: "tool", status: "in_progress" }), - { ...start, turnId: "different" }, - event("acp_read", { - method: "session/update", - params: { - sessionId: "other", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool", - title: "Other session", - }, - }, - }), - ]), - ); - expect(result.groups).toHaveLength(2); - const tools = result.groups[0]?.entries ?? []; - expect(tools).toHaveLength(2); - expect(tools[0]).toMatchObject({ - title: "Read README.md", - status: "completed", - sourceIds: ["0", "1", "2"], - input: '{\n "path": "README.md"\n}', - output: '{\n "text": "first"\n}', - }); - expect(tools[1]?.title).toBe("Other session"); -}); -it("keeps update-only, failed, unknown, nontext and malformed observations inspectable", () => { - const result = activityTranscript( - records([ - update("tool_call_update", { - toolCallId: "missing-start", - status: "failed", - content: [ - { - type: "content", - content: { type: "text", text: "Permission denied" }, - }, - ], - }), - update("agent_thought_chunk", { - content: { type: "image", data: "ignored" }, - }), - update("plan", { - entries: [ - { status: "completed", content: "Read file" }, - { status: "pending", content: "Run tests" }, - null, - ], - }), - event("acp_read", { text: "not a file read" }), - event("new-event", "literal"), - event("turn_error", {}), - event("turn_completed", {}), - ]), - ); - expect(result.groups[0]?.entries.map((e) => e.title)).toEqual([ - "Tool call", - "Thinking", - "Plan", - "ACP event", - "new-event", - "Turn error", - "Turn ended", - ]); - expect(result.groups[0]?.entries[0]).toMatchObject({ - status: "failed", - output: "Permission denied", - }); - expect(result.groups[0]?.entries[2]?.body).toBe("✓ Read file\n• Run tests"); - expect( - activityTranscript([ - { - id: "broken", - envelopeId: "broken", - agent: "agent", - receivedAt: 0, - kind: "unknown", - plaintext: "broken", - }, - ]).groups, - ).toEqual([]); -}); -it("does not merge tools without IDs or chunks with different message IDs", () => { - const result = activityTranscript( - records([ - update("tool_call", { title: "One" }), - update("tool_call", { title: "Two" }), - update("agent_message_chunk", { - messageId: "a", - content: { type: "text", text: "A" }, - }), - update("agent_message_chunk", { - messageId: "b", - content: { type: "text", text: "B" }, - }), - ]), - ); - expect(result.groups[0]?.entries).toHaveLength(4); -}); -it("bounds a large batch and recomputes from retained evidence without resurrecting removed details", () => { - const values = Array.from({ length: TRANSCRIPT_EVENT_LIMIT + 5 }, (_, i) => - event("acp_read", { text: String(i) }, String(i)), - ); - const raw = records([{ kind: "batch", payload: { events: values } }]); - const before = JSON.stringify(raw); - const result = activityTranscript(raw); - expect(result.omitted).toBe(5); - expect(result.groups).toHaveLength(TRANSCRIPT_EVENT_LIMIT); - expect(result.groups[0]?.turnId).toBe("5"); - expect(JSON.stringify(raw)).toBe(before); - expect(activityTranscript([])).toMatchObject({ groups: [], omitted: 0 }); - const onlyUpdate = activityTranscript( - records([ - update("tool_call_update", { - toolCallId: "missing", - status: "completed", - }), - ]), - ); - expect(onlyUpdate.groups[0]?.entries[0]?.title).toBe("Tool call"); -}); -it("does not unfold nested batches after exact-channel projection", () => { - const input = records([ - { - kind: "batch", - payload: { - events: [ - { - kind: "batch", - channelId: "alpha", - payload: { events: [event("acp_read", {}, "secret", "beta")] }, - }, - ], - }, - }, - ]); - const scoped = activityRecords(input, "agent", "alpha"); - expect(activityTranscript(scoped).groups.map((g) => g.channelId)).toEqual([ - "alpha", - ]); -}); -it("respects ACP session identity and intervening tool updates when joining chunks", () => { - const chunk = (body: string, session = "S") => - event("acp_read", { - method: "session/update", - params: { - sessionId: session, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: body }, - }, - }, - }); - const result = activityTranscript( - records([ - update("tool_call", { toolCallId: "tool", title: "Read file" }), - chunk("A"), - update("tool_call_update", { toolCallId: "tool", status: "completed" }), - chunk("B"), - chunk("C", "other"), - ]), - ); - expect( - result.groups[0]?.entries - .filter((e) => e.kind === "message") - .map((e) => e.body), - ).toEqual(["A", "B", "C"]); -}); -it("distinguishes omitted output from an explicitly cleared snapshot", () => { - const input = [ - update("tool_call", { toolCallId: "tool", rawOutput: "Old output" }), - update("tool_call_update", { toolCallId: "tool", status: "in_progress" }), - ]; - expect( - activityTranscript(records(input)).groups[0]?.entries[0]?.output, - ).toContain("Old output"); - input.push(update("tool_call_update", { toolCallId: "tool", content: [] })); - expect(activityTranscript(records(input)).groups[0]?.entries[0]?.output).toBe( - "", - ); -}); -it("classifies setup by direction, pairs only captured control responses, and never hides permission decisions", () => { - const result = activityTranscript( - records([ - event("acp_write", { id: 1, method: "initialize", params: {} }), - event("acp_read", { id: 1, result: {} }), - event("acp_read", { - id: 2, - method: "session/request_permission", - params: {}, - }), - event("acp_write", { - id: 2, - result: { outcome: { outcome: "selected", optionId: "allow" } }, - }), - event("acp_write", { id: 3, method: "session/new", params: {} }), - event("acp_read", { id: 3, error: { message: "no session" } }), - event("acp_read", { id: 4, result: {} }), - update("session_info_update", {}), - event("acp_read", { - method: "_goose/unstable/session/update", - params: { update: { sessionUpdate: "usage_update", used: 1 } }, - }), - event("acp_read", { id: 5, method: "initialize" }), - update("future_update", {}), - ]), - ); - expect( - result.groups[0]?.entries.map((e) => [e.title, !!e.diagnostic]), - ).toEqual([ - ["Initialize agent", true], - ["Session setup response", true], - ["Permission requested", false], - ["Permission response", false], - ["Prepare session", true], - ["Session setup response error", false], - ["ACP response", false], - ["Session update", true], - ["Usage update", true], - ["initialize", false], - ["future_update", false], - ]); -}); -it("raw source uses exact projected children and retains known and unknown evidence", () => { - const raw = records([ - { - kind: "batch", - payload: { - events: [ - event("new-event", "alpha secret", "T", "alpha"), - event("new-event", "beta secret", "T", "beta"), - ], - }, - }, - ]); - const general = activityTranscript(raw); - const first = general.groups[0]?.entries[0]?.sourceIds[0]; - expect(general.source(first ?? "")?.plaintext).toContain("alpha secret"); - expect(general.source(first ?? "")?.plaintext).not.toContain("beta secret"); - const scoped = activityTranscript(activityRecords(raw, "agent", "alpha")); - expect( - scoped.source(scoped.groups[0]?.entries[0]?.sourceIds[0] ?? "")?.plaintext, - ).not.toContain("beta secret"); - expect(scoped.source("evicted")).toBeUndefined(); -}); -it("pairs unassigned setup RPCs by stable scope without pairing across sessions", () => { - const req = event("acp_write", { id: 1, method: "initialize" }, null); - const res = event("acp_read", { id: 1, result: {} }, null); - const result = activityTranscript( - records([req, { ...res, sessionId: "other" }, res]), - ); - expect( - result.groups.flatMap((g) => - g.entries.map((e) => [e.title, !!e.diagnostic]), - ), - ).toEqual([ - ["Initialize agent", true], - ["ACP response", false], - ["Session setup response", true], - ]); -}); -it("preserves a known tool identity when the producer updates its display title", () => { - const result = activityTranscript( - records([ - update("tool_call", { - toolCallId: "shell", - title: "buzz-dev-mcp__shell", - rawInput: { command: "check" }, - }), - update("tool_call_update", { - toolCallId: "shell", - title: "Finished checking", - status: "completed", - }), - ]), - ); - expect(result.groups[0]?.entries[0]).toMatchObject({ - toolName: "buzz-dev-mcp__shell", - title: "Finished checking", - }); -}); -it("folds only exact recognized allowed permissions into details, with both raw halves retained", () => { - const request = (options: unknown) => - event("acp_read", { - id: 7, - method: "session/request_permission", - params: { options }, - }); - const response = (outcome: unknown) => - event("acp_write", { id: 7, result: { outcome } }); - const option = { optionId: "selected-option", kind: "allow_once" }; - const source = [ - request([option]), - response({ outcome: "selected", optionId: "selected-option" }), - ]; - const allowed = activityTranscript(records(source)); - expect(allowed.groups[0]?.entries).toHaveLength(1); - expect(allowed.groups[0]?.entries[0]).toMatchObject({ - title: "Permission allowed", - diagnostic: true, - sourceIds: ["0", "1"], - }); - expect(allowed.source("0")?.plaintext).toContain( - "session/request_permission", - ); - expect(allowed.source("1")?.plaintext).toContain("selected-option"); - for (const options of [ - [{ ...option, kind: "unknown" }], - [option, option], - [], - null, - ]) { - const entries = - activityTranscript(records([request(options), source[1]])).groups[0] - ?.entries ?? []; - expect(entries.every((e) => !e.diagnostic)).toBe(true); - expect(entries.map((e) => e.title)).not.toContain("Permission allowed"); - } - for (const kind of ["reject_once", "reject_always"]) { - const denied = activityTranscript( - records([request([{ ...option, kind }]), source[1]]), - ); - expect(denied.groups[0]?.entries[0]).toMatchObject({ - title: "Permission denied", - diagnostic: false, - }); - } - expect( - activityTranscript( - records([request([option]), response({ outcome: "cancelled" })]), - ).groups[0]?.entries[0], - ).toMatchObject({ title: "Permission cancelled", diagnostic: false }); - for (const values of [ - [source[1], source[0]], - [source[0], source[0], source[1]], - [source[0], { ...source[1], sessionId: "other" }], - ]) { - const entries = activityTranscript(records(values)).groups.flatMap( - (g) => g.entries, - ); - expect(entries.some((e) => e.title === "Permission allowed")).toBe(false); - } -}); -it("puts known context delivery in diagnostics and lets late failure override completion", () => { - const result = activityTranscript( - records([ - event("prompt_context_delivery", { promptBytes: 100 }), - update("tool_call", { toolCallId: "tool", status: "completed" }), - update("tool_call_update", { toolCallId: "tool", status: "failed" }), - update("tool_call_update", { toolCallId: "tool", status: "completed" }), - ]), - ); - expect(result.groups[0]?.entries[0]).toMatchObject({ - title: "Context delivered", - diagnostic: true, - }); - expect(result.groups[0]?.entries[1]?.status).toBe("failed"); -}); -it("keeps malformed empty permission choices primary and recovers after collided requests drain", () => { - const request = (options: unknown[]) => - event("acp_read", { - id: 1, - method: "session/request_permission", - params: { options }, - }); - const cancelled = event("acp_write", { - id: 1, - result: { outcome: { outcome: "cancelled" } }, - }); - const empty = - activityTranscript(records([request([]), cancelled])).groups[0]?.entries ?? - []; - expect(empty.map((e) => e.title)).toEqual([ - "Permission requested", - "Permission response", - ]); - expect(empty.every((e) => !e.diagnostic)).toBe(true); - const valid = request([{ optionId: "choice", kind: "allow_once" }]); - const allowed = event("acp_write", { - id: 1, - result: { outcome: { outcome: "selected", optionId: "choice" } }, - }); - const recovered = - activityTranscript( - records([valid, valid, allowed, allowed, valid, allowed]), - ).groups[0]?.entries ?? []; - expect( - recovered.filter((e) => e.title === "Permission allowed"), - ).toHaveLength(1); - expect(recovered.at(-1)?.sourceIds).toEqual(["4", "5"]); -}); - -it("preserves reported event and method names without inventing command semantics", () => { - const input = records([ - event("session_config_captured", { configOptions: [] }), - event("acp_write", { - id: 12, - method: "session/set_config_option", - params: {}, - }), - event("acp_read", { id: 12, result: {} }), - event("acp_read", { id: 13, error: { message: "failed" } }), - event("new_observer_event", {}), - event("acp_read", { method: "session/update", params: { update: {} } }), - event("", {}), - ]); - const transcript = activityTranscript(input); - expect( - transcript.groups.flatMap((group) => - group.entries.map((entry) => [ - entry.title, - !!entry.diagnostic, - entry.kind, - ]), - ), - ).toEqual([ - ["Session configuration captured", true, "event"], - ["session/set_config_option", false, "event"], - ["ACP response", false, "event"], - ["ACP error response", false, "event"], - ["new_observer_event", false, "event"], - ["session/update", false, "event"], - ["Unrecognized activity", false, "event"], - ]); - for (const group of transcript.groups) - for (const entry of group.entries) - expect( - transcript.source(entry.sourceIds[0] ?? "")?.plaintext, - ).toBeTruthy(); -}); diff --git a/src/bundled/agent-activity/transcript.ts b/src/bundled/agent-activity/transcript.ts deleted file mode 100644 index e4f11d105..000000000 --- a/src/bundled/agent-activity/transcript.ts +++ /dev/null @@ -1,424 +0,0 @@ -import type { ActivityRecord } from "../../features/agents/activity-records"; -import { activityErrorText } from "./failed-activity"; - -// Presentation only: rebuilt from the current RAM window, never another journal. -export const TRANSCRIPT_EVENT_LIMIT = 500; -type ObjectValue = { [key: string]: unknown }; -const object = (value: unknown): ObjectValue | undefined => - value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as ObjectValue) - : undefined; -const text = (value: unknown) => (typeof value === "string" ? value : ""); -const identity = (value: unknown) => { - const result = text(value); - return result.length <= 256 ? result : ""; -}; -const json = (value: unknown) => - value === undefined ? "" : JSON.stringify(value, null, 2); - -/** ACP text blocks only. Never interpret prompt prose as identity or markup. */ -function contentText(value: unknown, depth = 0): string { - if (depth > 3) return ""; - if (Array.isArray(value)) - return value - .map((part) => { - const block = object(part); - return contentText(block?.content ?? block, depth + 1); - }) - .filter(Boolean) - .join("\n"); - const block = object(value); - return block?.type === "text" ? text(block.text) : ""; -} -export type TranscriptEntry = { - id: string; - kind: "prompt" | "message" | "thought" | "tool" | "plan" | "event"; - title: string; - body: string; - input: string; - output: string; - status: string; - toolName?: string; - diagnostic?: boolean; - chunkKey?: string; - /** References into the current raw projection, not another copy of telemetry. */ - sourceIds: string[]; -}; -export type TranscriptGroup = { - id: string; - agent: string; - channelId: string | null; - turnId: string | null; - sessionId: string | null; - receivedAt: number; - entries: TranscriptEntry[]; -}; -type Item = { record: ActivityRecord; value: ObjectValue; id: string }; -const toolStatuses = new Set(["pending", "in_progress", "completed", "failed"]); -const terminal = (status: string) => - status === "completed" || status === "failed"; - -export function activityTranscript(records: readonly ActivityRecord[]) { - const events: Item[] = []; - let total = 0; - // Iterate newest-first so only the bounded display window is allocated. - for (let index = records.length - 1; index >= 0; index--) { - const record = records[index]; - if (!record) continue; - let value: ObjectValue | undefined; - try { - value = object(JSON.parse(record.plaintext)); - } catch { - /* raw view remains */ - } - const children = - value?.kind === "batch" && record.id === record.envelopeId - ? object(value.payload)?.events - : undefined; - const values = Array.isArray(children) ? children : [value]; - for (let child = values.length - 1; child >= 0; child--) { - const item = object(values[child]); - if (!item) continue; - total++; - if (events.length < TRANSCRIPT_EVENT_LIMIT) - events.push({ - record, - value: item, - id: Array.isArray(children) ? `${record.id}:${child}` : record.id, - }); - } - } - const sources = new Map(events.map((item) => [item.id, item])); - const groups = new Map(); - const tools = new Map(); - const chunks = new Map(); - const control = new Map< - string, - { - title: string; - diagnostic: boolean; - entry: TranscriptEntry; - options?: Map; - } - >(); - const ambiguousControl = new Map(); - const sourceOrder = new Map(events.map((item, index) => [item.id, -index])); - for (const { record, value, id } of events.reverse()) { - // Never inherit context from an enclosing batch or adjacent event. - const channelId = identity(value.channelId) || null; - const turnId = identity(value.turnId) || null; - const sessionId = identity(value.sessionId) || null; - // A turn may resolve its session mid-run; the turn itself remains the group. - const key = JSON.stringify([ - record.agent, - channelId, - turnId ? ["turn", turnId] : ["record", id], - ]); - let group = groups.get(key); - if (!group) { - group = { - id: key, - agent: record.agent, - channelId, - turnId, - sessionId, - receivedAt: record.receivedAt, - entries: [], - }; - groups.set(key, group); - } - const kind = text(value.kind); - const payload = object(value.payload); - const entry: TranscriptEntry = { - id, - kind: "event", - title: "Unrecognized activity", - body: "", - input: "", - output: "", - status: "", - sourceIds: [id], - }; - const method = text(payload?.method); - const params = object(payload?.params); - // Preserve reported identity when the renderer has no friendly mapping. - entry.title = - identity(method) || - (kind === "acp_read" || kind === "acp_write" - ? payload?.error !== undefined - ? "ACP error response" - : payload?.result !== undefined - ? "ACP response" - : "ACP event" - : identity(kind) || "Unrecognized activity"); - const priorChunk = chunks.get(key); - chunks.delete(key); - const rpcScope = JSON.stringify([ - record.agent, - channelId, - turnId, - sessionId, - ]); - const rpcId = - typeof payload?.id === "string" || typeof payload?.id === "number" - ? JSON.stringify([ - rpcScope, - kind === "acp_read" ? "acp_write" : "acp_read", - payload.id, - ]) - : undefined; - const setup = - kind === "acp_write" && - ["initialize", "session/new", "session/load"].includes(method); - const permission = - kind === "acp_read" && method === "session/request_permission"; - const replyKey = - typeof payload?.id === "string" || typeof payload?.id === "number" - ? JSON.stringify([rpcScope, kind, payload.id]) - : undefined; - if ( - !method && - replyKey && - (payload?.result !== undefined || payload?.error !== undefined) - ) { - const outstanding = ambiguousControl.get(replyKey); - if (outstanding === 1) ambiguousControl.delete(replyKey); - else if (outstanding) ambiguousControl.set(replyKey, outstanding - 1); - } - const reply = !method && replyKey ? control.get(replyKey) : undefined; - if (setup || permission) { - entry.title = permission - ? "Permission requested" - : method === "initialize" - ? "Initialize agent" - : "Prepare session"; - entry.diagnostic = setup; - if (rpcId) { - if (control.has(rpcId) || ambiguousControl.has(rpcId)) { - const outstanding = ambiguousControl.get(rpcId) ?? 1; - control.delete(rpcId); - ambiguousControl.set(rpcId, outstanding + 1); - } else { - const options = new Map(); - let valid = - permission && - Array.isArray(params?.options) && - params.options.length > 0; - if (Array.isArray(params?.options)) - for (const value of params.options) { - const option = object(value); - if ( - !identity(option?.optionId) || - ![ - "allow_once", - "allow_always", - "reject_once", - "reject_always", - ].includes(text(option?.kind)) || - options.has(text(option?.optionId)) - ) - valid = false; - else options.set(text(option?.optionId), text(option?.kind)); - } - control.set(rpcId, { - title: permission - ? "Permission response" - : "Session setup response", - diagnostic: setup, - entry, - ...(valid ? { options } : {}), - }); - } - } - } else if ( - reply && - (payload?.result !== undefined || payload?.error !== undefined) - ) { - if (replyKey) control.delete(replyKey); - if (reply.title === "Permission response" && reply.options) { - const outcome = object(object(payload?.result)?.outcome); - const option = reply.options.get(text(outcome?.optionId)); - const allowed = - payload?.error === undefined && - outcome?.outcome === "selected" && - (option === "allow_once" || option === "allow_always"); - const denied = - outcome?.outcome === "selected" && - (option === "reject_once" || option === "reject_always"); - if ( - allowed || - denied || - outcome?.outcome === "cancelled" || - payload?.error !== undefined - ) { - reply.entry.title = allowed - ? "Permission allowed" - : denied - ? "Permission denied" - : payload?.error !== undefined - ? "Permission error" - : "Permission cancelled"; - reply.entry.diagnostic = allowed; - reply.entry.sourceIds.push(id); - continue; - } - } - entry.title = - payload?.error !== undefined ? `${reply.title} error` : reply.title; - entry.diagnostic = reply.diagnostic && payload?.error === undefined; - } else if (kind === "prompt_context_delivery") { - entry.title = "Context delivered"; - entry.diagnostic = true; - } else if ( - kind === "agent_initialized" || - kind === "session_config_captured" - ) { - entry.title = - kind === "agent_initialized" - ? "Agent initialized" - : "Session configuration captured"; - entry.diagnostic = true; - } else if ( - kind === "acp_write" && - [ - "session/prompt", - "_goose/unstable/session/steer", - "_session/steering", - ].includes(method) - ) { - entry.kind = "prompt"; - entry.title = - method === "session/prompt" ? "Request context" : "Follow-up context"; - entry.diagnostic = true; - entry.body = contentText(params?.prompt); - } else if ( - kind === "acp_read" && - (method === "session/update" || - method === "_goose/unstable/session/update") - ) { - const update = object(params?.update); - const updateKind = text(update?.sessionUpdate); - if (["session_info_update", "usage_update"].includes(updateKind)) { - entry.title = - updateKind === "usage_update" ? "Usage update" : "Session update"; - entry.diagnostic = true; - } else if ( - method === "session/update" && - (updateKind === "tool_call" || updateKind === "tool_call_update") - ) { - const toolId = identity(update?.toolCallId); - const toolKey = JSON.stringify([ - key, - identity(params?.sessionId) || sessionId, - toolId, - ]); - const previous = toolId ? tools.get(toolKey) : undefined; - const tool = previous ?? entry; - tool.kind = "tool"; - tool.title = - text(update?.title) || (previous ? tool.title : "Tool call"); - if (text(update?.title) && !tool.toolName) - tool.toolName = text(update?.title); - const status = text(update?.status); - if ( - toolStatuses.has(status) && - (!terminal(tool.status) || status === "failed") - ) - tool.status = status; - if (update?.rawInput !== undefined) tool.input = json(update.rawInput); - // Tool updates are snapshots; unlike message chunks, output is not appended. - const output = - contentText(update?.content) || - (update?.rawOutput !== undefined ? json(update.rawOutput) : ""); - if (update?.content !== undefined || update?.rawOutput !== undefined) - tool.output = output; - if (previous) { - if (!tool.sourceIds.includes(id)) tool.sourceIds.push(id); - continue; - } - if (toolId) tools.set(toolKey, tool); - } else if ( - [ - "agent_message_chunk", - "agent_thought_chunk", - "user_message_chunk", - ].includes(updateKind) - ) { - entry.kind = - updateKind === "agent_thought_chunk" - ? "thought" - : updateKind === "user_message_chunk" - ? "prompt" - : "message"; - entry.title = - entry.kind === "thought" - ? "Thinking" - : entry.kind === "prompt" - ? "Prompt echo" - : "Response"; - entry.body = contentText(update?.content); - // An echo cannot establish another authored message; keep it in context. - if (entry.kind === "prompt") entry.diagnostic = true; - // Only contiguous chunks within this exact turn and message may coalesce. - const previous = priorChunk; - const chunkKey = JSON.stringify([ - key, - identity(params?.sessionId) || sessionId, - identity(update?.messageId), - updateKind, - ]); - chunks.set(key, previous?.chunkKey === chunkKey ? previous : entry); - if (previous?.chunkKey === chunkKey) { - previous.body += entry.body; - if (!previous.sourceIds.includes(id)) previous.sourceIds.push(id); - continue; - } - entry.chunkKey = chunkKey; - } else if (updateKind === "plan" && Array.isArray(update?.entries)) { - entry.kind = "plan"; - entry.title = "Plan"; - entry.body = update.entries - .map((value) => { - const item = object(value); - return text(item?.content) - ? `${item?.status === "completed" ? "✓" : "•"} ${text(item?.content)}` - : ""; - }) - .filter(Boolean) - .join("\n"); - } else { - entry.title = identity(updateKind) || identity(method) || "ACP update"; - } - } else if (kind === "turn_completed") { - entry.title = "Turn ended"; - } else if (kind === "turn_error" || kind === "agent_panic") { - entry.title = "Turn error"; - entry.body = activityErrorText(payload); - } else if ( - ["turn_liveness", "turn_started", "session_resolved"].includes(kind) - ) { - // These provide group context, not repetitive reading rows or success claims. - continue; - } - group.entries.push(entry); - } - return { - groups: [...groups.values()], - // Folded tool/chunk rows keep display position; source order tracks later updates. - sourceOrder, - omitted: Math.max(0, total - TRANSCRIPT_EVENT_LIMIT), - /** JSON is serialized on disclosure only. Mixed batch parents are never used. */ - source: (id: string) => { - const item = sources.get(id); - return item - ? { - envelopeId: item.record.envelopeId, - plaintext: - item.id === item.record.id - ? item.record.plaintext - : json(item.value), - } - : undefined; - }, - }; -} diff --git a/src/bundled/channels/ChannelActivityPopover.tsx b/src/bundled/channels/ChannelActivityPopover.tsx index aebf334e3..1d48eb97a 100644 --- a/src/bundled/channels/ChannelActivityPopover.tsx +++ b/src/bundled/channels/ChannelActivityPopover.tsx @@ -226,7 +226,12 @@ export function ChannelActivityPopover({ getTargets, getTargets, ), - ) as ({ startedAt: number; messageId?: string } | null)[]; + ) as ({ + startedAt: number; + messageId?: string; + label?: string; + local?: boolean; + } | null)[]; // Typing alone lists an agent while the Agent Activity plugin is off, when // no activity panel is registered to open. const openAgentActivity = useSyncExternalStore( @@ -314,7 +319,8 @@ export function ChannelActivityPopover({ {activeAgents.map((agent, index) => { const profile = agentProfiles?.get(agent); const name = profile?.name ?? keyLabels.get(agent) ?? "Agent"; - const { messageId, startedAt } = details[index] ?? {}; + const { messageId, startedAt, label, local } = + details[index] ?? {}; return ( { close(); openAgentActivity(channelId, agent); diff --git a/src/bundled/channels/ChannelSidebarItem.test.tsx b/src/bundled/channels/ChannelSidebarItem.test.tsx index 6a0213287..50f93248e 100644 --- a/src/bundled/channels/ChannelSidebarItem.test.tsx +++ b/src/bundled/channels/ChannelSidebarItem.test.tsx @@ -135,7 +135,9 @@ it("keeps the DM identity avatar and shows only a separate thinking badge", () = ).toBeNull(); const badge = document.querySelector("[data-channel-working]"); expect(badge).toHaveAttribute("aria-label", "Buzzy working in Buzzy"); - expect(badge?.querySelectorAll("i")).toHaveLength(3); + expect(badge?.querySelectorAll("[data-state=working] > span")).toHaveLength( + 3, + ); expect(badge?.closest("[data-dm-identity]")).toBeNull(); }); diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index f6cac6661..eba798d33 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -77,7 +77,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem( props: ItemProps, ) { const { channel, session, working } = props; - const { agents, agentProfiles } = useWorkingAgents( + const { agents, agentProfiles, waiting } = useWorkingAgents( session, channel.id, working, @@ -87,6 +87,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem( {...props} agents={agents} agentProfiles={agentProfiles} + waiting={waiting} /> ); }); @@ -119,7 +120,9 @@ function ChannelSidebarItemCore({ menuFinalFocus, agents, agentProfiles, + waiting, }: ItemProps & { + waiting: boolean; agents: readonly string[]; agentProfiles: ReadonlyMap; }) { @@ -203,6 +206,7 @@ function ChannelSidebarItemCore({ agents={agents} profiles={agentProfiles} channelName={channel.name} + waiting={waiting} /> } diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index 1bfb031a6..da419a127 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -878,37 +878,6 @@ border-radius: var(--radius-pill); background: var(--affordance-subtle-hover); } -.thinkingBadge i { - display: block; - width: 0.1875rem; - height: 0.1875rem; - border-radius: 50%; - background: var(--text-standard); -} -@keyframes thinking-badge-pulse { - 0%, - 40%, - 100% { - opacity: 0.45; - transform: translateY(0); - } - 20% { - opacity: 1; - transform: translateY(-0.125rem); - } -} -@media (prefers-reduced-motion: no-preference) { - .thinkingBadge i { - animation: thinking-badge-pulse 1.2s linear infinite; - } - .thinkingBadge i:nth-child(2) { - animation-delay: 80ms; - } - .thinkingBadge i:nth-child(3) { - animation-delay: 160ms; - } -} - /* Occupies the DM identity box while it replaces the avatar or count; shares the participant-count chip's subtle roles. */ .typing { diff --git a/src/bundled/channels/WorkingAgentsBadge.tsx b/src/bundled/channels/WorkingAgentsBadge.tsx index 1b0d2e1a3..f18b674c7 100644 --- a/src/bundled/channels/WorkingAgentsBadge.tsx +++ b/src/bundled/channels/WorkingAgentsBadge.tsx @@ -1,3 +1,4 @@ +import { TypingDots } from "../../shared/design-system/ui/TypingDots"; import type { Profile } from "../../features/relay/contracts"; import { publicKeyLabels } from "../../shared/identity/public-key"; import styles from "./Channels.module.css"; @@ -6,10 +7,12 @@ export function WorkingAgentsBadge({ agents, profiles, channelName, + waiting = false, }: { agents: readonly string[]; profiles: ReadonlyMap; channelName: string; + waiting?: boolean; }) { if (!agents.length) return null; const labels = publicKeyLabels(agents); @@ -17,15 +20,13 @@ export function WorkingAgentsBadge({ profiles.get(agent)?.name ?? labels.get(agent) ?? "Agent"; return ( -