+
+
{{ item.tool.title }}
+
+
+
+ +{{ item.tool.diffStats.addedLines }}
+
+ /
+
+ -{{ item.tool.diffStats.deletedLines }}
+
+
+ {{ item.tool.diffStats.totalLines }} lines
+
+
+
+
+
+
+ {{ item.tool.filePath }}
@@ -134,6 +172,23 @@ const labelColor = computed(() => {
return 'var(--ac-success)';
});
+const hasDiffStats = computed(() => {
+ const stats = props.item.tool.diffStats;
+ if (!stats) return false;
+ return (
+ stats.addedLines !== undefined ||
+ stats.deletedLines !== undefined ||
+ stats.totalLines !== undefined
+ );
+});
+
+const showFilePath = computed(() => {
+ const tool = props.item.tool;
+ // Show full path if title is just the filename
+ if (!tool.filePath) return false;
+ return tool.filePath !== tool.title && !tool.title.includes('/');
+});
+
const showCard = computed(() => {
const tool = props.item.tool;
return (
diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChat.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChat.ts
index 75c59e5..ac767a5 100644
--- a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChat.ts
+++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChat.ts
@@ -10,6 +10,7 @@ import type {
RealtimeEvent,
AgentStatusEvent,
AgentCliPreference,
+ AgentUsageStats,
} from 'chrome-mcp-shared';
export interface UseAgentChatOptions {
@@ -29,6 +30,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
const currentRequestId = ref
(null);
const cancelling = ref(false);
const attachments = ref([]);
+ const lastUsage = ref(null);
// Computed
const canSend = computed(() => {
@@ -54,6 +56,9 @@ export function useAgentChat(options: UseAgentChatOptions) {
case 'heartbeat':
// Heartbeat received, connection is alive
break;
+ case 'usage':
+ lastUsage.value = event.data;
+ break;
}
}
@@ -234,6 +239,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
currentRequestId,
cancelling,
attachments,
+ lastUsage,
// Computed
canSend,
diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentThreads.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentThreads.ts
index 9930fed..c102799 100644
--- a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentThreads.ts
+++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentThreads.ts
@@ -20,6 +20,13 @@ export type ToolKind = 'grep' | 'read' | 'edit' | 'run' | 'plan' | 'generic';
/** Tool severity for styling */
export type ToolSeverity = 'info' | 'success' | 'warning' | 'error';
+/** Diff statistics for edit operations */
+export interface DiffStats {
+ addedLines?: number;
+ deletedLines?: number;
+ totalLines?: number;
+}
+
/** Structured tool presentation */
export interface ToolPresentation {
kind: ToolKind;
@@ -28,8 +35,18 @@ export interface ToolPresentation {
subtitle?: string;
details?: string;
files?: string[];
+ /** File path for single-file operations */
+ filePath?: string;
+ /** Diff statistics for edit/write operations */
+ diffStats?: DiffStats;
command?: string;
+ /** Command description from bash tool */
+ commandDescription?: string;
query?: string;
+ /** Search pattern for grep/glob */
+ pattern?: string;
+ /** Search path */
+ searchPath?: string;
engine?: string;
severity: ToolSeverity;
phase: 'use' | 'result';
@@ -134,6 +151,27 @@ function titleCase(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
}
+/**
+ * Extract file name from path
+ */
+function getFileName(filePath: string): string {
+ return filePath.split('/').pop() || filePath;
+}
+
+/**
+ * Build diff stats from metadata
+ */
+function buildDiffStats(meta: Record): DiffStats | undefined {
+ const addedLines = typeof meta.addedLines === 'number' ? meta.addedLines : undefined;
+ const deletedLines = typeof meta.deletedLines === 'number' ? meta.deletedLines : undefined;
+ const totalLines = typeof meta.totalLines === 'number' ? meta.totalLines : undefined;
+
+ if (addedLines !== undefined || deletedLines !== undefined || totalLines !== undefined) {
+ return { addedLines, deletedLines, totalLines };
+ }
+ return undefined;
+}
+
/**
* Present a tool message as ToolPresentation
*/
@@ -152,12 +190,26 @@ function presentTool(msg: AgentMessage): ToolPresentation {
meta.isError === true ||
(typeof msg.content === 'string' && msg.content.trimStart().startsWith('Error:'));
- // Rule 1: Plan (Codex todo_list)
- if (meta.planPhase || normalize(toolName) === 'plan') {
+ // Extract common metadata fields
+ const filePath = firstString(meta.filePath as string);
+ const command = firstString(meta.command as string);
+ const commandDescription = firstString(meta.commandDescription as string);
+ const pattern = firstString(meta.pattern as string);
+ const searchPath = firstString(meta.searchPath as string);
+ const diffStats = buildDiffStats(meta);
+
+ // Rule 1: Plan / TodoWrite
+ if (
+ meta.planPhase ||
+ normalize(toolName) === 'plan' ||
+ normalize(toolName) === 'todo_write' ||
+ normalize(toolName) === 'todowrite'
+ ) {
+ const todoCount = typeof meta.todoCount === 'number' ? meta.todoCount : undefined;
return {
kind: 'plan',
label: 'Plan',
- title: summarizeOneLine(msg.content) || 'Plan update',
+ title: todoCount ? `${todoCount} tasks` : summarizeOneLine(msg.content) || 'Plan update',
details: phase === 'result' ? msg.content : undefined,
engine,
severity: isError ? 'error' : 'info',
@@ -166,18 +218,19 @@ function presentTool(msg: AgentMessage): ToolPresentation {
};
}
- // Rule 2: File summary (Codex file_change -> metadata.files)
- const files = Array.isArray(meta.files)
- ? (meta.files as string[]).filter((x) => typeof x === 'string')
- : [];
- if (files.length > 0) {
- const title = files.length === 1 ? files[0] : `${files.length} files`;
+ // Rule 2: Edit tool with file path and diff stats
+ if (
+ normalize(toolName).includes('edit') ||
+ normalize(toolName) === 'apply_patch' ||
+ normalize(toolName) === 'patch_file'
+ ) {
+ const fileName = filePath ? getFileName(filePath) : undefined;
return {
kind: 'edit',
label: 'Edit',
- title,
- subtitle: files.length > 1 ? files.slice(0, 3).join(', ') : undefined,
- files,
+ title: fileName || filePath || 'File',
+ filePath,
+ diffStats,
details: phase === 'result' ? msg.content : undefined,
engine,
severity: isError ? 'error' : 'success',
@@ -186,8 +239,46 @@ function presentTool(msg: AgentMessage): ToolPresentation {
};
}
- // Rule 3: Command (Codex bash, or Claude "Running:")
- const command = firstString(meta.command as string);
+ // Rule 3: Write/Create tool
+ if (normalize(toolName).includes('write') || normalize(toolName) === 'create_file') {
+ const fileName = filePath ? getFileName(filePath) : undefined;
+ return {
+ kind: 'edit',
+ label: 'Write',
+ title: fileName || filePath || 'File',
+ filePath,
+ diffStats,
+ details: phase === 'result' ? msg.content : undefined,
+ engine,
+ severity: isError ? 'error' : 'success',
+ phase,
+ raw: { content: msg.content, metadata: meta },
+ };
+ }
+
+ // Rule 4: File summary (Codex file_change -> metadata.files)
+ const files = Array.isArray(meta.files)
+ ? (meta.files as string[]).filter((x) => typeof x === 'string')
+ : [];
+ if (files.length > 0) {
+ const title = files.length === 1 ? getFileName(files[0]) : `${files.length} files`;
+ return {
+ kind: 'edit',
+ label: 'Edit',
+ title,
+ subtitle: files.length > 1 ? files.slice(0, 3).map(getFileName).join(', ') : undefined,
+ files,
+ filePath: files.length === 1 ? files[0] : undefined,
+ diffStats,
+ details: phase === 'result' ? msg.content : undefined,
+ engine,
+ severity: isError ? 'error' : 'success',
+ phase,
+ raw: { content: msg.content, metadata: meta },
+ };
+ }
+
+ // Rule 5: Command (Bash/shell)
if (
normalize(toolName) === 'bash' ||
normalize(toolName).includes('shell') ||
@@ -207,8 +298,10 @@ function presentTool(msg: AgentMessage): ToolPresentation {
return {
kind: 'run',
label: 'Run',
- title: extractedCommand?.trim() || 'Command',
+ title: commandDescription || extractedCommand?.trim() || 'Command',
+ subtitle: commandDescription && extractedCommand ? extractedCommand.trim() : undefined,
command: extractedCommand?.trim(),
+ commandDescription,
details,
engine,
severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',
@@ -217,14 +310,18 @@ function presentTool(msg: AgentMessage): ToolPresentation {
};
}
- // Rule 4: Search / Grep (Claude "Searching:")
- const queryFromContent = extractAfterPrefix(msg.content, 'Searching:');
- if (queryFromContent) {
+ // Rule 6: Grep/Search with pattern
+ if (normalize(toolName) === 'grep' || normalize(toolName).includes('search') || pattern) {
+ const queryFromContent = extractAfterPrefix(msg.content, 'Searching:');
+ const displayPattern = pattern || queryFromContent?.trim();
return {
kind: 'grep',
label: 'Grep',
- title: queryFromContent.trim(),
- query: queryFromContent.trim(),
+ title: displayPattern || 'Search',
+ pattern: displayPattern,
+ searchPath,
+ query: displayPattern,
+ details: phase === 'result' ? msg.content : undefined,
engine,
severity: isError ? 'error' : 'info',
phase,
@@ -232,7 +329,38 @@ function presentTool(msg: AgentMessage): ToolPresentation {
};
}
- // Rule 5: Read / Edit by action
+ // Rule 7: Glob with pattern
+ if (normalize(toolName) === 'glob' || normalize(toolName) === 'glob_files') {
+ return {
+ kind: 'grep',
+ label: 'Glob',
+ title: pattern || 'Pattern search',
+ pattern,
+ searchPath,
+ details: phase === 'result' ? msg.content : undefined,
+ engine,
+ severity: isError ? 'error' : 'info',
+ phase,
+ raw: { content: msg.content, metadata: meta },
+ };
+ }
+
+ // Rule 8: Read tool
+ if (normalize(toolName).includes('read') || filePath) {
+ const fileName = filePath ? getFileName(filePath) : undefined;
+ return {
+ kind: 'read',
+ label: 'Read',
+ title: fileName || filePath || 'File',
+ filePath,
+ engine,
+ severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',
+ phase,
+ raw: { content: msg.content, metadata: meta },
+ };
+ }
+
+ // Rule 9: Read / Edit by action (fallback for content-based detection)
const action = firstString(meta.action as string);
const fileFromContent = extractAfterPrefix(msg.content, 'Operating on:')?.trim();
const inferredKind =
@@ -247,7 +375,9 @@ function presentTool(msg: AgentMessage): ToolPresentation {
return {
kind,
label: kind === 'read' ? 'Read' : 'Edit',
- title: fileFromContent || toolName,
+ title: fileFromContent ? getFileName(fileFromContent) : toolName,
+ filePath: fileFromContent,
+ diffStats: kind === 'edit' ? diffStats : undefined,
engine,
severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',
phase,
diff --git a/app/chrome-extension/package.json b/app/chrome-extension/package.json
index 4dba780..d5fb0f9 100644
--- a/app/chrome-extension/package.json
+++ b/app/chrome-extension/package.json
@@ -30,6 +30,7 @@
"date-fns": "^4.1.0",
"elkjs": "^0.11.0",
"hnswlib-wasm-static": "0.8.5",
+ "markstream-vue": "0.0.3-beta.5",
"vue": "^3.5.13",
"zod": "^3.24.4"
},
diff --git a/app/native-server/src/agent/engines/claude.ts b/app/native-server/src/agent/engines/claude.ts
index cac5426..db9be38 100644
--- a/app/native-server/src/agent/engines/claude.ts
+++ b/app/native-server/src/agent/engines/claude.ts
@@ -105,15 +105,35 @@ export class ClaudeEngine implements AgentEngine {
let assistantBuffer = '';
let assistantMessageId: string | null = null;
let assistantCreatedAt: string | null = null;
+ let lastAssistantEmitted: { content: string; isFinal: boolean } | null = null;
const streamedToolHashes = new Set();
+ // Tool input accumulation for streaming tool_use blocks
+ // Key: content block index, Value: { toolName, toolId, inputJson }
+ const pendingToolInputs = new Map<
+ number,
+ { toolName: string; toolId: string; inputJsonParts: string[] }
+ >();
+ let currentContentBlockIndex = -1;
+
/**
* Emit assistant message to the stream.
+ * Includes deduplication to prevent multiple identical final emissions.
*/
const emitAssistant = (isFinal: boolean): void => {
const content = assistantBuffer.trim();
if (!content) return;
+ // Deduplicate: skip if same content and isFinal state was already emitted
+ if (
+ lastAssistantEmitted &&
+ lastAssistantEmitted.content === content &&
+ lastAssistantEmitted.isFinal === isFinal
+ ) {
+ return;
+ }
+ lastAssistantEmitted = { content, isFinal };
+
if (!assistantMessageId) {
assistantMessageId = randomUUID();
}
@@ -230,7 +250,7 @@ export class ClaudeEngine implements AgentEngine {
};
/**
- * Build tool metadata from content block.
+ * Build tool metadata from content block with detailed tool-specific information.
*/
const buildToolMetadata = (contentBlock: Record): Record => {
const toolName = this.pickFirstString(contentBlock.name) || 'unknown';
@@ -238,13 +258,97 @@ export class ClaudeEngine implements AgentEngine {
const input = contentBlock.input as Record | undefined;
const action = inferActionFromToolName(toolName);
- return {
+ const metadata: Record = {
toolName,
tool_name: toolName,
toolId,
action,
- input: input ? JSON.stringify(input).slice(0, 500) : undefined,
};
+
+ if (!input) {
+ return metadata;
+ }
+
+ // Extract tool-specific details
+ const normalizedName = toolName.toLowerCase();
+
+ // File operations (read, write, edit)
+ if (typeof input.file_path === 'string') {
+ metadata.filePath = input.file_path;
+ }
+
+ // Edit tool - extract diff information
+ if (
+ normalizedName.includes('edit') ||
+ normalizedName === 'apply_patch' ||
+ normalizedName === 'patch_file'
+ ) {
+ if (typeof input.old_string === 'string') {
+ metadata.oldString = input.old_string;
+ metadata.deletedLines = input.old_string.split('\n').length;
+ }
+ if (typeof input.new_string === 'string') {
+ metadata.newString = input.new_string;
+ metadata.addedLines = input.new_string.split('\n').length;
+ }
+ if (typeof input.replace_all === 'boolean') {
+ metadata.replaceAll = input.replace_all;
+ }
+ }
+
+ // Write tool - content preview
+ if (normalizedName.includes('write') || normalizedName === 'create_file') {
+ if (typeof input.content === 'string') {
+ metadata.contentPreview = input.content.slice(0, 200);
+ metadata.totalLines = input.content.split('\n').length;
+ }
+ }
+
+ // Read tool - offset/limit
+ if (normalizedName.includes('read')) {
+ if (typeof input.offset === 'number') metadata.offset = input.offset;
+ if (typeof input.limit === 'number') metadata.limit = input.limit;
+ }
+
+ // Bash/shell - command
+ if (
+ normalizedName === 'bash' ||
+ normalizedName.includes('shell') ||
+ normalizedName === 'run'
+ ) {
+ if (typeof input.command === 'string') {
+ metadata.command = input.command;
+ }
+ if (typeof input.description === 'string') {
+ metadata.commandDescription = input.description;
+ }
+ }
+
+ // Search tools (grep, glob)
+ if (normalizedName === 'grep' || normalizedName.includes('search')) {
+ if (typeof input.pattern === 'string') metadata.pattern = input.pattern;
+ if (typeof input.path === 'string') metadata.searchPath = input.path;
+ if (typeof input.glob === 'string') metadata.glob = input.glob;
+ if (typeof input.output_mode === 'string') metadata.outputMode = input.output_mode;
+ }
+
+ if (normalizedName === 'glob' || normalizedName === 'glob_files') {
+ if (typeof input.pattern === 'string') metadata.pattern = input.pattern;
+ if (typeof input.path === 'string') metadata.searchPath = input.path;
+ }
+
+ // TodoWrite
+ if (normalizedName === 'todo_write' || normalizedName === 'todowrite') {
+ if (Array.isArray(input.todos)) {
+ metadata.todoCount = input.todos.length;
+ metadata.todos = input.todos;
+ }
+ }
+
+ // Store raw input for debugging (truncated)
+ metadata.rawInput = JSON.stringify(input).slice(0, 1000);
+
+ return metadata;
};
try {
@@ -290,6 +394,9 @@ export class ClaudeEngine implements AgentEngine {
// Both permissionMode and allowDangerouslySkipPermissions are required for auto-approval
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
+ // Enable streaming: emit stream_event with content_block_delta for real-time UI updates
+ // Without this, SDK only outputs aggregated assistant/result messages
+ includePartialMessages: true,
images: imageFiles.length > 0 ? imageFiles : undefined,
executable: process.execPath,
// Pass merged env to support Claude Code Router (CCR)
@@ -339,25 +446,27 @@ export class ClaudeEngine implements AgentEngine {
assistantBuffer = '';
assistantMessageId = randomUUID();
assistantCreatedAt = new Date().toISOString();
+ lastAssistantEmitted = null;
break;
}
case 'content_block_start': {
const contentBlock = event.content_block as Record | undefined;
+ const blockIndex =
+ typeof event.index === 'number' ? event.index : ++currentContentBlockIndex;
+ currentContentBlockIndex = blockIndex;
+
if (contentBlock && contentBlock.type === 'tool_use') {
- const metadata = buildToolMetadata(contentBlock);
const toolName = this.pickFirstString(contentBlock.name) || 'tool';
- const input = contentBlock.input as Record | undefined;
+ const toolId = this.pickFirstString(contentBlock.id) || '';
- // Build more informative content based on tool input
- let content = `Using tool: ${toolName}`;
- if (input) {
- if (input.command) content = `Running: ${input.command}`;
- else if (input.file_path) content = `Operating on: ${input.file_path}`;
- else if (input.query) content = `Searching: ${input.query}`;
- }
-
- dispatchToolMessage(content, metadata, 'tool_use', true);
+ // Store pending tool input for accumulation
+ // Don't emit message here - wait for content_block_stop with complete input
+ pendingToolInputs.set(blockIndex, {
+ toolName,
+ toolId,
+ inputJsonParts: [],
+ });
} else if (contentBlock && contentBlock.type === 'tool_result') {
// Handle tool_result in content_block_start
const metadata = this.buildToolResultMetadata(contentBlock);
@@ -377,6 +486,47 @@ export class ClaudeEngine implements AgentEngine {
}
case 'content_block_stop': {
+ const blockIndex =
+ typeof event.index === 'number' ? event.index : currentContentBlockIndex;
+
+ // Check if we have accumulated tool input for this block
+ if (pendingToolInputs.has(blockIndex)) {
+ const pending = pendingToolInputs.get(blockIndex)!;
+ pendingToolInputs.delete(blockIndex);
+
+ // Parse the accumulated JSON
+ const fullJsonStr = pending.inputJsonParts.join('');
+ let input: Record = {};
+ try {
+ if (fullJsonStr) {
+ input = JSON.parse(fullJsonStr);
+ }
+ } catch (e) {
+ console.error(`[ClaudeEngine] Failed to parse tool input JSON: ${e}`);
+ }
+
+ console.error(
+ `[ClaudeEngine] content_block_stop - toolName: ${pending.toolName}, input: ${JSON.stringify(input).slice(0, 500)}`,
+ );
+
+ // Build metadata with full input
+ const metadata = buildToolMetadata({
+ name: pending.toolName,
+ id: pending.toolId,
+ input,
+ });
+
+ // Build informative content
+ let content = `Using tool: ${pending.toolName}`;
+ if (input.command) content = `Running: ${input.command}`;
+ else if (input.file_path) content = `Operating on: ${input.file_path}`;
+ else if (input.pattern) content = `Searching: ${input.pattern}`;
+ else if (input.query) content = `Searching: ${input.query}`;
+
+ // Emit final tool_use message with complete metadata
+ dispatchToolMessage(content, metadata, 'tool_use', false);
+ }
+
// Check if this block was a tool_result
const contentBlock = event.content_block as Record | undefined;
if (contentBlock && contentBlock.type === 'tool_result') {
@@ -398,6 +548,19 @@ export class ClaudeEngine implements AgentEngine {
case 'content_block_delta': {
const delta = event.delta as Record | string | undefined;
+ const blockIndex =
+ typeof event.index === 'number' ? event.index : currentContentBlockIndex;
+
+ // Check if this is a tool_use input_json_delta
+ if (delta && typeof delta === 'object' && delta.type === 'input_json_delta') {
+ const partialJson = delta.partial_json as string | undefined;
+ if (partialJson && pendingToolInputs.has(blockIndex)) {
+ pendingToolInputs.get(blockIndex)!.inputJsonParts.push(partialJson);
+ }
+ break;
+ }
+
+ // Handle text delta for assistant messages
let textChunk = '';
if (typeof delta === 'string') {
@@ -419,12 +582,15 @@ export class ClaudeEngine implements AgentEngine {
break;
}
- case 'message_stop':
case 'message_delta': {
- // Emit final assistant message
- if (assistantBuffer.trim()) {
- emitAssistant(true);
- }
+ // message_delta usually contains metadata only (stop_reason, usage)
+ // Don't emit final here to avoid duplicate finals
+ break;
+ }
+
+ case 'message_stop': {
+ // Emit final assistant message only on message_stop
+ emitAssistant(true);
break;
}
@@ -446,6 +612,37 @@ export class ClaudeEngine implements AgentEngine {
// Log full result for debugging
console.error(`[ClaudeEngine] Result message: ${JSON.stringify(resultRecord, null, 2)}`);
+ // Extract and emit usage statistics
+ const usage = resultRecord.usage as Record | undefined;
+ const totalCostUsd =
+ typeof resultRecord.total_cost_usd === 'number' ? resultRecord.total_cost_usd : 0;
+ const durationMs =
+ typeof resultRecord.duration_ms === 'number' ? resultRecord.duration_ms : 0;
+ const numTurns = typeof resultRecord.num_turns === 'number' ? resultRecord.num_turns : 0;
+
+ if (usage || totalCostUsd > 0) {
+ ctx.emit({
+ type: 'usage',
+ data: {
+ sessionId,
+ requestId,
+ inputTokens: typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0,
+ outputTokens: typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0,
+ cacheReadInputTokens:
+ typeof usage?.cache_read_input_tokens === 'number'
+ ? usage.cache_read_input_tokens
+ : undefined,
+ cacheCreationInputTokens:
+ typeof usage?.cache_creation_input_tokens === 'number'
+ ? usage.cache_creation_input_tokens
+ : undefined,
+ totalCostUsd,
+ durationMs,
+ numTurns,
+ },
+ });
+ }
+
// Check if result contains errors (SDK puts error details here)
// Note: is_error can be true even with empty errors array
if (resultRecord.is_error) {
@@ -640,11 +837,31 @@ export class ClaudeEngine implements AgentEngine {
/**
* Extract content from SDK message.
+ * Handles various message structures from Claude Agent SDK:
+ * - result.result (final result text)
+ * - assistant.message (nested message content)
+ * - content/text (direct content fields)
+ * - content[] (array of content blocks)
+ *
+ * @param message - The message object to extract content from
+ * @param depth - Current recursion depth (max 3 to prevent infinite loops)
*/
- private extractMessageContent(message: unknown): string | undefined {
- if (!message || typeof message !== 'object') return undefined;
+ private extractMessageContent(message: unknown, depth = 0): string | undefined {
+ // Prevent infinite recursion
+ if (depth > 3 || !message || typeof message !== 'object') return undefined;
const record = message as Record;
+ // Handle result message: result field contains final text
+ if (typeof record.result === 'string') {
+ return record.result.trim();
+ }
+
+ // Handle assistant message: message field may contain nested content
+ if (record.message && typeof record.message === 'object') {
+ const nested = this.extractMessageContent(record.message, depth + 1);
+ if (nested) return nested;
+ }
+
// Try common content fields
if (typeof record.content === 'string') {
return record.content.trim();
diff --git a/app/native-server/src/agent/message-service.ts b/app/native-server/src/agent/message-service.ts
index e4dfd6e..9862a65 100644
--- a/app/native-server/src/agent/message-service.ts
+++ b/app/native-server/src/agent/message-service.ts
@@ -122,7 +122,22 @@ export async function createMessage(
createdAt: input.createdAt || now,
};
- await db.insert(messages).values(messageData);
+ await db
+ .insert(messages)
+ .values(messageData)
+ .onConflictDoUpdate({
+ target: messages.id,
+ set: {
+ role: messageData.role,
+ messageType: messageData.messageType,
+ content: messageData.content,
+ metadata: messageData.metadata,
+ sessionId: messageData.sessionId,
+ conversationId: messageData.conversationId,
+ cliSource: messageData.cliSource,
+ requestId: messageData.requestId,
+ },
+ });
return rowToMessage(messageData);
}
diff --git a/app/native-server/src/agent/stream-manager.ts b/app/native-server/src/agent/stream-manager.ts
index 818ff12..12b15a6 100644
--- a/app/native-server/src/agent/stream-manager.ts
+++ b/app/native-server/src/agent/stream-manager.ts
@@ -100,6 +100,8 @@ export class AgentStreamManager {
return event.data?.sessionId;
case 'error':
return event.data?.sessionId;
+ case 'usage':
+ return event.data?.sessionId;
case 'heartbeat':
return undefined;
default:
diff --git a/packages/shared/src/agent-types.ts b/packages/shared/src/agent-types.ts
index 0896bf3..37fbfd6 100644
--- a/packages/shared/src/agent-types.ts
+++ b/packages/shared/src/agent-types.ts
@@ -48,12 +48,26 @@ export interface AgentHeartbeatEvent {
timestamp: string;
}
+/** Usage statistics for a request */
+export interface AgentUsageStats {
+ sessionId: string;
+ requestId?: string;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadInputTokens?: number;
+ cacheCreationInputTokens?: number;
+ totalCostUsd: number;
+ durationMs: number;
+ numTurns: number;
+}
+
export type RealtimeEvent =
| { type: 'message'; data: AgentMessage }
| { type: 'status'; data: AgentStatusEvent }
| { type: 'error'; error: string; data?: { sessionId?: string; requestId?: string } }
| { type: 'connected'; data: AgentConnectedEvent }
- | { type: 'heartbeat'; data: AgentHeartbeatEvent };
+ | { type: 'heartbeat'; data: AgentHeartbeatEvent }
+ | { type: 'usage'; data: AgentUsageStats };
// ============================================================
// HTTP API Contracts
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c07bb42..f4b66b1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -86,6 +86,9 @@ importers:
hnswlib-wasm-static:
specifier: 0.8.5
version: 0.8.5
+ markstream-vue:
+ specifier: 0.0.3-beta.5
+ version: 0.0.3-beta.5(vue@3.5.25)
vue:
specifier: ^3.5.13
version: 3.5.25(typescript@5.9.3)
@@ -1417,6 +1420,23 @@ packages:
ipaddr.js: 2.3.0
dev: false
+ /@floating-ui/core@1.7.3:
+ resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
+ dependencies:
+ '@floating-ui/utils': 0.2.10
+ dev: false
+
+ /@floating-ui/dom@1.7.4:
+ resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
+ dependencies:
+ '@floating-ui/core': 1.7.3
+ '@floating-ui/utils': 0.2.10
+ dev: false
+
+ /@floating-ui/utils@0.2.10:
+ resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
+ dev: false
+
/@huggingface/jinja@0.2.2:
resolution: {integrity: sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==}
engines: {node: '>=18'}
@@ -6474,6 +6494,12 @@ packages:
uhyphen: 0.2.0
dev: true
+ /linkify-it@5.0.0:
+ resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
+ dependencies:
+ uc.micro: 2.1.0
+ dev: false
+
/lint-staged@15.5.2:
resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==}
engines: {node: '>=18.12.0'}
@@ -6660,6 +6686,74 @@ packages:
resolution: {integrity: sha512-DHnZAD4phTbZ+qnJdjoNEVU1NecYoSdbOOoVmTDH46AuxDkEVh3MxTVpXq10GtcTC6mndN9dkv1rNfpjRcLnOw==}
dev: true
+ /markdown-it-container@4.0.0:
+ resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==}
+ dev: false
+
+ /markdown-it-footnote@4.0.0:
+ resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==}
+ dev: false
+
+ /markdown-it-ins@4.0.0:
+ resolution: {integrity: sha512-sWbjK2DprrkINE4oYDhHdCijGT+MIDhEupjSHLXe5UXeVr5qmVxs/nTUVtgi0Oh/qtF+QKV0tNWDhQBEPxiMew==}
+ dev: false
+
+ /markdown-it-mark@4.0.0:
+ resolution: {integrity: sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg==}
+ dev: false
+
+ /markdown-it-sub@2.0.0:
+ resolution: {integrity: sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==}
+ dev: false
+
+ /markdown-it-sup@2.0.0:
+ resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==}
+ dev: false
+
+ /markdown-it-task-checkbox@1.0.6:
+ resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==}
+ dev: false
+
+ /markdown-it-ts@0.0.2:
+ resolution: {integrity: sha512-QKNmck9IHgsCqJkvk5Zgw29534WZldNtuCnsEKm5HX80XsEUGfX1m3vexY9jmfHYDq8469fAsbwp78bRusI7HQ==}
+ engines: {node: '>=18'}
+ dependencies:
+ entities: 4.5.0
+ linkify-it: 5.0.0
+ mdurl: 2.0.0
+ punycode.js: 2.3.1
+ uc.micro: 2.1.0
+ dev: false
+
+ /markstream-vue@0.0.3-beta.5(vue@3.5.25):
+ resolution: {integrity: sha512-mqrwry/nWf25yNcSvdXijh5gZT8oC5wLafPncydFjC8Wtc7Zvr8MkTlRjiWP9wczlQxCr1GW2AHxZDQbKPgrJQ==}
+ peerDependencies:
+ katex: '>=0.16.22'
+ mermaid: '>=11'
+ shiki: ^3.13.0
+ stream-markdown: '>=0.0.11'
+ stream-monaco: '>=0.0.8'
+ vue: '>=3.0.0'
+ vue-i18n: '>=9'
+ peerDependenciesMeta:
+ katex:
+ optional: true
+ mermaid:
+ optional: true
+ shiki:
+ optional: true
+ stream-markdown:
+ optional: true
+ stream-monaco:
+ optional: true
+ vue-i18n:
+ optional: true
+ dependencies:
+ '@floating-ui/dom': 1.7.4
+ stream-markdown-parser: 0.0.42
+ vue: 3.5.25(typescript@5.9.3)
+ dev: false
+
/marky@1.3.0:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
dev: true
@@ -6668,6 +6762,10 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ /mdurl@2.0.0:
+ resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+ dev: false
+
/media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@@ -7556,6 +7654,11 @@ packages:
end-of-stream: 1.4.5
once: 1.4.0
+ /punycode.js@2.3.1:
+ resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
+ engines: {node: '>=6'}
+ dev: false
+
/punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -8105,6 +8208,19 @@ packages:
engines: {node: '>=18'}
dev: true
+ /stream-markdown-parser@0.0.42:
+ resolution: {integrity: sha512-e8mn3NW7moO/W5KsZ5jWg/iqw8PceuZq4bzynUf1yc+fUBGqu5SKu5SXDF7EH4jC9EDQiUsK49Fne/G/NYS7hQ==}
+ dependencies:
+ markdown-it-container: 4.0.0
+ markdown-it-footnote: 4.0.0
+ markdown-it-ins: 4.0.0
+ markdown-it-mark: 4.0.0
+ markdown-it-sub: 2.0.0
+ markdown-it-sup: 2.0.0
+ markdown-it-task-checkbox: 1.0.6
+ markdown-it-ts: 0.0.2
+ dev: false
+
/streamx@2.23.0:
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
dependencies:
@@ -8658,6 +8774,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ /uc.micro@2.1.0:
+ resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
+ dev: false
+
/ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
dev: true