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.
This commit is contained in:
catlog22
2026-01-18 19:49:33 +08:00
parent 40b003be68
commit 16c96229f9
5 changed files with 69 additions and 25 deletions

View File

@@ -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);
}

View File

@@ -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
});
});

View File

@@ -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)
}
/**

View File

@@ -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;
}

View File

@@ -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