From 1fc36a790bb83e36b10d0efc2f6fda21acf3de5f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 7 Jun 2026 10:02:29 +0100 Subject: [PATCH] Show Assistant context tools as activity rows --- .../v6/internal/subsystems/ai-runtime.md | 21 ++ .../v6/internal/subsystems/api-contracts.md | 6 + .../src/api/__tests__/aiChat.test.ts | 7 + .../src/api/aiChatDevStreamFixture.ts | 2 +- .../src/components/AI/Chat/MessageItem.tsx | 247 +----------------- .../AI/Chat/__tests__/MessageItem.test.tsx | 96 +++---- .../release_control/subsystem_lookup_test.py | 2 +- 7 files changed, 73 insertions(+), 308 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5f1bbb7a8..79c773da3 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -1364,6 +1364,20 @@ runtime cost control, and shared AI transport surfaces. out` cannot read like assistant output. Cost and context-limit percentages stay absent until the runtime exposes those values through a governed contract. + Assistant tool activity is visible transcript activity, not a hidden context + footer. The referenced OpenCode source at fetched `origin/dev` commit + `e82542b8023a8374f29c23b70ec019c8f256354e` + `packages/opencode/src/cli/cmd/run/types.ts` defines append-only + `StreamCommit` rows for assistant, reasoning, tool, and system sources at + lines 284-312, while + `packages/opencode/src/cli/cmd/run/footer.ts` queues commits and only + coalesces consecutive progress chunks for the same part/tool at lines + 512-545. Pulse adapts that contract by rendering each Assistant + `pending_tool`, completed `tool`, and `tool_cancel` event as its own + chronological transcript row. Tool inputs, command previews, progress text, + and outputs may remain collapsed inside the row, but context/read/query + tools must not be replaced by a generic grouped footer that makes several + operations appear all at once. 7. Keep AI chat presentation helpers aligned through `frontend-modern/src/components/AI/Chat/` and the shared `frontend-modern/src/utils/textPresentation.ts` 8. Keep assistant drawer context, session, and org-switch reset state aligned through the shared `frontend-modern/src/stores/aiChat.ts` boundary instead of letting `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, or feature callers fork their own assistant shell state That shared drawer ownership also covers passive resource reads while the @@ -2003,6 +2017,13 @@ runtime cost control, and shared AI transport surfaces. ## Current State +Assistant tool activity now follows an OpenCode-referenced chronological row +model where appropriate for Pulse. Consecutive context/read/query tools render +as visible transcript rows in arrival order instead of being replaced by a +grouped context footer, while command previews, inputs, progress, and large +outputs remain contained inside each tool row. This keeps the user-facing stream +feeling active without dumping large command output into the default answer. + Assistant provider retries are a first-class visible workflow state, not a hidden server log. The referenced OpenCode source at fetched `dev` commit `7ae856a9e97130f664f6f11fa5871a2795de9902` defines retry session status in diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7df1d1eca..c4ae2479a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -128,6 +128,12 @@ Assistant local stream fixtures are part of the same frontend API contract: `frontend-modern/src/api/aiChatDevStreamFixture.ts` may short-circuit only explicit `/fixture ...` prompts in development or test mode, must emit the same typed stream event sequence as live chat, and must never open a provider request. +Fixtures that emit consecutive context/read/query tool events must keep those +events as ordinary typed `tool_start` / `tool_end` activity and must not encode +obsolete grouped-context wording in fixture answer content. The fixture payload +contract proves the stream reducer and transcript renderer against the same +chronological event order a live provider would produce; UI grouping or footer +summaries are not part of the fixture contract. Queue verification fixtures must cover both the active hold turn and the queued drain turn so UX proof can exercise queued follow-up ordering and tool rows without consuming external model quota. diff --git a/frontend-modern/src/api/__tests__/aiChat.test.ts b/frontend-modern/src/api/__tests__/aiChat.test.ts index cbf3c3d3a..dc6e7308f 100644 --- a/frontend-modern/src/api/__tests__/aiChat.test.ts +++ b/frontend-modern/src/api/__tests__/aiChat.test.ts @@ -307,6 +307,13 @@ describe('AIChatAPI', () => { success: true, }, }); + expect(onEvent.mock.calls[7][0]).toMatchObject({ + type: 'content', + data: { + text: expect.stringContaining('separate visible activity rows'), + }, + }); + expect(onEvent.mock.calls[7][0].data.text).not.toContain('one compact context activity row'); expect(onEvent.mock.calls[8][0]).toMatchObject({ type: 'done', data: { diff --git a/frontend-modern/src/api/aiChatDevStreamFixture.ts b/frontend-modern/src/api/aiChatDevStreamFixture.ts index d1f92a59f..650912b3f 100644 --- a/frontend-modern/src/api/aiChatDevStreamFixture.ts +++ b/frontend-modern/src/api/aiChatDevStreamFixture.ts @@ -358,7 +358,7 @@ const buildContextGroupFixtureEvents = (model?: string): AIChatStreamEvent[] => { type: 'content', data: { - text: 'The context-group fixture gathered the resource identity and recent CPU history as one compact context activity row.', + text: 'The context fixture gathered the resource identity and recent CPU history as separate visible activity rows.', }, }, { diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 3bac48d84..140f1d974 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -10,7 +10,6 @@ import { onCleanup, } from 'solid-js'; import CheckIcon from 'lucide-solid/icons/check'; -import ChevronRightIcon from 'lucide-solid/icons/chevron-right'; import CircleAlertIcon from 'lucide-solid/icons/circle-alert'; import ClockIcon from 'lucide-solid/icons/clock'; import CopyIcon from 'lucide-solid/icons/copy'; @@ -32,27 +31,16 @@ import { latestWorkflowStatus, normalizeWorkflowStatusSequence, } from './workflowStatusPresentation'; -import { - isPlaceholderToolInputSummary, - parseToolInputSummary, - toolValueText, -} from './toolPresentation'; import type { ChatMessage, ModelRouteRecoveryOption, PendingApproval, PendingQuestion, - PendingTool, StreamDisplayEvent, - ToolExecution, WorkflowStatus, } from './types'; -import { - AI_CHAT_ASSISTANT_MESSAGE_LABEL, - AI_CHAT_CONTEXT_USED_LABEL, -} from '@/utils/aiChatPresentation'; +import { AI_CHAT_ASSISTANT_MESSAGE_LABEL } from '@/utils/aiChatPresentation'; import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation'; -import { formatIdentifierLabel } from '@/utils/textPresentation'; interface MessageItemProps { message: ChatMessage; @@ -99,94 +87,6 @@ const markdownClass = const TEXT_RENDER_PACE_MS = 24; const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/; -type ContextToolStreamEvent = - | (StreamDisplayEvent & { type: 'pending_tool'; pendingTool: PendingTool }) - | (StreamDisplayEvent & { type: 'tool'; tool: ToolExecution }); - -type DisplayStreamItem = - | { kind: 'event'; event: StreamDisplayEvent } - | { kind: 'context_tool_group'; events: ContextToolStreamEvent[]; key: string }; - -const CONTEXT_TOOL_NAMES = new Set([ - 'read', - 'query', - 'fetch_url', - 'get_infrastructure_state', - 'get_active_alerts', - 'get_metrics', - 'get_metrics_history', - 'get_baselines', - 'get_patterns', - 'get_disk_health', - 'get_storage', - 'get_storage_config', - 'get_resource_details', -]); - -const normalizedContextToolName = (name?: string) => name?.trim().replace(/^pulse_/, '') || ''; - -const isContextToolName = (name?: string) => - CONTEXT_TOOL_NAMES.has(normalizedContextToolName(name)); - -const asContextToolStreamEvent = (event: StreamDisplayEvent): ContextToolStreamEvent | null => { - if ( - event.type === 'pending_tool' && - event.pendingTool && - isContextToolName(event.pendingTool.name) - ) { - return event as ContextToolStreamEvent; - } - if (event.type === 'tool' && event.tool && isContextToolName(event.tool.name)) { - return event as ContextToolStreamEvent; - } - return null; -}; - -const contextToolEventKey = (event: ContextToolStreamEvent) => - [ - event.type, - event.toolId, - event.type === 'pending_tool' ? event.pendingTool.id : event.tool.name, - event.startedAt, - event.updatedAt, - ] - .map((value) => String(value ?? '')) - .join(':'); - -const groupContextToolStreamItems = (events: StreamDisplayEvent[]): DisplayStreamItem[] => { - const items: DisplayStreamItem[] = []; - let pendingGroup: ContextToolStreamEvent[] = []; - - const flushGroup = () => { - if (pendingGroup.length >= 2) { - items.push({ - kind: 'context_tool_group', - events: pendingGroup, - key: `context-tool:${pendingGroup.map(contextToolEventKey).join('|')}`, - }); - } else { - for (const event of pendingGroup) { - items.push({ kind: 'event', event }); - } - } - pendingGroup = []; - }; - - for (const event of events) { - const contextToolEvent = asContextToolStreamEvent(event); - if (contextToolEvent) { - pendingGroup.push(contextToolEvent); - continue; - } - - flushGroup(); - items.push({ kind: 'event', event }); - } - - flushGroup(); - return items; -}; - const textRenderStep = (size: number) => { if (size <= 12) return 2; if (size <= 48) return 4; @@ -332,88 +232,6 @@ const AssistantMarkdownBlock: Component<{ ); }; -const ContextToolActivityGroup: Component<{ - events: ContextToolStreamEvent[]; - live: boolean; -}> = (props) => { - const [expanded, setExpanded] = createSignal(false); - const active = createMemo(() => props.events.some((event) => event.type === 'pending_tool')); - const count = createMemo(() => props.events.length); - const countLabel = createMemo(() => `${count()} context ${count() === 1 ? 'check' : 'checks'}`); - const statusLabel = createMemo(() => (active() ? 'Gathering context' : 'Context gathered')); - const title = createMemo(() => `${statusLabel()} ยท ${countLabel()}`); - const toggle = () => setExpanded((value) => !value); - - return ( -
- - -
- - {(event) => { - const pendingTool = event.type === 'pending_tool' ? event.pendingTool : undefined; - const tool = event.type === 'tool' ? event.tool : undefined; - - return ( - - - {(pending) => } - - - {(completedTool) => ( - - )} - - - ); - }} - -
-
-
- ); -}; - /** * MessageItem - Renders a single message in the chat. * @@ -441,10 +259,6 @@ export const MessageItem: Component = (props) => { const groupedEvents = createMemo(() => groupStreamEventsForDisplay(props.message.streamEvents || []), ); - const displayStreamItems = createMemo(() => groupContextToolStreamItems(groupedEvents())); - const hasContextToolActivityGroup = createMemo(() => - displayStreamItems().some((item) => item.kind === 'context_tool_group'), - ); const isSelectedModelRouteEvent = (evt: StreamDisplayEvent) => evt.type === 'model_switch' && evt.modelEvent === 'selected' && !evt.failedModel?.trim(); const isConcreteStreamActivity = (evt: StreamDisplayEvent) => { @@ -510,27 +324,6 @@ export const MessageItem: Component = (props) => { .slice(0, index) .every((evt) => evt.type === 'thinking'); - const contextToolSummaries = createMemo(() => { - const events = props.message.streamEvents || []; - const summaries = new Set(); - - for (const evt of events) { - if (evt.type === 'tool' && evt.tool?.name) { - const summary = parseToolInputSummary( - toolValueText(evt.tool.input), - evt.tool.name, - evt.tool.rawInput, - ); - const label = - summary && !isPlaceholderToolInputSummary(summary) - ? summary - : formatIdentifierLabel(evt.tool.name, { stripPrefix: 'pulse_' }); - summaries.add(label); - } - } - - return Array.from(summaries); - }); const visibleMessageContent = () => stripAssistantOutputArtifacts(props.message.content || '').text; const modelRouteLabel = (route?: string) => { @@ -765,23 +558,13 @@ export const MessageItem: Component = (props) => { {/* Stream events - chronological display */} - - {(item, index) => { - const contextGroup = item.kind === 'context_tool_group' ? item : undefined; - const evt = item.kind === 'event' ? item.event : undefined; + + {(evt, index) => { const contentText = () => stripAssistantOutputArtifacts(evt?.content || '').text; return ( - - {(group) => ( - - )} - = (props) => { - - 0 - } - > -
- - {AI_CHAT_CONTEXT_USED_LABEL} - -
- {contextToolSummaries().map((summary) => ( - - {summary} - - ))} -
-
-
diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index f61525038..eaede5b56 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -1447,7 +1447,7 @@ describe('MessageItem', () => { }); describe('context tools display', () => { - it('groups consecutive context checks into one expandable transcript row', () => { + it('shows consecutive completed context checks as individual transcript rows', () => { const events: StreamDisplayEvent[] = [ { type: 'tool', @@ -1480,17 +1480,20 @@ describe('MessageItem', () => { /> )); - expect(screen.getByTestId('context-tool-group')).toHaveTextContent('Context gathered'); - expect(screen.getByTestId('context-tool-group')).toHaveTextContent('2 context checks'); - expect(screen.queryByTestId('tool-execution-block')).not.toBeInTheDocument(); - expect(screen.queryByText('Context used')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: /Context gathered/i })); - expect(screen.getAllByTestId('tool-execution-block')).toHaveLength(2); + expect(screen.getAllByTestId('tool-execution-block')[0]).toHaveAttribute( + 'data-tool-name', + 'pulse_read', + ); + expect(screen.getAllByTestId('tool-execution-block')[1]).toHaveAttribute( + 'data-tool-name', + 'pulse_get_metrics', + ); + expect(screen.queryByTestId('context-tool-group')).not.toBeInTheDocument(); + expect(screen.queryByText('Context used')).not.toBeInTheDocument(); }); - it('shows consecutive pending context checks as one active expandable transcript row', () => { + it('shows consecutive pending context checks as individual live transcript rows', () => { const events: StreamDisplayEvent[] = [ { type: 'pending_tool', @@ -1523,13 +1526,16 @@ describe('MessageItem', () => { /> )); - expect(screen.getByTestId('context-tool-group')).toHaveTextContent('Gathering context'); - expect(screen.getByTestId('context-tool-group')).toHaveTextContent('2 context checks'); - expect(screen.queryByTestId('pending-tool-block')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: /Gathering context/i })); - expect(screen.getAllByTestId('pending-tool-block')).toHaveLength(2); + expect(screen.getAllByTestId('pending-tool-block')[0]).toHaveAttribute( + 'data-tool-name', + 'pulse_get_resource_details', + ); + expect(screen.getAllByTestId('pending-tool-block')[1]).toHaveAttribute( + 'data-tool-name', + 'pulse_get_metrics_history', + ); + expect(screen.queryByTestId('context-tool-group')).not.toBeInTheDocument(); }); it('keeps action tools visible as individual transcript rows', () => { @@ -1569,47 +1575,7 @@ describe('MessageItem', () => { expect(screen.getAllByTestId('tool-execution-block')).toHaveLength(2); }); - it('shows "Context used" with unique tool summaries when context checks are not grouped', () => { - const events: StreamDisplayEvent[] = [ - { - type: 'tool', - tool: { - name: 'pulse_read', - input: '{"action":"exec","target_host":"current_resource","command":"ls /dev | wc -l"}', - output: 'ok', - success: true, - }, - }, - { type: 'content', content: 'I checked the device node count.' }, - { - type: 'tool', - tool: { - name: 'pulse_get_metrics', - input: '{"action":"history","resource":"vm-101"}', - output: 'ok', - success: true, - }, - }, - ]; - - render(() => ( - - )); - - expect(screen.getByText('Context used')).toBeInTheDocument(); - expect(screen.getByText('Inspect devices on current resource')).toBeInTheDocument(); - expect(screen.getByText('history')).toBeInTheDocument(); - expect(screen.queryByText('read')).not.toBeInTheDocument(); - }); - - it('deduplicates tool summaries in context footer', () => { + it('does not duplicate visible context rows with a context footer', () => { const events: StreamDisplayEvent[] = [ { type: 'tool', @@ -1648,8 +1614,8 @@ describe('MessageItem', () => { /> )); - expect(screen.getAllByText('Inspect devices on current resource')).toHaveLength(1); - expect(screen.getByText('get metrics')).toBeInTheDocument(); + expect(screen.getAllByTestId('tool-execution-block')).toHaveLength(3); + expect(screen.queryByText('Context used')).not.toBeInTheDocument(); }); it('does not show context tools section when streaming', () => { @@ -1691,7 +1657,7 @@ describe('MessageItem', () => { expect(screen.queryByText('Context used')).not.toBeInTheDocument(); }); - it('formats tool names correctly (strips pulse_ prefix and replaces underscores)', () => { + it('passes prefixed tool names through to visible tool rows', () => { const events: StreamDisplayEvent[] = [ { type: 'tool', @@ -1715,10 +1681,13 @@ describe('MessageItem', () => { /> )); - expect(screen.getByText('get container status')).toBeInTheDocument(); + expect(screen.getByTestId('tool-execution-block')).toHaveAttribute( + 'data-tool-name', + 'pulse_get_container_status', + ); }); - it('handles tool names without pulse_ prefix', () => { + it('passes tool names without pulse_ prefix through to visible tool rows', () => { const events: StreamDisplayEvent[] = [ { type: 'tool', @@ -1737,7 +1706,10 @@ describe('MessageItem', () => { /> )); - expect(screen.getByText('run command')).toBeInTheDocument(); + expect(screen.getByTestId('tool-execution-block')).toHaveAttribute( + 'data-tool-name', + 'run_command', + ); }); }); diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 2bf7fff2d..d69a6512d 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 398, + "line": 404, "heading_line": 117, } ],