Add unit tests for skill capabilities, skill planner, and UV setup

- Implement tests for random ID generation, ensuring preference for crypto.randomUUID.
- Create tests for runtime context capabilities, validating the injection of enabled skill capabilities.
- Add tests for skill capability parsing, including classification and command example extraction.
- Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements.
- Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
This commit is contained in:
DEV_DSW
2026-04-24 17:02:59 +08:00
parent e11a2296cc
commit 4c61e93c3e
42 changed files with 12560 additions and 224 deletions

View File

@@ -11,10 +11,17 @@ import {
Link2,
Loader2,
Paperclip,
Search,
Wrench,
} from 'lucide-react';
import type { ChatMessageItem } from './types';
import type { ToolStatus } from '../../shared/chat-model';
import type {
AttachedFileMeta,
ToolArtifact,
ToolRenderHints,
ToolResultPayload,
ToolStatus,
} from '../../shared/chat-model';
import { useI18n } from '../../i18n';
import { apiOpenSkillPath, apiOpenSkillReadme } from '../../lib/skills-api';
import ChatEmptyState from './ChatEmptyState';
@@ -118,10 +125,112 @@ type ToolDetail = {
value: string;
};
type ToolUiLabels = {
analysisOverview: string;
structuredData: string;
searchOverview?: string;
files: string;
sheets: string;
rows: string;
cols: string;
columns: string;
preview: string;
findings: string;
artifacts: string;
file: string;
path: string;
engine: string;
provider?: string;
query?: string;
answer?: string;
results?: string;
source?: string;
score?: string;
responseTime?: string;
installCommand?: string;
notAvailable: string;
emptyPreview: string;
moreColumns: string;
topLevelResult: string;
};
type NormalizedToolResult = {
payload: ToolResultPayload | null;
renderHints?: ToolRenderHints;
structuredData?: unknown;
artifacts: ToolArtifact[];
};
type StructuredPreview = {
details: ToolDetail[];
table?: {
headers: string[];
rows: string[][];
};
};
type SpreadsheetSheetPreview = {
name: string;
rows?: number;
cols?: number;
columns: string[];
previewRows: Array<Record<string, unknown>>;
findings: string[];
};
type SpreadsheetReportPreview = {
key: string;
fileName: string;
filePath?: string;
engine?: string;
totalRows?: number;
sheetCount?: number;
sheets: SpreadsheetSheetPreview[];
};
type SearchResultPreview = {
provider?: string;
query?: string;
answer?: string;
responseTimeMs?: number;
resultCount?: number;
results: Array<{
title: string;
url?: string;
snippet?: string;
source?: string;
score?: number;
age?: string;
publishedAt?: string;
installCommand?: string;
}>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function isAttachedFileMeta(value: unknown): value is AttachedFileMeta {
return isRecord(value)
&& typeof value.fileName === 'string'
&& typeof value.mimeType === 'string';
}
function isToolArtifact(value: unknown): value is ToolArtifact {
return isRecord(value)
&& (
typeof value.kind === 'string'
|| typeof value.name === 'string'
|| typeof value.label === 'string'
|| typeof value.filePath === 'string'
|| typeof value.uri === 'string'
);
}
function getRecordString(value: unknown, ...keys: string[]): string | undefined {
if (!isRecord(value)) return undefined;
@@ -135,6 +244,396 @@ function getRecordString(value: unknown, ...keys: string[]): string | undefined
return undefined;
}
function getRecordNumber(value: unknown, ...keys: string[]): number | undefined {
if (!isRecord(value)) return undefined;
for (const key of keys) {
const field = value[key];
if (typeof field === 'number' && Number.isFinite(field)) {
return field;
}
}
return undefined;
}
function getToolUiLabels(locale: string): ToolUiLabels {
if (locale.startsWith('zh')) {
return {
analysisOverview: '分析概览',
structuredData: '结构化结果',
files: '文件',
sheets: '工作表',
rows: '行',
cols: '列',
columns: '字段',
preview: '预览',
findings: '发现',
artifacts: '相关产物',
file: '文件',
path: '路径',
engine: '解析引擎',
notAvailable: '暂无',
emptyPreview: '没有可展示的预览内容',
moreColumns: '更多字段',
topLevelResult: '结果',
};
}
return {
analysisOverview: 'Analysis overview',
structuredData: 'Structured result',
files: 'Files',
sheets: 'Sheets',
rows: 'Rows',
cols: 'Cols',
columns: 'Columns',
preview: 'Preview',
findings: 'Findings',
artifacts: 'Artifacts',
file: 'File',
path: 'Path',
engine: 'Engine',
notAvailable: 'N/A',
emptyPreview: 'No preview available',
moreColumns: 'More columns',
topLevelResult: 'Result',
};
}
function formatNumberValue(value: number, locale: string, maximumFractionDigits = 2): string {
return new Intl.NumberFormat(locale.startsWith('zh') ? 'zh-CN' : undefined, {
maximumFractionDigits,
}).format(value);
}
function formatStructuredValue(value: unknown, locale: string, labels: ToolUiLabels): string {
if (value === null || value === undefined || value === '') {
return labels.notAvailable;
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return formatNumberValue(value, locale);
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (Array.isArray(value)) {
const compact = value
.map((item) => {
if (typeof item === 'string') return item;
if (typeof item === 'number') return formatNumberValue(item, locale);
return '';
})
.filter(Boolean);
if (compact.length > 0) {
const summary = compact.slice(0, 4).join(', ');
return compact.length > 4 ? `${summary} +${compact.length - 4}` : summary;
}
return `${value.length}`;
}
if (isRecord(value)) {
return getRecordString(value, 'label', 'title', 'name', 'message', 'summary')
|| `${Object.keys(value).length}`;
}
return String(value);
}
function prettifyKey(key: string): string {
if (!key) return key;
if (/[\u4e00-\u9fff]/.test(key)) return key;
return key
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^./, (char) => char.toUpperCase());
}
function isToolResultPayload(value: unknown): value is ToolResultPayload {
return isRecord(value)
&& (
'structuredData' in value
|| 'renderHints' in value
|| 'artifacts' in value
|| 'files' in value
|| 'logs' in value
|| 'retryable' in value
|| 'ok' in value
);
}
function mergeToolArtifacts(
payloadArtifacts: ToolArtifact[],
payloadFiles: AttachedFileMeta[],
toolArtifacts: ToolArtifact[],
): ToolArtifact[] {
const merged = [
...payloadArtifacts,
...payloadFiles.map((file) => ({
kind: 'file' as const,
name: file.fileName,
filePath: file.filePath,
mimeType: file.mimeType,
preview: file.preview,
metadata: {
fileSize: file.fileSize,
source: file.source,
},
})),
...toolArtifacts,
];
const seen = new Set<string>();
return merged.filter((artifact) => {
const key = [
artifact.kind,
artifact.name,
artifact.label,
artifact.filePath,
artifact.uri,
].filter(Boolean).join('|');
if (!key || seen.has(key)) {
return Boolean(!key);
}
seen.add(key);
return true;
});
}
function getNormalizedToolResult(tool: ToolStatus): NormalizedToolResult {
const payload = isToolResultPayload(tool.result) ? tool.result : null;
const payloadArtifacts = payload?.artifacts?.filter(isToolArtifact) ?? [];
const payloadFiles = payload?.files?.filter(isAttachedFileMeta) ?? [];
const toolArtifacts = tool.artifacts?.filter(isToolArtifact) ?? [];
return {
payload,
renderHints: payload?.renderHints || tool.renderHints,
structuredData: payload?.structuredData,
artifacts: mergeToolArtifacts(payloadArtifacts, payloadFiles, toolArtifacts),
};
}
function getToolResultSource(tool: ToolStatus): unknown {
const normalized = getNormalizedToolResult(tool);
if (isRecord(normalized.payload?.structuredData)) {
return normalized.payload.structuredData;
}
return normalized.payload || tool.result;
}
function buildMetricLabel(kind: 'files' | 'sheets' | 'rows' | 'cols', value: number, labels: ToolUiLabels, locale: string): string {
const count = formatNumberValue(value, locale);
if (locale.startsWith('zh')) {
switch (kind) {
case 'files':
return `${count}${labels.files}`;
case 'sheets':
return `${count}${labels.sheets}`;
case 'rows':
return `${count}${labels.rows}`;
case 'cols':
return `${count}${labels.cols}`;
default:
return count;
}
}
switch (kind) {
case 'files':
return `${count} ${value === 1 ? 'file' : labels.files.toLowerCase()}`;
case 'sheets':
return `${count} ${value === 1 ? 'sheet' : labels.sheets.toLowerCase()}`;
case 'rows':
return `${count} ${value === 1 ? 'row' : labels.rows.toLowerCase()}`;
case 'cols':
return `${count} ${value === 1 ? 'col' : labels.cols.toLowerCase()}`;
default:
return count;
}
}
function buildStructuredPreview(
structuredData: unknown,
locale: string,
labels: ToolUiLabels,
): StructuredPreview | null {
if (Array.isArray(structuredData)) {
const rows = structuredData.filter(isRecord).slice(0, 5);
if (rows.length === 0) {
return null;
}
const headers = Array.from(new Set(rows.flatMap((row) => Object.keys(row)))).slice(0, 6);
return {
details: [],
table: {
headers,
rows: rows.map((row) => headers.map((header) => formatStructuredValue(row[header], locale, labels))),
},
};
}
if (!isRecord(structuredData)) {
if (typeof structuredData === 'string' && structuredData.trim()) {
return {
details: [{ label: labels.topLevelResult, value: structuredData.trim() }],
};
}
return null;
}
const detailEntries = Object.entries(structuredData)
.filter(([, value]) =>
typeof value === 'string'
|| typeof value === 'number'
|| typeof value === 'boolean'
|| isStringArray(value),
)
.slice(0, 8)
.map(([key, value]) => ({
label: prettifyKey(key),
value: formatStructuredValue(value, locale, labels),
}));
const tableSource = Object.values(structuredData).find((value) =>
Array.isArray(value) && value.length > 0 && value.every(isRecord),
);
if (Array.isArray(tableSource)) {
const rows = tableSource.filter(isRecord).slice(0, 5);
const headers = Array.from(new Set(rows.flatMap((row) => Object.keys(row)))).slice(0, 6);
if (headers.length > 0) {
return {
details: detailEntries,
table: {
headers,
rows: rows.map((row) => headers.map((header) => formatStructuredValue(row[header], locale, labels))),
},
};
}
}
return detailEntries.length > 0 ? { details: detailEntries } : null;
}
function formatFinding(value: unknown, locale: string, labels: ToolUiLabels): string {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
if (!isRecord(value)) {
return formatStructuredValue(value, locale, labels);
}
return getRecordString(value, 'message', 'summary', 'description', 'label')
|| [
getRecordString(value, 'type', 'code'),
getRecordString(value, 'column', 'field'),
typeof value.count === 'number' ? formatNumberValue(value.count, locale) : undefined,
].filter(Boolean).join(' · ');
}
function buildSpreadsheetPreview(
structuredData: unknown,
locale: string,
labels: ToolUiLabels,
): SpreadsheetReportPreview[] {
if (!isRecord(structuredData) || !Array.isArray(structuredData.reports)) {
return [];
}
return structuredData.reports
.filter(isRecord)
.map((reportItem, reportIndex) => {
const report = isRecord(reportItem.report) ? reportItem.report : null;
const structure = isRecord(report?.structure) ? report.structure : null;
const quality = isRecord(report?.quality) ? report.quality : null;
const workbook = isRecord(report?.workbook) ? report.workbook : null;
const sheetNames = isStringArray(workbook?.sheetNames) ? workbook.sheetNames : [];
const sheets = Object.entries(structure ?? {})
.filter(([, value]) => isRecord(value))
.slice(0, 3)
.map(([sheetName, sheetValue]) => {
const shape = isRecord(sheetValue.shape) ? sheetValue.shape : null;
const columns = isStringArray(sheetValue.columns) ? sheetValue.columns : [];
const previewRows = Array.isArray(sheetValue.preview)
? sheetValue.preview.filter(isRecord).slice(0, 5)
: [];
const findings = Array.isArray(quality?.[sheetName])
? quality[sheetName]
.map((finding) => formatFinding(finding, locale, labels))
.filter(Boolean)
.slice(0, 4)
: [];
return {
name: sheetName,
rows: getRecordNumber(shape, 'rows'),
cols: getRecordNumber(shape, 'cols'),
columns,
previewRows,
findings,
};
});
return {
key: `${getRecordString(reportItem, 'filePath', 'fileName') || `report-${reportIndex}`}`,
fileName: getRecordString(reportItem, 'fileName') || getRecordString(reportItem, 'filePath') || `Report ${reportIndex + 1}`,
filePath: getRecordString(reportItem, 'filePath'),
engine: getRecordString(report, 'engine'),
totalRows: getRecordNumber(workbook, 'totalRows'),
sheetCount: sheetNames.length || sheets.length,
sheets,
};
});
}
function buildSearchResultsPreview(structuredData: unknown): SearchResultPreview | null {
if (!isRecord(structuredData) || !Array.isArray(structuredData.results)) {
return null;
}
const results = structuredData.results
.filter(isRecord)
.slice(0, 6)
.map((item) => ({
title: getRecordString(item, 'title', 'name') || 'Untitled result',
url: getRecordString(item, 'url'),
snippet: getRecordString(item, 'snippet', 'description', 'content'),
source: getRecordString(item, 'source'),
score: getRecordNumber(item, 'score'),
age: getRecordString(item, 'age'),
publishedAt: getRecordString(item, 'publishedAt', 'published_at', 'published_date'),
installCommand: getRecordString(item, 'installCommand', 'install_command'),
}));
if (results.length === 0) {
return null;
}
return {
provider: getRecordString(structuredData, 'provider'),
query: getRecordString(structuredData, 'query'),
answer: getRecordString(structuredData, 'answer'),
responseTimeMs: getRecordNumber(structuredData, 'responseTimeMs', 'response_time_ms'),
resultCount: getRecordNumber(structuredData, 'resultCount', 'result_count'),
results,
};
}
function getToolDisplayName(name: string, t: TranslateFn): string {
switch (name) {
case 'skills.install':
@@ -160,12 +659,15 @@ function getToolStatusLabel(status: ToolStatus['status'], t: TranslateFn): strin
}
function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] {
const normalized = getNormalizedToolResult(tool);
const toolResult = getToolResultSource(tool);
if (tool.name === 'skills.install') {
const slug = getRecordString(tool.result, 'slug') || getRecordString(tool.input, 'slug');
const source = getRecordString(tool.result, 'source') || getRecordString(tool.input, 'kind');
const baseDir = getRecordString(tool.result, 'baseDir');
const slug = getRecordString(toolResult, 'slug') || getRecordString(tool.input, 'slug');
const source = getRecordString(toolResult, 'source') || getRecordString(tool.input, 'kind');
const baseDir = getRecordString(toolResult, 'baseDir');
const requestUrl = getRecordString(tool.input, 'url');
const error = getRecordString(tool.result, 'error');
const error = getRecordString(toolResult, 'error') || getRecordString(normalized.payload?.error, 'message');
const details: ToolDetail[] = [];
if (slug) {
@@ -188,9 +690,9 @@ function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] {
}
if (tool.name === 'browser.open_url') {
const link = getRecordString(tool.result, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
const title = getRecordString(tool.result, 'title');
const error = getRecordString(tool.result, 'error');
const link = getRecordString(toolResult, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
const title = getRecordString(toolResult, 'title');
const error = getRecordString(toolResult, 'error') || getRecordString(normalized.payload?.error, 'message');
const details: ToolDetail[] = [];
if (link) {
@@ -206,7 +708,7 @@ function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] {
return details;
}
const error = getRecordString(tool.result, 'error');
const error = getRecordString(toolResult, 'error') || getRecordString(normalized.payload?.error, 'message');
return error ? [{ label: t('conversation.messageList.toolFields.error'), value: error }] : [];
}
@@ -251,18 +753,44 @@ function ToolResultCard({
}: {
tool: ToolStatus;
}) {
const { t } = useI18n();
const { t, locale } = useI18n();
const [feedback, setFeedback] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [copiedAction, setCopiedAction] = useState<string | null>(null);
const duration = formatToolDuration(tool.durationMs);
const isRunning = tool.status === 'running';
const isError = tool.status === 'error';
const labels = getToolUiLabels(locale);
const normalized = getNormalizedToolResult(tool);
const toolResult = getToolResultSource(tool);
const details = buildToolDetails(tool, t);
const skillKey = getRecordString(tool.result, 'skillKey', 'slug') || getRecordString(tool.input, 'slug');
const skillSlug = getRecordString(tool.result, 'slug') || getRecordString(tool.input, 'slug');
const skillBaseDir = getRecordString(tool.result, 'baseDir');
const browserUrl = getRecordString(tool.result, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
const skillKey = getRecordString(toolResult, 'skillKey', 'slug') || getRecordString(tool.input, 'slug');
const skillSlug = getRecordString(toolResult, 'slug') || getRecordString(tool.input, 'slug');
const skillBaseDir = getRecordString(toolResult, 'baseDir');
const browserUrl = getRecordString(toolResult, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
const spreadsheetReports = !isRunning && !isError
? buildSpreadsheetPreview(normalized.structuredData, locale, labels)
: [];
const searchPreview = !isRunning && !isError
? buildSearchResultsPreview(normalized.structuredData)
: null;
const genericStructuredPreview = !isRunning && !isError && spreadsheetReports.length === 0 && !searchPreview
? buildStructuredPreview(normalized.structuredData, locale, labels)
: null;
const totalSheets = spreadsheetReports.reduce((sum, report) => sum + (report.sheetCount || report.sheets.length), 0);
const totalRows = spreadsheetReports.reduce((sum, report) => {
if (typeof report.totalRows === 'number') {
return sum + report.totalRows;
}
return sum + report.sheets.reduce((sheetSum, sheet) => sheetSum + (sheet.rows || 0), 0);
}, 0);
const overviewMetrics = [
spreadsheetReports.length > 0 ? buildMetricLabel('files', spreadsheetReports.length, labels, locale) : null,
totalSheets > 0 ? buildMetricLabel('sheets', totalSheets, labels, locale) : null,
totalRows > 0 ? buildMetricLabel('rows', totalRows, labels, locale) : null,
].filter(Boolean) as string[];
const artifacts = normalized.artifacts.slice(0, 8);
async function handleAction(
actionKey: string,
@@ -359,6 +887,347 @@ function ToolResultCard({
</div>
) : null}
{spreadsheetReports.length > 0 ? (
<div
className="flex flex-col gap-3 rounded-2xl bg-white/45 p-3 dark:bg-black/10"
data-testid="tool-spreadsheet-preview"
>
<div className="flex flex-col gap-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{labels.analysisOverview}
</div>
{overviewMetrics.length > 0 ? (
<div className="flex flex-wrap gap-2">
{overviewMetrics.map((metric) => (
<span
key={`${tool.toolCallId || tool.id || tool.name}-${metric}`}
className="inline-flex items-center rounded-full border border-current/10 bg-white/60 px-2.5 py-1 text-[11px] font-medium text-current/85 dark:bg-black/10"
>
{metric}
</span>
))}
</div>
) : null}
</div>
{spreadsheetReports.map((report) => (
<div
key={report.key}
className="flex flex-col gap-3 rounded-[18px] border border-current/10 bg-white/60 p-3 dark:bg-black/10"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold leading-6 text-current">
{report.fileName}
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] leading-5 text-current/75">
{report.filePath ? (
<span className="break-all">{labels.path}: {report.filePath}</span>
) : null}
{report.engine ? <span>{labels.engine}: {report.engine}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{typeof report.sheetCount === 'number' && report.sheetCount > 0 ? (
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
{buildMetricLabel('sheets', report.sheetCount, labels, locale)}
</span>
) : null}
{typeof report.totalRows === 'number' && report.totalRows > 0 ? (
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
{buildMetricLabel('rows', report.totalRows, labels, locale)}
</span>
) : null}
</div>
</div>
{report.sheets.map((sheet) => {
const previewHeaders = Array.from(new Set([
...sheet.columns,
...sheet.previewRows.flatMap((row) => Object.keys(row)),
])).slice(0, 6);
const extraColumnCount = Math.max(sheet.columns.length - previewHeaders.length, 0);
return (
<div
key={`${report.key}-${sheet.name}`}
className="flex flex-col gap-2 rounded-2xl border border-current/10 bg-white/50 p-3 dark:bg-black/10"
>
<div className="flex flex-wrap items-center gap-2">
<div className="text-[12px] font-semibold leading-5 text-current">
{sheet.name}
</div>
{typeof sheet.rows === 'number' ? (
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
{buildMetricLabel('rows', sheet.rows, labels, locale)}
</span>
) : null}
{typeof sheet.cols === 'number' ? (
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
{buildMetricLabel('cols', sheet.cols, labels, locale)}
</span>
) : null}
</div>
{sheet.columns.length > 0 ? (
<div className="text-[11px] leading-5 text-current/75">
{labels.columns}: {sheet.columns.slice(0, 6).join(', ')}
{extraColumnCount > 0 ? ` · ${labels.moreColumns} +${extraColumnCount}` : ''}
</div>
) : null}
<div className="flex flex-col gap-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{labels.preview}
</div>
{previewHeaders.length > 0 && sheet.previewRows.length > 0 ? (
<div className="overflow-x-auto rounded-2xl border border-current/10 bg-white/65 dark:bg-black/10">
<table className="min-w-full border-collapse text-left text-[11px] leading-5">
<thead>
<tr className="border-b border-current/10 bg-white/70 dark:bg-black/20">
{previewHeaders.map((header) => (
<th key={`${sheet.name}-${header}`} className="px-3 py-2 font-semibold text-current/80">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{sheet.previewRows.map((row, rowIndex) => (
<tr key={`${sheet.name}-row-${rowIndex}`} className="border-b border-current/10 last:border-b-0">
{previewHeaders.map((header) => (
<td key={`${sheet.name}-${rowIndex}-${header}`} className="px-3 py-2 align-top text-current/85">
{formatStructuredValue(row[header], locale, labels)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="rounded-2xl border border-dashed border-current/15 px-3 py-2 text-[11px] text-current/70">
{labels.emptyPreview}
</div>
)}
</div>
{sheet.findings.length > 0 ? (
<div className="flex flex-col gap-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{labels.findings}
</div>
<ul className="grid gap-1 text-[11px] leading-5 text-current/85">
{sheet.findings.map((finding, index) => (
<li key={`${report.key}-${sheet.name}-finding-${index}`} className="break-words">
{finding}
</li>
))}
</ul>
</div>
) : null}
</div>
);
})}
</div>
))}
</div>
) : null}
{searchPreview ? (
<div
className="flex flex-col gap-3 rounded-2xl bg-white/45 p-3 dark:bg-black/10"
data-testid="tool-search-results"
>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-full border border-current/10 bg-white/60 px-2.5 py-1 text-[11px] font-medium text-current/85 dark:bg-black/10">
<Search className="h-3.5 w-3.5 shrink-0" />
<span>{labels.searchOverview || 'Search overview'}</span>
</div>
{searchPreview.provider ? (
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
{(labels.provider || 'Provider')}: {searchPreview.provider}
</span>
) : null}
{typeof searchPreview.resultCount === 'number' ? (
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
{formatNumberValue(searchPreview.resultCount, locale)} {(labels.results || 'Results').toLowerCase()}
</span>
) : null}
{typeof searchPreview.responseTimeMs === 'number' ? (
<span className="inline-flex items-center rounded-full border border-current/10 px-2 py-1 text-[10px] font-medium text-current/80">
{(labels.responseTime || 'Response time')}: {formatToolDuration(searchPreview.responseTimeMs) || `${searchPreview.responseTimeMs}ms`}
</span>
) : null}
</div>
{searchPreview.query ? (
<div className="grid gap-1 rounded-2xl border border-current/10 bg-white/55 p-3 dark:bg-black/10">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{labels.query || 'Query'}
</div>
<div className="break-words text-[12px] leading-5 text-current/90">
{searchPreview.query}
</div>
</div>
) : null}
{searchPreview.answer ? (
<div className="grid gap-1 rounded-2xl border border-current/10 bg-white/55 p-3 dark:bg-black/10">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{labels.answer || 'Answer'}
</div>
<div className="whitespace-pre-wrap break-words text-[12px] leading-5 text-current/90">
{searchPreview.answer}
</div>
</div>
) : null}
<div className="flex flex-col gap-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{labels.results || 'Results'}
</div>
{searchPreview.results.map((result, index) => (
<div
key={`${tool.toolCallId || tool.id || tool.name}-search-result-${index}`}
className="flex flex-col gap-2 rounded-[18px] border border-current/10 bg-white/60 p-3 dark:bg-black/10"
>
<div className="flex flex-col gap-1">
<div className="text-[13px] font-semibold leading-6 text-current">
{result.title}
</div>
{result.url ? (
<div className="break-all text-[11px] leading-5 text-current/75">
{result.url}
</div>
) : null}
</div>
{result.snippet ? (
<div className="text-[12px] leading-5 text-current/85">
{result.snippet}
</div>
) : null}
<div className="flex flex-wrap gap-2 text-[10px] leading-5 text-current/75">
{result.source ? (
<span>{labels.source || 'Source'}: {result.source}</span>
) : null}
{typeof result.score === 'number' ? (
<span>{labels.score || 'Score'}: {result.score.toFixed(4)}</span>
) : null}
{result.age ? <span>{result.age}</span> : null}
{result.publishedAt ? <span>{result.publishedAt}</span> : null}
</div>
{result.installCommand ? (
<div className="grid gap-1 rounded-2xl border border-current/10 bg-white/55 p-3 dark:bg-black/10">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{labels.installCommand || 'Install command'}
</div>
<div className="break-all font-mono text-[11px] leading-5 text-current/90">
{result.installCommand}
</div>
</div>
) : null}
</div>
))}
</div>
</div>
) : null}
{genericStructuredPreview && (genericStructuredPreview.details.length > 0 || genericStructuredPreview.table) ? (
<div
className="flex flex-col gap-3 rounded-2xl bg-white/45 p-3 dark:bg-black/10"
data-testid="tool-structured-result"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{labels.structuredData}
</div>
{genericStructuredPreview.details.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{genericStructuredPreview.details.map((detail) => (
<div
key={`${tool.toolCallId || tool.id || tool.name}-structured-${detail.label}`}
className="grid gap-1 rounded-2xl border border-current/10 bg-white/55 p-3 dark:bg-black/10"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{detail.label}
</div>
<div className="break-words text-[12px] leading-5 text-current/90">
{detail.value}
</div>
</div>
))}
</div>
) : null}
{genericStructuredPreview.table ? (
<div className="overflow-x-auto rounded-2xl border border-current/10 bg-white/65 dark:bg-black/10">
<table className="min-w-full border-collapse text-left text-[11px] leading-5">
<thead>
<tr className="border-b border-current/10 bg-white/70 dark:bg-black/20">
{genericStructuredPreview.table.headers.map((header) => (
<th key={`${tool.toolCallId || tool.id || tool.name}-${header}`} className="px-3 py-2 font-semibold text-current/80">
{prettifyKey(header)}
</th>
))}
</tr>
</thead>
<tbody>
{genericStructuredPreview.table.rows.map((row, rowIndex) => (
<tr key={`${tool.toolCallId || tool.id || tool.name}-structured-row-${rowIndex}`} className="border-b border-current/10 last:border-b-0">
{row.map((value, valueIndex) => (
<td
key={`${tool.toolCallId || tool.id || tool.name}-structured-cell-${rowIndex}-${valueIndex}`}
className="px-3 py-2 align-top text-current/85"
>
{value}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
) : null}
{artifacts.length > 0 ? (
<div className="flex flex-col gap-2 rounded-2xl bg-white/45 p-3 dark:bg-black/10">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{labels.artifacts}
</div>
<div className="flex flex-wrap gap-2">
{artifacts.map((artifact, index) => {
const title = artifact.label || artifact.name || artifact.filePath || artifact.uri || `${labels.file} ${index + 1}`;
const subtitle = artifact.description
|| artifact.filePath
|| artifact.uri
|| artifact.mimeType;
return (
<div
key={`${tool.toolCallId || tool.id || tool.name}-artifact-${title}-${index}`}
className="flex min-w-[180px] max-w-full flex-col gap-1 rounded-2xl border border-current/10 bg-white/60 px-3 py-2 dark:bg-black/10"
>
<div className="truncate text-[12px] font-semibold leading-5 text-current">
{title}
</div>
{subtitle ? (
<div className="break-all text-[11px] leading-5 text-current/70">
{subtitle}
</div>
) : null}
</div>
);
})}
</div>
</div>
) : null}
{tool.name === 'skills.install' && !isRunning && (skillKey || skillBaseDir) ? (
<div className="flex flex-col gap-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">

View File

@@ -2,17 +2,56 @@ import type { RawMessage } from '../../runtime-shared/shared/chat-model';
export * from '../../runtime-shared/shared/chat-model';
function flattenContentBlocks(content: RawMessage['content']): string {
if (typeof content === 'string') {
return content;
}
return content
.map((block) => {
if (!block || typeof block !== 'object') {
return '';
}
if (block.type === 'text' && typeof block.text === 'string') {
return block.text;
}
if ((block.type === 'tool_result' || block.type === 'toolResult')) {
if (typeof block.content === 'string' && block.content.trim()) {
return block.content;
}
if (Array.isArray(block.content)) {
return flattenContentBlocks(block.content);
}
if (typeof block.summary === 'string' && block.summary.trim()) {
return block.summary;
}
const result = block.result;
if (
result
&& typeof result === 'object'
&& 'summary' in result
&& typeof result.summary === 'string'
&& result.summary.trim()
) {
return result.summary;
}
}
return '';
})
.filter(Boolean)
.join('\n');
}
export function extractText(message?: RawMessage | null): string {
if (!message) return '';
if (typeof message.content === 'string') {
return message.content;
}
return message.content
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text ?? '')
.join('\n');
return flattenContentBlocks(message.content);
}
export function extractThinking(message?: RawMessage | null): string | null {
@@ -99,6 +138,16 @@ export function isInternalMessage(message: { role?: string; content?: unknown })
if (message.role === 'system') return true;
if (message.role === 'assistant') {
if (Array.isArray(message.content)) {
const hasVisibleText = message.content.some((block) => block.type === 'text' && block.text?.trim());
const hasOnlyToolUseBlocks = message.content.length > 0
&& message.content.every((block) => block.type === 'tool_use' || block.type === 'toolCall');
if (hasOnlyToolUseBlocks && !hasVisibleText) {
return true;
}
}
const text = typeof message.content === 'string'
? message.content
: extractText(message as RawMessage);

View File

@@ -13,6 +13,7 @@ import { t } from '../i18n';
import { extractText, isToolOnlyMessage } from '../shared/chat-model';
import { gatewayRpc, onGatewayEvent } from '../lib/gateway-client';
import { hostApiFetch } from '../lib/host-api';
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../lib/runtime-events';
import type { GatewayEvent } from '../types/runtime';
import { agentsStore } from './agents';
@@ -417,6 +418,11 @@ async function subscribeToGateway(): Promise<void> {
return;
}
if (isRuntimeChangedGatewayEvent(event) && runtimeEventHasTopic(event, 'skills')) {
void handleGatewayEvent(event);
return;
}
if (typeof event.sessionKey === 'string' && event.sessionKey !== state.currentSessionKey) {
return;
}
@@ -1031,6 +1037,14 @@ async function handleGatewayEvent(event: GatewayEvent): Promise<void> {
});
break;
}
case 'runtime:changed': {
if (runtimeEventHasTopic(event, 'skills') && state.initialized) {
patchState({ error: null });
lastHistoryLoadAtBySession.delete(state.currentSessionKey);
await loadHistory(state.currentSessionKey, true);
}
break;
}
default:
break;
}