Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Platform incompatibility and port discovery issue.

The standalone:kill script has two major issues:

  1. Unix-only: Uses lsof, which is not available on Windows. Windows users cannot use this script.
  2. Hardcoded port: Targets port 3456, but the standalone server can bind to ports 3456 through 3466 if the preferred port is busy (per HttpServer.ts:124-133). Additionally, if a user sets PORT=3457 in the environment, the server will start on 3457 but this script will target 3456.

Consider:

  • Detecting the actual port from a PID file or server output
  • Providing a Windows-compatible alternative using netstat or PowerShell
  • Making the port configurable via environment variable
🔍 Script to verify port binding behavior
#!/bin/bash
# Description: Verify that HttpServer can bind to ports beyond 3456

rg -n -A5 -B5 'for.*attempt.*tryPort.*preferredPort' src/main/services/infrastructure/HttpServer.ts
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 49, The standalone:kill npm script is Unix-only and
hardcodes port 3456; update it to be cross-platform and to detect the actual
running server port (respecting PORT env and the server's fallback range
3456–3466 used in HttpServer.ts). Change the script to (a) prefer reading a PID
or port file created by the server process if available (look for any
PID/port-writing logic in the standalone server startup), (b) otherwise read the
PORT env var and try that port first then iterate the 3456–3466 range to find a
listening process, and (c) use platform-appropriate commands: lsof (Unix) or
netstat/PowerShell/Get-Process (Windows) to map port→PID and then
kill/Stop-Process the PID. Ensure the npm script (standalone:kill) exposes a
PORT override via environment variable and falls back to the range so Windows
users and servers started on non-3456 ports are handled.

"standalone:restart": "pnpm standalone:kill && pnpm standalone:build && pnpm standalone:start"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
Expand Down
36 changes: 36 additions & 0 deletions src/main/services/analysis/SemanticStepExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 25 additions & 2 deletions src/main/types/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -66,12 +73,27 @@ export interface ImageContent extends BaseContent {
};
}

export interface ServerToolUseContent extends BaseContent {
type: 'server_tool_use';
id: string;
name: string;
input?: Record<string, unknown>;
}

export interface AdvisorToolResultContent extends BaseContent {
type: 'advisor_tool_result';
tool_use_id: string;
content: { type: 'advisor_result'; text: string };
}
Comment on lines +76 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consider making AdvisorToolResultContent.content optional.

Downstream code in displayItemBuilder.ts and SemanticStepExtractor.ts uses optional chaining (block.content?.text ?? ''), suggesting content may be undefined at runtime. The type currently requires it, creating a gap between the type contract and defensive runtime guards.

🛡️ Suggested type refinement
 export interface AdvisorToolResultContent extends BaseContent {
   type: 'advisor_tool_result';
   tool_use_id: string;
-  content: { type: 'advisor_result'; text: string };
+  content?: { type: 'advisor_result'; text: string };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/types/jsonl.ts` around lines 76 - 87, The AdvisorToolResultContent
type currently requires a content property but downstream files
(displayItemBuilder.ts and SemanticStepExtractor.ts) use optional chaining on
block.content?.text, so update the AdvisorToolResultContent interface (symbol:
AdvisorToolResultContent) to make content optional (content?) so the type
matches runtime usage; ensure the nested shape remains { type: 'advisor_result';
text: string } unless you also want to allow text to be absent — if downstream
expects text possibly empty, consider making text optional as well.


export type ContentBlock =
| TextContent
| ThinkingContent
| ToolUseContent
| ToolResultContent
| ImageContent;
| ImageContent
| ServerToolUseContent
| AdvisorToolResultContent;

// =============================================================================
// Usage Metadata
Expand Down Expand Up @@ -178,6 +200,7 @@ export interface AssistantEntry extends ConversationalEntry {
message: AssistantMessage;
requestId: string;
agentId?: string;
advisorModel?: string;
}

export interface SystemEntry extends ConversationalEntry {
Expand Down
2 changes: 2 additions & 0 deletions src/main/types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/main/utils/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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') {
Expand All @@ -175,6 +177,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
content,
usage,
model,
advisorModel,
// Metadata
cwd,
gitBranch,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)}
</span>
<span className="flex-1" />
<span
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import React from 'react';

import { capitalize } from '@renderer/utils/stringUtils';

import { formatTokens } from '../utils/formatting';

import type { ToolTokenBreakdown } from '@renderer/types/contextInjection';
Expand All @@ -17,7 +19,7 @@ export const ToolBreakdownItem = ({
}: Readonly<ToolBreakdownItemProps>): React.ReactElement => {
return (
<div className="flex items-center gap-2 py-0.5 text-xs">
<span style={{ color: 'var(--color-text-muted)' }}>{tool.toolName}</span>
<span style={{ color: 'var(--color-text-muted)' }}>{capitalize(tool.toolName)}</span>
<span style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}>
~{formatTokens(tool.tokenCount)}
</span>
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/components/chat/items/LinkedToolItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,7 +66,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = 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<HTMLDivElement>(null);

// Combined ref callback - handles both internal ref and external registration
Expand Down Expand Up @@ -154,7 +155,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = React.memo(function
style={{ color: isHighlighted ? getTriggerColorDef(highlightColor).hex : undefined }}
/>
}
label={linkedTool.name}
label={capitalize(linkedTool.name)}
summary={summary}
tokenCount={getToolContextTokens(linkedTool)}
status={status}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): React.ReactElement {
// Special rendering for Edit tool - show diff-like format
Expand Down Expand Up @@ -95,6 +96,10 @@ export function renderInput(toolName: string, input: Record<string, unknown>): R
);
}

if (Object.keys(input).length === 0) {
return <span style={{ color: COLOR_TEXT_MUTED }}>no parameters</span>;
}

// Default: key-value format with readable string values
return (
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 16 additions & 2 deletions src/renderer/utils/displayItemBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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({
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/utils/stringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
7 changes: 5 additions & 2 deletions src/renderer/utils/toolLinkingEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
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 ?? '',
Expand Down
12 changes: 11 additions & 1 deletion src/renderer/utils/toolRendering/toolSummaryHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): string {
export function getToolSummary(
toolName: string,
input: Record<string, unknown>,
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;
Expand Down
68 changes: 68 additions & 0 deletions test/main/services/analysis/SemanticStepExtractor.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading