fix(tui): keep background subagent activity visible (#3475)

Signed-off-by: owenkephart <owen.kephart@vercel.com>
This commit is contained in:
OwenKephart
2026-09-17 12:09:16 -07:00
committed by GitHub
parent 6ddfa9bfbe
commit 7e48f0e926
12 changed files with 380 additions and 8 deletions
@@ -0,0 +1,5 @@
---
"eve": patch
---
Keep the dev TUIs activity indicator animating while a background subagent is still running after its parent turn completes.
+41 -3
View File
@@ -27,6 +27,20 @@ function respond(request: MockModelRequest): MockModelResponse | string {
// Framework announcements are model context, not scenario turns.
const message = [...request.userMessages].reverse().find(isScenarioMessage) ?? "";
if (message === "TUI-HANG-IN-PROCESS") {
return startHangLabTool(request, "hold-in-process", "hold_in_process", { durationMs: 45_000 });
}
if (message === "TUI-HANG-WORKFLOW") {
return startHangLabTool(request, "hold-workflow", "hold_workflow", { duration: "45s" });
}
if (message === "TUI-HANG-COMMAND") {
return startHangLabTool(request, "hold-command", "hold_command", { durationSeconds: 45 });
}
if (message === "TUI-HANG-SUBAGENT") {
return startHangLabTool(request, "hold-subagent", "hang-worker", {
message: "Hold for 45 seconds.",
});
}
if (message.includes("TASK-FANOUT-INTERACTIVE-CHECK")) return "TASK-FANOUT-INTERACTIVE-OK";
if (message.includes("TASK-CANCEL-NOW")) return cancelWorkerTask(request);
if (message.includes("CHILD-TASK-EXCLUSIVITY-RACE")) return raceBusyWorker(request);
@@ -159,6 +173,14 @@ function respond(request: MockModelRequest): MockModelResponse | string {
if (message === "TASK-HITL-ROUTING") {
return startApprovalWorker(request, "task-hitl-worker", "TASK-HITL-STARTED");
}
if (message === "TUI-IDLE-WORK-REPRO") {
return startApprovalWorker(
request,
`tui-idle-work-repro-${scenarioUserMessageCount(request)}`,
"TUI-IDLE-WORK-STARTED",
{ delayed: true },
);
}
if (message === "TASK-C7-AUTHORIZATION") {
return startApprovalWorker(request, "task-c7-authorization-worker", "TASK-C7-STARTED");
}
@@ -184,6 +206,19 @@ function respond(request: MockModelRequest): MockModelResponse | string {
return `Mock reply: ${message}`;
}
function startHangLabTool(
request: MockModelRequest,
callIdPrefix: string,
name: string,
input: unknown,
): MockModelResponse | string {
const callId = `${callIdPrefix}-${scenarioUserMessageCount(request)}`;
if (resultById(request, callId) === undefined) {
return { toolCalls: [{ id: callId, input, name }] };
}
return `TUI-HANG-LAB-COMPLETE:${callIdPrefix}`;
}
function startAuthSnapshotTask(
request: MockModelRequest,
message: string,
@@ -365,6 +400,7 @@ function startApprovalWorker(
request: MockModelRequest,
callId: string,
completedText: string,
options?: { delayed?: boolean },
): MockModelResponse | string {
if (resultById(request, callId) === undefined) {
return {
@@ -375,9 +411,11 @@ function startApprovalWorker(
message:
callId === "task-c7-authorization-worker"
? "Run the C7 authorization mode, then return C7-AUTHORIZATION-COMPLETE."
: callId === "task-hitl-worker"
? "Run three approval gates in order, then return CHILD-GATES-COMPLETE."
: "Run both approval gates in order, then return CHILD-GATES-COMPLETE.",
: options?.delayed === true
? "Wait twenty seconds before the approval gates, then run three approval gates in order and return CHILD-GATES-COMPLETE."
: callId === "task-hitl-worker"
? "Run three approval gates in order, then return CHILD-GATES-COMPLETE."
: "Run both approval gates in order, then return CHILD-GATES-COMPLETE.",
},
name: "approval-worker",
},
@@ -19,10 +19,21 @@ function respond(request: MockModelRequest): MockModelResponse | string {
const continuation = request.userMessages.some((message) =>
message.includes("continuation approval gates"),
);
const delayed = request.userMessages.some((message) =>
message.toLowerCase().includes("wait twenty seconds before the approval gates"),
);
const threeGates =
continuation ||
request.userMessages.some((message) => message.includes("three approval gates"));
const id = (gate: string) => (continuation ? `continuation-${gate}` : `approval-${gate}`);
if (
delayed &&
request.toolResults.find((result) => result.id === "approval-wait") === undefined
) {
return {
toolCalls: [{ id: "approval-wait", input: { delay: "20s" }, name: "wait_before_approval" }],
};
}
const first = request.toolResults.find((result) => result.id === id("first"));
if (first === undefined) {
return {
@@ -0,0 +1,19 @@
import { defineWorkflowTool, type WorkflowToolDefinition } from "eve/tools";
import { sleep } from "workflow";
import { z } from "zod";
type Input = { delay: "20s" };
async function execute({ delay }: Input): Promise<{ waited: "20s" }> {
"use workflow";
await sleep(delay);
return { waited: delay };
}
const tool: WorkflowToolDefinition<Input, { waited: "20s" }> = defineWorkflowTool({
description: "Wait before the deterministic approval sequence.",
inputSchema: z.object({ delay: z.literal("20s") }),
execute,
});
export default tool;
@@ -0,0 +1,23 @@
import { defineAgent } from "eve";
import { mockModel, type MockModelRequest, type MockModelResponse } from "eve/evals";
function respond(request: MockModelRequest): MockModelResponse | string {
if (request.toolResults.some((result) => result.id === "hang-worker-hold")) {
return "HANG-WORKER-COMPLETE";
}
return {
toolCalls: [
{
id: "hang-worker-hold",
input: { durationMs: 45_000 },
name: "hold",
},
],
};
}
export default defineAgent({
description: "Test-only subagent that holds a foreground delegation open.",
model: mockModel(respond),
modelContextWindowTokens: 1_000_000,
});
@@ -0,0 +1,11 @@
import { defineTool } from "eve/tools";
import { z } from "zod";
export default defineTool({
description: "Test-only subagent hold.",
inputSchema: z.object({ durationMs: z.literal(45_000) }),
async execute({ durationMs }) {
await new Promise((resolve) => setTimeout(resolve, durationMs));
return { held: `${durationMs}ms` };
},
});
@@ -0,0 +1,18 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { defineTool } from "eve/tools";
import { z } from "zod";
const runFile = promisify(execFile);
export default defineTool({
description: "Test-only foreground child-process hold.",
inputSchema: z.object({ durationSeconds: z.literal(45) }),
async execute({ durationSeconds }) {
console.error(`[tui-hang-lab] command started; sleeping ${durationSeconds}s`);
await runFile("sleep", [String(durationSeconds)]);
console.error("[tui-hang-lab] command completed");
return { held: `${durationSeconds}s` };
},
});
@@ -0,0 +1,11 @@
import { defineTool } from "eve/tools";
import { z } from "zod";
export default defineTool({
description: "Test-only in-process foreground hold.",
inputSchema: z.object({ durationMs: z.literal(45_000) }),
async execute({ durationMs }) {
await new Promise((resolve) => setTimeout(resolve, durationMs));
return { held: `${durationMs}ms` };
},
});
@@ -0,0 +1,19 @@
import { defineWorkflowTool, type WorkflowToolDefinition } from "eve/tools";
import { sleep } from "workflow";
import { z } from "zod";
type Input = { duration: "45s" };
async function execute({ duration }: Input): Promise<{ held: "45s" }> {
"use workflow";
await sleep(duration);
return { held: duration };
}
const tool: WorkflowToolDefinition<Input, { held: "45s" }> = defineWorkflowTool({
description: "Test-only durable foreground workflow hold.",
inputSchema: z.object({ duration: z.literal("45s") }),
execute,
});
export default tool;
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Reproduce a TUI that follows background work after its foreground turn ended.
#
# Run this from the repository root:
# e2e/fixtures/fixture-tasks/scripts/reproduce-tui-idle-work.sh
#
# In the TUI, send TUI-IDLE-WORK-REPRO. The parent reports
# TUI-IDLE-WORK-STARTED and returns to its prompt; its background task then
# durably waits 20 seconds before asking for first_gate, second_gate, and
# third_gate approvals.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
port="${EVE_TUI_REPRO_PORT:-3217}"
log_file="${TMPDIR:-/tmp}/eve-tui-idle-work-${port}.log"
cleanup() {
if [[ -n "${server_pid:-}" ]] && kill -0 "$server_pid" 2>/dev/null; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
cd "$repo_root"
EVE_E2E_MODEL=mock pnpm --filter fixture-tasks exec eve dev --no-ui --port "$port" >"$log_file" 2>&1 &
server_pid=$!
until curl --silent --fail --max-time 1 "http://127.0.0.1:${port}/eve/v1/health" >/dev/null; do
if ! kill -0 "$server_pid" 2>/dev/null; then
cat "$log_file" >&2
exit 1
fi
sleep 0.2
done
printf 'Fixture server: http://127.0.0.1:%s\nServer log: %s\n\n' "$port" "$log_file"
cat <<'EOF'
Background idle-follow repro:
TUI-IDLE-WORK-REPRO
Returns to the prompt, then waits 20 seconds before requesting approval.
Foreground hang lab (each holds for 45 seconds):
TUI-HANG-IN-PROCESS ordinary Node.js promise timer
TUI-HANG-WORKFLOW durable workflow sleep
TUI-HANG-COMMAND child process: sleep 45 (logs start/completion)
TUI-HANG-SUBAGENT foreground subagent whose own tool waits
EOF
printf '\n'
pnpm --filter fixture-tasks exec eve dev --url "http://127.0.0.1:${port}"
@@ -1331,6 +1331,117 @@ describe("TerminalRenderer (inline scrollback)", () => {
renderer.shutdown();
});
it("keeps the activity ticker running while a background subagent remains live", async () => {
vi.useFakeTimers();
try {
const { screen, renderer } = makeRenderer();
renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" });
renderer.beginSubagent({ callId: "background", name: "researcher" });
renderer.backgroundSubagent({ callId: "background" });
renderer.upsertSubagentTool({
callId: "background",
subagentName: "researcher",
childCallId: "child-hold",
toolName: "hold",
input: { durationMs: 45_000 },
status: "executing",
});
await renderer.renderStream(streamOf([{ type: "finish" }]), {
continueSession: true,
submittedPrompt: "start background work",
});
const outputBeforeTick = screen.rawOutput().length;
await vi.advanceTimersByTimeAsync(90);
expect(screen.rawOutput().length).toBeGreaterThan(outputBeforeTick);
renderer.completeSubagent({ authoritative: true, callId: "background" });
const outputAfterCompletion = screen.rawOutput().length;
await vi.advanceTimersByTimeAsync(180);
expect(screen.rawOutput()).toHaveLength(outputAfterCompletion);
renderer.shutdown();
} finally {
vi.useRealTimers();
}
});
it("keeps the ticker running when one of two background subagents completes", async () => {
vi.useFakeTimers();
try {
const { screen, renderer } = makeRenderer();
renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" });
const startBackground = (callId: string) => {
renderer.beginSubagent({ callId, name: "hang-worker" });
renderer.backgroundSubagent({ callId });
renderer.upsertSubagentTool({
callId,
subagentName: "hang-worker",
childCallId: `${callId}-hold`,
toolName: "hold",
input: { durationMs: 45_000 },
status: "executing",
});
};
startBackground("first");
await renderer.renderStream(streamOf([{ type: "finish" }]), {
continueSession: true,
submittedPrompt: "start first background worker",
});
await renderer.renderStream(
streamOf([
{ type: "assistant-complete", id: "a", text: "Mock reply: a" },
{ type: "finish" },
]),
{ continueSession: true, submittedPrompt: "a" },
);
startBackground("second");
await renderer.renderStream(streamOf([{ type: "finish" }]), {
continueSession: true,
submittedPrompt: "start second background worker",
});
renderer.completeSubagent({ authoritative: true, callId: "first" });
const outputBeforeTick = screen.rawOutput().length;
await vi.advanceTimersByTimeAsync(90);
expect(screen.rawOutput().length).toBeGreaterThan(outputBeforeTick);
renderer.completeSubagent({ authoritative: true, callId: "second" });
const outputAfterCompletion = screen.rawOutput().length;
await vi.advanceTimersByTimeAsync(180);
expect(screen.rawOutput()).toHaveLength(outputAfterCompletion);
renderer.shutdown();
} finally {
vi.useRealTimers();
}
});
it("stops background activity ticking when a session boundary abandons its child", async () => {
vi.useFakeTimers();
try {
const { screen, renderer } = makeRenderer();
renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" });
renderer.beginSubagent({ callId: "background", name: "researcher" });
renderer.backgroundSubagent({ callId: "background" });
renderer.upsertSubagentTool({
callId: "background",
subagentName: "researcher",
childCallId: "child-hold",
toolName: "hold",
input: { durationMs: 45_000 },
status: "executing",
});
renderer.renderSessionBoundary();
const outputAfterBoundary = screen.rawOutput().length;
await vi.advanceTimersByTimeAsync(180);
expect(screen.rawOutput()).toHaveLength(outputAfterBoundary);
renderer.shutdown();
} finally {
vi.useRealTimers();
}
});
it("commits completed foreground turns ahead of a live background subagent", async () => {
const { screen, renderer } = makeRenderer(48, 8);
renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" });
@@ -4505,6 +4616,30 @@ describe("TerminalRenderer setup flow session", () => {
renderer.shutdown();
});
it("does not repaint while inherited stdio owns the terminal", async () => {
vi.useFakeTimers();
try {
const { screen, renderer } = makeRenderer();
renderer.setupFlow.begin("Add integration", "pulse");
renderer.beginSubagent({ callId: "background", name: "researcher" });
renderer.backgroundSubagent({ callId: "background" });
let release!: () => void;
const inherited = renderer.setupFlow.withInheritedStdio(
() => new Promise<void>((resolve) => (release = resolve)),
);
const outputDuringHandoff = screen.rawOutput().length;
await vi.advanceTimersByTimeAsync(180);
expect(screen.rawOutput()).toHaveLength(outputDuringHandoff);
release();
await inherited;
renderer.setupFlow.end();
renderer.shutdown();
} finally {
vi.useRealTimers();
}
});
it("discards inherited subprocess output when restoring the transcript", async () => {
const { screen, input, renderer } = makeRenderer();
@@ -765,7 +765,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
async readPrompt(options?: AgentTUISessionOptions): Promise<string> {
this.#start(options);
this.#stopTicker();
this.#syncBackgroundActivityTicker();
this.#commitTurnStats();
this.#inputActive = true;
this.#promptPlaceholderActive = true;
@@ -1050,11 +1050,11 @@ export class TerminalRenderer implements AgentTUIRenderer {
this.#requestTurnCancel = undefined;
this.#sendSteering = undefined;
this.#detachInput();
this.#stopTicker();
this.#streamDraftActive = false;
if (this.#turnIndicator.kind === "waiting") {
this.#turnIndicator = { kind: "idle" };
}
this.#syncBackgroundActivityTicker();
this.#status = completedTurnStatus({
interrupted: this.#interrupted,
cancelled: this.#turnCancelled,
@@ -1068,6 +1068,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
// prefix wedged and freeze scrollback for the rest of the session.
if (this.#interrupted || turnState.cancelled) this.#settleCurrentTurnToolBlocks(turnState);
this.#finalizeAllBlocks();
this.#syncBackgroundActivityTicker();
this.#diagnostics?.reportStats();
this.#paint();
@@ -1117,6 +1118,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
this.#sweepPreparingToolBlocks(turnState);
if (turnState.cancelled) this.#settleCurrentTurnToolBlocks(turnState);
this.#finalizeAllBlocks();
this.#syncBackgroundActivityTicker();
this.#diagnostics?.reportStats();
this.#paint();
}
@@ -1624,6 +1626,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
const wasBackground = this.#backgroundSubagentCallIds.has(update.callId);
this.#backgroundSubagentCallIds.add(update.callId);
if (!wasBackground) this.#moveSubagentCohortToBackgroundTail(update.callId);
this.#syncBackgroundActivityTicker();
this.#paint();
}
@@ -1648,6 +1651,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
if (block.subagentCallId === update.callId) block.live = true;
}
}
this.#syncBackgroundActivityTicker();
this.#paint();
}
@@ -1785,6 +1789,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
this.#nextSubmittedPromptOrigin = undefined;
this.#fileContents.clear();
this.#turnClock.reset();
this.#syncBackgroundActivityTicker();
}
/**
@@ -2900,7 +2905,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
this.#flowInterrupt = undefined;
this.#disarmFlowIdleTrap();
this.#detachInput();
this.#stopTicker();
this.#clearTicker();
this.#live.clear();
this.#removeLogCapture();
this.#altScreen.enter({ cursor: "visible", mouse: false });
@@ -2921,6 +2926,8 @@ export class TerminalRenderer implements AgentTUIRenderer {
if (this.#setupFlow !== undefined) {
this.#startTicker();
this.#armFlowIdleTrap();
} else {
this.#syncBackgroundActivityTicker();
}
this.#live.reset();
this.#paint();
@@ -3087,7 +3094,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
rejectReader?.(interruptedError());
this.#detachInput();
this.#stopCaretBlink();
this.#stopTicker();
this.#clearTicker();
if (this.#logLevelHintTimer !== undefined) {
clearTimeout(this.#logLevelHintTimer);
this.#logLevelHintTimer = undefined;
@@ -3359,7 +3366,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
}
#startTicker() {
this.#stopTicker();
this.#clearTicker();
this.#tickTimer = setInterval(() => {
this.#spinnerIndex += 1;
this.#paint();
@@ -3375,12 +3382,36 @@ export class TerminalRenderer implements AgentTUIRenderer {
}
#stopTicker() {
if (this.#hasLiveBackgroundActivity()) return;
this.#clearTicker();
}
#clearTicker() {
if (this.#tickTimer) {
clearInterval(this.#tickTimer);
this.#tickTimer = undefined;
}
}
#hasLiveBackgroundActivity(): boolean {
return this.#blocks.some(
(block) =>
block.live &&
block.subagentCallId !== undefined &&
(this.#backgroundSubagentCallIds.has(block.subagentCallId) ||
this.#provisionalSubagentCallIds.has(block.subagentCallId)),
);
}
/** Keeps mutable subagent sections visibly active after their parent turn settles. */
#syncBackgroundActivityTicker(): void {
if (this.#hasLiveBackgroundActivity()) {
this.#startTicker();
} else if (!this.#streamDraftActive && this.#turnIndicator.kind === "idle") {
this.#clearTicker();
}
}
// ---------------------------------------------------------------------------
// Block management
// ---------------------------------------------------------------------------