mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 08:10:26 +00:00
Show Assistant context tools as activity rows
This commit is contained in:
@@ -1364,6 +1364,20 @@ runtime cost control, and shared AI transport surfaces.
|
|||||||
out` cannot read like assistant output. Cost and context-limit percentages
|
out` cannot read like assistant output. Cost and context-limit percentages
|
||||||
stay absent until the runtime exposes those values through a governed
|
stay absent until the runtime exposes those values through a governed
|
||||||
contract.
|
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`
|
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
|
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
|
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
|
## 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
|
Assistant provider retries are a first-class visible workflow state, not a
|
||||||
hidden server log. The referenced OpenCode source at fetched `dev` commit
|
hidden server log. The referenced OpenCode source at fetched `dev` commit
|
||||||
`7ae856a9e97130f664f6f11fa5871a2795de9902` defines retry session status in
|
`7ae856a9e97130f664f6f11fa5871a2795de9902` defines retry session status in
|
||||||
|
|||||||
@@ -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
|
`frontend-modern/src/api/aiChatDevStreamFixture.ts` may short-circuit only
|
||||||
explicit `/fixture ...` prompts in development or test mode, must emit the same
|
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.
|
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
|
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
|
drain turn so UX proof can exercise queued follow-up ordering and tool rows
|
||||||
without consuming external model quota.
|
without consuming external model quota.
|
||||||
|
|||||||
@@ -307,6 +307,13 @@ describe('AIChatAPI', () => {
|
|||||||
success: true,
|
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({
|
expect(onEvent.mock.calls[8][0]).toMatchObject({
|
||||||
type: 'done',
|
type: 'done',
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ const buildContextGroupFixtureEvents = (model?: string): AIChatStreamEvent[] =>
|
|||||||
{
|
{
|
||||||
type: 'content',
|
type: 'content',
|
||||||
data: {
|
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.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
onCleanup,
|
onCleanup,
|
||||||
} from 'solid-js';
|
} from 'solid-js';
|
||||||
import CheckIcon from 'lucide-solid/icons/check';
|
import CheckIcon from 'lucide-solid/icons/check';
|
||||||
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
|
|
||||||
import CircleAlertIcon from 'lucide-solid/icons/circle-alert';
|
import CircleAlertIcon from 'lucide-solid/icons/circle-alert';
|
||||||
import ClockIcon from 'lucide-solid/icons/clock';
|
import ClockIcon from 'lucide-solid/icons/clock';
|
||||||
import CopyIcon from 'lucide-solid/icons/copy';
|
import CopyIcon from 'lucide-solid/icons/copy';
|
||||||
@@ -32,27 +31,16 @@ import {
|
|||||||
latestWorkflowStatus,
|
latestWorkflowStatus,
|
||||||
normalizeWorkflowStatusSequence,
|
normalizeWorkflowStatusSequence,
|
||||||
} from './workflowStatusPresentation';
|
} from './workflowStatusPresentation';
|
||||||
import {
|
|
||||||
isPlaceholderToolInputSummary,
|
|
||||||
parseToolInputSummary,
|
|
||||||
toolValueText,
|
|
||||||
} from './toolPresentation';
|
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ModelRouteRecoveryOption,
|
ModelRouteRecoveryOption,
|
||||||
PendingApproval,
|
PendingApproval,
|
||||||
PendingQuestion,
|
PendingQuestion,
|
||||||
PendingTool,
|
|
||||||
StreamDisplayEvent,
|
StreamDisplayEvent,
|
||||||
ToolExecution,
|
|
||||||
WorkflowStatus,
|
WorkflowStatus,
|
||||||
} from './types';
|
} from './types';
|
||||||
import {
|
import { AI_CHAT_ASSISTANT_MESSAGE_LABEL } from '@/utils/aiChatPresentation';
|
||||||
AI_CHAT_ASSISTANT_MESSAGE_LABEL,
|
|
||||||
AI_CHAT_CONTEXT_USED_LABEL,
|
|
||||||
} from '@/utils/aiChatPresentation';
|
|
||||||
import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation';
|
import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation';
|
||||||
import { formatIdentifierLabel } from '@/utils/textPresentation';
|
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
@@ -99,94 +87,6 @@ const markdownClass =
|
|||||||
const TEXT_RENDER_PACE_MS = 24;
|
const TEXT_RENDER_PACE_MS = 24;
|
||||||
const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/;
|
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) => {
|
const textRenderStep = (size: number) => {
|
||||||
if (size <= 12) return 2;
|
if (size <= 12) return 2;
|
||||||
if (size <= 48) return 4;
|
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 (
|
|
||||||
<div
|
|
||||||
class="my-1 overflow-hidden rounded-md border border-blue-200 bg-blue-50/60 text-[11px] dark:border-blue-900/60 dark:bg-blue-950/20"
|
|
||||||
data-testid="context-tool-group"
|
|
||||||
role="group"
|
|
||||||
aria-label={title()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full min-w-0 items-center gap-2 px-2.5 py-2 text-left transition-colors hover:bg-blue-100/60 focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:ring-inset dark:hover:bg-blue-950/30"
|
|
||||||
aria-expanded={expanded()}
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
|
||||||
active() ? 'animate-pulse bg-blue-500' : 'bg-emerald-500'
|
|
||||||
}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span class="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-muted">
|
|
||||||
{statusLabel()}
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0 truncate text-[12px] font-medium text-base-content">
|
|
||||||
{countLabel()}
|
|
||||||
</span>
|
|
||||||
<ChevronRightIcon
|
|
||||||
class={`ml-auto h-3.5 w-3.5 shrink-0 text-muted transition-transform ${
|
|
||||||
expanded() ? 'rotate-90' : ''
|
|
||||||
}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<Show when={expanded()}>
|
|
||||||
<div class="border-t border-blue-200/70 px-2 py-1.5 dark:border-blue-900/60">
|
|
||||||
<For each={props.events}>
|
|
||||||
{(event) => {
|
|
||||||
const pendingTool = event.type === 'pending_tool' ? event.pendingTool : undefined;
|
|
||||||
const tool = event.type === 'tool' ? event.tool : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch>
|
|
||||||
<Match when={pendingTool}>
|
|
||||||
{(pending) => <PendingToolBlock tool={pending()} />}
|
|
||||||
</Match>
|
|
||||||
<Match when={tool}>
|
|
||||||
{(completedTool) => (
|
|
||||||
<ToolExecutionBlock
|
|
||||||
startedAt={event.startedAt}
|
|
||||||
completedAt={event.updatedAt}
|
|
||||||
live={props.live}
|
|
||||||
tool={{
|
|
||||||
name: completedTool().name || 'unknown',
|
|
||||||
input: completedTool().input || '{}',
|
|
||||||
rawInput: completedTool().rawInput,
|
|
||||||
output: completedTool().output || '',
|
|
||||||
success: completedTool().success ?? true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MessageItem - Renders a single message in the chat.
|
* MessageItem - Renders a single message in the chat.
|
||||||
*
|
*
|
||||||
@@ -441,10 +259,6 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||||||
const groupedEvents = createMemo(() =>
|
const groupedEvents = createMemo(() =>
|
||||||
groupStreamEventsForDisplay(props.message.streamEvents || []),
|
groupStreamEventsForDisplay(props.message.streamEvents || []),
|
||||||
);
|
);
|
||||||
const displayStreamItems = createMemo(() => groupContextToolStreamItems(groupedEvents()));
|
|
||||||
const hasContextToolActivityGroup = createMemo(() =>
|
|
||||||
displayStreamItems().some((item) => item.kind === 'context_tool_group'),
|
|
||||||
);
|
|
||||||
const isSelectedModelRouteEvent = (evt: StreamDisplayEvent) =>
|
const isSelectedModelRouteEvent = (evt: StreamDisplayEvent) =>
|
||||||
evt.type === 'model_switch' && evt.modelEvent === 'selected' && !evt.failedModel?.trim();
|
evt.type === 'model_switch' && evt.modelEvent === 'selected' && !evt.failedModel?.trim();
|
||||||
const isConcreteStreamActivity = (evt: StreamDisplayEvent) => {
|
const isConcreteStreamActivity = (evt: StreamDisplayEvent) => {
|
||||||
@@ -510,27 +324,6 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||||||
.slice(0, index)
|
.slice(0, index)
|
||||||
.every((evt) => evt.type === 'thinking');
|
.every((evt) => evt.type === 'thinking');
|
||||||
|
|
||||||
const contextToolSummaries = createMemo(() => {
|
|
||||||
const events = props.message.streamEvents || [];
|
|
||||||
const summaries = new Set<string>();
|
|
||||||
|
|
||||||
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 = () =>
|
const visibleMessageContent = () =>
|
||||||
stripAssistantOutputArtifacts(props.message.content || '').text;
|
stripAssistantOutputArtifacts(props.message.content || '').text;
|
||||||
const modelRouteLabel = (route?: string) => {
|
const modelRouteLabel = (route?: string) => {
|
||||||
@@ -765,23 +558,13 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||||||
|
|
||||||
{/* Stream events - chronological display */}
|
{/* Stream events - chronological display */}
|
||||||
<Show when={hasRenderableStreamEvents()}>
|
<Show when={hasRenderableStreamEvents()}>
|
||||||
<For each={displayStreamItems()}>
|
<For each={groupedEvents()}>
|
||||||
{(item, index) => {
|
{(evt, index) => {
|
||||||
const contextGroup = item.kind === 'context_tool_group' ? item : undefined;
|
|
||||||
const evt = item.kind === 'event' ? item.event : undefined;
|
|
||||||
const contentText = () =>
|
const contentText = () =>
|
||||||
stripAssistantOutputArtifacts(evt?.content || '').text;
|
stripAssistantOutputArtifacts(evt?.content || '').text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={contextGroup}>
|
|
||||||
{(group) => (
|
|
||||||
<ContextToolActivityGroup
|
|
||||||
events={group().events}
|
|
||||||
live={props.message.isStreaming === true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
<Match
|
<Match
|
||||||
when={
|
when={
|
||||||
evt?.type === 'thinking' &&
|
evt?.type === 'thinking' &&
|
||||||
@@ -1019,30 +802,6 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||||||
<Show when={isStreamingText() && !isWaitingForFirstToken()}>
|
<Show when={isStreamingText() && !isWaitingForFirstToken()}>
|
||||||
<span class="inline-block w-1.5 h-4 ml-0.5 align-middle bg-blue-500 dark:bg-blue-400 animate-pulse rounded-full" />
|
<span class="inline-block w-1.5 h-4 ml-0.5 align-middle bg-blue-500 dark:bg-blue-400 animate-pulse rounded-full" />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
!props.message.isStreaming &&
|
|
||||||
!hasContextToolActivityGroup() &&
|
|
||||||
contextToolSummaries().length > 0
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="mt-4 pt-3 border-t border-border-subtle flex flex-wrap gap-2">
|
|
||||||
<span class="text-[10px] uppercase font-semibold text-muted">
|
|
||||||
{AI_CHAT_CONTEXT_USED_LABEL}
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
{contextToolSummaries().map((summary) => (
|
|
||||||
<span
|
|
||||||
class="max-w-[18rem] truncate px-1.5 py-0.5 rounded text-[10px] bg-surface-hover text-muted border border-border font-medium"
|
|
||||||
title={summary}
|
|
||||||
>
|
|
||||||
{summary}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1447,7 +1447,7 @@ describe('MessageItem', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('context tools display', () => {
|
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[] = [
|
const events: StreamDisplayEvent[] = [
|
||||||
{
|
{
|
||||||
type: 'tool',
|
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')).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[] = [
|
const events: StreamDisplayEvent[] = [
|
||||||
{
|
{
|
||||||
type: 'pending_tool',
|
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')).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', () => {
|
it('keeps action tools visible as individual transcript rows', () => {
|
||||||
@@ -1569,47 +1575,7 @@ describe('MessageItem', () => {
|
|||||||
expect(screen.getAllByTestId('tool-execution-block')).toHaveLength(2);
|
expect(screen.getAllByTestId('tool-execution-block')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Context used" with unique tool summaries when context checks are not grouped', () => {
|
it('does not duplicate visible context rows with a context footer', () => {
|
||||||
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(() => (
|
|
||||||
<MessageItem
|
|
||||||
message={makeMessage({
|
|
||||||
role: 'assistant',
|
|
||||||
streamEvents: events,
|
|
||||||
isStreaming: false,
|
|
||||||
})}
|
|
||||||
{...makeHandlers()}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
|
|
||||||
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', () => {
|
|
||||||
const events: StreamDisplayEvent[] = [
|
const events: StreamDisplayEvent[] = [
|
||||||
{
|
{
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
@@ -1648,8 +1614,8 @@ describe('MessageItem', () => {
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(screen.getAllByText('Inspect devices on current resource')).toHaveLength(1);
|
expect(screen.getAllByTestId('tool-execution-block')).toHaveLength(3);
|
||||||
expect(screen.getByText('get metrics')).toBeInTheDocument();
|
expect(screen.queryByText('Context used')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show context tools section when streaming', () => {
|
it('does not show context tools section when streaming', () => {
|
||||||
@@ -1691,7 +1657,7 @@ describe('MessageItem', () => {
|
|||||||
expect(screen.queryByText('Context used')).not.toBeInTheDocument();
|
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[] = [
|
const events: StreamDisplayEvent[] = [
|
||||||
{
|
{
|
||||||
type: 'tool',
|
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[] = [
|
const events: StreamDisplayEvent[] = [
|
||||||
{
|
{
|
||||||
type: 'tool',
|
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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
"heading": "## Shared Boundaries",
|
"heading": "## Shared Boundaries",
|
||||||
"path": "internal/api/access_control_handlers.go",
|
"path": "internal/api/access_control_handlers.go",
|
||||||
"line": 398,
|
"line": 404,
|
||||||
"heading_line": 117,
|
"heading_line": 117,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user