Improve Assistant failed-turn recovery and tool output

This commit is contained in:
rcourtman
2026-06-05 17:11:43 +01:00
parent 5ea51b4b25
commit 67f896d93a
11 changed files with 354 additions and 255 deletions
@@ -117,6 +117,11 @@ runtime cost control, and shared AI transport surfaces.
structured mentions, finding id, approval override, handoff resources,
handoff actions, and handoff metadata, but must not reconstruct scoped
context from prompt history or saved transcript prose.
Failed-turn recovery must also expose model-route switching through the
existing drawer model selector so operators can move from a blocked direct
provider route to a configured gateway or alternate model without losing the
draft or creating a parallel picker. Retry remains available, but it must not
be the only visible action when a failed Assistant turn is shown.
Restored Assistant sessions must hydrate saved assistant content and
persisted tool calls into the same transcript event shape used by live
streams so switching sessions does not hide prior tool evidence or collapse
@@ -143,8 +148,11 @@ runtime cost control, and shared AI transport surfaces.
approval, or question blocks; if a provider emits `pulse_*` / `patrol_*`
calls, DSML, XML/function-call envelopes, or JSON tool-call shapes as text
content, the chat runtime must strip them before streaming, persistence, and
frontend rendering. Token accounting and other provider metadata remain
runtime/accounting data, not normal transcript prose.
frontend rendering. Completed tool rows in the drawer may show compact tool
name, action summary, status, and an explicit details affordance, but raw
tool input/output JSON must not render in the default transcript. Token
accounting and other provider metadata remain runtime/accounting data, not
normal transcript prose.
4. Add or change Patrol, alert-analysis, or remediation transport through `internal/api/ai_handlers.go`, `internal/api/ai_intelligence_handlers.go`, and `frontend-modern/src/api/patrol.ts`
Provider preflight diagnostics returned from `internal/api/ai_handlers.go`
must reuse the Patrol runtime failure classifier in `internal/ai/` and
@@ -15,6 +15,7 @@ interface ChatMessagesProps {
) => void;
onSkipQuestion: (messageId: string, questionId: string) => void;
onRetry?: (messageId: string) => void;
onChangeModel?: () => void;
// Dashboard props
recentSessions?: ChatSession[];
onLoadSession?: (sessionId: string) => void;
@@ -146,6 +147,7 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
}
onSkipQuestion={(questionId) => props.onSkipQuestion(message.id, questionId)}
onRetry={props.onRetry}
onChangeModel={props.onChangeModel}
/>
)}
</For>
@@ -3,6 +3,7 @@ import CheckIcon from 'lucide-solid/icons/check';
import CircleAlertIcon from 'lucide-solid/icons/circle-alert';
import ClockIcon from 'lucide-solid/icons/clock';
import CopyIcon from 'lucide-solid/icons/copy';
import CpuIcon from 'lucide-solid/icons/cpu';
import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import { renderMarkdown } from '../aiChatUtils';
@@ -28,6 +29,7 @@ interface MessageItemProps {
) => void;
onSkipQuestion: (questionId: string) => void;
onRetry?: (messageId: string) => void;
onChangeModel?: () => void;
}
const markdownClass =
@@ -277,16 +279,28 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
<CircleAlertIcon class="mt-0.5 h-4 w-4 shrink-0 text-red-500 dark:text-red-400" />
<div class="flex-1 min-w-0">
<p class="text-sm text-red-700 dark:text-red-300">{props.message.error}</p>
<Show when={props.onRetry}>
<button
type="button"
onClick={() => props.onRetry?.(props.message.id)}
class="mt-2 inline-flex items-center gap-1.5 rounded-md border border-red-300 dark:border-red-800 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300 transition-colors hover:bg-red-100 dark:hover:bg-red-900/40"
>
<RotateCcwIcon class="h-3.5 w-3.5" />
Try again
</button>
</Show>
<div class="mt-2 flex flex-wrap gap-1.5">
<Show when={props.onChangeModel}>
<button
type="button"
onClick={() => props.onChangeModel?.()}
class="inline-flex items-center gap-1.5 rounded-md border border-red-300 bg-white/80 px-2 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/20 dark:text-red-300 dark:hover:bg-red-900/40"
>
<CpuIcon class="h-3.5 w-3.5" />
Change model
</button>
</Show>
<Show when={props.onRetry}>
<button
type="button"
onClick={() => props.onRetry?.(props.message.id)}
class="inline-flex items-center gap-1.5 rounded-md border border-red-300 px-2 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-900/40"
>
<RotateCcwIcon class="h-3.5 w-3.5" />
Try again
</button>
</Show>
</div>
</div>
</div>
</Show>
@@ -1,7 +1,19 @@
import { Component, For, Show, createSignal, createMemo, onMount, onCleanup } from 'solid-js';
import {
Component,
For,
Show,
createEffect,
createSignal,
createMemo,
onMount,
onCleanup,
} from 'solid-js';
import { getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils';
import { SearchField } from '@/components/shared/SearchField';
import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation';
import {
formatAIModelRouteLabel,
getAIProviderDisplayName,
} from '@/utils/aiProviderPresentation';
import { AI_CHAT_MODEL_SELECTOR_EMPTY_STATE } from '@/utils/aiChatPresentation';
import type { ModelInfo } from './types';
@@ -13,6 +25,7 @@ export interface ModelSelectorProps {
chatOverrideLabel?: string;
isLoading?: boolean;
error?: string;
openRequest?: number;
onModelSelect: (modelId: string) => void;
onRefresh?: () => void;
}
@@ -27,6 +40,8 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
const [searchQuery, setSearchQuery] = createSignal('');
const [dropdownPosition, setDropdownPosition] = createSignal({ top: 0, right: 0 });
let buttonRef: HTMLButtonElement | undefined;
let searchInputRef: HTMLInputElement | undefined;
let lastOpenRequest = props.openRequest || 0;
// Filter models by notable status (show only recent/notable models by default)
const notableFilteredModels = createMemo(() => {
@@ -81,10 +96,13 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
};
const handleToggle = () => {
if (!isOpen()) {
if (isOpen()) {
setIsOpen(false);
} else {
updateDropdownPosition();
setIsOpen(true);
queueMicrotask(() => searchInputRef?.focus());
}
setIsOpen(!isOpen());
};
const handleSelect = (modelId: string) => {
@@ -93,6 +111,18 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
setSearchQuery('');
};
createEffect(() => {
const request = props.openRequest || 0;
if (request <= 0 || request === lastOpenRequest) return;
lastOpenRequest = request;
queueMicrotask(() => {
updateDropdownPosition();
setSearchQuery('');
setIsOpen(true);
queueMicrotask(() => searchInputRef?.focus());
});
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
e.preventDefault();
@@ -110,7 +140,7 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
return props.defaultModelLabel || 'Default';
}
const match = props.models.find((model) => model.id === selected);
if (match) return match.name || match.id.split(':').pop() || match.id;
if (match) return formatAIModelRouteLabel(match);
return selected;
});
@@ -203,6 +233,9 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
placeholder="Search or enter model ID"
class="flex-1"
inputClass="py-1.5 text-xs focus:ring-blue-400"
inputRef={(el) => {
searchInputRef = el;
}}
/>
<Show when={props.onRefresh}>
<button
@@ -297,7 +330,7 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
>
<div class="flex items-center gap-1.5">
<span class="font-medium text-base-content">
{model.name || model.id.split(':').pop() || model.id}
{formatAIModelRouteLabel(model)}
</span>
</div>
<Show when={model.description}>
@@ -7,123 +7,111 @@ interface ToolExecutionBlockProps {
tool: ToolExecution;
}
const getToolLabel = (name: string) => {
if (name === 'run_command' || name === 'pulse_run_command') return 'cmd';
if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch';
if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state')
return 'infra';
if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts';
if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics';
if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines';
if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns';
if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks';
if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage';
if (name === 'pulse_get_storage_config') return 'storage cfg';
if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource';
if (name.includes('finding')) return 'finding';
return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 });
};
const parseToolInputSummary = (input: string) => {
const trimmed = input.trim();
if (!trimmed) return '';
try {
const parsed = JSON.parse(trimmed) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const record = parsed as Record<string, unknown>;
if (typeof record.action === 'string' && record.action.trim()) {
return formatIdentifierLabel(record.action, { maxLength: 28 });
}
if (typeof record.command === 'string' && record.command.trim()) {
return formatIdentifierLabel(record.command, { maxLength: 28 });
}
return 'request';
}
} catch {
return formatIdentifierLabel(trimmed, { maxLength: 28 });
}
return 'request';
};
const hasReadableToolOutput = (output: string) => {
const trimmed = output.trim();
return trimmed.length > 0 && !trimmed.toLowerCase().includes('not available');
};
/**
* ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style.
*/
export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) => {
const [showOutput, setShowOutput] = createSignal(false); // Collapsed by default like Claude Code
const [showDetails, setShowDetails] = createSignal(false);
// Get display name for tool
const toolLabel = createMemo(() => {
const name = props.tool.name;
if (name === 'run_command' || name === 'pulse_run_command') return 'cmd';
if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch';
if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state')
return 'infra';
if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts';
if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics';
if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines';
if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns';
if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks';
if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage';
if (name === 'pulse_get_storage_config') return 'storage cfg';
if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource';
if (name.includes('finding')) return 'finding';
return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 });
});
// Check if output is non-empty and interesting
const hasOutput = createMemo(() => {
const output = props.tool.output || '';
return output.trim().length > 0 && !output.includes('not available');
});
// Show only last few lines by default, full output when expanded
const displayOutput = createMemo(() => {
const output = props.tool.output || '';
if (showOutput()) {
// Show full output when expanded
return output;
}
// Show last 3 lines by default
const lines = output.split('\n').filter((line) => line.trim());
if (lines.length <= 3) {
return output.trim();
}
const lastLines = lines.slice(-3).join('\n');
return '...\n' + lastLines;
});
const hasMoreOutput = createMemo(() => {
const output = props.tool.output || '';
const lines = output.split('\n').filter((line) => line.trim());
return lines.length > 3;
});
const toolLabel = createMemo(() => getToolLabel(props.tool.name));
const inputSummary = createMemo(() => parseToolInputSummary(props.tool.input || ''));
const hasInput = createMemo(() => (props.tool.input || '').trim().length > 0);
const hasOutput = createMemo(() => hasReadableToolOutput(props.tool.output || ''));
const hasDetails = createMemo(() => hasInput() || hasOutput());
const statusIcon = () => (props.tool.success ? '✓' : '✗');
const statusLabel = () => (props.tool.success ? 'completed' : 'failed');
return (
<div class="my-1 font-mono text-[11px]">
{/* Compact single-line header */}
<div
class={`flex items-center gap-1.5 px-2 py-1 rounded ${
hasMoreOutput() ? 'cursor-pointer hover:bg-surface-hover' : ''
} ${showOutput() ? 'bg-surface-alt' : ''}`}
onClick={() => hasMoreOutput() && setShowOutput(!showOutput())}
>
{/* Status icon */}
<div class="flex items-center gap-1.5 rounded px-2 py-1">
<span class={`${getToolCallResultTextClass(props.tool.success)} font-bold`}>
{statusIcon()}
</span>
{/* Tool label */}
<span class="text-muted uppercase text-[9px] font-medium tracking-wider min-w-[50px]">
{toolLabel()}
</span>
{/* Command/input - truncated */}
<code class="text-base-content truncate flex-1">
{(props.tool.input || '').length > 60
? (props.tool.input || '').substring(0, 60) + '...'
: props.tool.input || '{}'}
</code>
<span class="min-w-0 flex-1 truncate text-base-content">{inputSummary()}</span>
<span class="text-[10px] text-muted">{statusLabel()}</span>
{/* Expand indicator if has more output */}
<Show when={hasMoreOutput()}>
<svg
class={`w-3 h-3 text-slate-400 transition-transform ${showOutput() ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<Show when={hasDetails()}>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setShowDetails(!showDetails());
}}
class="rounded px-1.5 py-0.5 text-[9px] font-medium text-muted hover:bg-surface-hover hover:text-base-content"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
{showDetails() ? 'Hide details' : 'Details'}
</button>
</Show>
</div>
{/* Output - always show last few lines, expandable for full output */}
<Show when={hasOutput()}>
<Show when={showDetails() && hasDetails()}>
<div class="ml-4 mt-1 mb-2 pl-2 border-l-2 border-border overflow-hidden">
<pre
class={`text-[10px] text-muted whitespace-pre-wrap break-all leading-relaxed overflow-y-auto overflow-x-hidden bg-surface-alt rounded p-2 ${showOutput() ? 'max-h-64' : 'max-h-20'}`}
>
{displayOutput()}
</pre>
<Show when={hasMoreOutput()}>
<button
onClick={(e) => {
e.stopPropagation();
setShowOutput(!showOutput());
}}
class="mt-1 text-[9px] text-muted hover:text-base-content hover:underline"
>
{showOutput() ? 'Show less' : 'Show full output'}
</button>
<Show when={hasInput()}>
<div class="mb-1 text-[9px] font-semibold uppercase tracking-wide text-muted">
Input
</div>
<pre class="mb-2 max-h-32 overflow-y-auto overflow-x-hidden rounded bg-surface-alt p-2 text-[10px] leading-relaxed text-muted whitespace-pre-wrap break-all">
{(props.tool.input || '').trim()}
</pre>
</Show>
<Show when={hasOutput()}>
<div class="mb-1 text-[9px] font-semibold uppercase tracking-wide text-muted">
Output
</div>
<pre class="max-h-64 overflow-y-auto overflow-x-hidden rounded bg-surface-alt p-2 text-[10px] leading-relaxed text-muted whitespace-pre-wrap break-all">
{(props.tool.output || '').trim()}
</pre>
</Show>
</div>
</Show>
@@ -139,22 +127,8 @@ interface PendingToolBlockProps {
}
export const PendingToolBlock: Component<PendingToolBlockProps> = (props) => {
const toolLabel = createMemo(() => {
const name = props.tool.name;
if (name === 'run_command' || name === 'pulse_run_command') return 'cmd';
if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch';
if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state')
return 'infra';
if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts';
if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics';
if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines';
if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns';
if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks';
if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage';
if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource';
if (name.includes('finding')) return 'finding';
return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 });
});
const toolLabel = createMemo(() => getToolLabel(props.tool.name));
const inputSummary = createMemo(() => parseToolInputSummary(props.tool.input || ''));
return (
<div class="my-0.5 font-mono text-[11px] flex items-center gap-1.5 px-2 py-1 rounded bg-surface-alt border border-border">
@@ -177,12 +151,7 @@ export const PendingToolBlock: Component<PendingToolBlockProps> = (props) => {
{toolLabel()}
</span>
{/* Command - truncated */}
<code class="text-base-content truncate flex-1">
{props.tool.input.length > 50
? props.tool.input.substring(0, 50) + '...'
: props.tool.input}
</code>
<span class="text-base-content truncate flex-1">{inputSummary()}</span>
</div>
);
};
@@ -14,7 +14,11 @@ const {
mockByType,
mockResources,
mockWebSocketState,
mockChatMessagesProps,
} = vi.hoisted(() => {
const mockChatMessagesProps: Array<{
onChangeModel?: () => void;
}> = [];
const mockChat = {
messages: vi.fn((): ChatMessage[] => []),
isLoading: vi.fn(() => false),
@@ -169,6 +173,7 @@ const {
mockByType,
mockResources,
mockWebSocketState,
mockChatMessagesProps,
};
});
@@ -182,24 +187,36 @@ vi.mock('../ChatMessages', () => ({
ChatMessages: (props: {
messages: ChatMessage[];
emptyState?: { title: string; subtitle?: string };
}) => (
<div data-testid="chat-messages" data-msg-count={props.messages.length}>
{props.emptyState?.title && (
<span data-testid="empty-state-title">{props.emptyState.title}</span>
)}
{props.emptyState?.subtitle && (
<span data-testid="empty-state-subtitle">{props.emptyState.subtitle}</span>
)}
</div>
),
onChangeModel?: () => void;
}) => {
mockChatMessagesProps.push(props);
return (
<div data-testid="chat-messages" data-msg-count={props.messages.length}>
{props.emptyState?.title && (
<span data-testid="empty-state-title">{props.emptyState.title}</span>
)}
{props.emptyState?.subtitle && (
<span data-testid="empty-state-subtitle">{props.emptyState.subtitle}</span>
)}
<button
type="button"
data-testid="mock-change-model"
onClick={() => props.onChangeModel?.()}
>
Change model
</button>
</div>
);
},
}));
vi.mock('../ModelSelector', () => ({
ModelSelector: (props: { selectedModel: string; models: ModelInfo[] }) => (
ModelSelector: (props: { selectedModel: string; models: ModelInfo[]; openRequest?: number }) => (
<div
data-testid="model-selector"
data-selected={props.selectedModel}
data-count={props.models.length}
data-open-request={String(props.openRequest || 0)}
/>
),
}));
@@ -305,6 +322,7 @@ async function waitForProviderCheckSettled() {
beforeEach(() => {
vi.clearAllMocks();
mockChatMessagesProps.length = 0;
setViewportWidth(1440);
resetAIRuntimeState();
mockAiChatStore.isOpenSignal.mockReturnValue(true);
@@ -382,6 +400,18 @@ describe('AIChat', () => {
expect(screen.getByTestId('model-selector')).toBeInTheDocument();
});
it('opens the model selector from failed-turn recovery', async () => {
renderChat();
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '0');
fireEvent.click(screen.getByTestId('mock-change-model'));
await waitFor(() => {
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '1');
});
});
it('checks the selected provider and shows a readiness issue before the first send', async () => {
mockAIAPI.getSettings.mockResolvedValue({
model: 'deepseek:deepseek-v4-pro',
@@ -13,6 +13,7 @@ let capturedMessageItemProps: Array<{
answers: Array<{ id: string; value: string }>,
) => void;
onSkipQuestion: (questionId: string) => void;
onChangeModel?: () => void;
}> = [];
vi.mock('../MessageItem', () => ({
@@ -25,6 +26,7 @@ vi.mock('../MessageItem', () => ({
answers: Array<{ id: string; value: string }>,
) => void;
onSkipQuestion: (questionId: string) => void;
onChangeModel?: () => void;
}) => {
capturedMessageItemProps.push(props);
return (
@@ -316,6 +318,20 @@ describe('ChatMessages', () => {
expect(handlers.onSkipQuestion).toHaveBeenCalledWith('msg-5', 'q-77');
});
it('forwards onChangeModel unchanged for failed-turn recovery', () => {
const onChangeModel = vi.fn();
render(() => (
<ChatMessages
messages={[makeMessage({ id: 'msg-model', role: 'assistant', error: 'failed' })]}
{...makeHandlers()}
onChangeModel={onChangeModel}
/>
));
const propsForMsg = capturedMessageItemProps.find((p) => p.message.id === 'msg-model');
expect(propsForMsg?.onChangeModel).toBe(onChangeModel);
});
it('forwards correct message.id when multiple messages exist', () => {
const handlers = makeHandlers();
render(() => (
@@ -146,6 +146,7 @@ describe('MessageItem', () => {
describe('error block', () => {
it('renders a distinct error block with the message and a retry button', () => {
const onRetry = vi.fn();
const onChangeModel = vi.fn();
render(() => (
<MessageItem
message={makeMessage({
@@ -155,6 +156,7 @@ describe('MessageItem', () => {
})}
{...makeHandlers()}
onRetry={onRetry}
onChangeModel={onChangeModel}
/>
));
@@ -162,6 +164,10 @@ describe('MessageItem', () => {
expect(alert).toBeInTheDocument();
expect(alert.textContent).toContain('billing or quota reasons');
const changeModel = screen.getByRole('button', { name: /change model/i });
fireEvent.click(changeModel);
expect(onChangeModel).toHaveBeenCalledTimes(1);
const retry = screen.getByRole('button', { name: /try again/i });
fireEvent.click(retry);
expect(onRetry).toHaveBeenCalledWith('msg-1');
@@ -1,5 +1,6 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { ModelSelector } from '../ModelSelector';
import type { ModelInfo } from '../types';
@@ -53,6 +54,26 @@ describe('ModelSelector', () => {
expect(screen.getByText('Claude Sonnet 4')).toBeInTheDocument();
});
it('keeps gateway-routed selected model labels distinct', () => {
render(() => (
<ModelSelector
models={[
...SAMPLE_MODELS,
makeModel({
id: 'openrouter:deepseek/deepseek-v4-pro',
name: 'DeepSeek: DeepSeek V4 Pro',
provider: 'openrouter',
notable: true,
}),
]}
selectedModel="openrouter:deepseek/deepseek-v4-pro"
onModelSelect={vi.fn()}
/>
));
expect(screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter')).toBeInTheDocument();
});
it('shows "Default" when no model is selected', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
@@ -114,6 +135,32 @@ describe('ModelSelector', () => {
expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument();
});
it('opens dropdown and focuses search when an external recovery action requests it', async () => {
const [openRequest, setOpenRequest] = createSignal(0);
render(() => (
<>
<button type="button" onClick={() => setOpenRequest((value) => value + 1)}>
Recovery action
</button>
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
openRequest={openRequest()}
onModelSelect={vi.fn()}
/>
</>
));
expect(screen.queryByPlaceholderText('Search or enter model ID')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Recovery action' }));
const searchInput = await screen.findByPlaceholderText('Search or enter model ID');
await waitFor(() => {
expect(document.activeElement).toBe(searchInput);
});
});
it('closes dropdown when toggled again', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
@@ -109,22 +109,34 @@ describe('ToolExecutionBlock', () => {
expect(screen.getByText('ls -la')).toBeInTheDocument();
});
it('truncates input longer than 60 chars', () => {
it('truncates input summaries longer than 28 chars', () => {
const longInput = 'A'.repeat(70);
render(() => <ToolExecutionBlock tool={makeTool({ input: longInput })} />);
expect(screen.getByText('A'.repeat(60) + '...')).toBeInTheDocument();
expect(screen.getByText('A'.repeat(28))).toBeInTheDocument();
});
it('shows "{}" when input is empty', () => {
render(() => <ToolExecutionBlock tool={makeTool({ input: '' })} />);
expect(screen.getByText('{}')).toBeInTheDocument();
it('summarizes JSON action input without showing raw JSON by default', () => {
render(() => (
<ToolExecutionBlock
tool={makeTool({
name: 'query',
input: '{"action":"topology","include":"all","summary_only":true}',
output: '{"summary":{"total_nodes":3}}',
})}
/>
));
expect(screen.getByText('topology')).toBeInTheDocument();
expect(screen.queryByText(/summary_only/)).not.toBeInTheDocument();
expect(screen.queryByText(/total_nodes/)).not.toBeInTheDocument();
});
// --- Output display ---
it('shows output when non-empty', () => {
it('does not show output by default when non-empty', () => {
render(() => <ToolExecutionBlock tool={makeTool({ output: 'hello world' })} />);
expect(screen.getByText('hello world')).toBeInTheDocument();
expect(screen.queryByText('hello world')).not.toBeInTheDocument();
expect(screen.getByText('Details')).toBeInTheDocument();
});
it('hides output that is only whitespace', () => {
@@ -142,90 +154,42 @@ describe('ToolExecutionBlock', () => {
expect(container.querySelector('pre')).toBeNull();
});
it('shows last 3 lines by default when output has more lines', () => {
const output = 'line1\nline2\nline3\nline4\nline5';
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
const pre = screen.getByText(/line5/);
expect(pre.textContent).toContain('...');
expect(pre.textContent).toContain('line3');
expect(pre.textContent).toContain('line4');
expect(pre.textContent).toContain('line5');
});
it('shows all lines when 3 or fewer non-empty lines', () => {
const output = 'line1\nline2\nline3';
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
const pre = screen.getByText(/line1/);
expect(pre.textContent).not.toContain('...');
});
// --- Expand/collapse behavior ---
it('shows "Show full output" button when output has more than 3 lines', () => {
it('shows a details button when raw input or output is available', () => {
const output = 'l1\nl2\nl3\nl4\nl5';
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
expect(screen.getByText('Show full output')).toBeInTheDocument();
expect(screen.getByText('Details')).toBeInTheDocument();
});
it('does not show expand button when output has 3 or fewer lines', () => {
const output = 'l1\nl2\nl3';
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
expect(screen.queryByText('Show full output')).toBeNull();
it('does not show a details button when there are no details', () => {
render(() => <ToolExecutionBlock tool={makeTool({ input: '', output: '' })} />);
expect(screen.queryByText('Details')).toBeNull();
});
it('shows full output when "Show full output" button is clicked', async () => {
it('shows raw input and output when details are opened', async () => {
const output = 'line1\nline2\nline3\nline4\nline5';
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
const btn = screen.getByText('Show full output');
render(() => (
<ToolExecutionBlock tool={makeTool({ input: '{"action":"list"}', output })} />
));
const btn = screen.getByText('Details');
fireEvent.click(btn);
// After expanding, the button should say "Show less"
expect(screen.getByText('Show less')).toBeInTheDocument();
// And full output should be visible
const pre = screen.getByText(/line1/);
expect(pre.textContent).toContain('line1');
expect(pre.textContent).toContain('line5');
expect(screen.getByText('Hide details')).toBeInTheDocument();
expect(screen.getByText('Input')).toBeInTheDocument();
expect(screen.getByText('Output')).toBeInTheDocument();
expect(screen.getByText('{"action":"list"}')).toBeInTheDocument();
expect(screen.getByText(/line1/).textContent).toContain('line5');
});
it('collapses back when "Show less" is clicked', async () => {
it('hides raw details when toggled closed', async () => {
const output = 'line1\nline2\nline3\nline4\nline5';
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
fireEvent.click(screen.getByText('Show full output'));
expect(screen.getByText('Show less')).toBeInTheDocument();
fireEvent.click(screen.getByText('Show less'));
expect(screen.getByText('Show full output')).toBeInTheDocument();
});
it('clicking header row toggles output when has more lines', async () => {
const output = 'l1\nl2\nl3\nl4\nl5';
const { container } = render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
// The header row has cursor-pointer class when collapsible
const header = container.querySelector('.cursor-pointer');
expect(header).not.toBeNull();
fireEvent.click(header!);
expect(screen.getByText('Show less')).toBeInTheDocument();
});
it('header row is not clickable when output has 3 or fewer lines', () => {
const output = 'l1\nl2';
const { container } = render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
expect(container.querySelector('.cursor-pointer')).toBeNull();
});
// --- Expand chevron icon ---
it('renders expand chevron SVG when output is collapsible', () => {
const output = 'l1\nl2\nl3\nl4';
const { container } = render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
const svg = container.querySelector('svg');
expect(svg).not.toBeNull();
});
it('rotates chevron when expanded', async () => {
const output = 'l1\nl2\nl3\nl4';
const { container } = render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
fireEvent.click(screen.getByText('Show full output'));
const svg = container.querySelector('svg');
expect(svg?.classList.contains('rotate-180')).toBe(true);
fireEvent.click(screen.getByText('Details'));
expect(screen.getByText('Hide details')).toBeInTheDocument();
fireEvent.click(screen.getByText('Hide details'));
expect(screen.getByText('Details')).toBeInTheDocument();
expect(screen.queryByText(/line1/)).not.toBeInTheDocument();
});
// --- Edge cases ---
@@ -248,20 +212,19 @@ describe('ToolExecutionBlock', () => {
// hasOutput returns false when output contains "not available"
expect(container.querySelector('pre')).toBeNull();
// The expand button should also not appear since output section is hidden
expect(screen.queryByText('Show full output')).toBeNull();
expect(screen.getByText('Details')).toBeInTheDocument();
});
it('does not truncate input at exactly 60 chars', () => {
const input60 = 'X'.repeat(60);
render(() => <ToolExecutionBlock tool={makeTool({ input: input60 })} />);
// Exactly 60 chars should NOT be truncated (condition is > 60)
expect(screen.getByText(input60)).toBeInTheDocument();
it('does not truncate input summaries at exactly 28 chars', () => {
const input28 = 'X'.repeat(28);
render(() => <ToolExecutionBlock tool={makeTool({ input: input28 })} />);
expect(screen.getByText(input28)).toBeInTheDocument();
});
it('truncates input at 61 chars', () => {
const input61 = 'Y'.repeat(61);
render(() => <ToolExecutionBlock tool={makeTool({ input: input61 })} />);
expect(screen.getByText('Y'.repeat(60) + '...')).toBeInTheDocument();
it('truncates input summaries at 29 chars', () => {
const input29 = 'Y'.repeat(29);
render(() => <ToolExecutionBlock tool={makeTool({ input: input29 })} />);
expect(screen.getByText('Y'.repeat(28))).toBeInTheDocument();
});
});
@@ -295,16 +258,9 @@ describe('PendingToolBlock', () => {
expect(screen.getByText('custom op')).toBeInTheDocument();
});
it('falls through to fallback for pulse_get_storage_config (not mapped in pending)', () => {
// PendingToolBlock does NOT have pulse_get_storage_config mapped unlike ToolExecutionBlock
// Fallback: strip pulse_, replace underscores, truncate to 12 → "get storage "
const { container } = render(() => (
<PendingToolBlock tool={makePending({ name: 'pulse_get_storage_config' })} />
));
// Use container query since getByText trims trailing whitespace
const label = container.querySelector('span.uppercase');
expect(label).not.toBeNull();
expect(label!.textContent).toBe('get storage ');
it('maps pulse_get_storage_config consistently while pending', () => {
render(() => <PendingToolBlock tool={makePending({ name: 'pulse_get_storage_config' })} />);
expect(screen.getByText('storage cfg')).toBeInTheDocument();
});
// --- Input display ---
@@ -314,22 +270,33 @@ describe('PendingToolBlock', () => {
expect(screen.getByText('df -h')).toBeInTheDocument();
});
it('truncates input longer than 50 chars', () => {
it('truncates input summaries longer than 28 chars', () => {
const longInput = 'B'.repeat(55);
render(() => <PendingToolBlock tool={makePending({ input: longInput })} />);
expect(screen.getByText('B'.repeat(50) + '...')).toBeInTheDocument();
expect(screen.getByText('B'.repeat(28))).toBeInTheDocument();
});
it('does not truncate input at exactly 50 chars', () => {
const input50 = 'C'.repeat(50);
render(() => <PendingToolBlock tool={makePending({ input: input50 })} />);
expect(screen.getByText(input50)).toBeInTheDocument();
it('does not truncate input summaries at exactly 28 chars', () => {
const input28 = 'C'.repeat(28);
render(() => <PendingToolBlock tool={makePending({ input: input28 })} />);
expect(screen.getByText(input28)).toBeInTheDocument();
});
it('truncates input at 51 chars', () => {
const input51 = 'D'.repeat(51);
render(() => <PendingToolBlock tool={makePending({ input: input51 })} />);
expect(screen.getByText('D'.repeat(50) + '...')).toBeInTheDocument();
it('truncates input summaries at 29 chars', () => {
const input29 = 'D'.repeat(29);
render(() => <PendingToolBlock tool={makePending({ input: input29 })} />);
expect(screen.getByText('D'.repeat(28))).toBeInTheDocument();
});
it('summarizes JSON action input without showing raw JSON', () => {
render(() => (
<PendingToolBlock
tool={makePending({ name: 'query', input: '{"action":"topology","include":"all"}' })}
/>
));
expect(screen.getByText('topology')).toBeInTheDocument();
expect(screen.queryByText(/include/)).not.toBeInTheDocument();
});
// --- Spinner ---
@@ -355,7 +322,7 @@ describe('PendingToolsList', () => {
render(() => <PendingToolsList tools={tools} />);
expect(screen.getByText('cmd1')).toBeInTheDocument();
expect(screen.getByText('url1')).toBeInTheDocument();
expect(screen.getByText('{}')).toBeInTheDocument();
expect(screen.getByText('request')).toBeInTheDocument();
});
it('collapses when more than 3 tools, showing first 2', () => {
@@ -451,6 +451,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const [showSessions, setShowSessions] = createSignal(false);
const [sessionDropdownPosition, setSessionDropdownPosition] = createSignal({ top: 0, right: 0 });
let sessionButtonRef: HTMLButtonElement | undefined;
const [modelSelectorOpenRequest, setModelSelectorOpenRequest] = createSignal(0);
const [defaultModel, setDefaultModel] = createSignal('');
const [chatOverrideModel, setChatOverrideModel] = createSignal('');
const [providerReadiness, setProviderReadiness] = createSignal<ChatProviderReadinessState>({
@@ -707,14 +708,14 @@ export const AIChat: Component<AIChatProps> = (props) => {
const fallback = defaultModel().trim();
if (!fallback) return '';
const match = aiRuntimeModels().find((model) => model.id === fallback);
return match ? match.name || match.id.split(':').pop() || match.id : fallback;
return match ? formatAIModelRouteLabel(match) : fallback;
});
const chatOverrideLabel = createMemo(() => {
const override = chatOverrideModel().trim();
if (!override) return '';
const match = aiRuntimeModels().find((model) => model.id === override);
return match ? match.name || match.id.split(':').pop() || match.id : override;
return match ? formatAIModelRouteLabel(match) : override;
});
const selectedChatModel = createMemo(() => {
@@ -879,6 +880,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
updateStoredModel(chat.sessionId(), modelId);
};
const openModelSelectorFromError = () => {
setModelSelectorOpenRequest((value) => value + 1);
};
const switchToProviderReadinessAlternative = () => {
const alternative = providerReadinessAlternative();
if (!alternative) return;
@@ -1764,6 +1769,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
chatOverrideLabel={chatOverrideLabel()}
isLoading={aiRuntimeModelsLoading()}
error={aiRuntimeModelsError()}
openRequest={modelSelectorOpenRequest()}
onModelSelect={selectModel}
onRefresh={() => loadModels(true)}
/>
@@ -2139,6 +2145,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
onAnswerQuestion={handleAnswerQuestion}
onSkipQuestion={handleSkipQuestion}
onRetry={(messageId) => chat.retryMessage(messageId)}
onChangeModel={openModelSelectorFromError}
recentSessions={sessions()
.filter((s) => s.id !== chat.sessionId() && s.message_count > 0)
.slice(0, 3)}