diff --git a/package.json b/package.json index a99f83e8..7dd59712 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts", "standalone": "tsx src/main/standalone.ts", "standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts", - "standalone:start": "node dist-standalone/index.cjs" + "standalone:start": "node dist-standalone/index.cjs", + "standalone:kill": "PIDS=$(lsof -ti :3456); [ -n \"$PIDS\" ] && kill -9 $PIDS || true", + "standalone:restart": "pnpm standalone:kill && pnpm standalone:build && pnpm standalone:start" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/src/main/services/analysis/SemanticStepExtractor.ts b/src/main/services/analysis/SemanticStepExtractor.ts index 12c4195f..fefda795 100644 --- a/src/main/services/analysis/SemanticStepExtractor.ts +++ b/src/main/services/analysis/SemanticStepExtractor.ts @@ -90,6 +90,42 @@ export function extractSemanticStepsFromAIChunk(chunk: AIChunk | EnhancedAIChunk }); } + if (block.type === 'server_tool_use' && block.name === 'advisor' && block.id) { + // advisor CALL — input is empty so callTokens stay undefined (fallback estimates ~0) + steps.push({ + id: block.id, + type: 'tool_call', + startTime: new Date(msg.timestamp), + durationMs: 0, + content: { + toolName: 'advisor', + toolInput: {}, + sourceModel: msg.advisorModel, + }, + context: msg.agentId ? 'subagent' : 'main', + agentId: msg.agentId, + sourceMessageId: msg.uuid, + }); + } + + if (block.type === 'advisor_tool_result' && block.tool_use_id) { + // advisor RESULT — advice text is real consumed context, counted like any tool result + const advisorText = block.content?.text ?? ''; + steps.push({ + id: block.tool_use_id, + type: 'tool_result', + startTime: new Date(msg.timestamp), + durationMs: 0, + content: { + toolResultContent: advisorText, + isError: false, + tokenCount: advisorText ? countContentTokens(advisorText) : 0, + }, + context: msg.agentId ? 'subagent' : 'main', + agentId: msg.agentId, + }); + } + if (block.type === 'text' && block.text) { // Calculate tokens for text output (Claude's generated text) const textTokens = countContentTokens(block.text); diff --git a/src/main/types/jsonl.ts b/src/main/types/jsonl.ts index 6435a707..2d3d090f 100644 --- a/src/main/types/jsonl.ts +++ b/src/main/types/jsonl.ts @@ -20,7 +20,14 @@ type EntryType = | 'file-history-snapshot' | 'queue-operation'; -type ContentType = 'text' | 'thinking' | 'tool_use' | 'tool_result' | 'image'; +type ContentType = + | 'text' + | 'thinking' + | 'tool_use' + | 'tool_result' + | 'image' + | 'server_tool_use' + | 'advisor_tool_result'; type StopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | null; @@ -66,12 +73,27 @@ export interface ImageContent extends BaseContent { }; } +export interface ServerToolUseContent extends BaseContent { + type: 'server_tool_use'; + id: string; + name: string; + input?: Record; +} + +export interface AdvisorToolResultContent extends BaseContent { + type: 'advisor_tool_result'; + tool_use_id: string; + content: { type: 'advisor_result'; text: string }; +} + export type ContentBlock = | TextContent | ThinkingContent | ToolUseContent | ToolResultContent - | ImageContent; + | ImageContent + | ServerToolUseContent + | AdvisorToolResultContent; // ============================================================================= // Usage Metadata @@ -178,6 +200,7 @@ export interface AssistantEntry extends ConversationalEntry { message: AssistantMessage; requestId: string; agentId?: string; + advisorModel?: string; } export interface SystemEntry extends ConversationalEntry { diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index cc745c0d..c1a693e7 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -77,6 +77,8 @@ export interface ParsedMessage { usage?: TokenUsage; /** Model used for this response */ model?: string; + /** Advisor model identifier; set only when this entry invoked the advisor tool. */ + advisorModel?: string; // Metadata /** Current working directory when message was created */ cwd?: string; diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 8c90181e..484a9b9c 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -117,6 +117,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let role: string | undefined; let usage: TokenUsage | undefined; let model: string | undefined; + let advisorModel: string | undefined; let requestId: string | undefined; let cwd: string | undefined; let gitBranch: string | undefined; @@ -155,6 +156,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { role = entry.message.role; usage = entry.message.usage; model = entry.message.model; + advisorModel = entry.advisorModel; agentId = entry.agentId; requestId = entry.requestId; } else if (entry.type === 'system') { @@ -175,6 +177,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { content, usage, model, + advisorModel, // Metadata cwd, gitBranch, diff --git a/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx index c7666541..aa033cd2 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx @@ -10,6 +10,7 @@ import React, { useMemo, useState } from 'react'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { capitalize } from '@renderer/utils/stringUtils'; import { ChevronRight } from 'lucide-react'; import { formatTokens } from '../utils/formatting'; @@ -165,7 +166,7 @@ const ToolOutputRankedItem = ({ className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium" style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }} > - {tool.toolName} + {capitalize(tool.toolName)} ): React.ReactElement => { return (
- {tool.toolName} + {capitalize(tool.toolName)} ~{formatTokens(tool.tokenCount)} diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 910c2be3..2a805d48 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -10,6 +10,7 @@ import React, { useRef } from 'react'; import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { capitalize } from '@renderer/utils/stringUtils'; import { getToolContextTokens, getToolStatus, @@ -65,7 +66,7 @@ export const LinkedToolItem: React.FC = React.memo(function registerRef, }) { const status = getToolStatus(linkedTool); - const summary = getToolSummary(linkedTool.name, linkedTool.input); + const summary = getToolSummary(linkedTool.name, linkedTool.input, linkedTool.sourceModel); const elementRef = useRef(null); // Combined ref callback - handles both internal ref and external registration @@ -154,7 +155,7 @@ export const LinkedToolItem: React.FC = React.memo(function style={{ color: isHighlighted ? getTriggerColorDef(highlightColor).hex : undefined }} /> } - label={linkedTool.name} + label={capitalize(linkedTool.name)} summary={summary} tokenCount={getToolContextTokens(linkedTool)} status={status} diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx index 4b64eed4..32431c64 100644 --- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx +++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx @@ -15,6 +15,7 @@ import { /** * Renders the input section based on tool type with theme-aware styling. + * Returns a "no parameters" placeholder when input is an empty object. */ export function renderInput(toolName: string, input: Record): React.ReactElement { // Special rendering for Edit tool - show diff-like format @@ -95,6 +96,10 @@ export function renderInput(toolName: string, input: Record): R ); } + if (Object.keys(input).length === 0) { + return no parameters; + } + // Default: key-value format with readable string values return (
@@ -152,7 +157,7 @@ export function extractOutputText(content: string | unknown[]): string { .map((block) => typeof block === 'object' && block !== null && 'text' in block ? (block as { text: string }).text - : JSON.stringify(block, null, 2), + : JSON.stringify(block, null, 2) ) .join('\n'); } else { diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts index 07b4e786..eb1076b2 100644 --- a/src/renderer/utils/displayItemBuilder.ts +++ b/src/renderer/utils/displayItemBuilder.ts @@ -425,8 +425,7 @@ export function buildDisplayItemsFromMessages( } // Only treat as subagent input if there are NO tool_result blocks in this message const hasToolResults = - Array.isArray(msg.content) && - msg.content.some((b) => b.type === 'tool_result'); + Array.isArray(msg.content) && msg.content.some((b) => b.type === 'tool_result'); if (rawText.trim() && !hasToolResults) { displayItems.push({ type: 'subagent_input', @@ -460,6 +459,21 @@ export function buildDisplayItemsFromMessages( sourceMessageId: msg.uuid, sourceModel: msg.model, }); + } else if (block.type === 'server_tool_use' && block.name === 'advisor' && block.id) { + toolCallsById.set(block.id, { + id: block.id, + name: 'advisor', + input: {}, + timestamp: msgTimestamp, + sourceMessageId: msg.uuid, + sourceModel: msg.advisorModel, + }); + } else if (block.type === 'advisor_tool_result' && block.tool_use_id) { + toolResultsById.set(block.tool_use_id, { + content: block.content?.text ?? '', + isError: false, + timestamp: msgTimestamp, + }); } else if (block.type === 'text' && block.text) { // Add text output displayItems.push({ diff --git a/src/renderer/utils/stringUtils.ts b/src/renderer/utils/stringUtils.ts index 675ddb17..074b2cca 100644 --- a/src/renderer/utils/stringUtils.ts +++ b/src/renderer/utils/stringUtils.ts @@ -21,6 +21,19 @@ export function generateUUID(): string { return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`; } +/** + * Capitalizes the first character of a string, leaving the rest unchanged. + * Used for tool-name display labels so server tools like `advisor` render as `Advisor` + * while already-PascalCase names (Bash, Read, …) are unaffected. + * + * @example capitalize('advisor') → 'Advisor' + * @example capitalize('Bash') → 'Bash' + */ +export function capitalize(text: string): string { + if (!text) return text; + return text.charAt(0).toUpperCase() + text.slice(1); +} + const isMacPlatform = typeof window !== 'undefined' && window.navigator.userAgent.includes('Macintosh'); diff --git a/src/renderer/utils/toolLinkingEngine.ts b/src/renderer/utils/toolLinkingEngine.ts index dfe1ee7d..a7249e0b 100644 --- a/src/renderer/utils/toolLinkingEngine.ts +++ b/src/renderer/utils/toolLinkingEngine.ts @@ -81,13 +81,16 @@ export function linkToolCallsToResults( // Calculate callTokens directly from tool name + input // This reflects what actually enters the context window (not proportioned output_tokens) - const callTokens = estimateTokens(toolName + JSON.stringify(toolInput)); + // advisor input is empty ({}), so callTokens stays undefined and the fallback estimates ~0 + const callTokens = + toolName === 'advisor' ? undefined : estimateTokens(toolName + JSON.stringify(toolInput)); const linkedItem: LinkedToolItem = { id: toolCallId, name: toolName, input: toolInput as Record, - callTokens, // Token count for tool call (what Claude generated) + callTokens, + sourceModel: callStep.content.sourceModel, // carried from the call step (advisor model) result: resultStep ? { content: resultStep.content.toolResultContent ?? '', diff --git a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts index a130e44e..6501307b 100644 --- a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts +++ b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts @@ -16,9 +16,19 @@ function truncate(str: string, maxLength: number): string { /** * Generates a human-readable summary for a tool call. + * + * @param sourceModel - For server tools (advisor), the model that served the call; + * shown as the summary so the reader sees which model gave the advice. */ -export function getToolSummary(toolName: string, input: Record): string { +export function getToolSummary( + toolName: string, + input: Record, + sourceModel?: string +): string { switch (toolName) { + case 'advisor': + return sourceModel ?? 'advisor'; + case 'Edit': { const filePath = input.file_path as string | undefined; const oldString = input.old_string as string | undefined; diff --git a/test/main/services/analysis/SemanticStepExtractor.test.ts b/test/main/services/analysis/SemanticStepExtractor.test.ts new file mode 100644 index 00000000..b1c03512 --- /dev/null +++ b/test/main/services/analysis/SemanticStepExtractor.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { extractSemanticStepsFromAIChunk } from '../../../../src/main/services/analysis/SemanticStepExtractor'; +import type { AIChunk } from '../../../../src/main/types/chunks'; +import type { ParsedMessage } from '../../../../src/main/types/messages'; +import { + ADVISOR_CALL_ID, + ADVISOR_MODEL, + ADVISOR_TEXT, + advisorCallMessage, + advisorResultMessage, +} from '../../../mocks/advisorBlocks.fixture'; + +function makeChunk(responses: ParsedMessage[]): AIChunk { + return { + chunkType: 'ai', + id: 'chunk-1', + startTime: new Date('2026-06-06T10:00:00Z'), + responses, + processes: [], + sidechainMessages: [], + toolExecutions: [], + userMessage: null as unknown as ParsedMessage, + } as unknown as AIChunk; +} + +describe('SemanticStepExtractor — advisor blocks', () => { + it('extracts a tool_call step from server_tool_use(advisor)', () => { + const chunk = makeChunk([advisorCallMessage]); + const steps = extractSemanticStepsFromAIChunk(chunk); + + const callStep = steps.find((s) => s.type === 'tool_call' && s.content.toolName === 'advisor'); + expect(callStep).toBeDefined(); + expect(callStep!.id).toBe(ADVISOR_CALL_ID); + expect(callStep!.content.toolInput).toEqual({}); + expect(callStep!.content.sourceModel).toBe(ADVISOR_MODEL); + }); + + it('does NOT add tokens to the advisor tool_call step', () => { + const chunk = makeChunk([advisorCallMessage]); + const steps = extractSemanticStepsFromAIChunk(chunk); + + const callStep = steps.find((s) => s.type === 'tool_call' && s.content.toolName === 'advisor'); + expect(callStep).toBeDefined(); + expect(callStep!.tokens).toBeUndefined(); + expect(callStep!.content.tokenCount).toBeUndefined(); + }); + + it('extracts a tool_result step from advisor_tool_result', () => { + const chunk = makeChunk([advisorResultMessage]); + const steps = extractSemanticStepsFromAIChunk(chunk); + + const resultStep = steps.find((s) => s.type === 'tool_result' && s.id === ADVISOR_CALL_ID); + expect(resultStep).toBeDefined(); + expect(resultStep!.content.toolResultContent).toBe(ADVISOR_TEXT); + expect(resultStep!.content.isError).toBe(false); + }); + + it('counts the advisor result tokens from the advice text', () => { + const chunk = makeChunk([advisorResultMessage]); + const steps = extractSemanticStepsFromAIChunk(chunk); + + const resultStep = steps.find((s) => s.type === 'tool_result' && s.id === ADVISOR_CALL_ID); + expect(resultStep).toBeDefined(); + // 40 = countContentTokens(ADVISOR_TEXT) — literal guards against tautological re-import + expect(resultStep!.content.tokenCount).toBe(40); + expect(resultStep!.content.tokenCount).toBeGreaterThan(0); + }); +}); diff --git a/test/mocks/advisorBlocks.fixture.ts b/test/mocks/advisorBlocks.fixture.ts new file mode 100644 index 00000000..1834361c --- /dev/null +++ b/test/mocks/advisorBlocks.fixture.ts @@ -0,0 +1,67 @@ +/** + * Fixture for advisor tool block tests. + * Shapes verified from session 01c3ae09-6366-4303-902e-19b864e0a76c. + * Do not re-grep the raw .jsonl — use these exports. + */ + +import type { ParsedMessage } from '../../src/main/types/messages'; + +export const ADVISOR_CALL_ID = 'srvtoolu_01Eiz79KF8odGzUWVppYmN2q'; +export const ADVISOR_MODEL = 'claude-opus-4-8'; +export const ADVISOR_TEXT = + 'Your guard edit is sound — make it. Two confirmations: first, the guard correctly narrows the type; second, the existing tests will pass without modification.'; + +/** + * Assistant message containing the advisor server_tool_use CALL block. + * entry.advisorModel is the entry-level field (not message.model). + * Cast needed until M2 adds advisorModel to ParsedMessage. + */ +export const advisorCallMessage = { + uuid: 'advisor-call-msg-uuid', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-06-06T10:00:00Z'), + role: 'assistant', + advisorModel: ADVISOR_MODEL, + model: undefined, + isMeta: false, + isSidechain: false, + toolCalls: [], + toolResults: [], + content: [ + { + type: 'server_tool_use', + id: ADVISOR_CALL_ID, + name: 'advisor', + // advisor takes no input parameters + }, + ], +} as unknown as ParsedMessage; + +/** + * Assistant message containing the advisor_tool_result block. + * Rides an assistant entry (not a user entry like normal tool results). + */ +export const advisorResultMessage = { + uuid: 'advisor-result-msg-uuid', + parentUuid: 'advisor-call-msg-uuid', + type: 'assistant', + timestamp: new Date('2026-06-06T10:00:11Z'), + role: 'assistant', + advisorModel: ADVISOR_MODEL, + model: undefined, + isMeta: false, + isSidechain: false, + toolCalls: [], + toolResults: [], + content: [ + { + type: 'advisor_tool_result', + tool_use_id: ADVISOR_CALL_ID, + content: { + type: 'advisor_result', + text: ADVISOR_TEXT, + }, + }, + ], +} as unknown as ParsedMessage; diff --git a/test/renderer/utils/displayItemBuilder.test.ts b/test/renderer/utils/displayItemBuilder.test.ts index 1a843713..1c2b0a2e 100644 --- a/test/renderer/utils/displayItemBuilder.test.ts +++ b/test/renderer/utils/displayItemBuilder.test.ts @@ -1,11 +1,20 @@ import { describe, expect, it } from 'vitest'; import { buildDisplayItemsFromMessages } from '../../../src/renderer/utils/displayItemBuilder'; import type { ParsedMessage } from '../../../src/main/types/messages'; +import { + ADVISOR_CALL_ID, + ADVISOR_MODEL, + ADVISOR_TEXT, + advisorCallMessage, + advisorResultMessage, +} from '../../mocks/advisorBlocks.fixture'; /** * Helper to create a minimal ParsedMessage for testing. */ -function makeMessage(overrides: Partial & Pick): ParsedMessage { +function makeMessage( + overrides: Partial & Pick +): ParsedMessage { return { uuid: `msg-${Math.random().toString(36).slice(2, 8)}`, parentUuid: null, @@ -96,4 +105,39 @@ describe('buildDisplayItemsFromMessages', () => { expect(inputItems[0].content).toBe('Please run the tests'); }); }); + + describe('advisor blocks (server_tool_use + advisor_tool_result)', () => { + it('produces a tool display item for the advisor call+result pair', () => { + const items = buildDisplayItemsFromMessages([advisorCallMessage, advisorResultMessage], []); + + const toolItems = items.filter((item) => item.type === 'tool'); + expect(toolItems).toHaveLength(1); + + const item = toolItems[0]; + if (item.type !== 'tool') throw new Error('Expected tool item'); + expect(item.tool.name).toBe('advisor'); + expect(item.tool.isOrphaned).toBe(false); + }); + + it('sets the result content from advisor_tool_result text', () => { + const items = buildDisplayItemsFromMessages([advisorCallMessage, advisorResultMessage], []); + const toolItem = items.find((i) => i.type === 'tool' && i.tool.name === 'advisor'); + if (toolItem?.type !== 'tool') throw new Error('Expected tool item'); + expect(toolItem.tool.result?.content).toBe(ADVISOR_TEXT); + }); + + it('carries sourceModel on the tool item', () => { + const items = buildDisplayItemsFromMessages([advisorCallMessage, advisorResultMessage], []); + const toolItem = items.find((i) => i.type === 'tool' && i.tool.name === 'advisor'); + if (toolItem?.type !== 'tool') throw new Error('Expected tool item'); + expect(toolItem.tool.sourceModel).toBe(ADVISOR_MODEL); + }); + + it('uses ADVISOR_CALL_ID as the tool id', () => { + const items = buildDisplayItemsFromMessages([advisorCallMessage, advisorResultMessage], []); + const toolItem = items.find((i) => i.type === 'tool' && i.tool.name === 'advisor'); + if (toolItem?.type !== 'tool') throw new Error('Expected tool item'); + expect(toolItem.tool.id).toBe(ADVISOR_CALL_ID); + }); + }); }); diff --git a/test/renderer/utils/renderHelpers.test.ts b/test/renderer/utils/renderHelpers.test.ts index f1aaa35d..51749c6b 100644 --- a/test/renderer/utils/renderHelpers.test.ts +++ b/test/renderer/utils/renderHelpers.test.ts @@ -1,6 +1,10 @@ +import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; -import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers'; +import { + extractOutputText, + renderInput, +} from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers'; describe('renderHelpers', () => { describe('extractOutputText', () => { @@ -56,4 +60,16 @@ describe('renderHelpers', () => { expect(result).toContain('"type": "image"'); }); }); + + describe('renderInput', () => { + it('renders "no parameters" when the input is empty', () => { + expect(renderToStaticMarkup(renderInput('advisor', {}))).toContain('no parameters'); + }); + + it('renders the parameter keys and no empty-state label when the input has parameters', () => { + const html = renderToStaticMarkup(renderInput('WebFetch', { url: 'https://example.com' })); + expect(html).toContain('url'); + expect(html).not.toContain('no parameters'); + }); + }); }); diff --git a/test/renderer/utils/stringUtils.test.ts b/test/renderer/utils/stringUtils.test.ts index 567e7cab..86b1f76c 100644 --- a/test/renderer/utils/stringUtils.test.ts +++ b/test/renderer/utils/stringUtils.test.ts @@ -1,9 +1,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { generateUUID } from '../../../src/renderer/utils/stringUtils'; +import { capitalize, generateUUID } from '../../../src/renderer/utils/stringUtils'; const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +describe('capitalize', () => { + it('capitalizes a lowercase server tool name (advisor → Advisor)', () => { + expect(capitalize('advisor')).toBe('Advisor'); + }); + + it('leaves an already-capitalized tool name unchanged', () => { + expect(capitalize('Bash')).toBe('Bash'); + }); + + it('only touches the first character', () => { + expect(capitalize('askUserQuestion')).toBe('AskUserQuestion'); + }); + + it('returns an empty string unchanged', () => { + expect(capitalize('')).toBe(''); + }); +}); + describe('generateUUID', () => { it('delegates to crypto.randomUUID when available', () => { const KNOWN_UUID = '12345678-1234-4234-8234-123456789abc'; diff --git a/test/renderer/utils/toolLinkingEngine.test.ts b/test/renderer/utils/toolLinkingEngine.test.ts new file mode 100644 index 00000000..b3f68906 --- /dev/null +++ b/test/renderer/utils/toolLinkingEngine.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { linkToolCallsToResults } from '../../../src/renderer/utils/toolLinkingEngine'; +import type { SemanticStep } from '../../../src/renderer/types/data'; +import { ADVISOR_CALL_ID, ADVISOR_MODEL, ADVISOR_TEXT } from '../../mocks/advisorBlocks.fixture'; + +function makeAdvisorCallStep(): SemanticStep { + return { + id: ADVISOR_CALL_ID, + type: 'tool_call', + startTime: new Date('2026-06-06T10:00:00Z'), + durationMs: 0, + content: { + toolName: 'advisor', + toolInput: {}, + sourceModel: ADVISOR_MODEL, + }, + context: 'main', + }; +} + +function makeAdvisorResultStep(): SemanticStep { + return { + id: ADVISOR_CALL_ID, + type: 'tool_result', + startTime: new Date('2026-06-06T10:00:11Z'), + durationMs: 0, + content: { + toolResultContent: ADVISOR_TEXT, + isError: false, + }, + context: 'main', + }; +} + +describe('linkToolCallsToResults — advisor', () => { + it('carries sourceModel from the call step', () => { + const steps = [makeAdvisorCallStep(), makeAdvisorResultStep()]; + const linked = linkToolCallsToResults(steps); + + const item = linked.get(ADVISOR_CALL_ID); + expect(item).toBeDefined(); + expect(item!.sourceModel).toBe(ADVISOR_MODEL); + }); + + it('does NOT synthesize callTokens for advisor', () => { + const steps = [makeAdvisorCallStep(), makeAdvisorResultStep()]; + const linked = linkToolCallsToResults(steps); + + const item = linked.get(ADVISOR_CALL_ID); + expect(item).toBeDefined(); + expect(item!.callTokens).toBeUndefined(); + }); + + it('links call to result correctly', () => { + const steps = [makeAdvisorCallStep(), makeAdvisorResultStep()]; + const linked = linkToolCallsToResults(steps); + + const item = linked.get(ADVISOR_CALL_ID); + expect(item).toBeDefined(); + expect(item!.name).toBe('advisor'); + expect(item!.result?.content).toBe(ADVISOR_TEXT); + expect(item!.isOrphaned).toBe(false); + }); + + it('counts advisor in the linked tool map (part of tool total)', () => { + const normalCallStep: SemanticStep = { + id: 'toolu_normal', + type: 'tool_call', + startTime: new Date('2026-06-06T10:00:00Z'), + durationMs: 0, + content: { toolName: 'Bash', toolInput: { command: 'ls' } }, + context: 'main', + }; + const steps = [makeAdvisorCallStep(), makeAdvisorResultStep(), normalCallStep]; + const linked = linkToolCallsToResults(steps); + + // Both advisor and the normal tool should be in the map + expect(linked.size).toBe(2); + expect(linked.has(ADVISOR_CALL_ID)).toBe(true); + expect(linked.has('toolu_normal')).toBe(true); + }); +}); diff --git a/test/renderer/utils/toolRendering/toolTokens.test.ts b/test/renderer/utils/toolRendering/toolTokens.test.ts new file mode 100644 index 00000000..5dee5dde --- /dev/null +++ b/test/renderer/utils/toolRendering/toolTokens.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import type { LinkedToolItem } from '../../../../src/renderer/types/groups'; +import { getToolContextTokens } from '../../../../src/renderer/utils/toolRendering/toolTokens'; +import { ADVISOR_CALL_ID, ADVISOR_TEXT } from '../../../mocks/advisorBlocks.fixture'; + +const makeAdvisorItem = (resultTokens: number): LinkedToolItem => + ({ + id: ADVISOR_CALL_ID, + name: 'advisor', + input: {}, + callTokens: undefined, + result: { content: ADVISOR_TEXT, isError: false, tokenCount: resultTokens }, + isOrphaned: false, + }) as LinkedToolItem; + +describe('getToolContextTokens', () => { + it('counts the advisor result tokens instead of short-circuiting to zero', () => { + const advisor = makeAdvisorItem(100); + const total = getToolContextTokens(advisor); + expect(total).not.toBe(0); + expect(total).toBeGreaterThanOrEqual(100); + }); +});