Merge pull request #4045 from mastertyko/fix/3714-headless-multi-question-fallback
fix(headless): keep idle timeout off during interactive tools
This commit is contained in:
commit
eb16ef421d
3 changed files with 64 additions and 3 deletions
|
|
@ -70,6 +70,7 @@ export const IDLE_TIMEOUT_MS = 15_000
|
|||
// between tool calls (e.g. after mkdir, before writing files). Use a
|
||||
// longer idle timeout to avoid killing the session prematurely (#808).
|
||||
export const NEW_MILESTONE_IDLE_TIMEOUT_MS = 120_000
|
||||
const INTERACTIVE_HEADLESS_TOOLS = new Set(['ask_user_questions', 'secure_env_collect'])
|
||||
|
||||
export function isTerminalNotification(event: Record<string, unknown>): boolean {
|
||||
if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false
|
||||
|
|
@ -89,6 +90,14 @@ export function isMilestoneReadyNotification(event: Record<string, unknown>): bo
|
|||
return /milestone\s+m\d+.*ready/i.test(String(event.message ?? ''))
|
||||
}
|
||||
|
||||
export function isInteractiveHeadlessTool(toolName: string | undefined): boolean {
|
||||
return INTERACTIVE_HEADLESS_TOOLS.has(String(toolName ?? ''))
|
||||
}
|
||||
|
||||
export function shouldArmHeadlessIdleTimeout(toolCallCount: number, interactiveToolCount: number): boolean {
|
||||
return toolCallCount > 0 && interactiveToolCount === 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick Command Detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import {
|
|||
FIRE_AND_FORGET_METHODS,
|
||||
IDLE_TIMEOUT_MS,
|
||||
NEW_MILESTONE_IDLE_TIMEOUT_MS,
|
||||
isInteractiveHeadlessTool,
|
||||
shouldArmHeadlessIdleTimeout,
|
||||
EXIT_SUCCESS,
|
||||
EXIT_ERROR,
|
||||
EXIT_BLOCKED,
|
||||
|
|
@ -367,6 +369,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
let exitCode = 0
|
||||
let milestoneReady = false // tracks "Milestone X ready." for auto-chaining
|
||||
const recentEvents: TrackedEvent[] = []
|
||||
const interactiveToolCallIds = new Set<string>()
|
||||
|
||||
// JSON batch mode: cost aggregation (cumulative-max pattern per K004)
|
||||
let cumulativeCostUsd = 0
|
||||
|
|
@ -460,7 +463,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
|
||||
function resetIdleTimer(): void {
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
if (toolCallCount > 0) {
|
||||
if (shouldArmHeadlessIdleTimeout(toolCallCount, interactiveToolCallIds.size)) {
|
||||
idleTimer = setTimeout(() => {
|
||||
completed = true
|
||||
resolveCompletion()
|
||||
|
|
@ -484,6 +487,20 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
client.onEvent((event) => {
|
||||
const eventObj = event as unknown as Record<string, unknown>
|
||||
trackEvent(eventObj)
|
||||
|
||||
const eventType = String(eventObj.type ?? '')
|
||||
if (eventType === 'tool_execution_start') {
|
||||
const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? '')
|
||||
if (toolCallId && isInteractiveHeadlessTool(String(eventObj.toolName ?? ''))) {
|
||||
interactiveToolCallIds.add(toolCallId)
|
||||
}
|
||||
} else if (eventType === 'tool_execution_end') {
|
||||
const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? '')
|
||||
if (toolCallId) {
|
||||
interactiveToolCallIds.delete(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
resetIdleTimer()
|
||||
|
||||
// Answer injector: observe events for question metadata
|
||||
|
|
@ -492,7 +509,6 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
// --json / --output-format stream-json: forward events as JSONL to stdout (filtered if --events)
|
||||
// --output-format json (batch mode): suppress streaming, track cost for final result
|
||||
if (options.json && options.outputFormat === 'stream-json') {
|
||||
const eventType = String(eventObj.type ?? '')
|
||||
if (!options.eventFilter || options.eventFilter.has(eventType)) {
|
||||
process.stdout.write(JSON.stringify(eventObj) + '\n')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,15 @@ test('empty filter blocks all events', () => {
|
|||
assert.ok(!shouldEmit('message_update'))
|
||||
})
|
||||
|
||||
import { mapStatusToExitCode, EXIT_SUCCESS, EXIT_ERROR, EXIT_BLOCKED, EXIT_CANCELLED } from '../headless-events.js'
|
||||
import {
|
||||
mapStatusToExitCode,
|
||||
EXIT_SUCCESS,
|
||||
EXIT_ERROR,
|
||||
EXIT_BLOCKED,
|
||||
EXIT_CANCELLED,
|
||||
isInteractiveHeadlessTool,
|
||||
shouldArmHeadlessIdleTimeout,
|
||||
} from '../headless-events.js'
|
||||
|
||||
// ─── mapStatusToExitCode ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -185,3 +193,31 @@ test('mapStatusToExitCode: "cancelled" returns EXIT_CANCELLED', () => {
|
|||
test('mapStatusToExitCode: unknown status returns EXIT_ERROR', () => {
|
||||
assert.equal(mapStatusToExitCode('unknown'), EXIT_ERROR)
|
||||
})
|
||||
|
||||
test('isInteractiveHeadlessTool: ask_user_questions is interactive', () => {
|
||||
assert.equal(isInteractiveHeadlessTool('ask_user_questions'), true)
|
||||
})
|
||||
|
||||
test('isInteractiveHeadlessTool: secure_env_collect is interactive', () => {
|
||||
assert.equal(isInteractiveHeadlessTool('secure_env_collect'), true)
|
||||
})
|
||||
|
||||
test('isInteractiveHeadlessTool: non-interactive tools stay false', () => {
|
||||
assert.equal(isInteractiveHeadlessTool('bash'), false)
|
||||
assert.equal(isInteractiveHeadlessTool(undefined), false)
|
||||
})
|
||||
|
||||
test('shouldArmHeadlessIdleTimeout: arms after tool calls when no interactive tool is in flight', () => {
|
||||
assert.equal(shouldArmHeadlessIdleTimeout(1, 0), true)
|
||||
assert.equal(shouldArmHeadlessIdleTimeout(3, 0), true)
|
||||
})
|
||||
|
||||
test('shouldArmHeadlessIdleTimeout: stays disarmed while interactive tools are in flight (#3714)', () => {
|
||||
assert.equal(shouldArmHeadlessIdleTimeout(1, 1), false)
|
||||
assert.equal(shouldArmHeadlessIdleTimeout(5, 2), false)
|
||||
})
|
||||
|
||||
test('shouldArmHeadlessIdleTimeout: stays disarmed before any tool call has started', () => {
|
||||
assert.equal(shouldArmHeadlessIdleTimeout(0, 0), false)
|
||||
assert.equal(shouldArmHeadlessIdleTimeout(0, 1), false)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue