feat(hil): surface human-in-the-loop state across all agent providers - #656
Conversation
There was a problem hiding this comment.
Pull request overview
Adds end-to-end “human-in-the-loop” (HIL) detection surfaced as a new awaiting_input session status across the executor/provider layer and the TUI session graph, plus a builtin hil-test workflow and targeted unit tests.
Changes:
- Introduces
awaiting_inputinto session status types/store/panel API and updates UI rendering (icons/colors/pulsing + expanded node card/layout). - Implements provider-specific HIL detection hooks (Claude transcript inspection during idle detection; Copilot pane polling; OpenCode SSE question events) feeding a unified
onHILcallback into the panel. - Adds a builtin
hil-testworkflow (Claude/Copilot/OpenCode) and comprehensive unit tests for the new HIL helpers and UI/store behavior.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/sdk/runtime/executor.ts |
Adds Copilot send wrapper + Copilot pane poller + OpenCode stream watcher and wires unified onHIL into session runner lifecycle. |
src/sdk/providers/claude.ts |
Adds transcript-based HIL detection helpers and integrates HIL state transitions into Claude idle waiting. |
src/sdk/components/orchestrator-panel-types.ts |
Extends SessionStatus union with awaiting_input. |
src/sdk/components/orchestrator-panel-store.ts |
Adds awaitingInput() / resumeSession() state transitions. |
src/sdk/components/orchestrator-panel.tsx |
Exposes sessionAwaitingInput() / sessionResumed() to drive store transitions from runtime. |
src/sdk/components/status-helpers.ts |
Maps awaiting_input to theme.info, label "input needed", and icon "?". |
src/sdk/components/session-graph-panel.tsx |
Keeps pulse animation active when sessions are awaiting_input. |
src/sdk/components/node-card.tsx |
Renders awaiting-input nodes with blue pulsing border + extra “waiting for response”/hint rows. |
src/sdk/components/layout.ts |
Increases row height to 6 when any node at a depth is awaiting_input. |
src/sdk/components/header.tsx |
Adds awaiting-input counter badge and includes it in status counts. |
src/sdk/workflows/builtin/hil-test/helpers/prompts.ts |
Adds shared sandboxed prompts that force user-question behavior to exercise HIL. |
src/sdk/workflows/builtin/hil-test/claude/index.ts |
New builtin workflow to exercise AskUserQuestion HIL path in Claude. |
src/sdk/workflows/builtin/hil-test/copilot/index.ts |
New builtin workflow to exercise Copilot ask_user-style HIL behavior. |
src/sdk/workflows/builtin/hil-test/opencode/index.ts |
New builtin workflow to exercise OpenCode question events and HIL transitions. |
tests/sdk/runtime/executor-hil.test.ts |
Unit tests for Copilot send wrapper, Copilot pane poller aborting, OpenCode stream watcher, and Copilot HIL regex. |
tests/sdk/providers/claude-hil.test.ts |
Unit tests for Claude transcript HIL detection and watcher state transitions. |
tests/sdk/components/status-helpers.test.ts |
Adds assertions for awaiting_input color/label/icon. |
tests/sdk/components/orchestrator-panel.test.tsx |
Adds smoke tests for the new panel API methods. |
tests/sdk/components/orchestrator-panel-store.test.ts |
Adds coverage for new store transitions (await/resume). |
src/sdk/components/orchestrator-panel-store.test.ts |
Adds parallel store tests under src/ test suite for awaiting/resume transitions. |
tests/sdk/components/layout.test.ts |
Adds layout height expectations for awaiting-input nodes. |
tests/sdk/components/node-card.test.tsx |
Adds rendering assertions for awaiting-input node copy and pulse-phase robustness. |
specs/2026-04-14-hil-detection-ui-surfacing.md |
Adds design spec for HIL detection + UI surfacing. |
research/docs/2026-04-14-hil-detection-implementation-research.md |
Adds implementation research summary and references. |
research/web/2026-04-14-copilot-sdk-hil-events.md |
Adds Copilot SDK event research notes. |
research/web/2026-04-14-opencode-sdk-hil-events.md |
Adds OpenCode SDK v2 event research notes. |
research/web/2026-04-14-claude-agent-sdk-hil-transcript.md |
Adds Claude Agent SDK transcript/HIL research notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }); | ||
| const result = await nativeSend(options); | ||
| await idle; | ||
| return result; |
There was a problem hiding this comment.
wrapCopilotSend can leak listeners (and potentially trigger an unhandled rejection) if nativeSend(options) rejects/throws before idle is awaited. In that case the session.idle / session.error handlers remain registered and the idle promise may later reject with no handler. Restructure so cleanup always runs (e.g., keep cleanup outside the Promise executor, wrap nativeSend in try/finally, and ensure idle is awaited/cancelled safely on send failure).
| const timer = setTimeout(resolve, pollMs); | ||
| // If abort fires mid-sleep, resolve early so we exit immediately. | ||
| const onAbort = () => { | ||
| clearTimeout(timer); | ||
| resolve(); | ||
| }; |
There was a problem hiding this comment.
watchCopilotPaneForHIL adds a new abort event listener on every poll iteration, but never removes it when the timeout completes normally. Because { once: true } only auto-removes after an abort, this accumulates listeners over time and can leak memory. Remove the listener when the timer fires (or use a single shared sleep helper that races setTimeout with the abort signal and always unregisters).
| const timer = setTimeout(resolve, pollMs); | |
| // If abort fires mid-sleep, resolve early so we exit immediately. | |
| const onAbort = () => { | |
| clearTimeout(timer); | |
| resolve(); | |
| }; | |
| const onAbort = () => { | |
| clearTimeout(timer); | |
| signal.removeEventListener("abort", onAbort); | |
| resolve(); | |
| }; | |
| const timer = setTimeout(() => { | |
| signal.removeEventListener("abort", onAbort); | |
| resolve(); | |
| }, pollMs); | |
| // If abort fires mid-sleep, resolve early so we exit immediately. |
| async function watchTranscriptForHIL( | ||
| sessionId: string, | ||
| signal: AbortSignal, | ||
| onHIL: (waiting: boolean) => void, | ||
| ): Promise<void> { | ||
| const jsonlPath = `${resolveSessionDir(process.cwd())}/${sessionId}.jsonl`; |
There was a problem hiding this comment.
watchTranscriptForHIL() is defined but never called anywhere (confirmed by repo-wide search). With oxlint running no-unused-vars, this is likely to raise an unused symbol warning and adds dead code to maintain. Either wire it into the Claude flow (if the file-watch approach is intended) or remove it to keep the implementation single-path (current HIL detection is already integrated into waitForIdle).
| - [ ] Extend `SessionStatus` type with `"awaiting_input"` variant | ||
| - [ ] Add `awaitingInput(name)` and `resumeSession(name)` transition methods to `PanelStore` | ||
| - [ ] Add `sessionAwaitingInput(name)` and `sessionResumed(name)` methods to `OrchestratorPanel` public API | ||
| - [ ] Add `awaiting_input` entries to `statusColor` (blue/`theme.info`), `statusLabel` (`"input needed"`), and `statusIcon` (`"?"`) | ||
| - [ ] Implement Claude HIL detection via `fs.watch()` on the JSONL transcript file in `claudeQuery()`, running as a parallel file watcher alongside the existing `waitForIdle()` loop (zero changes to `waitForIdle()`) | ||
| - [ ] Implement Copilot HIL detection via `session.on("user_input.requested"/"user_input.completed")` passive events in the `send()` wrapper | ||
| - [ ] Implement OpenCode HIL detection via `client.event.subscribe()` stream filtering for `question.asked`/`question.replied`/`question.rejected` events | ||
| - [ ] Render `awaiting_input` nodes with blue pulsing border, expanded height (6 rows), "waiting for response" label, and "↵ enter to respond" hint |
There was a problem hiding this comment.
Spec §3.1 lists provider implementations that don’t match the code: it says Claude HIL uses a parallel fs.watch() watcher with “zero changes to waitForIdle()” and Copilot uses SDK user_input.requested/completed events, but the implementation integrates Claude HIL checks into waitForIdle() and Copilot uses tmux pane polling (watchCopilotPaneForHIL) while intentionally omitting requestUserInput. Please update the spec to reflect the implemented design (or adjust code to match the spec) to avoid documentation drift.
Add HIL detection for all three provider SDKs (Claude, Copilot, OpenCode) and surface the `awaiting_input` status in the orchestrator panel UI. - Claude: poll transcript JSONL for unresolved `AskUserQuestion` tool_use blocks; integrate detection into `waitForIdle` so HIL doesn't look idle - Copilot: wrap `session.send()` to listen for `user_input.requested` / `user_input.completed` events and defer idle resolution while HIL pending - OpenCode: subscribe to SSE event stream for `question.asked` / `question.replied` / `question.rejected` events per session - Add `awaiting_input` session status with pulsing info-colored border, "waiting for response" label, and "↵ enter to respond" hint in node cards - Expand header badge row with an `awaiting_input` count badge - Include research docs, spec, and HIL test workflows for all three agents Assistant-model: Claude Code
…olling The Copilot SDK only broadcasts `user_input.requested` events when `requestUserInput: true` is passed during session creation, which requires an `onUserInputRequest` handler that takes over user-input presentation from the CLI. We intentionally omit it so the CLI keeps its native tmux-pane dialog. Replace the event-based HIL detection in `wrapCopilotSend` with a new `watchCopilotPaneForHIL` poller that reads the visible pane content at a regular interval and checks for the CLI's user-input prompt pattern. Simplify `wrapCopilotSend` to only block until `session.idle` or `session.error`. Assistant-model: Claude Code
…tion asking The OpenCode HIL event stream was subscribed in a fire-and-forget IIFE, so the stream could miss early events if the stage callback fired before the subscription was open. Await `subscribe()` before running the stage and separate subscription errors from stream-disconnect errors. Additionally, prompt builders now accept an explicit `questionTool` parameter so each agent is instructed to invoke its own ask-user tool (`question` / `AskUserQuestion` / `ask_user`) rather than printing the question as plain text, which was not detected as a HIL state. Assistant-model: Claude Code
Remove the built-in HIL test workflow implementations (Claude, Copilot, OpenCode) and their corresponding test suites, as they are no longer needed after the HIL feature has been finalized. Assistant-model: Claude Code
110697d to
c9ddc6c
Compare
PR Review: HIL (Human-in-the-Loop) Detection & UI SurfacingOverviewThis PR adds Code Quality & ArchitectureStrengths:
Suggestions:
Potential Bugs & Issues
Performance Considerations
Security ConcernsNo security issues identified. Changes are internal to the TUI and executor runtime. No new external data sources, no shell injection vectors. Test CoverageCovered well:
Missing test coverage (important):
These functions were specifically designed for testability (exported with Minor Nits
SummaryWell-architected feature with clean separation of concerns. The state machine design is solid. Main items to address:
Items 1 and 2 are the most important to address before merge. |
Replaces `watchCopilotPaneForHIL` (tmux pane polling) with `watchCopilotSessionForHIL`, which subscribes to the Copilot session's `tool.execution_start` / `tool.execution_complete` events for the `ask_user` built-in tool. These events fire regardless of whether an `onUserInputRequest` handler is registered, so HIL can be detected via the native SDK event stream while the CLI keeps rendering its own tmux-pane dialog. Tracks overlapping `ask_user` calls by `toolCallId` so `onHIL(false)` only fires after the last active request resolves. Replaces the AbortController-driven poller teardown with a plain unsubscribe function invoked in the stage `finally` block. Assistant-model: Claude Code
Adds parallel two-stage workflows for claude, copilot, and opencode that force the agent to ask the user a question via its native question tool, so the runtime's HIL detection paths (transcript watcher, tool.execution_* events, question.asked/replied events) can be verified end-to-end. Assistant-model: Claude Code
Code Review — HIL detection & UI surfacingSolid, well-structured PR. HIL state is cleanly modeled across the three providers, the store transitions are idempotent, and the dependency-injected helpers ( Findings below. Bugs / Correctness
Test CoverageThe PR touts
Minor / Style
Security / Performance
Overall: the feature works end-to-end, the UI pieces are thoughtful (pulsing border, hint text), and the store layer is well-covered. Addressing (1), (2), and the three missing test suites would be my bar for merge; the rest are nice-to-haves. |
PR Review — HIL detection across providersThanks for the thorough work here — the 🐛 Bugs / correctness1. Dead code in 2. OpenCode HIL stream is never unsubscribed ( watchOpencodeStreamForHIL(stream, ocSessionId, onHIL).catch((err) => { ... });There's no 3. Listener leak in const result = await nativeSend(options); // line 538
await idle;If 4. Silent transcript-read failure in 🧪 Test coverage gapsThe PR body states:
But only
🎨 Code quality / conventions5. 6. Research/spec docs are enormous (~2,600 lines) 7. ✅ What works well
SummaryMergeable once the Copilot listener leak (#3), OpenCode subscription leak (#2), and dead Claude helpers (#1) are addressed. The missing tests (#5) and transcript-read fallback (#4) are also worth fixing but lower priority. |
Summary
Adds full human-in-the-loop (HIL) detection and UI surfacing for all three agent providers (Claude Code, Copilot, OpenCode). When an agent pauses to await user input, the session node transitions to a new
awaiting_inputstatus with animated visual indicators in the TUI.Key Changes
New
awaiting_inputSession Statusawaiting_inputto theSessionStatusunion type inorchestrator-panel-types.ts?icon intheme.infocolor tracking sessions awaiting inputstatusColor,statusLabel, andstatusIconhelpers handle the new statusClaude Provider HIL Detection
_hasUnresolvedHILTool(): scans session transcript forAskUserQuestiontool calls without a correspondingtool_resultin a subsequent user message (pure function, exported for unit testing)_runHILWatcher(): dependency-injected async watcher loop that firesonHILon HIL state transitions; read errors are swallowed to tolerate partial JSONL writeswaitForIdle()checks for unresolved HIL state before treating pane inactivity as a completed turn — keeps polling rather than returning prematurely when anAskUserQuestionis pendingclaudeQuery()andClaudeSessionWrapperpropagate the newonHILcallbackCopilot Provider HIL Detection
watchCopilotSessionForHIL(): subscribes totool.execution_start/tool.execution_completeevents for theask_userbuilt-in tool — noonUserInputRequesthandler requiredwrapCopilotSend()extracted from inline executor code for independent testabilityask_userinvocations tracked bytoolCallIdsoonHIL(false)only fires after the last active request resolvesOpenCode Provider HIL Detection
watchOpencodeStreamForHIL(): consumes the OpenCode SSE event stream and callsonHIL(true/false)onquestion.asked/question.replied/question.rejectedevents for the matching sessionPanel Store & Executor
PanelStore.awaitingInput()andPanelStore.resumeSession()driverunning ↔ awaiting_inputstatus transitions with no-op guards for invalid stateOrchestratorPanelexposessessionAwaitingInput()andsessionResumed()on its public APIonHILcallback increateSessionRunner()wires all three providers to panel state updatesTests
awaitingInputandresumeSessionstore methods covering state transitions, version bumping, listener notifications, and no-op guardsawaiting_inputwrapCopilotSend,watchCopilotSessionForHIL, andwatchOpencodeStreamForHIL