diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ff8a0e7703b..8da7fdcd85a 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -49,6 +49,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/transcript-tool-run-quality.smoke.spec.ts", "**/core-memory-screenshots.spec.ts", "**/activity-scope-label-screenshots.spec.ts", "**/welcome-agent-modal-screenshots.spec.ts", diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx index e0f2ce9fbc3..4913938cf3c 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx @@ -29,11 +29,16 @@ export function ToolItem({ agentPubkey, item, profiles, + expanded, + onExpansionChange, }: AgentTranscriptIdentityProps & { item: Extract; profiles?: UserProfileLookup; + expanded?: boolean; + onExpansionChange?: (expanded: boolean) => void; }) { - const [isExpanded, setIsExpanded] = React.useState(false); + const [localExpanded, setLocalExpanded] = React.useState(false); + const isExpanded = expanded ?? localExpanded; const hasArgs = Object.keys(item.args).length > 0; const hasResult = item.result.trim().length > 0; const canonicalToolName = item.buzzToolName ?? item.toolName; @@ -52,9 +57,14 @@ export function ToolItem({ const agentResolvedAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { - setIsExpanded(event.currentTarget.open); + const nextExpanded = event.currentTarget.open; + if (onExpansionChange) { + onExpansionChange(nextExpanded); + } else { + setLocalExpanded(nextExpanded); + } }, - [], + [onExpansionChange], ); if (compactSummary.presentation === "message") { diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptEmptyState.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptEmptyState.tsx new file mode 100644 index 00000000000..bf50360a495 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptEmptyState.tsx @@ -0,0 +1,70 @@ +import { CircleHelp, Radio } from "lucide-react"; + +import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; +import { + getUncertainHistoryCopy, + type UncertainHistoryCertainty, +} from "./agentSessionHistoryCertainty"; + +/** + * An uncertain variant means history may exist but could not be read (no + * `owner_p` save subscription, or archive hydration incomplete). Those are + * deliberately distinct from `"idle"`: only `"idle"` may state that there was + * no activity. The specific variant is carried rather than a single `"unknown"` + * so the rendered copy can name the actual gap — see + * `getUncertainHistoryCopy`. + */ +export type AgentSessionTranscriptEmptyState = + | "idle" + | "loading" + | UncertainHistoryCertainty; + +/** + * What a transcript with nothing to render says. + * + * Three outcomes, and the distinction between the last two is the point: an + * idle channel may state that no activity happened, while a channel whose + * archive could not be read may not. All uncertain wording comes from + * `getUncertainHistoryCopy` so this view cannot drift from the module that + * owns it. + */ +export function AgentSessionTranscriptEmptyBody({ + emptyDescription, + isLoading, + state, +}: { + emptyDescription: string; + /** True while a turn is live or the caller is still loading. */ + isLoading: boolean; + state: AgentSessionTranscriptEmptyState; +}) { + if (isLoading) { + return ( + + ); + } + + if (state !== "idle" && state !== "loading") { + const copy = getUncertainHistoryCopy(state); + return ( + <> + +

{copy.title}

+

{copy.description}

+ + ); + } + + return ( + <> + +

No ACP activity yet

+

{emptyDescription}

+ + ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index d24c8fab79f..1af45803eec 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -23,8 +23,11 @@ import { } from "@/shared/ui/dialog"; import { Toggle } from "@/shared/ui/toggle"; import { AnimatedCount } from "@/shared/ui/AnimatedCount"; -import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import type { PromptSection, TranscriptItem } from "./agentSessionTypes"; +import { + AgentSessionTranscriptEmptyBody, + type AgentSessionTranscriptEmptyState, +} from "./AgentSessionTranscriptEmptyState"; import { TurnLivenessIndicator } from "./TurnLivenessIndicator"; import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion"; import { @@ -36,8 +39,6 @@ import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference"; import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference"; import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem"; import { - ActivityRow, - ActivityRowContent, ActivityRowLabel, type ActivityRowStats, splitActivityRowCountedObject, @@ -53,8 +54,15 @@ import { turnSetupDetail, turnSetupTimestamp, type TranscriptDisplayBlock, + type TranscriptToolRunChildSegment, type TranscriptTurnSegment, } from "./agentSessionTranscriptGrouping"; +import { TranscriptToolRunGroup } from "./TranscriptToolRunGroup"; +import { + TranscriptToolRunGroupKeyProvider, + useResolvedToolRunGroupKeys, +} from "./useTranscriptToolRunGroupKeys"; +import { TranscriptRowTimestamp } from "./TranscriptRowTimestamp"; import { buildCompactToolSummary } from "./agentSessionToolSummary"; import { shouldShowTranscriptRowTimestamp } from "./agentSessionTranscriptPresentation"; import { formatTranscriptTimestampTitle } from "./agentSessionUtils"; @@ -90,7 +98,7 @@ function useHasCompletedInitialRender() { */ const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource(); -export type AgentSessionTranscriptEmptyState = "idle" | "loading"; +export type { AgentSessionTranscriptEmptyState }; function shouldShowTranscriptAcpSource() { const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE; @@ -156,6 +164,15 @@ export function AgentSessionTranscriptList({ () => buildTranscriptDisplayBlocks(items, latestLiveSessionId), [items, latestLiveSessionId], ); + // Durable per-group identities for this pass, resolved once for the whole + // transcript so a reader's expansion choice survives leaves leaving a group + // (a failed call is ejected from it). Scoped by the transcript instance, not + // globally: the compact preview and the channel pane render the same work and + // must not overwrite each other's recognition table. + const toolRunGroupKeys = useResolvedToolRunGroupKeys( + `${agentPubkey}:${channelId ?? "all"}:${variant}`, + displayBlocks, + ); // Derive the same block keys the DOM renders as `data-message-id` so // useAnchoredScroll anchors on real DOM rows. Value-stabilized so the // hook's restoration effect only fires when the ordered block sequence @@ -202,27 +219,14 @@ export function AgentSessionTranscriptList({ ); if (!hasRenderableContent) { - const isLoading = emptyState === "loading" || isTurnLive; - return (
- {isLoading ? ( - - ) : ( - <> - -

No ACP activity yet

-

- {emptyDescription} -

- - )} +
); @@ -248,36 +252,38 @@ export function AgentSessionTranscriptList({ role="log" > - {displayBlocks.map((block) => { - const blockKey = getDisplayBlockKey(block); - return ( - - {/* content-visibility stays on a non-animated child: motion + + {displayBlocks.map((block) => { + const blockKey = getDisplayBlockKey(block); + return ( + + {/* content-visibility stays on a non-animated child: motion measures the outer wrapper for layout animations, which would otherwise force skipped offscreen rows to render. */} -
- -
-
- ); - })} +
+ +
+
+ ); + })} + {isTurnLive && !isCompactPreview ? : null}
@@ -503,84 +509,57 @@ function SameKindSummaryItem({ [summary.items, summary.renderClass, summary.variant], ); const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs); - const expandsToToolItems = summary.items.every( - (item) => item.type === "tool", - ); const variant = useAgentSessionTranscriptVariant(); const timestampsEnabled = useTranscriptTimestampsEnabled(); const showTimestamp = timestampsEnabled && variant !== "compactPreview"; + // Mixed bursts expand to their child segments in original order: raw tool // rows plus nested same-kind summaries that joined the burst (which stay - // expandable to their own child rows). - const childSegments = summary.segments ?? null; + // expandable to their own child rows). Rendering is passed as a callback so + // the group card never has to import this module back. Not memoized: the + // group card calls it during render and never uses its identity as a + // dependency, and memoizing would need this component as its own dep. + const renderChild = ( + child: TranscriptToolRunChildSegment, + expansion?: { + expanded: boolean; + onExpansionChange: (expanded: boolean) => void; + }, + ) => + child.kind === "summary" ? ( + + ) : ( + + ); return ( - <> - + - - {childSegments - ? childSegments.map((child) => - child.kind === "summary" ? ( - - ) : ( - - ), - ) - : expandsToToolItems - ? summary.items.map((item) => ( - - )) - : summary.items.map((item) => ( -

- {item.type === "tool" - ? item.descriptor.preview || item.descriptor.label - : item.title} -

- ))} -
-
- {showTimestamp ? ( - - ) : null} - + } + renderChild={renderChild} + showTimestamp={showTimestamp} + summary={summary} + /> ); } @@ -886,25 +865,9 @@ function TranscriptItemRow({ } /** - * Opt-in per-row timestamp, anchored bottom-left under the row content and - * styled to match the chat/transcript timestamps. + * Opt-in per-row timestamp lives in `./TranscriptRowTimestamp` so the tool-run + * group card can render it without importing this module. */ -function TranscriptRowTimestamp({ - messageLink = null, - timestamp, -}: { - messageLink?: { channelId: string; messageId: string } | null; - timestamp: string; -}) { - return ( -
- -
- ); -} function TurnSetupStatus({ items, @@ -979,17 +942,23 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ agentName, agentPubkey, item, + onExpansionChange, profiles, + expanded, }: AgentTranscriptIdentityProps & { item: TranscriptItem; profiles?: UserProfileLookup; + expanded?: boolean; + onExpansionChange?: (expanded: boolean) => void; }) { return ( ); diff --git a/desktop/src/features/agents/ui/TranscriptRowTimestamp.tsx b/desktop/src/features/agents/ui/TranscriptRowTimestamp.tsx new file mode 100644 index 00000000000..e8e63893300 --- /dev/null +++ b/desktop/src/features/agents/ui/TranscriptRowTimestamp.tsx @@ -0,0 +1,26 @@ +import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp"; + +/** + * Opt-in per-row timestamp, anchored bottom-left under the row content and + * styled to match the chat/transcript timestamps. + * + * Extracted from `AgentSessionTranscriptList` so tool-run group chrome can + * render the same timestamp without importing the list module (which imports + * the group card, and would form a cycle). + */ +export function TranscriptRowTimestamp({ + messageLink = null, + timestamp, +}: { + messageLink?: { channelId: string; messageId: string } | null; + timestamp: string; +}) { + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx b/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx new file mode 100644 index 00000000000..1f76c957b0f --- /dev/null +++ b/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx @@ -0,0 +1,262 @@ +import * as React from "react"; +import { CircleAlert, FileDiff, FilePen, Loader } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { + ActivityRow, + ActivityRowContent, +} from "./activityRenderClasses/ActivityRow"; +import type { + TranscriptToolRunChildSegment, + TranscriptToolRunSummary, +} from "./agentSessionTranscriptGrouping"; +import { getToolRunGroupKey } from "./agentSessionToolRunGroupKey"; +import { + getToolRunGroupStatus, + isToolRunGroupActive, +} from "./agentSessionToolRunStatus"; +import { + collectToolRunArtifacts, + partitionToolRunSteps, +} from "./agentSessionToolRunPartition"; +import { + setToolRunGroupExpanded, + setToolRunGroupInternalStepsShown, + setToolRunGroupItemExpanded, +} from "./agentSessionToolRunViewState"; +import { useToolRunGroupViewState } from "./useToolRunGroupViewState"; +import { useToolRunGroupKey } from "./useTranscriptToolRunGroupKeys"; +import { formatTranscriptTimestampTitle } from "./agentSessionUtils"; +import { TranscriptRowTimestamp } from "./TranscriptRowTimestamp"; + +/** + * Status shown beside a collapsed group's label. + * + * Only non-clean outcomes render. A completed group says nothing — "done" is + * the expected case and a badge on every finished group would train the reader + * to ignore the badge that matters. In-flight work is exactly the state worth + * interrupting for. + * + * The `failed` branch is a safety net that mirrors the failure-leaning fold in + * `getToolRunGroupStatus`; no observer frame reaches it today because grouping + * ejects failed calls into standalone rows (see that module's note). It is kept + * deliberately plain — one word, no count — because an aggregate that cannot be + * produced should not carry presentation detail nobody can verify. If grouping + * ever admits failures, this says the true thing rather than nothing. + */ +function ToolRunGroupStatusBadge({ + status, +}: { + status: ReturnType; +}) { + if (status === "completed") return null; + + if (status === "failed") { + return ( + + + ); + } + + return ( + + + ); +} + +/** + * Files a group touched, rendered OUTSIDE its collapsible body. + * + * A file the agent edited is an outcome of the turn, not a detail of how the + * steps happened to be batched — so it stays visible when the group is + * collapsed. Otherwise the most consequential thing a group did would be the + * thing a reader has to expand to discover. + */ +function ToolRunGroupArtifacts({ + artifacts, +}: { + artifacts: ReturnType; +}) { + if (artifacts.length === 0) return null; + + return ( +
+ {artifacts.map((artifact) => ( + + {artifact.kind === "edit" ? ( + + ))} +
+ ); +} + +export type TranscriptToolRunGroupProps = { + summary: TranscriptToolRunSummary; + /** + * Group label, already split/animated by the list. Passed as a node (rather + * than a string plus stats) so the group card owns none of the label's + * animation policy. + */ + label: React.ReactNode; + showTimestamp: boolean; + /** Renders one child segment. Passed in to avoid a list↔group import cycle. */ + renderChild: ( + child: TranscriptToolRunChildSegment, + expansion?: { + expanded: boolean; + onExpansionChange: (expanded: boolean) => void; + }, + ) => React.ReactNode; +}; + +/** + * A collapsible group of adjacent tool calls. + * + * Owns three behaviors the plain `ActivityRow` cannot express: + * - a failure-leaning aggregate status, so a collapsed group never reads as + * fine while something inside it failed or is still running; + * - mount-aware sticky expansion keyed by a durable identity, so live work is + * open while it runs, history arrives collapsed, and a reader's own choice is + * never reverted by re-grouping or by the group finishing; + * - artifacts and status rendered outside the collapsible body, so collapsing + * hides the steps without hiding the outcome. + */ +export function TranscriptToolRunGroup({ + label, + renderChild, + showTimestamp, + summary, +}: TranscriptToolRunGroupProps) { + const status = getToolRunGroupStatus(summary.items); + const anchorKey = getToolRunGroupKey(summary); + const groupKey = useToolRunGroupKey(summary.id, anchorKey); + const { state, update } = useToolRunGroupViewState(groupKey, status); + + const artifacts = React.useMemo( + () => collectToolRunArtifacts(summary.items), + [summary.items], + ); + + // Children are the mixed burst's own segments when present, otherwise the + // group's leaf items. + const childSegments = React.useMemo( + () => + summary.segments ?? + summary.items.map((item) => ({ kind: "item" as const, item })), + [summary.items, summary.segments], + ); + + const { hiddenItems, primaryItems } = React.useMemo( + () => + partitionToolRunSteps( + childSegments.flatMap((child) => + child.kind === "item" ? [child.item] : child.summary.items, + ), + state.expandedItemIds, + ), + [childSegments, state.expandedItemIds], + ); + + const hiddenIds = React.useMemo( + () => new Set(hiddenItems.map((item) => item.id)), + [hiddenItems], + ); + const visibleChildren = state.showInternalSteps + ? childSegments + : childSegments.filter( + (child) => child.kind !== "item" || !hiddenIds.has(child.item.id), + ); + + return ( + <> + + update((previous) => setToolRunGroupExpanded(previous, open)) + } + open={state.expanded} + openToneScope="summary" + testId="transcript-same-kind-summary" + title={formatTranscriptTimestampTitle(summary.timestamp)} + > + {label} + + + {visibleChildren.map((child) => + renderChild( + child, + child.kind === "item" && child.item.type === "tool" + ? { + expanded: state.expandedItemIds.includes(child.item.id), + onExpansionChange: (expanded) => + update((previous) => + setToolRunGroupItemExpanded( + previous, + child.item.id, + expanded, + ), + ), + } + : undefined, + ), + )} + {hiddenItems.length > 0 ? ( + + ) : null} + {primaryItems.length === 0 && visibleChildren.length === 0 ? ( +

No steps to show.

+ ) : null} +
+
+ + {showTimestamp ? ( + + ) : null} + + ); +} + +export { isToolRunGroupActive }; diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx index 8c82bb0d6cd..ac1b8c74e85 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx @@ -22,6 +22,15 @@ type ActivityRowProps = { openToneScope?: Exclude; testId?: string; title?: string; + /** + * Controlled disclosure. When provided, the row's open state is owned by the + * caller — needed for tool-run groups, whose expansion must survive the + * remount that re-grouping causes and must be reverted by automatic policy + * when work completes. Omit both to keep the default uncontrolled `
` + * behavior every other row uses. + */ + open?: boolean; + onOpenChange?: (open: boolean) => void; }; type ActivityRowContentProps = { @@ -38,6 +47,8 @@ type ActivityRowContentComponent = React.FC & { export function ActivityRow({ children, className, + onOpenChange, + open, openToneScope = "tool", testId, title, @@ -60,6 +71,8 @@ export function ActivityRow({ ); } + const isControlled = onOpenChange !== undefined; + return (
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: is natively an interactive disclosure control, not a static element */} { + event.preventDefault(); + onOpenChange(!open); + } + : undefined + } > {summaryChildren} void; }; export type ActivityRenderClassPresenter = diff --git a/desktop/src/features/agents/ui/agentSessionHistoryCertainty.test.mjs b/desktop/src/features/agents/ui/agentSessionHistoryCertainty.test.mjs new file mode 100644 index 00000000000..8ca3b6b4d5c --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionHistoryCertainty.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getTranscriptHistoryCertainty, + getUncertainHistoryCopy, + isUncertainHistory, +} from "./agentSessionHistoryCertainty.ts"; + +const complete = { + hasSubscription: true, + channelId: "channel-1", + archiveHydrated: true, +}; + +test("a fully checked channel can state emptiness as fact", () => { + assert.equal(getTranscriptHistoryCertainty(complete), "known-empty"); + assert.equal(isUncertainHistory("known-empty"), false); +}); + +test("an unresolved subscription check is never a definitive empty", () => { + const certainty = getTranscriptHistoryCertainty({ + ...complete, + hasSubscription: null, + }); + assert.equal(certainty, "unknown-subscription-unresolved"); + assert.equal(isUncertainHistory(certainty), true); +}); + +test("a missing owner_p subscription is never a definitive empty", () => { + assert.equal( + getTranscriptHistoryCertainty({ ...complete, hasSubscription: false }), + "unknown-not-indexed", + ); +}); + +test("an unhydrated archive is never a definitive empty", () => { + assert.equal( + getTranscriptHistoryCertainty({ ...complete, archiveHydrated: false }), + "unknown-archive-loading", + ); +}); + +test("no channel means no archive was consulted", () => { + assert.equal( + getTranscriptHistoryCertainty({ ...complete, channelId: null }), + "unknown-archive-loading", + ); +}); + +test("a missing subscription outranks a missing channel", () => { + // Order matters for the copy: without a subscription the archive was never + // going to load, so "isn't indexed" is the honest reason rather than + // "still loading", which implies waiting would help. + assert.equal( + getTranscriptHistoryCertainty({ + hasSubscription: false, + channelId: null, + archiveHydrated: false, + }), + "unknown-not-indexed", + ); +}); + +const uncertainVariants = [ + "unknown-subscription-unresolved", + "unknown-not-indexed", + "unknown-archive-loading", +]; + +test("uncertain copy never claims there was no activity", () => { + for (const certainty of uncertainVariants) { + const copy = getUncertainHistoryCopy(certainty); + const text = `${copy.title} ${copy.description}`.toLowerCase(); + assert.ok(!text.includes("no acp activity"), text); + assert.ok(!text.includes("no activity"), text); + assert.ok(copy.description.length > 0); + } +}); + +test("uncertain copy names the specific gap", () => { + assert.match( + getUncertainHistoryCopy("unknown-subscription-unresolved").title, + /Checking/, + ); + assert.match( + getUncertainHistoryCopy("unknown-not-indexed").description, + /isn't indexed/, + ); + assert.match( + getUncertainHistoryCopy("unknown-archive-loading").description, + /hasn't finished loading/, + ); +}); + +test("every uncertain variant has distinct copy", () => { + const titles = uncertainVariants.map( + (certainty) => getUncertainHistoryCopy(certainty).title, + ); + assert.equal(new Set(titles).size, uncertainVariants.length); +}); diff --git a/desktop/src/features/agents/ui/agentSessionHistoryCertainty.ts b/desktop/src/features/agents/ui/agentSessionHistoryCertainty.ts new file mode 100644 index 00000000000..2dd1e6afd86 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionHistoryCertainty.ts @@ -0,0 +1,104 @@ +/** + * Whether an empty transcript is a fact or merely an absence of evidence. + * + * Archived observer history is only readable when the viewing identity holds an + * `owner_p` save subscription and the backfill index has been populated for the + * channel. When either is missing, `useLoadArchivedObserverEvents` returns no + * rows and forces `hasOlderArchived` to false — the same shape as a channel + * where the agent genuinely never ran. Rendering "No ACP activity yet" in that + * state asserts something we did not check, and a supervisor who reads it may + * conclude an agent did nothing when its history was simply unavailable. + * + * So the distinction is preserved here rather than collapsed at the call site. + * The uncertain variants name WHICH check came up short, because that is what + * the reader needs in order to know whether to wait, look elsewhere, or trust + * what they see. + */ +export type TranscriptHistoryCertainty = + | "known-empty" + | UncertainHistoryCertainty; + +/** Specific reason the history could not be established as complete. */ +export type UncertainHistoryCertainty = + | "unknown-subscription-unresolved" + | "unknown-not-indexed" + | "unknown-archive-loading"; + +export type TranscriptHistoryInputs = { + /** + * Result of the `owner_p` save-subscription check: null while unresolved, + * false when the identity has no subscription (or the lookup failed). + */ + hasSubscription: boolean | null; + /** Channel whose archive would be read; null means no archive was consulted. */ + channelId: string | null; + /** True once the archive backfill/hydration pass finished for this channel. */ + archiveHydrated: boolean; +}; + +/** + * Decide whether emptiness can be stated as fact. + * + * Only an identity with a resolved subscription, a real channel, and a + * completed hydration pass has actually looked at the whole history. Every + * other combination is uncertainty and must say so. + */ +export function getTranscriptHistoryCertainty({ + hasSubscription, + channelId, + archiveHydrated, +}: TranscriptHistoryInputs): TranscriptHistoryCertainty { + if (hasSubscription === null) return "unknown-subscription-unresolved"; + if (hasSubscription === false) return "unknown-not-indexed"; + // A subscription exists but no channel was consulted: nothing was read from + // the archive, which is indistinguishable from an unfinished read. + if (!channelId) return "unknown-archive-loading"; + if (!archiveHydrated) return "unknown-archive-loading"; + return "known-empty"; +} + +/** True when an empty transcript must not be presented as "no activity". */ +export function isUncertainHistory( + certainty: TranscriptHistoryCertainty, +): certainty is UncertainHistoryCertainty { + return certainty !== "known-empty"; +} + +export type TranscriptEmptyCopy = { + title: string; + description: string; +}; + +/** + * Copy for the uncertain case. + * + * Phrased as a statement about what this view can see rather than about what + * the agent did, and it never uses "no activity" — the whole point is that we + * do not know. Each variant names the specific gap so the message stays + * actionable instead of vaguely ominous. + * + * This is the only place uncertain-history wording lives. Rendering code must + * call it rather than inlining its own, so the two cannot drift apart. + */ +export function getUncertainHistoryCopy( + certainty: UncertainHistoryCertainty, +): TranscriptEmptyCopy { + if (certainty === "unknown-subscription-unresolved") { + return { + title: "Checking for earlier activity", + description: "Looking up whether archived history is available here.", + }; + } + if (certainty === "unknown-not-indexed") { + return { + title: "Earlier activity may not be shown", + description: + "Archived history isn't indexed for this identity, so only activity observed live appears here.", + }; + } + return { + title: "Earlier activity may still be loading", + description: + "Archived history for this channel hasn't finished loading, so older activity may be missing.", + }; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.test.mjs b/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.test.mjs new file mode 100644 index 00000000000..19ad987a85f --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.test.mjs @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getToolRunGroupKey, + getToolRunGroupMemberKeys, + resolveToolRunGroupIdentities, + scopeToolRunGroupKey, +} from "./agentSessionToolRunGroupKey.ts"; + +const timestamp = "2026-06-14T22:20:23.000Z"; + +function tool(id, turnId = "turn-1") { + return { + id, + type: "tool", + renderClass: "shell", + descriptor: { renderClass: "shell", label: "Ran command", preview: null }, + title: "Ran command", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp, + startedAt: timestamp, + completedAt: timestamp, + turnId, + }; +} + +test("the key is derived from turn and first leaf, not the batch id", () => { + const items = [tool("tool:ch1:call-1"), tool("tool:ch1:call-2")]; + assert.equal( + getToolRunGroupKey({ id: "summary:shell:tool:ch1:call-1", items }), + "group:turn-1:tool:ch1:call-1", + ); +}); + +test("the key survives the same-kind to mixed transition", () => { + const items = [tool("tool:ch1:call-1"), tool("tool:ch1:call-2")]; + const sameKind = getToolRunGroupKey({ + id: "summary:shell:tool:ch1:call-1", + items, + }); + const mixed = getToolRunGroupKey({ + id: "summary:mixed:tool:ch1:call-1", + items: [...items, tool("tool:ch1:call-3")], + }); + assert.equal(sameKind, mixed); +}); + +test("appending live calls to a growing group does not churn the key", () => { + const first = tool("tool:ch1:call-1"); + const growing = getToolRunGroupKey({ id: "summary:mixed:x", items: [first] }); + const grown = getToolRunGroupKey({ + id: "summary:mixed:x", + items: [first, tool("tool:ch1:call-2"), tool("tool:ch1:call-3")], + }); + assert.equal(growing, grown); +}); + +test("two turns doing identical work never collide", () => { + const a = getToolRunGroupKey({ + id: "summary:shell:a", + items: [tool("tool:ch1:call-1", "turn-1")], + }); + const b = getToolRunGroupKey({ + id: "summary:shell:a", + items: [tool("tool:ch1:call-1", "turn-2")], + }); + assert.notEqual(a, b); +}); + +test("a group without turn identity still keys off its first leaf", () => { + assert.equal( + getToolRunGroupKey({ + id: "summary:shell:x", + items: [tool("tool:ch1:call-9", null)], + }), + "group:no-turn:tool:ch1:call-9", + ); +}); + +test("distinct groups in one turn get distinct keys", () => { + const a = getToolRunGroupKey({ + id: "summary:shell:a", + items: [tool("tool:ch1:call-1")], + }); + const b = getToolRunGroupKey({ + id: "summary:shell:b", + items: [tool("tool:ch1:call-7")], + }); + assert.notEqual(a, b); +}); + +test("an empty group falls back to its batch id rather than colliding", () => { + const a = getToolRunGroupKey({ id: "summary:mixed:a", items: [] }); + const b = getToolRunGroupKey({ id: "summary:mixed:b", items: [] }); + assert.notEqual(a, b); +}); + +// --- durable identity resolution -------------------------------------------- + +const noTable = new Map(); + +/** Resolve one pass and return `[keys, table]`. */ +function resolve(groups, previous = noTable) { + const { keys, table } = resolveToolRunGroupIdentities(groups, previous); + return [keys, table]; +} + +test("member keys cover every leaf, not just the first", () => { + assert.deepEqual( + getToolRunGroupMemberKeys({ + id: "summary:shell:x", + items: [tool("tool:ch1:call-1"), tool("tool:ch1:call-2")], + }), + ["group:turn-1:tool:ch1:call-1", "group:turn-1:tool:ch1:call-2"], + ); +}); + +test("reader state keys are isolated per transcript scope", () => { + const groupKey = "group:turn-1:tool:channel:call-1"; + const fullViewer = scopeToolRunGroupKey("agent:channel:default", groupKey); + const compactPreview = scopeToolRunGroupKey( + "agent:channel:compactPreview", + groupKey, + ); + assert.notEqual(fullViewer, compactPreview); + assert.match(fullViewer, /group:turn-1:tool:channel:call-1$/); +}); + +test("scope boundaries cannot collide", () => { + assert.notEqual( + scopeToolRunGroupKey("a:b", "c"), + scopeToolRunGroupKey("a", "b:c"), + ); +}); + +test("a first pass anchors each group on its own first leaf", () => { + const [keys] = resolve([ + ["group:t:a", "group:t:b"], + ["group:t:x", "group:t:y"], + ]); + assert.deepEqual(keys, ["group:t:a", "group:t:x"]); +}); + +test("ejecting the leading leaf keeps the group's identity", () => { + // The Finding B case: the first call fails, grouping ejects it, and the + // remaining run must still answer to the identity the reader's state is under. + const [, table] = resolve([["group:t:a", "group:t:b", "group:t:c"]]); + const [keys] = resolve([["group:t:b", "group:t:c"]], table); + assert.deepEqual(keys, ["group:t:a"]); +}); + +test("identity survives repeated ejections down to the last leaf", () => { + let table = resolve([["group:t:a", "group:t:b", "group:t:c"]])[1]; + for (const remaining of [["group:t:b", "group:t:c"], ["group:t:c"]]) { + const [keys, next] = resolve([remaining], table); + assert.deepEqual(keys, ["group:t:a"]); + table = next; + } +}); + +test("a group that grows by appending keeps its identity", () => { + const [, table] = resolve([["group:t:a", "group:t:b"]]); + const [keys] = resolve([["group:t:a", "group:t:b", "group:t:c"]], table); + assert.deepEqual(keys, ["group:t:a"]); +}); + +test("re-resolving an unchanged pass is idempotent", () => { + const groups = [["group:t:a", "group:t:b"], ["group:t:x"]]; + const [first, table] = resolve(groups); + const [second] = resolve(groups, table); + assert.deepEqual(second, first); +}); + +test("a split run gives the identity to the part holding the earlier leaves", () => { + // A failure in the MIDDLE splits one group into two. Both halves recognise + // leaves from the original, but only one may inherit its reader state. + const [, table] = resolve([ + ["group:t:a", "group:t:b", "group:t:c", "group:t:d"], + ]); + const [keys] = resolve([["group:t:a", "group:t:b"], ["group:t:d"]], table); + assert.deepEqual(keys, ["group:t:a", "group:t:d"]); +}); + +test("a trailing split half never inherits the leading half's identity", () => { + // Same split, but the ejected failure is the FIRST leaf as well, so the + // leading half no longer holds the original anchor. It still claims it, and + // the trailing half must not. + const [, table] = resolve([ + ["group:t:a", "group:t:b", "group:t:c", "group:t:d"], + ]); + const [keys] = resolve([["group:t:b"], ["group:t:d"]], table); + assert.deepEqual(keys, ["group:t:a", "group:t:d"]); + assert.notEqual(keys[0], keys[1]); +}); + +test("two groups never share one identity", () => { + const [, table] = resolve([["group:t:a", "group:t:b"]]); + const [keys] = resolve([["group:t:a"], ["group:t:b"]], table); + assert.equal(new Set(keys).size, 2); +}); + +test("wholly new groups do not inherit anything", () => { + const [, table] = resolve([["group:t:a"]]); + const [keys] = resolve([["group:t:z"]], table); + assert.deepEqual(keys, ["group:t:z"]); +}); + +test("the remembered table drops leaves that left the transcript", () => { + // Otherwise the table would grow for the lifetime of the community: every + // tool call ever grouped would stay resident. + const [, table] = resolve([["group:t:a", "group:t:b"]]); + const [, next] = resolve([["group:t:b"]], table); + assert.deepEqual([...next.keys()], ["group:t:b"]); + assert.equal(next.get("group:t:b"), "group:t:a"); +}); + +test("resolution never mutates the table it was given", () => { + const [, table] = resolve([["group:t:a", "group:t:b"]]); + const before = [...table.entries()].sort(); + resolve([["group:t:b"]], table); + assert.deepEqual([...table.entries()].sort(), before); +}); + +test("a group whose own anchor is already claimed takes its next free leaf", () => { + // Defensive, and asserted here at the function boundary because it is the + // one collision the caller cannot rule out: a remembered identity can equal + // some LATER group's own first leaf. Sharing one identity would mean two + // cards sharing one reader state, so the loser falls through instead. + const table = new Map([["group:t:a", "group:t:b"]]); + const [keys] = resolve([["group:t:a"], ["group:t:b", "group:t:c"]], table); + assert.deepEqual(keys, ["group:t:b", "group:t:c"]); + assert.equal(new Set(keys).size, 2); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.ts b/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.ts new file mode 100644 index 00000000000..7bb3d19baa5 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.ts @@ -0,0 +1,212 @@ +import type { + TranscriptDisplayBlock, + TranscriptToolRunSummary, +} from "./agentSessionTranscriptGrouping"; + +/** + * Candidate identities for a tool-run group — one per leaf, in arrival order. + * + * A summary's own `id` encodes how the group was batched at the moment it was + * built: a same-kind run is `summary:read_file:`, and the moment one + * differing tool call lands beside it the same work is rebuilt as + * `summary:mixed:`. Keying reader state on that id would silently + * discard a deliberate choice to open a group every time the agent emitted one + * more call. + * + * Leaf item ids are the stable part: they derive from the channel and the ACP + * tool-call id, so they survive re-derivation over merged live/archive windows. + * `turnId` scopes each candidate so two turns can never collide, and is read + * from the leaf rather than the summary because only leaves carry turn + * identity. + * + * Every leaf is returned rather than just the first because leaf membership is + * not monotonic: a call that fails is ejected from the group (see + * `isGroupingEligible`), and when the ejected call was the FIRST leaf the group + * needs a way to recognise itself under a new leading leaf. Resolving that is + * the job of `resolveToolRunGroupKey`, which remembers which candidates already + * belong to a live identity. + */ +export function getToolRunGroupMemberKeys( + summary: Pick, +): string[] { + if (summary.items.length === 0) { + // A group with no leaves cannot be identified by content; fall back to the + // batch-derived id rather than colliding every empty group onto one key. + return [`group:empty:${summary.id}`]; + } + return summary.items.map( + (item) => `group:${item.turnId ?? "no-turn"}:${item.id}`, + ); +} + +/** + * The group's anchor candidate: the identity a group takes when nothing is + * remembered about it yet. + * + * Callers that need reader state to survive re-grouping must go through + * {@link resolveToolRunGroupIdentities} instead — this value alone churns when + * the leading leaf leaves the group. + */ +export function getToolRunGroupKey( + summary: Pick, +): string { + return getToolRunGroupMemberKeys(summary)[0]; +} + +/** + * Remembered mapping from a leaf candidate key to the durable identity of the + * group that leaf belonged to. + */ +export type ToolRunGroupIdentityTable = ReadonlyMap; + +export type ToolRunGroupIdentityResolution = { + /** Durable identity per input group, in input order. */ + keys: string[]; + /** + * Table to remember for the next pass. Contains only leaves present in this + * pass: continuity is carried by the leaves that stayed, so remembering + * departed ones would grow without bound for no benefit. + */ + table: Map; +}; + +/** + * Assign a durable identity to every group in one grouping pass. + * + * Grouping is re-derived from scratch on every transcript pass, so a group has + * no identity of its own — only its leaves do. This resolves the two together: + * a group keeps the identity any of its current leaves already carried, and + * only invents a new one when none of them is recognised. That is what makes a + * reader's collapse survive the leading tool call being ejected after it fails + * (`isGroupingEligible` drops failures, so the next leaf becomes first and the + * anchor candidate changes). + * + * Two rules keep it honest: + * - An identity is claimed by at most one group per pass. When a run splits in + * the middle — a failure between two eligible stretches — the part holding + * the earlier leaves keeps the identity and the remainder starts fresh, + * rather than both halves sharing (and fighting over) one reader state. + * - Groups are visited in transcript order purely to break that claim tie + * deterministically. Position never becomes part of an identity: every + * identity is a content-derived leaf key, so archive pages prepending older + * turns cannot renumber anything. + * + * Pure and idempotent: re-running with the returned table yields the same keys, + * so a repeated render can never churn identities. + */ +export function resolveToolRunGroupIdentities( + groups: readonly (readonly string[])[], + previous: ToolRunGroupIdentityTable, +): ToolRunGroupIdentityResolution { + const table = new Map(); + const claimed = new Set(); + const keys: string[] = []; + + for (const memberKeys of groups) { + let resolved: string | undefined; + for (const memberKey of memberKeys) { + const remembered = previous.get(memberKey); + if (remembered !== undefined && !claimed.has(remembered)) { + resolved = remembered; + break; + } + } + // Nothing recognised (or every match is already spoken for): anchor on this + // group's own first leaf. A group with no leaves still has its batch id as + // a candidate, so `memberKeys` is never empty. + if (resolved === undefined) { + resolved = memberKeys[0]; + } + // A fresh anchor can itself collide with an identity another group in this + // pass already claimed (two groups whose leaves overlap cannot both be it). + // Fall through to the next unclaimed candidate so distinct groups never + // share one reader state. + if (claimed.has(resolved)) { + resolved = + memberKeys.find((memberKey) => !claimed.has(memberKey)) ?? resolved; + } + claimed.add(resolved); + keys.push(resolved); + for (const memberKey of memberKeys) { + table.set(memberKey, resolved); + } + } + + return { keys, table }; +} + +/** + * Every group a transcript will render, in reading order, as + * `[summary id, member candidate keys]`. + * + * Nested same-kind summaries are included because a mixed burst renders them as + * their own group cards, and each therefore owns reader state of its own. + * Summary ids are unique within a transcript (each embeds the id of its own + * first leaf), so they are safe as the lookup handle for the resolved identity. + */ +function collectToolRunGroupMembers( + blocks: readonly TranscriptDisplayBlock[], +): Array<[string, string[]]> { + const collected: Array<[string, string[]]> = []; + + const walk = (summary: TranscriptToolRunSummary): void => { + collected.push([summary.id, getToolRunGroupMemberKeys(summary)]); + for (const child of summary.segments ?? []) { + if (child.kind === "summary") walk(child.summary); + } + }; + + for (const block of blocks) { + if (block.kind !== "turn") continue; + for (const segment of block.segments) { + if (segment.kind === "summary") walk(segment.summary); + } + } + + return collected; +} + +export type TranscriptToolRunGroupKeyResolution = { + /** Durable identity for each rendered group, by summary id. */ + keysBySummaryId: Map; + /** Table to remember for the next pass. */ + table: Map; +}; + +/** + * Scope reader state to one rendered transcript. + * + * Tool-call ids are agent-supplied and can repeat across agents, while a channel + * can render the full viewer and compact preview together. A length-prefixed + * scope keeps those surfaces independent without making position part of the + * group's identity. + */ +export function scopeToolRunGroupKey(scope: string, groupKey: string): string { + return `transcript:${scope.length}:${scope}:${groupKey}`; +} + +/** + * Resolve durable identities for a whole transcript pass. + * + * One pass over all groups (rather than per-card resolution) is what lets a + * group recognise leaves that another group is also claiming: identity is + * allocated once, in order, with no group able to take an identity a sibling + * already holds. + */ +export function resolveTranscriptToolRunGroupKeys( + blocks: readonly TranscriptDisplayBlock[], + previous: ToolRunGroupIdentityTable, +): TranscriptToolRunGroupKeyResolution { + const members = collectToolRunGroupMembers(blocks); + const { keys, table } = resolveToolRunGroupIdentities( + members.map(([, memberKeys]) => memberKeys), + previous, + ); + + const keysBySummaryId = new Map(); + members.forEach(([summaryId], index) => { + keysBySummaryId.set(summaryId, keys[index]); + }); + + return { keysBySummaryId, table }; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunGroupReachability.test.mjs b/desktop/src/features/agents/ui/agentSessionToolRunGroupReachability.test.mjs new file mode 100644 index 00000000000..c1c8278c5bb --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunGroupReachability.test.mjs @@ -0,0 +1,474 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildTranscript } from "./agentSessionTranscript.ts"; +import { buildTranscriptDisplayBlocks } from "./agentSessionTranscriptGrouping.ts"; +import { + getToolRunGroupKey, + resolveTranscriptToolRunGroupKeys, +} from "./agentSessionToolRunGroupKey.ts"; +import { collectToolRunArtifacts } from "./agentSessionToolRunPartition.ts"; +import { getToolRunGroupStatus } from "./agentSessionToolRunStatus.ts"; +import { initialToolRunGroupViewState } from "./agentSessionToolRunViewState.ts"; +import { + readToolRunGroupIdentityTable, + readToolRunGroupViewState, + resetAgentSessionToolRunViewState, + writeToolRunGroupIdentityTable, + writeToolRunGroupViewState, +} from "./agentSessionToolRunViewStore.ts"; + +/** + * The group-presentation units (status, partition, artifacts, view state) are + * each unit-tested against hand-built `TranscriptItem` fixtures. Those tests + * cannot say whether the states they describe are ever produced by real + * observer frames — grouping eligibility is decided in + * `agentSessionTranscriptGrouping`, several layers away from the fixtures. + * + * So these tests drive the REAL pipeline (`buildTranscript` → + * `buildTranscriptDisplayBlocks`) from ACP session/update frames and assert + * what a reader can actually end up looking at: where a failure lands, whether + * artifact chips have anything to show, and whether a group's durable key + * really holds still across re-derivation. + */ + +const baseEvent = { + seq: 1, + timestamp: "2026-06-18T00:00:00Z", + kind: "acp_read", + agentIndex: 0, + channelId: "11111111-1111-1111-1111-111111111111", + sessionId: "sess-1", + turnId: "turn-1", +}; + +function frame(seq, update) { + return { + ...baseEvent, + seq, + payload: { + method: "session/update", + params: { sessionId: baseEvent.sessionId, update }, + }, + }; +} + +function readFile(seq, toolCallId, path, overrides = {}) { + return frame(seq, { + sessionUpdate: "tool_call", + toolCallId, + status: "completed", + title: "read_file", + kind: "read_file", + rawInput: { path }, + rawOutput: "contents", + ...overrides, + }); +} + +function shell(seq, toolCallId, command, overrides = {}) { + return frame(seq, { + sessionUpdate: "tool_call", + toolCallId, + status: "completed", + title: "shell", + kind: "shell", + rawInput: { command }, + rawOutput: "ok", + ...overrides, + }); +} + +function editFile(seq, toolCallId, path, overrides = {}) { + return frame(seq, { + sessionUpdate: "tool_call", + toolCallId, + status: "completed", + title: "str_replace", + kind: "edit", + rawInput: { path, old_str: "a", new_str: "b" }, + rawOutput: "edited", + ...overrides, + }); +} + +/** Every turn segment as `"item:"` or `"summary::"`. */ +function describeSegments(events) { + const blocks = buildTranscriptDisplayBlocks(buildTranscript(events)); + const described = []; + for (const block of blocks) { + if (block.kind !== "turn") continue; + for (const segment of block.segments) { + if (segment.kind === "item") { + described.push(`item:${segment.item.id}`); + continue; + } + if (segment.kind !== "summary") continue; + const { summary } = segment; + described.push( + `summary:${getToolRunGroupStatus(summary.items)}:${summary.items + .map((item) => item.id) + .join(",")}`, + ); + } + } + return described; +} + +function summaries(events) { + const blocks = buildTranscriptDisplayBlocks(buildTranscript(events)); + const collected = []; + const walk = (summary) => { + collected.push(summary); + for (const child of summary.segments ?? []) { + if (child.kind === "summary") walk(child.summary); + } + }; + for (const block of blocks) { + if (block.kind !== "turn") continue; + for (const segment of block.segments) { + if (segment.kind === "summary") walk(segment.summary); + } + } + return collected; +} + +// --- failure placement ------------------------------------------------------ + +test("a failed tool call is never folded inside a group summary", () => { + // isGroupingEligible rejects `isError` items, so a failure breaks the run and + // lands as its own segment. That — not the group's aggregate badge — is what + // actually keeps a failure visible in a collapsed transcript, and it is the + // invariant a future grouping change must not quietly drop. + const events = [ + readFile(1, "read-a", "src/a.ts"), + frame(2, { + sessionUpdate: "tool_call", + toolCallId: "boom", + status: "failed", + title: "shell", + kind: "shell", + rawInput: { command: "pnpm build" }, + rawOutput: "exit 1", + }), + readFile(3, "read-b", "src/b.ts"), + readFile(4, "read-c", "src/c.ts"), + ]; + + const described = describeSegments(events); + const failedSegment = described.find((entry) => entry.includes("boom")); + assert.ok(failedSegment, `no segment mentioned the failure: ${described}`); + assert.ok( + failedSegment.startsWith("item:"), + `the failure must be its own row, got ${failedSegment}`, + ); + + for (const summary of summaries(events)) { + assert.equal( + summary.items.some((item) => item.id.includes("boom")), + false, + `summary ${summary.id} absorbed the failed call`, + ); + assert.equal( + getToolRunGroupStatus(summary.items), + "completed", + `summary ${summary.id} should hold only clean work`, + ); + } +}); + +test("a call that fails on a later update is ejected from the group it was in", () => { + // The interesting case is a call that groups cleanly while executing and only + // fails later: re-derivation must move it out rather than leave a failure + // hidden behind a collapsed "Ran 3 commands". + const executing = [ + shell(1, "cmd-a", "pnpm lint", { status: "executing", rawOutput: "" }), + shell(2, "cmd-b", "pnpm test", { status: "executing", rawOutput: "" }), + shell(3, "cmd-c", "pnpm build", { status: "executing", rawOutput: "" }), + ]; + + const grouped = summaries(executing); + assert.equal( + grouped.length, + 1, + "three executing shells group while in-flight", + ); + assert.equal(getToolRunGroupStatus(grouped[0].items), "executing"); + + const settled = [ + ...executing, + frame(4, { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-a", + status: "completed", + rawOutput: "ok", + }), + frame(5, { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-b", + status: "failed", + rawOutput: "1 failing", + }), + frame(6, { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-c", + status: "completed", + rawOutput: "ok", + }), + ]; + + for (const summary of summaries(settled)) { + assert.equal( + summary.items.some((item) => item.id.includes("cmd-b")), + false, + `summary ${summary.id} kept the now-failed call`, + ); + } + const described = describeSegments(settled); + assert.ok( + described.some( + (entry) => entry.startsWith("item:") && entry.includes("cmd-b"), + ), + `the failed call must surface as its own row: ${described}`, + ); +}); + +test("in-flight work does reach the group aggregate", () => { + // Guards the two assertions above against a false pass: if grouping stopped + // producing summaries at all, "no summary contains a failure" would be + // vacuously true. A running burst must still aggregate as running. + const events = [ + readFile(1, "read-a", "src/a.ts", { status: "executing", rawOutput: "" }), + readFile(2, "read-b", "src/b.ts", { status: "executing", rawOutput: "" }), + readFile(3, "read-c", "src/c.ts", { status: "pending", rawOutput: "" }), + ]; + + const grouped = summaries(events); + assert.equal(grouped.length, 1); + assert.equal(grouped[0].items.length, 3); + assert.equal(getToolRunGroupStatus(grouped[0].items), "executing"); +}); + +// --- artifacts -------------------------------------------------------------- + +test("artifact chips are derivable from the args real frames carry", () => { + // collectToolRunArtifacts reads `path`/`file_path`/`filePath` off item args. + // Its unit test supplies those directly; this pins that ACP `rawInput` + // actually survives into that shape, so the chips outside the collapsed body + // are not empty in practice. + const events = [ + readFile(1, "read-a", "src/kept.ts"), + readFile(2, "read-b", "src/also.ts"), + editFile(3, "edit-a", "src/kept.ts"), + editFile(4, "edit-b", "src/new.ts"), + ]; + + const items = buildTranscript(events).filter((item) => item.type === "tool"); + const artifacts = collectToolRunArtifacts(items); + const byPath = new Map( + artifacts.map((artifact) => [artifact.path, artifact.kind]), + ); + + assert.deepEqual( + [...byPath.entries()].sort(), + [ + ["src/also.ts", "read"], + ["src/kept.ts", "edit"], + ["src/new.ts", "edit"], + ], + "an edit outranks a read of the same path", + ); + assert.deepEqual(artifacts.map((artifact) => artifact.filename).sort(), [ + "also.ts", + "kept.ts", + "new.ts", + ]); +}); + +// --- durable group identity ------------------------------------------------- + +/** Group keys for each top-level summary a real frame sequence produces. */ +function summaryKeys(events) { + return summaries(events).map((summary) => getToolRunGroupKey(summary)); +} + +/** + * Durable group identities for one transcript pass, resolved exactly the way the + * list does: against the recognition table left by the previous pass in the + * module-scoped store. Calling this twice models two renders. + */ +function resolvedSummaryKeys(events) { + const blocks = buildTranscriptDisplayBlocks(buildTranscript(events)); + const { keysBySummaryId, table } = resolveTranscriptToolRunGroupKeys( + blocks, + readToolRunGroupIdentityTable(IDENTITY_SCOPE), + ); + writeToolRunGroupIdentityTable(IDENTITY_SCOPE, table); + return summaries(events).map((summary) => keysBySummaryId.get(summary.id)); +} + +const IDENTITY_SCOPE = "test-scope"; + +test("the group key survives a group growing by appended live calls", () => { + // The stated reason for keying on the first leaf rather than the summary id: + // a burst that absorbs more calls must not churn. Driven through the real + // pipeline so the guarantee is about actual re-derivation, not fixture shape. + const two = [ + readFile(1, "read-a", "src/a.ts", { status: "executing", rawOutput: "" }), + readFile(2, "read-b", "src/b.ts", { status: "executing", rawOutput: "" }), + ]; + const three = [ + ...two, + readFile(3, "read-c", "src/c.ts", { status: "executing", rawOutput: "" }), + ]; + + assert.deepEqual(summaryKeys(two), summaryKeys(three)); +}); + +test("the group key survives the same-kind to mixed transition", () => { + const sameKind = [ + readFile(1, "read-a", "src/a.ts", { status: "executing", rawOutput: "" }), + readFile(2, "read-b", "src/b.ts", { status: "executing", rawOutput: "" }), + readFile(3, "read-c", "src/c.ts", { status: "executing", rawOutput: "" }), + ]; + const mixed = [ + ...sameKind, + editFile(4, "edit-a", "src/a.ts", { status: "executing", rawOutput: "" }), + ]; + + const before = summaryKeys(sameKind); + const after = summaryKeys(mixed); + assert.equal(before.length, 1); + assert.ok( + after.includes(before[0]), + `key ${before[0]} vanished after the burst went mixed: ${after}`, + ); +}); + +test("a group keeps its identity when its FIRST leaf is ejected after failing", () => { + // The case that used to churn: the first call is the one that fails, + // `isGroupingEligible` ejects it, the next leaf becomes first, and a key + // derived from "first leaf" changes — so the store found nothing under the + // new key and the group remounted at mount policy, discarding the reader's + // deliberate collapse. + // + // Identity is now resolved against the leaves that stayed + // (`resolveTranscriptToolRunGroupKeys`), so the surviving run answers to the + // identity the reader's state is filed under. + const running = [ + shell(1, "cmd-a", "pnpm lint", { status: "executing", rawOutput: "" }), + shell(2, "cmd-b", "pnpm test", { status: "executing", rawOutput: "" }), + shell(3, "cmd-c", "pnpm build", { status: "executing", rawOutput: "" }), + ]; + const firstLeafFailed = [ + ...running, + frame(4, { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-a", + status: "failed", + rawOutput: "boom", + }), + ]; + + resetAgentSessionToolRunViewState(); + try { + const [keyBefore] = resolvedSummaryKeys(running); + assert.equal( + keyBefore, + "group:turn-1:tool:11111111-1111-1111-1111-111111111111:cmd-a", + ); + + // The reader collapses the running group. + const readerClosedIt = { + ...initialToolRunGroupViewState("executing"), + expanded: false, + userInteracted: true, + }; + writeToolRunGroupViewState(keyBefore, readerClosedIt); + + // The leading call then fails and is ejected. + const keysAfter = resolvedSummaryKeys(firstLeafFailed); + assert.equal( + keysAfter.length, + 1, + "the surviving calls still group together", + ); + assert.equal( + keysAfter[0], + keyBefore, + "the surviving run must keep the identity the reader's choice is under", + ); + assert.deepEqual( + readToolRunGroupViewState(keysAfter[0]), + readerClosedIt, + "the reader's collapse must survive the ejection", + ); + + // And the ejected failure is still its own conspicuous row. + const described = describeSegments(firstLeafFailed); + assert.ok( + described.some( + (entry) => entry.startsWith("item:") && entry.includes("cmd-a"), + ), + `the failed call must surface as its own row: ${described}`, + ); + } finally { + resetAgentSessionToolRunViewState(); + } +}); + +test("a failure splitting a run in two gives only one half the reader's state", () => { + // Both halves descend from one group, so both recognise leaves from it. Only + // the half holding the earlier leaves may inherit the reader's state — + // otherwise two independent cards would share (and fight over) one entry. + const running = [ + shell(1, "cmd-a", "pnpm lint", { status: "executing", rawOutput: "" }), + shell(2, "cmd-b", "pnpm test", { status: "executing", rawOutput: "" }), + shell(3, "cmd-c", "pnpm build", { status: "executing", rawOutput: "" }), + shell(4, "cmd-d", "pnpm check", { status: "executing", rawOutput: "" }), + shell(5, "cmd-e", "pnpm docs", { status: "executing", rawOutput: "" }), + shell(6, "cmd-f", "pnpm size", { status: "executing", rawOutput: "" }), + ]; + const middleFailed = [ + ...running, + frame(7, { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-d", + status: "failed", + rawOutput: "boom", + }), + ]; + + resetAgentSessionToolRunViewState(); + try { + const [keyBefore] = resolvedSummaryKeys(running); + const keysAfter = resolvedSummaryKeys(middleFailed); + assert.equal(keysAfter.length, 2, `expected a split: ${keysAfter}`); + assert.equal(keysAfter[0], keyBefore, "the leading half inherits"); + assert.notEqual( + keysAfter[1], + keyBefore, + "the trailing half must not share the same reader state", + ); + } finally { + resetAgentSessionToolRunViewState(); + } +}); + +test("group identity is stable when nothing about the run changed", () => { + // Guards against churn from re-derivation alone: the transcript is rebuilt + // from scratch on every pass, and a pass that produces the same groups must + // produce the same identities. + const events = [ + shell(1, "cmd-a", "pnpm lint", { status: "executing", rawOutput: "" }), + shell(2, "cmd-b", "pnpm test", { status: "executing", rawOutput: "" }), + shell(3, "cmd-c", "pnpm build", { status: "executing", rawOutput: "" }), + ]; + + resetAgentSessionToolRunViewState(); + try { + assert.deepEqual(resolvedSummaryKeys(events), resolvedSummaryKeys(events)); + } finally { + resetAgentSessionToolRunViewState(); + } +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolRunPartition.test.mjs b/desktop/src/features/agents/ui/agentSessionToolRunPartition.test.mjs new file mode 100644 index 00000000000..c535168f91b --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunPartition.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectToolRunArtifacts, + isLowSignalToolStep, + partitionToolRunSteps, +} from "./agentSessionToolRunPartition.ts"; + +const timestamp = "2026-06-14T22:20:23.000Z"; + +function tool(id, renderClass, args, overrides = {}) { + return { + id, + type: "tool", + renderClass, + descriptor: { renderClass, label: "Did a thing", preview: null }, + title: "Did a thing", + toolName: renderClass, + buzzToolName: null, + status: "completed", + args, + result: "", + isError: false, + timestamp, + startedAt: timestamp, + completedAt: timestamp, + turnId: "turn-1", + ...overrides, + }; +} + +const shell = (id, command, overrides) => + tool(id, "shell", { command }, overrides); + +test("routine shell scaffolding is low signal", () => { + assert.equal(isLowSignalToolStep(shell("a", "ls -la src")), true); + assert.equal(isLowSignalToolStep(shell("b", "rg -n foo")), true); + assert.equal(isLowSignalToolStep(shell("c", "wc -l file.ts")), true); +}); + +test("a pipeline is plumbing even when its head is meaningful", () => { + assert.equal( + isLowSignalToolStep(shell("a", "pnpm test 2>&1 | tail -5")), + true, + ); +}); + +test("meaningful commands are never hidden", () => { + assert.equal(isLowSignalToolStep(shell("a", "pnpm test")), false); + assert.equal(isLowSignalToolStep(shell("b", "git commit")), false); +}); + +test("a failing or unfinished step is never hidden", () => { + assert.equal(isLowSignalToolStep(shell("a", "ls", { isError: true })), false); + assert.equal( + isLowSignalToolStep(shell("b", "ls", { status: "failed" })), + false, + ); + assert.equal( + isLowSignalToolStep(shell("c", "ls", { status: "executing" })), + false, + ); +}); + +test("non-shell work is never hidden", () => { + assert.equal( + isLowSignalToolStep(tool("a", "file-edit", { path: "a.ts" })), + false, + ); + assert.equal( + isLowSignalToolStep(tool("b", "relay-op", { channel: "c1" })), + false, + ); +}); + +test("a short group hides nothing", () => { + const items = [shell("a", "ls"), shell("b", "pwd"), shell("c", "wc -l x")]; + const { primaryItems, hiddenItems } = partitionToolRunSteps(items); + assert.equal(hiddenItems.length, 0); + assert.equal(primaryItems.length, 3); +}); + +test("a long group folds its scaffolding away", () => { + const items = [ + shell("a", "ls"), + shell("b", "pwd"), + shell("c", "pnpm test"), + tool("d", "file-edit", { path: "a.ts" }), + ]; + const { primaryItems, hiddenItems } = partitionToolRunSteps(items); + assert.deepEqual( + hiddenItems.map((item) => item.id), + ["a", "b"], + ); + assert.deepEqual( + primaryItems.map((item) => item.id), + ["c", "d"], + ); +}); + +test("a group with only one hideable step keeps everything visible", () => { + const items = [ + shell("a", "ls"), + shell("b", "pnpm test"), + tool("c", "file-edit", { path: "a.ts" }), + tool("d", "file-edit", { path: "b.ts" }), + ]; + assert.equal(partitionToolRunSteps(items).hiddenItems.length, 0); +}); + +test("a group that would fold to nothing keeps everything visible", () => { + const items = [ + shell("a", "ls"), + shell("b", "pwd"), + shell("c", "cat x"), + shell("d", "wc -l y"), + ]; + const { primaryItems, hiddenItems } = partitionToolRunSteps(items); + assert.equal(hiddenItems.length, 0); + assert.equal(primaryItems.length, 4); +}); + +test("a step the reader opened is pinned visible", () => { + const items = [ + shell("a", "ls"), + shell("b", "pwd"), + shell("c", "pnpm test"), + tool("d", "file-edit", { path: "a.ts" }), + ]; + const { primaryItems, hiddenItems } = partitionToolRunSteps(items, ["a"]); + assert.equal(hiddenItems.length, 0); + assert.equal(primaryItems.length, 4); +}); + +test("artifacts collect one entry per distinct file", () => { + const artifacts = collectToolRunArtifacts([ + tool("a", "file-read", { path: "src/a.ts" }), + tool("b", "file-edit", { path: "src/b.ts" }), + tool("c", "file-read", { path: "src/a.ts" }), + shell("d", "pnpm test"), + ]); + assert.deepEqual( + artifacts.map((artifact) => [artifact.filename, artifact.kind]), + [ + ["a.ts", "read"], + ["b.ts", "edit"], + ], + ); +}); + +test("an edit outranks a read of the same file", () => { + const artifacts = collectToolRunArtifacts([ + tool("a", "file-read", { path: "src/a.ts" }), + tool("b", "file-edit", { path: "src/a.ts" }), + ]); + assert.equal(artifacts.length, 1); + assert.equal(artifacts[0].kind, "edit"); + assert.equal(artifacts[0].itemId, "b"); +}); + +test("a failed step contributes no artifact", () => { + assert.deepEqual( + collectToolRunArtifacts([ + tool("a", "file-edit", { path: "src/a.ts" }, { isError: true }), + ]), + [], + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolRunPartition.ts b/desktop/src/features/agents/ui/agentSessionToolRunPartition.ts new file mode 100644 index 00000000000..9e34c17be42 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunPartition.ts @@ -0,0 +1,202 @@ +import type { TranscriptItem } from "./agentSessionTypes"; +import { getToolString } from "./agentSessionUtils"; + +type ToolItem = Extract; + +/** + * Shell entry points that are almost always scaffolding for the real work + * (inspecting the tree, reading a file, checking a binary exists) rather than + * the work itself. Matched against the FIRST token of the command only, so + * `grep -rn …` is internal but a project script named `grep-report` is not. + */ +const INTERNAL_SHELL_COMMANDS = new Set([ + "awk", + "basename", + "cat", + "cd", + "chmod", + "cp", + "dirname", + "echo", + "env", + "file", + "find", + "grep", + "head", + "ls", + "mkdir", + "mv", + "printf", + "pwd", + "rg", + "sed", + "sort", + "stat", + "tail", + "tr", + "uniq", + "wc", + "which", +]); + +/** Compound shells are pipelines/chains — plumbing, not a narrative step. */ +const COMPOUND_SHELL_MARKERS = ["&&", "||", "2>&1", " | ", ";"]; + +/** + * A group must be at least this long before any child is hidden. Below it, the + * group is already short enough to read, and hiding would trade one row of + * signal for one row of chrome. + */ +export const INTERNAL_STEP_GROUP_MINIMUM = 4; + +/** At least this many children must be hideable for the toggle to earn a row. */ +export const INTERNAL_STEP_MINIMUM_HIDDEN = 2; + +function isCompletedAndClean(item: TranscriptItem): boolean { + if (item.type !== "tool") return false; + if (item.isError || item.status === "failed") return false; + return item.status === "completed"; +} + +/** + * Whether a single completed step is low-signal enough to fold away. + * + * Deliberately conservative: only successful, finished, shell-flavored + * scaffolding qualifies. A failure, an in-flight step, a file edit, a relay op, + * or anything the classifier could not identify always stays visible — the cost + * of hiding something consequential is far higher than the cost of one extra + * routine row. + */ +export function isLowSignalToolStep(item: TranscriptItem): boolean { + if (!isCompletedAndClean(item)) return false; + const tool = item as ToolItem; + const renderClass = tool.renderClass; + if (renderClass !== "shell") return false; + + const command = getToolString(tool.args, ["command"])?.trim(); + if (!command) return false; + + if (COMPOUND_SHELL_MARKERS.some((marker) => command.includes(marker))) { + return true; + } + + const firstToken = command.split(/\s+/)[0]?.toLowerCase() ?? ""; + const bareToken = firstToken.slice(firstToken.lastIndexOf("/") + 1); + return INTERNAL_SHELL_COMMANDS.has(bareToken); +} + +export type ToolRunPartition = { + /** Steps always rendered when the group is open. */ + primaryItems: TranscriptItem[]; + /** Low-signal steps folded behind progressive disclosure. */ + hiddenItems: TranscriptItem[]; +}; + +/** + * Split a group's children into always-visible and foldable steps. + * + * Returns everything as primary (an empty `hiddenItems`) unless every gate + * passes: the group is long enough, at least two children would hide, and at + * least one primary step remains. A group that folds down to nothing but a + * disclosure toggle is worse than the group it replaced. + * + * `expandedItemIds` pins steps the reader opened, so disclosure never yanks + * away something being read. + */ +export function partitionToolRunSteps( + items: readonly TranscriptItem[], + expandedItemIds: readonly string[] = [], +): ToolRunPartition { + const all = [...items]; + if (all.length < INTERNAL_STEP_GROUP_MINIMUM) { + return { primaryItems: all, hiddenItems: [] }; + } + + const pinned = new Set(expandedItemIds); + const primaryItems: TranscriptItem[] = []; + const hiddenItems: TranscriptItem[] = []; + + for (const item of all) { + if (!pinned.has(item.id) && isLowSignalToolStep(item)) { + hiddenItems.push(item); + continue; + } + primaryItems.push(item); + } + + if ( + primaryItems.length === 0 || + hiddenItems.length < INTERNAL_STEP_MINIMUM_HIDDEN + ) { + return { primaryItems: all, hiddenItems: [] }; + } + + return { primaryItems, hiddenItems }; +} + +export type ToolRunArtifact = { + /** Path as the agent referenced it — used for the tooltip. */ + path: string; + /** Trailing filename segment — the chip label. */ + filename: string; + /** Id of the step that produced or touched it, so a chip can reveal it. */ + itemId: string; + kind: "edit" | "read"; +}; + +function artifactPathForItem(item: TranscriptItem): { + path: string; + kind: "edit" | "read"; +} | null { + if (item.type !== "tool" || item.isError) return null; + const renderClass = item.renderClass; + if (renderClass !== "file-edit" && renderClass !== "file-read") return null; + + const path = + getToolString(item.args, ["path", "file_path", "filePath"]) ?? + item.descriptor.object ?? + null; + if (!path) return null; + + return { path, kind: renderClass === "file-edit" ? "edit" : "read" }; +} + +function filenameForPath(path: string): string { + const trimmed = path.replace(/\/+$/, ""); + const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1); + return segment || trimmed; +} + +/** + * Every file a group touched, one entry per distinct path. + * + * These render OUTSIDE the group's collapsible body: a file the agent worked on + * is a durable outcome, not a detail of how the group happened to be batched, + * so it must not disappear when the group collapses and must look the same + * whether the group holds one step or twenty. An edit wins over a read for the + * same path — writing is the more consequential fact about that file. + */ +export function collectToolRunArtifacts( + items: readonly TranscriptItem[], +): ToolRunArtifact[] { + const byPath = new Map(); + + for (const item of items) { + const resolved = artifactPathForItem(item); + if (!resolved) continue; + + const existing = byPath.get(resolved.path); + if (existing && !(existing.kind === "read" && resolved.kind === "edit")) { + continue; + } + + byPath.set(resolved.path, { + path: resolved.path, + filename: filenameForPath(resolved.path), + itemId: item.id, + kind: resolved.kind, + }); + } + + return [...byPath.values()]; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunStatus.test.mjs b/desktop/src/features/agents/ui/agentSessionToolRunStatus.test.mjs new file mode 100644 index 00000000000..ca84a16d284 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunStatus.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getToolRunGroupStatus, + isToolRunGroupActive, +} from "./agentSessionToolRunStatus.ts"; + +const timestamp = "2026-06-14T22:20:23.000Z"; + +function tool(id, status, overrides = {}) { + return { + id, + type: "tool", + renderClass: "shell", + descriptor: { renderClass: "shell", label: "Ran command", preview: null }, + title: "Ran command", + toolName: "shell", + buzzToolName: null, + status, + args: { command: "pnpm test" }, + result: "", + isError: false, + timestamp, + startedAt: timestamp, + completedAt: status === "completed" ? timestamp : null, + turnId: "turn-1", + ...overrides, + }; +} + +test("an all-completed group reports completed", () => { + assert.equal( + getToolRunGroupStatus([tool("a", "completed"), tool("b", "completed")]), + "completed", + ); +}); + +test("failure outranks completed, running, and pending", () => { + const items = [ + tool("a", "completed"), + tool("b", "executing"), + tool("c", "pending"), + tool("d", "failed"), + ]; + assert.equal(getToolRunGroupStatus(items), "failed"); +}); + +test("isError alone is enough to fail the group", () => { + const items = [ + tool("a", "completed"), + tool("b", "completed", { isError: true }), + ]; + assert.equal(getToolRunGroupStatus(items), "failed"); +}); + +test("running outranks pending and completed", () => { + const items = [ + tool("a", "completed"), + tool("b", "pending"), + tool("c", "executing"), + ]; + assert.equal(getToolRunGroupStatus(items), "executing"); +}); + +test("pending outranks completed", () => { + assert.equal( + getToolRunGroupStatus([tool("a", "completed"), tool("b", "pending")]), + "pending", + ); +}); + +test("status precedence is order-independent", () => { + const failing = tool("a", "failed"); + const running = tool("b", "executing"); + const done = tool("c", "completed"); + assert.equal(getToolRunGroupStatus([failing, running, done]), "failed"); + assert.equal(getToolRunGroupStatus([done, running, failing]), "failed"); + assert.equal(getToolRunGroupStatus([running, failing, done]), "failed"); +}); + +test("a lifecycle error row fails the group it sits in", () => { + const items = [ + tool("a", "completed"), + { + id: "err", + type: "lifecycle", + renderClass: "error", + title: "Session error", + text: "boom", + timestamp, + }, + ]; + assert.equal(getToolRunGroupStatus(items), "failed"); +}); + +test("an empty group has nothing outstanding", () => { + assert.equal(getToolRunGroupStatus([]), "completed"); +}); + +test("only executing and pending count as active", () => { + assert.equal(isToolRunGroupActive("executing"), true); + assert.equal(isToolRunGroupActive("pending"), true); + assert.equal(isToolRunGroupActive("failed"), false); + assert.equal(isToolRunGroupActive("completed"), false); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolRunStatus.ts b/desktop/src/features/agents/ui/agentSessionToolRunStatus.ts new file mode 100644 index 00000000000..8668be0a080 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunStatus.ts @@ -0,0 +1,71 @@ +import type { TranscriptItem } from "./agentSessionTypes"; + +/** + * Aggregate status for a collapsed group of transcript activity. + * + * The ordering below is deliberately failure-leaning: a collapsed parent must + * never present a group as finished-and-fine while one of its children failed + * or is still running. Reading the aggregate is the whole point of collapsing, + * so the aggregate is the one place we refuse to round in the optimistic + * direction. + * + * `"failed"` is currently a SAFETY NET, not a state real frames reach. Grouping + * ejects failures (`isGroupingEligible` in `agentSessionTranscriptGrouping` + * rejects `isError`, and every failed item is error-flagged — `tool_call` + * frames pass `isError: false` and `tool_call_update` derives it from + * `status === "failed"`), so a failed call always breaks out as its own row. + * That ejection, not this aggregate, is what keeps failures conspicuous today. + * The fold still handles failure so that widening eligibility later cannot + * silently hide a failure behind a collapsed summary. + */ +export type ToolRunGroupStatus = + | "failed" + | "executing" + | "pending" + | "completed"; + +/** Precedence order applied by {@link getToolRunGroupStatus} (worst first). */ +const STATUS_PRECEDENCE: ToolRunGroupStatus[] = [ + "failed", + "executing", + "pending", + "completed", +]; + +function statusForItem(item: TranscriptItem): ToolRunGroupStatus { + if (item.type === "tool") { + if (item.isError || item.status === "failed") return "failed"; + if (item.status === "executing") return "executing"; + if (item.status === "pending") return "pending"; + return "completed"; + } + + // Non-tool rows can still carry failure (a lifecycle error reclassified into + // the group's span). Everything else is inert for aggregation purposes. + return item.renderClass === "error" ? "failed" : "completed"; +} + +/** + * Fold a group's children into one status, leaning toward the worst outcome. + * + * `failed` beats `executing` beats `pending` beats `completed`. An empty group + * reports `completed` — there is nothing outstanding to warn about. + */ +export function getToolRunGroupStatus( + items: readonly TranscriptItem[], +): ToolRunGroupStatus { + let worstIndex = STATUS_PRECEDENCE.length - 1; + for (const item of items) { + const index = STATUS_PRECEDENCE.indexOf(statusForItem(item)); + if (index < worstIndex) { + worstIndex = index; + } + if (worstIndex === 0) break; + } + return STATUS_PRECEDENCE[worstIndex]; +} + +/** True while a group still has work outstanding. */ +export function isToolRunGroupActive(status: ToolRunGroupStatus): boolean { + return status === "executing" || status === "pending"; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunViewState.test.mjs b/desktop/src/features/agents/ui/agentSessionToolRunViewState.test.mjs new file mode 100644 index 00000000000..bfe2427475e --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunViewState.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + initialToolRunGroupViewState, + reconcileToolRunGroupViewState, + setToolRunGroupExpanded, + setToolRunGroupInternalStepsShown, + setToolRunGroupItemExpanded, +} from "./agentSessionToolRunViewState.ts"; + +test("a group that mounts already finished starts collapsed", () => { + assert.equal(initialToolRunGroupViewState("completed").expanded, false); +}); + +test("a group that mounts live or failed starts open", () => { + assert.equal(initialToolRunGroupViewState("executing").expanded, true); + assert.equal(initialToolRunGroupViewState("pending").expanded, true); + assert.equal(initialToolRunGroupViewState("failed").expanded, true); +}); + +test("an untouched group auto-collapses when it finishes cleanly", () => { + const running = initialToolRunGroupViewState("executing"); + const settled = reconcileToolRunGroupViewState( + running, + "executing", + "completed", + ); + assert.equal(settled.expanded, false); + assert.equal(settled.userInteracted, false); +}); + +test("a group the reader opened is never auto-collapsed", () => { + const touched = setToolRunGroupExpanded( + initialToolRunGroupViewState("executing"), + true, + ); + const settled = reconcileToolRunGroupViewState( + touched, + "executing", + "completed", + ); + assert.equal(settled.expanded, true); + assert.equal(settled, touched); +}); + +test("a group the reader deliberately closed stays closed when it fails", () => { + const closed = setToolRunGroupExpanded( + initialToolRunGroupViewState("executing"), + false, + ); + const failed = reconcileToolRunGroupViewState(closed, "executing", "failed"); + assert.equal(failed.expanded, false); +}); + +test("an untouched group opens itself when it starts failing", () => { + const running = initialToolRunGroupViewState("pending"); + const collapsed = reconcileToolRunGroupViewState( + running, + "pending", + "completed", + ); + assert.equal(collapsed.expanded, false); + const failed = reconcileToolRunGroupViewState( + collapsed, + "completed", + "failed", + ); + assert.equal(failed.expanded, true); +}); + +test("an unchanged status never moves the group", () => { + const state = initialToolRunGroupViewState("executing"); + assert.equal( + reconcileToolRunGroupViewState(state, "executing", "executing"), + state, + ); +}); + +test("a group that was never active does not collapse on completed", () => { + const state = initialToolRunGroupViewState("failed"); + const next = reconcileToolRunGroupViewState(state, "failed", "completed"); + assert.equal(next.expanded, true); +}); + +test("opening a child step opens the group and latches interaction", () => { + const state = setToolRunGroupItemExpanded( + initialToolRunGroupViewState("completed"), + "tool:b", + true, + ); + assert.equal(state.expanded, true); + assert.equal(state.userInteracted, true); + assert.deepEqual(state.expandedItemIds, ["tool:b"]); +}); + +test("closing a child step leaves the group open but drops the pin", () => { + const opened = setToolRunGroupItemExpanded( + initialToolRunGroupViewState("completed"), + "tool:b", + true, + ); + const closed = setToolRunGroupItemExpanded(opened, "tool:b", false); + assert.equal(closed.expanded, true); + assert.deepEqual(closed.expandedItemIds, []); +}); + +test("expanded child ids are deterministic regardless of click order", () => { + const base = initialToolRunGroupViewState("completed"); + const forward = setToolRunGroupItemExpanded( + setToolRunGroupItemExpanded(base, "tool:a", true), + "tool:b", + true, + ); + const reverse = setToolRunGroupItemExpanded( + setToolRunGroupItemExpanded(base, "tool:b", true), + "tool:a", + true, + ); + assert.deepEqual(forward.expandedItemIds, reverse.expandedItemIds); +}); + +test("revealing internal steps latches interaction", () => { + const state = setToolRunGroupInternalStepsShown( + initialToolRunGroupViewState("completed"), + true, + ); + assert.equal(state.showInternalSteps, true); + assert.equal(state.userInteracted, true); + const settled = reconcileToolRunGroupViewState( + state, + "executing", + "completed", + ); + assert.equal(settled.showInternalSteps, true); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolRunViewState.ts b/desktop/src/features/agents/ui/agentSessionToolRunViewState.ts new file mode 100644 index 00000000000..02be029975a --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunViewState.ts @@ -0,0 +1,116 @@ +import { + isToolRunGroupActive, + type ToolRunGroupStatus, +} from "./agentSessionToolRunStatus"; + +/** + * Per-group presentation state for a collapsible tool-run group. + * + * Kept as a plain value with pure transitions so the expansion policy is + * testable without a DOM, and so the React layer only has to store it. + */ +export type ToolRunGroupViewState = { + /** Whether the group body is currently open. */ + expanded: boolean; + /** + * True once the reader has opened or closed anything in this group. Sticky: + * after this flips, automatic policy never moves the group again. A reader + * who deliberately opened a group to watch it must not have it closed under + * them when the work finishes. + */ + userInteracted: boolean; + /** Whether low-signal internal steps are revealed. */ + showInternalSteps: boolean; + /** Ids of individual child steps the reader expanded. */ + expandedItemIds: string[]; +}; + +/** + * Mount-aware initial state. + * + * A group that is already finished when it first mounts is history — it starts + * collapsed so a replayed transcript reads as a narrative rather than a wall of + * steps. A group that mounts while still running is live work being supervised, + * so it starts open. Failed groups also start open: a failure the reader has to + * click to discover is a failure the transcript hid. + */ +export function initialToolRunGroupViewState( + status: ToolRunGroupStatus, +): ToolRunGroupViewState { + return { + expanded: isToolRunGroupActive(status) || status === "failed", + userInteracted: false, + showInternalSteps: false, + expandedItemIds: [], + }; +} + +/** + * Apply the automatic policy after a status change. + * + * An untouched group that was active and has now completed successfully + * auto-collapses, so the transcript keeps moving forward as the agent works. + * Any of these keeps it open: + * - the reader interacted (sticky, forever) + * - the group ended in failure (conspicuous by default) + * - the group is still active + */ +export function reconcileToolRunGroupViewState( + previous: ToolRunGroupViewState, + previousStatus: ToolRunGroupStatus, + nextStatus: ToolRunGroupStatus, +): ToolRunGroupViewState { + if (previous.userInteracted) return previous; + if (previousStatus === nextStatus) return previous; + + if (nextStatus === "failed") { + return previous.expanded ? previous : { ...previous, expanded: true }; + } + + const settled = + isToolRunGroupActive(previousStatus) && nextStatus === "completed"; + if (!settled) return previous; + + return { + expanded: false, + userInteracted: false, + showInternalSteps: false, + expandedItemIds: [], + }; +} + +/** Reader toggled the whole group open or closed. */ +export function setToolRunGroupExpanded( + previous: ToolRunGroupViewState, + expanded: boolean, +): ToolRunGroupViewState { + return { ...previous, expanded, userInteracted: true }; +} + +/** Reader toggled one child step. Opening a step also opens the group. */ +export function setToolRunGroupItemExpanded( + previous: ToolRunGroupViewState, + itemId: string, + expanded: boolean, +): ToolRunGroupViewState { + const current = new Set(previous.expandedItemIds); + if (expanded) { + current.add(itemId); + } else { + current.delete(itemId); + } + return { + ...previous, + expanded: expanded ? true : previous.expanded, + userInteracted: true, + expandedItemIds: [...current].sort(), + }; +} + +/** Reader toggled the low-signal internal-step disclosure. */ +export function setToolRunGroupInternalStepsShown( + previous: ToolRunGroupViewState, + showInternalSteps: boolean, +): ToolRunGroupViewState { + return { ...previous, showInternalSteps, userInteracted: true }; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunViewStore.ts b/desktop/src/features/agents/ui/agentSessionToolRunViewStore.ts new file mode 100644 index 00000000000..79ad1861eb6 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunViewStore.ts @@ -0,0 +1,62 @@ +import type { ToolRunGroupIdentityTable } from "./agentSessionToolRunGroupKey"; +import type { ToolRunGroupViewState } from "./agentSessionToolRunViewState"; + +/** + * Per-group presentation state, keyed by a group's stable identity. + * + * This lives outside React because a group's React identity is not stable + * across regrouping: a same-kind run that later absorbs a differing tool call + * becomes a mixed burst with a different summary id, which remounts the card. + * Without this store, a reader's deliberate choice to open a group would be + * silently reverted by the agent emitting one more tool call. + * + * Community-scoped like every other transcript cache — see + * `resetCommunityState()` in `features/communities/useCommunityInit.ts`. + */ +const groupViewStates = new Map(); + +/** + * Leaf-key → group-identity tables, one per transcript scope. + * + * Group identity is resolved by recognising leaves carried over from the + * previous grouping pass (see `resolveToolRunGroupIdentities`), so the + * recognition table has to outlive a render. It is scoped per transcript rather + * than shared globally because two transcripts can be mounted at once (a + * channel pane plus a compact preview) and each resolves only the groups it + * renders — a shared table would let one panel forget the other's leaves. + */ +const groupIdentityTables = new Map>(); + +const emptyIdentityTable: ToolRunGroupIdentityTable = new Map(); + +export function readToolRunGroupViewState( + groupKey: string, +): ToolRunGroupViewState | undefined { + return groupViewStates.get(groupKey); +} + +export function writeToolRunGroupViewState( + groupKey: string, + state: ToolRunGroupViewState, +): void { + groupViewStates.set(groupKey, state); +} + +export function readToolRunGroupIdentityTable( + scope: string, +): ToolRunGroupIdentityTable { + return groupIdentityTables.get(scope) ?? emptyIdentityTable; +} + +export function writeToolRunGroupIdentityTable( + scope: string, + table: Map, +): void { + groupIdentityTables.set(scope, table); +} + +/** Clear all remembered group state (community switch / test isolation). */ +export function resetAgentSessionToolRunViewState(): void { + groupViewStates.clear(); + groupIdentityTables.clear(); +} diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 0c44a64d5f2..539187022ba 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -17,6 +17,7 @@ import { import { decryptObserverEvent } from "@/shared/api/tauriObserver"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ObserverEvent, TranscriptItem } from "./agentSessionTypes"; +import { getTranscriptHistoryCertainty } from "./agentSessionHistoryCertainty"; import type { RelayEvent } from "@/shared/api/types"; import { createArchivePagingState, @@ -405,5 +406,14 @@ export function useLoadArchivedObserverEvents( }; }, [enabled, identityPubkey, hasSubscription, channelId]); - return { fetchOlderArchived, hasOlderArchived }; + // Expose whether an empty transcript can be stated as fact. Callers that + // render an empty state must not claim "no activity" when history was simply + // unreadable — see agentSessionHistoryCertainty. + const historyCertainty = getTranscriptHistoryCertainty({ + hasSubscription, + channelId, + archiveHydrated: !hasOlderArchived, + }); + + return { fetchOlderArchived, hasOlderArchived, historyCertainty }; } diff --git a/desktop/src/features/agents/ui/useToolRunGroupViewState.ts b/desktop/src/features/agents/ui/useToolRunGroupViewState.ts new file mode 100644 index 00000000000..7ddc02dae9a --- /dev/null +++ b/desktop/src/features/agents/ui/useToolRunGroupViewState.ts @@ -0,0 +1,80 @@ +import * as React from "react"; + +import type { ToolRunGroupStatus } from "./agentSessionToolRunStatus"; +import { + initialToolRunGroupViewState, + reconcileToolRunGroupViewState, + type ToolRunGroupViewState, +} from "./agentSessionToolRunViewState"; +import { + readToolRunGroupViewState, + writeToolRunGroupViewState, +} from "./agentSessionToolRunViewStore"; + +type ToolRunGroupViewStateEntry = { + groupKey: string; + status: ToolRunGroupStatus; + state: ToolRunGroupViewState; +}; + +function createEntry( + groupKey: string, + status: ToolRunGroupStatus, +): ToolRunGroupViewStateEntry { + return { + groupKey, + status, + state: + readToolRunGroupViewState(groupKey) ?? + initialToolRunGroupViewState(status), + }; +} + +/** + * Own one group's expansion state, restoring any remembered choice and applying + * the automatic policy when the group's aggregate status changes. + * + * State is recomputed during render (rather than in an effect) so the very + * first paint after a group finishes is already correct — an effect-based + * collapse would flash the open group for a frame. + */ +export function useToolRunGroupViewState( + groupKey: string, + status: ToolRunGroupStatus, +): { + state: ToolRunGroupViewState; + update: ( + next: (previous: ToolRunGroupViewState) => ToolRunGroupViewState, + ) => void; +} { + const [entry, setEntry] = React.useState(() => + createEntry(groupKey, status), + ); + + let current = entry; + if (entry.groupKey !== groupKey) { + current = createEntry(groupKey, status); + setEntry(current); + } else if (entry.status !== status) { + current = { + groupKey, + status, + state: reconcileToolRunGroupViewState(entry.state, entry.status, status), + }; + setEntry(current); + } + + const currentState = current.state; + React.useEffect(() => { + writeToolRunGroupViewState(groupKey, currentState); + }, [groupKey, currentState]); + + const update = React.useCallback( + (next: (previous: ToolRunGroupViewState) => ToolRunGroupViewState) => { + setEntry((previous) => ({ ...previous, state: next(previous.state) })); + }, + [], + ); + + return { state: currentState, update }; +} diff --git a/desktop/src/features/agents/ui/useTranscriptToolRunGroupKeys.tsx b/desktop/src/features/agents/ui/useTranscriptToolRunGroupKeys.tsx new file mode 100644 index 00000000000..55f5138b878 --- /dev/null +++ b/desktop/src/features/agents/ui/useTranscriptToolRunGroupKeys.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; + +import type { TranscriptDisplayBlock } from "./agentSessionTranscriptGrouping"; +import { + resolveTranscriptToolRunGroupKeys, + scopeToolRunGroupKey, +} from "./agentSessionToolRunGroupKey"; +import { + readToolRunGroupIdentityTable, + writeToolRunGroupIdentityTable, +} from "./agentSessionToolRunViewStore"; + +const TranscriptToolRunGroupKeyContext = React.createContext< + ReadonlyMap +>(new Map()); + +export const TranscriptToolRunGroupKeyProvider = + TranscriptToolRunGroupKeyContext.Provider; + +/** + * Resolve one durable identity per rendered tool-run group for this transcript. + * + * Runs at the list level rather than inside each group card because identity is + * allocated across the whole pass: a group claims the identity of a leaf it + * kept, and no two groups may claim the same one. A card resolving on its own + * could not see its siblings' claims. + * + * The recognition table is written during render (not in an effect) so the very + * first paint after a leaf is ejected already uses the carried-over identity — + * an effect would let the card mount once at policy default and visibly reset a + * reader's collapse before correcting itself. + */ +export function useResolvedToolRunGroupKeys( + scope: string, + blocks: readonly TranscriptDisplayBlock[], +): ReadonlyMap { + return React.useMemo(() => { + const { keysBySummaryId, table } = resolveTranscriptToolRunGroupKeys( + blocks, + readToolRunGroupIdentityTable(scope), + ); + writeToolRunGroupIdentityTable(scope, table); + return new Map( + [...keysBySummaryId].map(([summaryId, groupKey]) => [ + summaryId, + scopeToolRunGroupKey(scope, groupKey), + ]), + ); + }, [blocks, scope]); +} + +/** + * The durable identity for one group card, falling back to the summary's own id + * if this transcript never resolved it (e.g. a card rendered outside a + * provider). The fallback is per-summary, so it is never shared between groups. + */ +export function useToolRunGroupKey(summaryId: string, anchorKey: string) { + const keys = React.useContext(TranscriptToolRunGroupKeyContext); + return keys.get(summaryId) ?? anchorKey; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index c1933f14bb7..d899c3aa9a9 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -138,7 +138,7 @@ export function AgentSessionThreadPanel({ ? undefined : `Last updated ${new Date(latestActivityAt).toLocaleString()}`; - const { fetchOlderArchived, hasOlderArchived } = + const { fetchOlderArchived, hasOlderArchived, historyCertainty } = useLoadArchivedObserverEvents( // Archive history must load regardless of live status — an idle agent's // channel should still show its archived observer history. Enable whenever @@ -491,6 +491,9 @@ export function AgentSessionThreadPanel({ ? `Mention ${agent.name} in the channel to see its work here.` : `Mention ${agent.name} in any channel to see its work here.` } + emptyState={ + historyCertainty === "known-empty" ? "idle" : historyCertainty + } profiles={profiles} rawLayout="exclusive" showHeader={false} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index e792358713f..2d9ff8a06cb 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -29,6 +29,7 @@ import { restoreActiveAgentTurnsForCommunity, } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; +import { resetAgentSessionToolRunViewState } from "@/features/agents/ui/agentSessionToolRunViewStore"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; @@ -60,6 +61,7 @@ async function resetCommunityState({ resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); + resetAgentSessionToolRunViewState(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); if (isTauri() && isMacPlatform()) { diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 52d91dea3ed..d1b93a6ae0d 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2084,9 +2084,18 @@ test("shows and clears activity indicators for active channel agents", async ({ await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible(); await expect(page.getByTestId("agent-session-stop-turn")).toBeDisabled(); await page.keyboard.press("Escape"); + // The mock bridge resolves an owner_p subscription but never finishes an + // archive hydration pass for this channel, so archive completeness cannot be + // established and the pane must not claim there was no activity. It reports + // what it can actually see instead — see getUncertainHistoryCopy / + // agentSessionHistoryCertainty, whose "unknown-archive-loading" variant owns + // this wording. await expect(page.getByTestId("agent-session-thread-panel")).toContainText( - "No ACP activity yet", + "Earlier activity may still be loading", ); + await expect( + page.getByTestId("agent-session-thread-panel"), + ).not.toContainText("No ACP activity yet"); await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0); await page.evaluate((pubkey) => { diff --git a/desktop/tests/e2e/transcript-tool-run-quality.smoke.spec.ts b/desktop/tests/e2e/transcript-tool-run-quality.smoke.spec.ts new file mode 100644 index 00000000000..8e0b88bebd5 --- /dev/null +++ b/desktop/tests/e2e/transcript-tool-run-quality.smoke.spec.ts @@ -0,0 +1,293 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey; +const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; +const NOW = new Date("2025-06-15T12:00:00Z").toISOString(); + +const MANAGED_AGENTS = [ + { + pubkey: AGENT_PUBKEY, + name: "Observer Agent", + status: "running" as const, + channelNames: ["agents"], + }, +]; + +async function openFeed(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + ); + await page.getByTestId("channel-agents").click(); + const row = page + .getByTestId("message-row") + .filter({ has: page.getByText("Observer Agent", { exact: false }) }) + .first(); + await row.getByRole("button").first().click(); + await page.getByTestId(`user-profile-view-activity-${AGENT_PUBKEY}`).click(); + return page.getByTestId("agent-session-thread-panel"); +} + +function event( + seq: number, + toolCallId: string, + update: Record, +) { + return { + seq, + timestamp: NOW, + kind: "acp_read", + agentIndex: 0, + channelId: CHANNEL_ID, + sessionId: "session-quality", + turnId: "turn-quality", + payload: { + method: "session/update", + params: { + sessionId: "session-quality", + update: { toolCallId, ...update }, + }, + }, + }; +} + +async function seed( + page: import("@playwright/test").Page, + events: ReturnType[], +) { + await page.evaluate( + ({ pubkey, events }) => + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey: pubkey, + events, + }), + { pubkey: AGENT_PUBKEY, events }, + ); +} + +test("tool-run group stays readable through live completion and user expansion", async ({ + page, +}) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + const panel = await openFeed(page); + + await seed(page, [ + event(1, "read-a", { + sessionUpdate: "tool_call", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/a.ts" }, + }), + event(2, "read-b", { + sessionUpdate: "tool_call", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/b.ts" }, + }), + event(3, "read-c", { + sessionUpdate: "tool_call", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/c.ts" }, + }), + ]); + + const group = panel.getByTestId("transcript-same-kind-summary"); + await expect(group).toHaveAttribute("open", ""); + await expect( + group.getByTestId("tool-run-group-status-running"), + ).toBeVisible(); + + await group.locator("summary").first().click(); + await expect(group).not.toHaveAttribute("open", ""); + await group.locator("summary").first().click(); + await expect(group).toHaveAttribute("open", ""); + + await seed(page, [ + event(4, "read-a", { + sessionUpdate: "tool_call_update", + status: "completed", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/a.ts" }, + rawOutput: "a", + }), + event(5, "read-b", { + sessionUpdate: "tool_call_update", + status: "completed", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/b.ts" }, + rawOutput: "b", + }), + event(6, "read-c", { + sessionUpdate: "tool_call_update", + status: "completed", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/c.ts" }, + rawOutput: "c", + }), + ]); + + await expect(group).toHaveAttribute("open", ""); + const child = group + .getByTestId("transcript-tool-item") + .first() + .locator("details"); + await child.locator("summary").click(); + await expect(child).toHaveAttribute("open", ""); + await group.locator("summary").first().click(); + await group.locator("summary").first().click(); + await expect(child).toHaveAttribute("open", ""); +}); + +test("an untouched group auto-collapses once its work completes", async ({ + page, +}) => { + // The counterpart to the stickiness test above: a reader who never touched a + // group gets it collapsed when the work settles, so a long transcript keeps + // reading as a narrative instead of a wall of finished steps. + // + // This is browser-level because the policy was silently dead in the real app + // while passing at the unit level. A `
` fires `toggle` when React + // sets `open` on mount, and that echo was recorded as a reader action — + // latching the sticky `userInteracted` flag on every group that mounted open + // and disabling automatic collapse forever. Only a rendered DOM shows it. + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + const panel = await openFeed(page); + + await seed(page, [ + event(1, "read-a", { + sessionUpdate: "tool_call", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/a.ts" }, + }), + event(2, "read-b", { + sessionUpdate: "tool_call", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/b.ts" }, + }), + ]); + + // Live work mounts open, and is left strictly untouched. + const group = panel.getByTestId("transcript-same-kind-summary"); + await expect(group).toHaveAttribute("open", ""); + + await seed(page, [ + event(3, "read-a", { + sessionUpdate: "tool_call_update", + status: "completed", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/a.ts" }, + rawOutput: "a", + }), + event(4, "read-b", { + sessionUpdate: "tool_call_update", + status: "completed", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/b.ts" }, + rawOutput: "b", + }), + ]); + + // Finished and never touched, so the policy collapses it. + await expect(group).not.toHaveAttribute("open", ""); + await expect(group.getByTestId("tool-run-group-status-running")).toHaveCount( + 0, + ); +}); + +test("a collapsed group stays collapsed when its leading call fails and is ejected", async ({ + page, +}) => { + // Finding B, through the real app: grouping ejects a failed call, so when the + // FIRST call fails the group's leading leaf changes. A group identity derived + // from "first leaf" churned there, the store found nothing under the new key, + // and the card remounted open — silently undoing the reader's collapse. + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + const panel = await openFeed(page); + + // Four reads, so three still remain (and still group) after one is ejected. + await seed(page, [ + event(1, "read-a", { + sessionUpdate: "tool_call", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/a.ts" }, + }), + event(2, "read-b", { + sessionUpdate: "tool_call", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/b.ts" }, + }), + event(3, "read-c", { + sessionUpdate: "tool_call", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/c.ts" }, + }), + event(4, "read-d", { + sessionUpdate: "tool_call", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/d.ts" }, + }), + ]); + + // Group membership is asserted by counting child rows rather than reading the + // "Read N files" label, whose digits are animated per-character. + const group = panel.getByTestId("transcript-same-kind-summary"); + const groupedSteps = group.getByTestId("transcript-tool-item"); + await expect(groupedSteps).toHaveCount(4); + await expect(group).toHaveAttribute("open", ""); + + // The reader deliberately collapses the running group. + await group.locator("summary").first().click(); + await expect(group).not.toHaveAttribute("open", ""); + + // The LEADING call then fails, which ejects it from the group. + await seed(page, [ + event(5, "read-a", { + sessionUpdate: "tool_call_update", + status: "failed", + title: "read_file", + kind: "read_file", + rawInput: { path: "src/a.ts" }, + rawOutput: "permission denied", + }), + ]); + + // The failed call left the group — it is down to the three that still + // succeeded — and it now stands as its own row in the transcript. + await expect(groupedSteps).toHaveCount(3); + await expect(groupedSteps.filter({ hasText: "src/a.ts" })).toHaveCount(0); + await expect( + panel.getByTestId("transcript-tool-item").filter({ hasText: "src/a.ts" }), + ).toHaveCount(1); + + // And the group the reader collapsed is still collapsed — asserted as a + // state that has to HOLD, not merely be true for one sampled frame. A plain + // `not.toHaveAttribute` passes the instant it observes "closed", so it would + // also pass against a card that reopens a beat later; regrouping and the + // store write both land after the membership change above. + await expect(group).not.toHaveAttribute("open", ""); + await page.waitForTimeout(500); + await expect(group).not.toHaveAttribute("open", ""); +});