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:
TÂCHES 2026-03-27 21:57:11 -06:00 committed by GitHub
parent efe61c2fcc
commit 0a2c9b64c6
3 changed files with 234 additions and 14 deletions

View file

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

View file

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

View file

@ -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', () => {