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 ( -