From 16c96229f98332e4657c01a042ec21ee7a3a7f53 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 18 Jan 2026 19:49:33 +0800 Subject: [PATCH] feat(cli): add agent_message type for precise --final output filtering Introduce dedicated agent_message IR type to distinguish final AI responses from generic stdout. This enables --final flag to show only agent messages, filtering out all intermediate content (JSONL events, reasoning, tool calls). Changes: - Add agent_message type to CliOutputUnitType - Update JsonLinesParser to map final responses from all tools (codex, gemini, claude, opencode) to agent_message type - Add final_output field to database schema with migration - Update getCachedOutput and getConversation to return finalOutput - Prefer finalOutput in outputAction for --final flag Fixes issue where --final showed raw JSONL instead of filtered content. --- ccw/src/commands/cli.ts | 4 +-- ccw/src/tools/cli-executor-core.ts | 17 +++++++++--- ccw/src/tools/cli-executor-state.ts | 2 ++ ccw/src/tools/cli-history-store.ts | 37 ++++++++++++++++++++++----- ccw/src/tools/cli-output-converter.ts | 34 ++++++++++++++---------- 5 files changed, 69 insertions(+), 25 deletions(-) diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index 6bfe48e2..cf01d6dc 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -386,8 +386,8 @@ async function outputAction(conversationId: string | undefined, options: OutputV if (options.final) { // Final result only with usage hint - // Prefer parsedOutput (filtered, intermediate content removed) over raw stdout - const outputContent = result.parsedOutput?.content || result.stdout?.content; + // Prefer finalOutput (agent_message only) > parsedOutput (filtered) > raw stdout + const outputContent = result.finalOutput?.content || result.parsedOutput?.content || result.stdout?.content; if (outputContent) { console.log(outputContent); } diff --git a/ccw/src/tools/cli-executor-core.ts b/ccw/src/tools/cli-executor-core.ts index 9305b51c..04ca5299 100644 --- a/ccw/src/tools/cli-executor-core.ts +++ b/ccw/src/tools/cli-executor-core.ts @@ -982,12 +982,18 @@ async function executeCliTool( // Create new turn - cache full output when not streaming (default) const shouldCache = !parsed.data.stream; - // Compute parsed output (filtered, intermediate content removed) for final display + // Compute parsed output (filtered, intermediate content removed) for general display const computedParsedOutput = flattenOutputUnits(allOutputUnits, { excludeTypes: ['stderr', 'progress', 'metadata', 'system', 'tool_call', 'thought', 'code', 'file_diff', 'streaming_content'], stripCommandJsonBlocks: true // Strip embedded command execution JSON from agent_message }); + // Compute final output (only agent_message) for --final flag + const computedFinalOutput = flattenOutputUnits(allOutputUnits, { + includeTypes: ['agent_message'], + stripCommandJsonBlocks: true // Strip embedded command execution JSON from agent_message + }); + const newTurnOutput = { stdout: stdout.substring(0, 10240), // Truncate preview to 10KB stderr: stderr.substring(0, 2048), // Truncate preview to 2KB @@ -995,7 +1001,8 @@ async function executeCliTool( cached: shouldCache, stdout_full: shouldCache ? stdout : undefined, stderr_full: shouldCache ? stderr : undefined, - parsed_output: computedParsedOutput || undefined, // Filtered output for final display + parsed_output: computedParsedOutput || undefined, // Filtered output for general display + final_output: computedFinalOutput || undefined, // Agent message only for --final flag structured: allOutputUnits // Save structured IR units }; @@ -1156,7 +1163,8 @@ async function executeCliTool( exit_code: code, duration_ms: duration, output: newTurnOutput, - parsedOutput: computedParsedOutput // Use already-computed filtered output + parsedOutput: computedParsedOutput, // Use already-computed filtered output + finalOutput: computedFinalOutput // Use already-computed agent_message only output }; resolve({ @@ -1165,7 +1173,8 @@ async function executeCliTool( conversation, stdout, stderr, - parsedOutput: execution.parsedOutput + parsedOutput: execution.parsedOutput, + finalOutput: execution.finalOutput }); }); diff --git a/ccw/src/tools/cli-executor-state.ts b/ccw/src/tools/cli-executor-state.ts index a41511b8..e782b712 100644 --- a/ccw/src/tools/cli-executor-state.ts +++ b/ccw/src/tools/cli-executor-state.ts @@ -85,6 +85,7 @@ export interface ExecutionRecord { truncated: boolean; }; parsedOutput?: string; // Extracted clean text from structured output units + finalOutput?: string; // Agent message only (for --final flag) } interface HistoryIndex { @@ -109,6 +110,7 @@ export interface ExecutionOutput { stdout: string; stderr: string; parsedOutput?: string; // Extracted text from stream JSON response + finalOutput?: string; // Agent message only (for --final flag) } /** diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts index 762b4994..8a7f7c96 100644 --- a/ccw/src/tools/cli-history-store.ts +++ b/ccw/src/tools/cli-history-store.ts @@ -28,6 +28,7 @@ export interface ConversationTurn { stdout_full?: string; stderr_full?: string; parsed_output?: string; // Filtered output (intermediate content removed) + final_output?: string; // Agent message only (for --final flag) structured?: CliOutputUnit[]; // Structured IR sequence for advanced parsing }; } @@ -328,6 +329,7 @@ export class CliHistoryStore { const hasStdoutFull = turnsInfo.some(col => col.name === 'stdout_full'); const hasStderrFull = turnsInfo.some(col => col.name === 'stderr_full'); const hasParsedOutput = turnsInfo.some(col => col.name === 'parsed_output'); + const hasFinalOutput = turnsInfo.some(col => col.name === 'final_output'); if (!hasCached) { console.log('[CLI History] Migrating database: adding cached column to turns table...'); @@ -349,6 +351,11 @@ export class CliHistoryStore { this.db.exec('ALTER TABLE turns ADD COLUMN parsed_output TEXT;'); console.log('[CLI History] Migration complete: parsed_output column added'); } + if (!hasFinalOutput) { + console.log('[CLI History] Migrating database: adding final_output column to turns table...'); + this.db.exec('ALTER TABLE turns ADD COLUMN final_output TEXT;'); + console.log('[CLI History] Migration complete: final_output column added'); + } } catch (err) { console.error('[CLI History] Migration error:', (err as Error).message); // Don't throw - allow the store to continue working with existing schema @@ -455,8 +462,8 @@ export class CliHistoryStore { `); const upsertTurn = this.db.prepare(` - INSERT INTO turns (conversation_id, turn_number, timestamp, prompt, duration_ms, status, exit_code, stdout, stderr, truncated, cached, stdout_full, stderr_full, parsed_output) - VALUES (@conversation_id, @turn_number, @timestamp, @prompt, @duration_ms, @status, @exit_code, @stdout, @stderr, @truncated, @cached, @stdout_full, @stderr_full, @parsed_output) + INSERT INTO turns (conversation_id, turn_number, timestamp, prompt, duration_ms, status, exit_code, stdout, stderr, truncated, cached, stdout_full, stderr_full, parsed_output, final_output) + VALUES (@conversation_id, @turn_number, @timestamp, @prompt, @duration_ms, @status, @exit_code, @stdout, @stderr, @truncated, @cached, @stdout_full, @stderr_full, @parsed_output, @final_output) ON CONFLICT(conversation_id, turn_number) DO UPDATE SET timestamp = @timestamp, prompt = @prompt, @@ -469,7 +476,8 @@ export class CliHistoryStore { cached = @cached, stdout_full = @stdout_full, stderr_full = @stderr_full, - parsed_output = @parsed_output + parsed_output = @parsed_output, + final_output = @final_output `); const transaction = this.db.transaction(() => { @@ -505,7 +513,8 @@ export class CliHistoryStore { cached: turn.output.cached ? 1 : 0, stdout_full: turn.output.stdout_full || null, stderr_full: turn.output.stderr_full || null, - parsed_output: turn.output.parsed_output || null + parsed_output: turn.output.parsed_output || null, + final_output: turn.output.final_output || null }); } }); @@ -553,7 +562,8 @@ export class CliHistoryStore { cached: !!t.cached, stdout_full: t.stdout_full || undefined, stderr_full: t.stderr_full || undefined, - parsed_output: t.parsed_output || undefined + parsed_output: t.parsed_output || undefined, + final_output: t.final_output || undefined // Agent message only for --final flag } })) }; @@ -599,6 +609,7 @@ export class CliHistoryStore { stdout?: { content: string; totalBytes: number; offset: number; hasMore: boolean }; stderr?: { content: string; totalBytes: number; offset: number; hasMore: boolean }; parsedOutput?: { content: string; totalBytes: number; offset: number; hasMore: boolean }; + finalOutput?: { content: string; totalBytes: number; offset: number; hasMore: boolean }; cached: boolean; prompt: string; status: string; @@ -626,6 +637,7 @@ export class CliHistoryStore { stdout?: { content: string; totalBytes: number; offset: number; hasMore: boolean }; stderr?: { content: string; totalBytes: number; offset: number; hasMore: boolean }; parsedOutput?: { content: string; totalBytes: number; offset: number; hasMore: boolean }; + finalOutput?: { content: string; totalBytes: number; offset: number; hasMore: boolean }; cached: boolean; prompt: string; status: string; @@ -664,7 +676,7 @@ export class CliHistoryStore { }; } - // Add parsed output if available (filtered output for final display) + // Add parsed output if available (filtered output for general display) if (turn.parsed_output) { const parsedContent = turn.parsed_output; const totalBytes = parsedContent.length; @@ -677,6 +689,19 @@ export class CliHistoryStore { }; } + // Add final output if available (agent_message only for --final flag) + if (turn.final_output) { + const finalContent = turn.final_output; + const totalBytes = finalContent.length; + const content = finalContent.substring(offset, offset + limit); + result.finalOutput = { + content, + totalBytes, + offset, + hasMore: offset + limit < totalBytes + }; + } + return result; } diff --git a/ccw/src/tools/cli-output-converter.ts b/ccw/src/tools/cli-output-converter.ts index 626a008c..764292e7 100644 --- a/ccw/src/tools/cli-output-converter.ts +++ b/ccw/src/tools/cli-output-converter.ts @@ -21,6 +21,7 @@ export type CliOutputUnitType = | 'metadata' // Session/execution metadata | 'system' // System events/messages | 'tool_call' // Tool invocation/result (Gemini tool_use/tool_result) + | 'agent_message' // Final agent response (for --final output) | 'streaming_content'; // Streaming delta content (only last one used in final output) /** @@ -293,9 +294,9 @@ export class JsonLinesParser implements IOutputParser { if (json.type === 'message' && json.role) { // Gemini assistant/user message if (json.role === 'assistant') { - // Delta messages use 'streaming_content' type - only last one is used in final output - // Non-delta (final) messages use 'stdout' type - const outputType = json.delta === true ? 'streaming_content' : 'stdout'; + // Delta messages use 'streaming_content' type - aggregated to agent_message later + // Non-delta (final) messages use 'agent_message' type directly + const outputType = json.delta === true ? 'streaming_content' : 'agent_message'; return { type: outputType, content: json.content || '', @@ -420,7 +421,7 @@ export class JsonLinesParser implements IOutputParser { if (item.type === 'agent_message') { return { - type: 'stdout', + type: 'agent_message', // Use dedicated type for final agent response content: item.text || '', timestamp }; @@ -495,7 +496,7 @@ export class JsonLinesParser implements IOutputParser { .join('\n') || ''; return { - type: 'stdout', + type: 'agent_message', // Use dedicated type for Claude final response content: textContent, timestamp }; @@ -537,7 +538,7 @@ export class JsonLinesParser implements IOutputParser { if (json.type === 'text' && json.part) { return { - type: 'stdout', + type: 'agent_message', // Use dedicated type for OpenCode text response content: json.part.text || '', timestamp }; @@ -658,7 +659,7 @@ export class JsonLinesParser implements IOutputParser { .join('\n') || ''; return { - type: 'stdout', + type: 'agent_message', // Use dedicated type for legacy Codex response content, timestamp }; @@ -682,9 +683,16 @@ export class JsonLinesParser implements IOutputParser { } // Check for Gemini/Qwen message format (role-based) - if (json.role === 'user' || json.role === 'assistant') { + if (json.role === 'assistant') { return { - type: 'stdout', + type: 'agent_message', // Use dedicated type for Gemini/Qwen assistant response + content: json.content || json.text || '', + timestamp + }; + } + if (json.role === 'user') { + return { + type: 'stdout', // User messages remain as stdout content: json.content || json.text || '', timestamp }; @@ -1128,7 +1136,7 @@ export function flattenOutputUnits( stripCommandJsonBlocks = false } = options || {}; - // Special handling for streaming_content: concatenate all into a single stdout unit + // Special handling for streaming_content: concatenate all into a single agent_message unit // Gemini delta messages are incremental (each contains partial content to append) let processedUnits = units; const streamingUnits = units.filter(u => u.type === 'streaming_content'); @@ -1138,9 +1146,9 @@ export function flattenOutputUnits( .map(u => typeof u.content === 'string' ? u.content : '') .join(''); processedUnits = units.filter(u => u.type !== 'streaming_content'); - // Add concatenated content as stdout type for inclusion + // Add concatenated content as agent_message type for final output processedUnits.push({ - type: 'stdout', + type: 'agent_message', content: concatenatedContent, timestamp: streamingUnits[streamingUnits.length - 1].timestamp }); @@ -1168,7 +1176,7 @@ export function flattenOutputUnits( let content = unit.content; // Strip command execution JSON code blocks if requested (codex agent_message often includes these) - if (stripCommandJsonBlocks && unit.type === 'stdout') { + if (stripCommandJsonBlocks && (unit.type === 'stdout' || unit.type === 'agent_message')) { // Pattern 1: Backtick-wrapped JSON blocks // Format: ```...{"command":"...","output":"...","exitCode":N,"status":"..."...}...``` // Uses [\s\S]*? to match any characters (including newlines) non-greedily