feat: stream full text and thinking output in headless verbose mode (#2934)
Previously, headless --verbose mode accumulated text_delta events into a buffer and displayed a single truncated 120-char [thinking] line before tool calls. The model's actual text responses between tool calls were effectively invisible. Changes: - Stream text_delta and thinking_delta events directly to stderr in verbose mode with [text] and [thinking] block markers - No truncation — full model output is visible - Fix non-verbose fallback: read from ame.delta (correct field) instead of ame.text (always undefined for text_delta events) - Track inTextBlock/inThinkingBlock state to properly close streaming blocks before tool calls - Expand summarizeToolArgs with support for async_bash, await_job, cancel_job, find, ls, lsp, hashline_edit, subagent, browser_navigate, and gsd_* tools - Add streaming formatter functions: formatTextStart, formatTextEnd, formatThinkingStart, formatThinkingEnd - Update tests for new tool arg summarization and path field handling
This commit is contained in:
parent
efe61c2fcc
commit
0a2c9b64c6
3 changed files with 234 additions and 14 deletions
|
|
@ -76,24 +76,45 @@ export function summarizeToolArgs(toolName: unknown, toolInput: unknown): string
|
|||
const name = String(toolName ?? '')
|
||||
const input = (toolInput && typeof toolInput === 'object') ? toolInput as Record<string, unknown> : {}
|
||||
|
||||
// Helper: extract file path from either 'path' or 'file_path' (tools use both)
|
||||
const filePath = (): string => shortPath(input.path ?? input.file_path) || ''
|
||||
|
||||
switch (name) {
|
||||
case 'Read':
|
||||
case 'read':
|
||||
return shortPath(input.file_path) || ''
|
||||
return filePath()
|
||||
case 'Write':
|
||||
case 'write':
|
||||
return shortPath(input.file_path) || ''
|
||||
return filePath()
|
||||
case 'Edit':
|
||||
case 'edit':
|
||||
return shortPath(input.file_path) || ''
|
||||
return filePath()
|
||||
case 'hashline_edit':
|
||||
return filePath()
|
||||
case 'Bash':
|
||||
case 'bash': {
|
||||
const cmd = String(input.command ?? '')
|
||||
return cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd
|
||||
}
|
||||
case 'async_bash': {
|
||||
const cmd = String(input.command ?? '')
|
||||
return cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd
|
||||
}
|
||||
case 'await_job': {
|
||||
const jobs = input.jobs
|
||||
if (Array.isArray(jobs) && jobs.length > 0) return jobs.join(', ')
|
||||
return ''
|
||||
}
|
||||
case 'cancel_job':
|
||||
return String(input.job_id ?? '')
|
||||
case 'Glob':
|
||||
case 'glob':
|
||||
return String(input.pattern ?? '')
|
||||
case 'find': {
|
||||
const pat = String(input.pattern ?? '')
|
||||
const p = shortPath(input.path)
|
||||
return p ? `${pat} in ${p}` : pat
|
||||
}
|
||||
case 'Grep':
|
||||
case 'grep':
|
||||
case 'Search':
|
||||
|
|
@ -102,12 +123,32 @@ export function summarizeToolArgs(toolName: unknown, toolInput: unknown): string
|
|||
const g = input.glob ? ` ${input.glob}` : ''
|
||||
return `${pat}${g}`
|
||||
}
|
||||
case 'ls':
|
||||
return shortPath(input.path) || ''
|
||||
case 'lsp': {
|
||||
const action = String(input.action ?? '')
|
||||
const file = shortPath(input.file)
|
||||
const sym = input.symbol ? ` ${input.symbol}` : ''
|
||||
return file ? `${action} ${file}${sym}` : action
|
||||
}
|
||||
case 'Task':
|
||||
case 'task': {
|
||||
const desc = String(input.description ?? input.prompt ?? '')
|
||||
return desc.length > 60 ? desc.slice(0, 57) + '...' : desc
|
||||
}
|
||||
case 'subagent': {
|
||||
const agent = String(input.agent ?? '')
|
||||
const t = String(input.task ?? '')
|
||||
const summary = t.length > 50 ? t.slice(0, 47) + '...' : t
|
||||
return agent ? `${agent}: ${summary}` : summary
|
||||
}
|
||||
case 'browser_navigate':
|
||||
return String(input.url ?? '')
|
||||
default: {
|
||||
// GSD tools: show milestone/slice/task IDs when present
|
||||
if (name.startsWith('gsd_')) {
|
||||
return summarizeGsdTool(name, input)
|
||||
}
|
||||
// Fallback: show first string-valued key up to 60 chars
|
||||
for (const v of Object.values(input)) {
|
||||
if (typeof v === 'string' && v.length > 0) {
|
||||
|
|
@ -119,6 +160,29 @@ export function summarizeToolArgs(toolName: unknown, toolInput: unknown): string
|
|||
}
|
||||
}
|
||||
|
||||
/** Summarize GSD extension tool args into a compact identifier string. */
|
||||
function summarizeGsdTool(name: string, input: Record<string, unknown>): string {
|
||||
const parts: string[] = []
|
||||
if (input.milestoneId) parts.push(String(input.milestoneId))
|
||||
if (input.sliceId) parts.push(String(input.sliceId))
|
||||
if (input.taskId) parts.push(String(input.taskId))
|
||||
if (parts.length > 0) {
|
||||
const id = parts.join('/')
|
||||
// For completion tools, add the one-liner if present
|
||||
if (name.includes('complete') && typeof input.oneLiner === 'string') {
|
||||
const ol = input.oneLiner.length > 50 ? input.oneLiner.slice(0, 47) + '...' : input.oneLiner
|
||||
return `${id} ${ol}`
|
||||
}
|
||||
return id
|
||||
}
|
||||
// Fallback for GSD tools without IDs (e.g. gsd_decision_save)
|
||||
if (input.decision) {
|
||||
const d = String(input.decision)
|
||||
return d.length > 60 ? d.slice(0, 57) + '...' : d
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function shortPath(p: unknown): string {
|
||||
if (typeof p !== 'string') return ''
|
||||
// Strip common CWD prefix to save space
|
||||
|
|
@ -268,6 +332,7 @@ export function formatProgress(event: Record<string, unknown>, ctx: ProgressCont
|
|||
|
||||
/**
|
||||
* Format a thinking preview line from accumulated LLM text deltas.
|
||||
* Used as a fallback when streaming is not enabled — shows a truncated one-liner.
|
||||
*/
|
||||
export function formatThinkingLine(text: string): string {
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim()
|
||||
|
|
@ -275,6 +340,38 @@ export function formatThinkingLine(text: string): string {
|
|||
return `${c.dim}${c.italic}[thinking] ${truncated}${c.reset}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming Text / Thinking Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a text_start marker — printed once when the assistant begins a text block.
|
||||
*/
|
||||
export function formatTextStart(): string {
|
||||
return `${c.dim}[text]${c.reset}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a text_end marker — printed after the last text_delta.
|
||||
*/
|
||||
export function formatTextEnd(): string {
|
||||
return '' // empty — newline handled by caller
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a thinking_start marker.
|
||||
*/
|
||||
export function formatThinkingStart(): string {
|
||||
return `${c.dim}${c.italic}[thinking]${c.reset}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a thinking_end marker.
|
||||
*/
|
||||
export function formatThinkingEnd(): string {
|
||||
return '' // empty — newline handled by caller
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a cost line (used for periodic cost updates in verbose mode).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ import {
|
|||
handleExtensionUIRequest,
|
||||
formatProgress,
|
||||
formatThinkingLine,
|
||||
formatTextStart,
|
||||
formatTextEnd,
|
||||
formatThinkingStart,
|
||||
formatThinkingEnd,
|
||||
startSupervisedStdinReader,
|
||||
} from './headless-ui.js'
|
||||
import type { ExtensionUIRequest, ProgressContext } from './headless-ui.js'
|
||||
|
|
@ -374,6 +378,9 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
const toolStartTimes = new Map<string, number>()
|
||||
let lastCostData: { costUsd: number; inputTokens: number; outputTokens: number } | undefined
|
||||
let thinkingBuffer = ''
|
||||
// Streaming state: tracks whether we're inside a text or thinking block
|
||||
let inTextBlock = false
|
||||
let inThinkingBlock = false
|
||||
|
||||
// Emit HeadlessJsonResult to stdout for --output-format json batch mode
|
||||
function emitBatchJsonResult(): void {
|
||||
|
|
@ -526,11 +533,56 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
}
|
||||
}
|
||||
|
||||
// Accumulate thinking text from message_update text_delta events
|
||||
// Stream assistant text and thinking deltas in verbose mode
|
||||
if (eventType === 'message_update') {
|
||||
const ame = eventObj.assistantMessageEvent as Record<string, unknown> | undefined
|
||||
if (ame?.type === 'text_delta') {
|
||||
thinkingBuffer += String(ame.text ?? '')
|
||||
if (ame && options.verbose) {
|
||||
const ameType = String(ame.type ?? '')
|
||||
|
||||
// --- Text streaming ---
|
||||
if (ameType === 'text_start') {
|
||||
inTextBlock = true
|
||||
process.stderr.write(formatTextStart())
|
||||
} else if (ameType === 'text_delta') {
|
||||
const delta = String(ame.delta ?? ame.text ?? '')
|
||||
if (delta) {
|
||||
if (!inTextBlock) {
|
||||
// Edge case: delta without start
|
||||
inTextBlock = true
|
||||
process.stderr.write(formatTextStart())
|
||||
}
|
||||
process.stderr.write(delta)
|
||||
}
|
||||
} else if (ameType === 'text_end') {
|
||||
if (inTextBlock) {
|
||||
process.stderr.write(formatTextEnd() + '\n')
|
||||
inTextBlock = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Thinking streaming ---
|
||||
else if (ameType === 'thinking_start') {
|
||||
inThinkingBlock = true
|
||||
process.stderr.write(formatThinkingStart())
|
||||
} else if (ameType === 'thinking_delta') {
|
||||
const delta = String(ame.delta ?? ame.text ?? '')
|
||||
if (delta) {
|
||||
if (!inThinkingBlock) {
|
||||
inThinkingBlock = true
|
||||
process.stderr.write(formatThinkingStart())
|
||||
}
|
||||
process.stderr.write(delta)
|
||||
}
|
||||
} else if (ameType === 'thinking_end') {
|
||||
if (inThinkingBlock) {
|
||||
process.stderr.write(formatThinkingEnd() + '\n')
|
||||
inThinkingBlock = false
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-verbose: accumulate text_delta for truncated one-liner
|
||||
else if (ame?.type === 'text_delta') {
|
||||
thinkingBuffer += String(ame.delta ?? ame.text ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -540,8 +592,19 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
if (toolCallId) toolStartTimes.set(toolCallId, Date.now())
|
||||
}
|
||||
|
||||
// Flush thinking buffer before tool calls or message end
|
||||
if (options.verbose && thinkingBuffer.trim() &&
|
||||
// Close any open streaming blocks before tool calls or message end
|
||||
if (options.verbose && (eventType === 'tool_execution_start' || eventType === 'message_end')) {
|
||||
if (inTextBlock) {
|
||||
process.stderr.write('\n')
|
||||
inTextBlock = false
|
||||
}
|
||||
if (inThinkingBlock) {
|
||||
process.stderr.write('\n')
|
||||
inThinkingBlock = false
|
||||
}
|
||||
}
|
||||
// Non-verbose: flush accumulated buffer as truncated one-liner
|
||||
else if (!options.verbose && thinkingBuffer.trim() &&
|
||||
(eventType === 'tool_execution_start' || eventType === 'message_end')) {
|
||||
process.stderr.write(formatThinkingLine(thinkingBuffer) + '\n')
|
||||
thinkingBuffer = ''
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ describe('formatProgress', () => {
|
|||
const result = formatProgress({
|
||||
type: 'tool_execution_start',
|
||||
toolName: 'Read',
|
||||
args: { file_path: 'src/main.ts' },
|
||||
args: { path: 'src/main.ts' },
|
||||
}, ctx())
|
||||
assert.ok(result)
|
||||
assert.ok(result.includes('Read'))
|
||||
|
|
@ -179,12 +179,20 @@ describe('formatProgress', () => {
|
|||
})
|
||||
|
||||
describe('summarizeToolArgs', () => {
|
||||
it('extracts file_path for Read', () => {
|
||||
assert.equal(summarizeToolArgs('Read', { file_path: 'src/index.ts' }), 'src/index.ts')
|
||||
it('extracts path for Read', () => {
|
||||
assert.equal(summarizeToolArgs('Read', { path: 'src/index.ts' }), 'src/index.ts')
|
||||
})
|
||||
|
||||
it('extracts file_path for write', () => {
|
||||
assert.equal(summarizeToolArgs('write', { file_path: '/tmp/out.json' }), '/tmp/out.json')
|
||||
it('extracts path for write', () => {
|
||||
assert.equal(summarizeToolArgs('write', { path: '/tmp/out.json' }), '/tmp/out.json')
|
||||
})
|
||||
|
||||
it('extracts file_path for legacy compatibility', () => {
|
||||
assert.equal(summarizeToolArgs('read', { file_path: 'src/foo.ts' }), 'src/foo.ts')
|
||||
})
|
||||
|
||||
it('prefers path over file_path when both present', () => {
|
||||
assert.equal(summarizeToolArgs('read', { path: 'real.ts', file_path: 'legacy.ts' }), 'real.ts')
|
||||
})
|
||||
|
||||
it('extracts command for bash', () => {
|
||||
|
|
@ -198,18 +206,70 @@ describe('summarizeToolArgs', () => {
|
|||
assert.ok(result.length < 100)
|
||||
})
|
||||
|
||||
it('extracts command for async_bash', () => {
|
||||
assert.equal(summarizeToolArgs('async_bash', { command: 'npm run build' }), 'npm run build')
|
||||
})
|
||||
|
||||
it('extracts jobs for await_job', () => {
|
||||
assert.equal(summarizeToolArgs('await_job', { jobs: ['bg_abc', 'bg_def'] }), 'bg_abc, bg_def')
|
||||
})
|
||||
|
||||
it('extracts pattern for grep', () => {
|
||||
const result = summarizeToolArgs('grep', { pattern: 'TODO', glob: '*.ts' })
|
||||
assert.equal(result, 'TODO *.ts')
|
||||
})
|
||||
|
||||
it('extracts pattern and path for find', () => {
|
||||
assert.equal(summarizeToolArgs('find', { pattern: '*.ts', path: 'src' }), '*.ts in src')
|
||||
})
|
||||
|
||||
it('extracts action and file for lsp', () => {
|
||||
const result = summarizeToolArgs('lsp', { action: 'definition', file: 'src/main.ts', symbol: 'foo' })
|
||||
assert.equal(result, 'definition src/main.ts foo')
|
||||
})
|
||||
|
||||
it('extracts path for ls', () => {
|
||||
assert.equal(summarizeToolArgs('ls', { path: 'src/utils' }), 'src/utils')
|
||||
})
|
||||
|
||||
it('summarizes gsd tool with milestone/slice/task IDs', () => {
|
||||
assert.equal(summarizeToolArgs('gsd_task_complete', {
|
||||
milestoneId: 'M001', sliceId: 'S01', taskId: 'T01', oneLiner: 'Built the thing',
|
||||
}), 'M001/S01/T01 Built the thing')
|
||||
})
|
||||
|
||||
it('summarizes gsd_plan_milestone with milestone ID', () => {
|
||||
assert.equal(summarizeToolArgs('gsd_plan_milestone', { milestoneId: 'M002' }), 'M002')
|
||||
})
|
||||
|
||||
it('summarizes gsd_decision_save with decision text', () => {
|
||||
const result = summarizeToolArgs('gsd_decision_save', { decision: 'Use SQLite for persistence' })
|
||||
assert.equal(result, 'Use SQLite for persistence')
|
||||
})
|
||||
|
||||
it('returns first string value for unknown tools', () => {
|
||||
assert.equal(summarizeToolArgs('gsd_task_complete', { taskId: 'T01' }), 'T01')
|
||||
assert.equal(summarizeToolArgs('custom_tool', { someKey: 'hello' }), 'hello')
|
||||
})
|
||||
|
||||
it('returns empty string for no args', () => {
|
||||
assert.equal(summarizeToolArgs('unknown', {}), '')
|
||||
})
|
||||
|
||||
it('extracts path for edit', () => {
|
||||
assert.equal(summarizeToolArgs('edit', { path: 'src/config.ts' }), 'src/config.ts')
|
||||
})
|
||||
|
||||
it('extracts path for hashline_edit', () => {
|
||||
assert.equal(summarizeToolArgs('hashline_edit', { path: 'src/main.ts' }), 'src/main.ts')
|
||||
})
|
||||
|
||||
it('extracts agent and task for subagent', () => {
|
||||
assert.equal(summarizeToolArgs('subagent', { agent: 'scout', task: 'Find auth patterns' }), 'scout: Find auth patterns')
|
||||
})
|
||||
|
||||
it('extracts url for browser_navigate', () => {
|
||||
assert.equal(summarizeToolArgs('browser_navigate', { url: 'http://localhost:3000' }), 'http://localhost:3000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatThinkingLine', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue